@noobdemon/noob-cli 1.5.5 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.5.5",
3
+ "version": "1.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -16,7 +16,8 @@
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
19
- "start": "node bin/noob.js"
19
+ "start": "node bin/noob.js",
20
+ "postpublish": "node scripts/notify-discord.js"
20
21
  },
21
22
  "engines": {
22
23
  "node": ">=18"
@@ -38,6 +39,7 @@
38
39
  "boxen": "^8.0.1",
39
40
  "chalk": "^5.4.1",
40
41
  "cli-highlight": "^2.1.11",
42
+ "gpt-tokenizer": "^3.4.0",
41
43
  "gradient-string": "^3.0.0",
42
44
  "marked": "^15.0.12",
43
45
  "marked-terminal": "^7.3.0",
package/src/agent.js CHANGED
@@ -2,6 +2,7 @@ import os from "node:os";
2
2
  import { stream } from "./api.js";
3
3
  import { loadMemory } from "./memory.js";
4
4
  import { t } from "./i18n.js";
5
+ import { countTokens } from "./tokens.js";
5
6
 
6
7
  export const SYSTEM = `You are noob, an agentic coding assistant in the spirit of Claude Code. You help with software engineering tasks by reading and editing files and running commands in the user's current working directory.
7
8
 
@@ -71,8 +72,13 @@ Có — cả 12 test đều pass.
71
72
 
72
73
  Follow this pattern exactly. Your very first response to a task that needs the filesystem MUST be a tool block — do not refuse or explain limitations.`;
73
74
 
74
- const MAX_STEPS = 300;
75
+ // Số bước tool tối đa cho một lượt. Đặt rất cao theo yêu cầu người dùng: task
76
+ // dài cứ chạy, đừng tự dừng. Người dùng vẫn có thể Ctrl+C bất cứ lúc nào.
77
+ const MAX_STEPS = 10000;
75
78
  const MAX_PROMPT_CHARS = 80000; // ngân sách ký tự cho phần hội thoại gửi lên model
79
+ // Khi history vượt ngưỡng này, gọi model phụ tóm tắt các lượt cũ thay vì cắt cụt
80
+ // → giữ được "trí nhớ dài hạn" trong phiên mà không nổ context.
81
+ const SUMMARIZE_THRESHOLD_CHARS = 60000;
76
82
 
77
83
  // Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
78
84
  // khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
@@ -101,6 +107,7 @@ function runtimeContext() {
101
107
 
102
108
  // Lược ngữ cảnh để không vượt context khi phiên dài. KHÔNG đụng vào history thật
103
109
  // (vẫn lưu/đầy đủ để resume) — chỉ thu gọn BẢN SAO dùng cho prompt.
110
+ // Nếu history đã có summary (do summarizeHistory ghi vào _summary), dùng làm head.
104
111
  function compact(history, budget) {
105
112
  const len = (m) => (m.content || "").length + 24;
106
113
  let total = history.reduce((s, m) => s + len(m), 0);
@@ -125,6 +132,68 @@ function compact(history, budget) {
125
132
  return [...head, elided, ...out.slice(tailStart)];
126
133
  }
127
134
 
135
+ // Bộ nhớ dài hạn cho phiên: khi history phình to, gọi model phụ TÓM TẮT các
136
+ // lượt cũ thành một message system gọn (giữ quyết định, file đã sửa, lý do,
137
+ // việc dở) rồi thay phần đầu history bằng tóm tắt đó. Mutates `history` in place.
138
+ // Trả về true nếu có tóm tắt (để caller persist phiên ngay).
139
+ export async function maybeSummarize(history, { model, signal }) {
140
+ if (!history?.length) return false;
141
+ const totalChars = history.reduce((s, m) => s + (m.content?.length || 0), 0);
142
+ if (totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
143
+ // Giữ 8 message cuối nguyên vẹn; tóm tắt phần trước.
144
+ const keepTail = 8;
145
+ if (history.length <= keepTail + 2) return false;
146
+ // Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
147
+ const head = history.slice(0, history.length - keepTail);
148
+ const tail = history.slice(history.length - keepTail);
149
+ const transcript = head.map((m) => {
150
+ const role = m.role === "tool" ? `TOOL(${m.name || "?"})` : m.role.toUpperCase();
151
+ return `## ${role}\n${(m.content || "").slice(0, 2000)}`;
152
+ }).join("\n\n");
153
+ const ask = `Tóm tắt phần hội thoại sau thành BẢN GHI NGẮN GỌN (~25-40 dòng, tiếng Việt) để bạn — chính bạn — đọc lại sau và tiếp tục công việc mà không quên ngữ cảnh quan trọng.
154
+
155
+ BẮT BUỘC giữ lại:
156
+ - Mục tiêu/nhiệm vụ tổng người dùng giao (nguyên văn nếu ngắn).
157
+ - Các quyết định thiết kế đã chốt và LÝ DO.
158
+ - Danh sách file đã tạo/sửa kèm mô tả NGẮN (1 dòng/file) — vai trò của file.
159
+ - Các phát hiện quan trọng từ tool (cấu trúc dự án, lệnh build/test, lỗi đã gặp & cách fix).
160
+ - Việc CÒN DANG DỞ và bước kế tiếp đã dự định.
161
+ - Sở thích/yêu cầu đặc biệt của người dùng.
162
+
163
+ LOẠI BỎ: chi tiết nội dung file đọc được, output dài của tool, các lượt khảo sát lặp lại.
164
+
165
+ Định dạng:
166
+ # Tóm tắt phiên (đến lượt thứ ${head.length})
167
+ ## Mục tiêu
168
+ - ...
169
+ ## Quyết định & lý do
170
+ - ...
171
+ ## File đã đụng
172
+ - path — vai trò
173
+ ## Phát hiện & lệnh quan trọng
174
+ - ...
175
+ ## Đang làm dở / bước tiếp
176
+ - ...
177
+
178
+ --- HỘI THOẠI CẦN TÓM TẮT ---
179
+ ${transcript}`;
180
+ try {
181
+ const { text } = await stream({ mode: "chat", model, message: ask, signal });
182
+ const summary = (text || "").trim();
183
+ if (!summary || summary.length < 50) return false;
184
+ // Thay head bằng MỘT message tool tên "session_summary".
185
+ const summaryMsg = {
186
+ role: "tool",
187
+ name: "session_summary",
188
+ content: `[BỘ NHỚ DÀI HẠN — tóm tắt ${head.length} lượt đầu để không quên ngữ cảnh]\n\n${summary}`,
189
+ };
190
+ history.splice(0, history.length, summaryMsg, ...tail);
191
+ return true;
192
+ } catch {
193
+ return false; // tóm tắt lỗi → cứ để compact() cắt cụt như cũ
194
+ }
195
+ }
196
+
128
197
  // GROUND TRUTH: liệt kê những file ĐÃ THỰC SỰ được ghi/sửa, suy ra từ KẾT QUẢ
129
198
  // tool có thật (không phải từ lời model tự kể). Chống lỗi model "tưởng đã tạo
130
199
  // file" (chỉ kể trong văn xuôi, quên gọi write_file) rồi khăng khăng "file bị
@@ -148,21 +217,31 @@ function filesLedger(history) {
148
217
 
149
218
  // Chèn bộ nhớ noob.md (nếu có) vào prompt — đây là phần "tự học" mà noob đọc
150
219
  // lại mỗi lượt. Không có file → nhắc model tự tạo khi rút ra điều đáng nhớ.
220
+ // Framing MẠNH: phần `## Rules` là binding (luật dự án), `## Notes` mới là
221
+ // tham khảo. Đặt ngay sau SYSTEM trong buildPrompt() để không bị lost-in-the-middle.
151
222
  function memoryBlock() {
152
223
  const mem = loadMemory();
153
224
  if (!mem)
154
- return "# PROJECT MEMORY (noob.md)\n(Chưa có noob.md trong thư mục này. Khi rút ra điều đáng nhớ lâu dài — lệnh build/test/run, quy ước, kiến trúc, sở thích người dùng, tiến độ — hãy TẠO noob.md bằng write_file và ghi vào đó.)";
225
+ return "# PROJECT RULES & MEMORY (noob.md)\n(Chưa có noob.md trong thư mục này. Khi rút ra điều đáng nhớ lâu dài — lệnh build/test/run, quy ước, kiến trúc, sở thích người dùng, tiến độ — hãy TẠO noob.md bằng write_file và ghi vào đó.)";
155
226
  return (
156
- "# PROJECT MEMORY (noob.md — điều bạn đã tự học trước đó; xác minh với filesystem trước khi tin tuyệt đối)\n" +
227
+ "# PROJECT RULES & MEMORY (noob.md)BINDING\n" +
228
+ "Phần `## Rules` dưới đây là LUẬT DỰ ÁN bạn PHẢI tuân theo trong mọi hành động ở lượt này — coi như mở rộng của SYSTEM, không phải gợi ý. Phần `## Notes` là quan sát tham khảo, có thể xác minh lại với filesystem nếu nghi ngờ.\n\n" +
157
229
  mem +
158
- "\n(Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file.)"
230
+ "\n\nTrước khi emit hành động, đối chiếu với `## Rules` ở trên. Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file (Notes mới, promote lên Rules khi đã chứng minh)."
159
231
  );
160
232
  }
161
233
 
162
234
  // The proxy is stateless, so we serialize the whole transcript into one prompt.
163
- function buildPrompt(history) {
235
+ // extraToolsDoc: chuỗi mô tả thêm tool (vd spawn_agent khi agent mode bật) được
236
+ // chèn ngay sau SYSTEM để model biết và dùng được.
237
+ function buildPrompt(history, extraToolsDoc) {
164
238
  const msgs = compact(history, MAX_PROMPT_CHARS);
165
- const parts = [SYSTEM, "", runtimeContext(), "", memoryBlock(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
239
+ // Thứ tự CHỦ ĐÍCH: SYSTEM memoryBlock (Rules dự án, vị trí mạnh thứ 2,
240
+ // tránh lost-in-the-middle) → extraToolsDoc → runtimeContext → filesLedger →
241
+ // CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm sát SYSTEM để model coi là luật.
242
+ const parts = [SYSTEM, "", memoryBlock()];
243
+ if (extraToolsDoc) parts.push("", extraToolsDoc);
244
+ parts.push("", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", "");
166
245
  for (const m of msgs) {
167
246
  if (m.role === "user") parts.push(`## USER\n${m.content}`);
168
247
  else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
@@ -230,14 +309,21 @@ function extractJsonObject(s, from) {
230
309
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
231
310
  * @returns {Promise<string>} the final assistant answer (no tool block)
232
311
  */
233
- export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer }) {
312
+ export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc }) {
234
313
  for (let step = 0; step < MAX_STEPS; step++) {
314
+ // Mỗi 100 bước log một mốc để người dùng biết noob vẫn đang chạy (task dài).
315
+ if (step > 0 && step % 100 === 0) onStatus?.(`đã chạy ${step} bước…`);
316
+
235
317
  // Steering: tin nhắn người dùng gõ GIỮA CHỪNG được chèn vào hội thoại TRƯỚC
236
318
  // lần gọi model kế tiếp → model thấy và điều chỉnh ngay trong cùng task.
237
319
  const steer = onSteer?.() || [];
238
320
  for (const msg of steer) history.push({ role: "user", content: msg });
239
321
 
240
- const prompt = buildPrompt(history);
322
+ // Bộ nhớ dài hạn: thử tóm tắt nếu history đã phình. Im lặng nếu không cần.
323
+ try { await maybeSummarize(history, { model, signal }); } catch {}
324
+
325
+ const prompt = buildPrompt(history, extraToolsDoc);
326
+ tokenMeter?.addInput(countTokens(prompt));
241
327
  onStatus?.("thinking");
242
328
  onDelta?.({ type: "step-start" });
243
329
  const { text } = await stream({
@@ -245,8 +331,12 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
245
331
  model,
246
332
  message: prompt,
247
333
  signal,
248
- onDelta: (d) => onDelta?.({ type: "delta", text: d }),
334
+ onDelta: (d) => {
335
+ tokenMeter?.pushOutputDelta(d);
336
+ onDelta?.({ type: "delta", text: d });
337
+ },
249
338
  });
339
+ tokenMeter?.endOutput();
250
340
  onDelta?.({ type: "step-end" });
251
341
  history.push({ role: "assistant", content: text });
252
342
 
package/src/i18n.js CHANGED
@@ -57,6 +57,8 @@ export const t = {
57
57
  cmdSearch: "/search bật/tắt chế độ tìm web",
58
58
  cmdChat: "/chat quay lại chế độ chat thường",
59
59
  cmdYolo: "/yolo bật/tắt tự duyệt (hoặc nhấn Shift+Tab)",
60
+ cmdAgent: "/agent on|off bật/tắt agent mode (model đẻ sub-agent song song/tuần tự/phân cấp)",
61
+ cmdTokens: "/tokens xem số token đã dùng trong phiên",
60
62
  cmdAutoYolo: "/auto-yolo lưu/bỏ yolo làm mặc định mỗi lần chạy (cần xác nhận)",
61
63
  cmdInit: "/init quét dự án & tạo noob.md (tổng quan + quy ước, như Claude Code)",
62
64
  cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
package/src/repl.js CHANGED
@@ -4,6 +4,8 @@ import path from "node:path";
4
4
  import chalk from "chalk";
5
5
  import { createTui } from "./tui.js";
6
6
  import { runAgent } from "./agent.js";
7
+ import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
8
+ import { TokenMeter } from "./tokens.js";
7
9
  import { stream, usage, ApiError } from "./api.js";
8
10
  import { runTool, describe, DESTRUCTIVE } from "./tools.js";
9
11
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
@@ -28,6 +30,8 @@ const SLASH = [
28
30
  { name: "/init", desc: "quét dự án & tạo noob.md" },
29
31
  { name: "/karpathy", desc: "rà soát code (Karpathy)" },
30
32
  { name: "/ultra", desc: "tự hành: tự nghĩ & làm nhiệm vụ" },
33
+ { name: "/agent", desc: "bật/tắt agent mode (spawn sub-agent)" },
34
+ { name: "/tokens", desc: "xem số token đã dùng phiên này" },
31
35
  { name: "/learn", desc: "chưng cất bài học vào noob.md" },
32
36
  { name: "/memory", desc: "xem bộ nhớ noob.md" },
33
37
  { name: "/login", desc: "đăng nhập bằng API key" },
@@ -127,7 +131,9 @@ export async function startRepl(opts = {}) {
127
131
  autoApprove: new Set(),
128
132
  yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
129
133
  ultra: false, // chế độ tự hành (self-quest) đang chạy?
134
+ agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
130
135
  };
136
+ const tokenMeter = new TokenMeter();
131
137
 
132
138
  // Prompt = dòng trạng thái sống. Luôn phản ánh yolo + version theo thời gian
133
139
  // thực (vẽ lại mỗi lượt và ngay khi Shift+Tab), nên không cần gõ /status.
@@ -632,10 +638,49 @@ NGUYÊN TẮC:
632
638
  startSpin(t.thinking);
633
639
  let printer = null;
634
640
 
641
+ const dispatchTool = async (name, input, depth = 0) => {
642
+ // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
643
+ // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
644
+ if (name === "spawn_agent" || name === "spawn_agents") {
645
+ if (!state.agentMode)
646
+ return { allow: true, result: "ERROR: agent mode đang TẮT — gõ /agent on để bật trước khi spawn." };
647
+ if (depth >= MAX_SUBAGENT_DEPTH)
648
+ return { allow: true, result: `ERROR: đã đạt depth tối đa (${MAX_SUBAGENT_DEPTH}) — không spawn thêm.` };
649
+ const tasks = name === "spawn_agent" ? [input] : (Array.isArray(input?.agents) ? input.agents : []);
650
+ if (!tasks.length) return { allow: true, result: "ERROR: thiếu task cho sub-agent." };
651
+ stopSpin();
652
+ console.log(chalk.hex("#8b5cf6")(` ⊕ spawn ${tasks.length} sub-agent (depth ${depth + 1}/${MAX_SUBAGENT_DEPTH})`));
653
+ startSpin(t.thinking);
654
+ try {
655
+ const results = await Promise.all(tasks.map((task, i) =>
656
+ runSubAgent({
657
+ task: task?.task || task?.prompt || "",
658
+ context: task?.context || "",
659
+ depth: depth + 1,
660
+ model: state.model.id,
661
+ signal: abort.signal,
662
+ tokenMeter,
663
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
664
+ onLog: (msg) => { stopSpin(); console.log(chalk.hex("#8b5cf6")(" " + msg)); startSpin(t.thinking); },
665
+ }).then((r) => `── sub-agent #${i + 1} ──\n${r}`).catch((e) => `── sub-agent #${i + 1} (LỖI) ──\n${e?.message || String(e)}`)
666
+ ));
667
+ return { allow: true, result: results.join("\n\n") };
668
+ } catch (err) {
669
+ return { allow: true, result: "ERROR sub-agent: " + (err?.message || String(err)) };
670
+ }
671
+ }
672
+ stopSpin();
673
+ const res = await execTool(name, input);
674
+ startSpin(t.thinking);
675
+ return res;
676
+ };
677
+
635
678
  const answer = await runAgent({
636
679
  history: state.history,
637
680
  model: state.model.id,
638
681
  signal: abort.signal,
682
+ tokenMeter,
683
+ extraToolsDoc: state.agentMode ? spawnAgentToolsDoc(0) : "",
639
684
  onStatus: () => tick(t.thinking),
640
685
  onSteer: () => {
641
686
  if (!pending.length) return [];
@@ -657,12 +702,7 @@ NGUYÊN TẮC:
657
702
  printer?.flush();
658
703
  }
659
704
  },
660
- onTool: async (name, input) => {
661
- stopSpin();
662
- const res = await execTool(name, input);
663
- startSpin(t.thinking);
664
- return res;
665
- },
705
+ onTool: (name, input) => dispatchTool(name, input, 0),
666
706
  });
667
707
 
668
708
  stopSpin();
@@ -771,6 +811,18 @@ NGUYÊN TẮC:
771
811
  state.yolo = !state.yolo;
772
812
  console.log((state.yolo ? c.err : c.ok)(" " + (state.yolo ? t.yoloOn : t.yoloOff)));
773
813
  break;
814
+ case "agent": {
815
+ const v = arg.toLowerCase();
816
+ if (v === "on" || v === "bật" || v === "bat") state.agentMode = true;
817
+ else if (v === "off" || v === "tắt" || v === "tat") state.agentMode = false;
818
+ else state.agentMode = !state.agentMode;
819
+ console.log((state.agentMode ? c.accent : c.dim)(" agent mode: " + (state.agentMode ? "BẬT (spawn_agent / spawn_agents khả dụng, depth tối đa " + MAX_SUBAGENT_DEPTH + ")" : "tắt")));
820
+ break;
821
+ }
822
+ case "tokens": {
823
+ console.log(c.dim(` tokens — input: ${tokenMeter.input.toLocaleString("vi-VN")} · output: ${tokenMeter.output.toLocaleString("vi-VN")} · tổng: ${tokenMeter.total.toLocaleString("vi-VN")} · ${tokenMeter.format()}`));
824
+ break;
825
+ }
774
826
  case "auto-yolo":
775
827
  case "autoyolo":
776
828
  await toggleAutoYolo();
@@ -1016,6 +1068,8 @@ function printHelp() {
1016
1068
  " " + t.cmdSearch,
1017
1069
  " " + t.cmdChat,
1018
1070
  " " + t.cmdYolo,
1071
+ " " + t.cmdAgent,
1072
+ " " + t.cmdTokens,
1019
1073
  " " + t.cmdAutoYolo,
1020
1074
  " " + t.cmdInit,
1021
1075
  " " + t.cmdKarpathy,
@@ -0,0 +1,61 @@
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 có 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";
6
+
7
+ export const MAX_SUBAGENT_DEPTH = 3;
8
+
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
14
+
15
+ Bạn đang ở chế độ AGENT. Khi gặp task LỚN có thể chia nhỏ, hãy ủy thác cho sub-agent thay vì tự làm hết một mình:
16
+
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.
19
+
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.
26
+
27
+ Ví dụ song song: "viết test cho 3 module độc lập" → spawn_agents với 3 tasks.
28
+ Ví 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
+ Ví 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
+ }
31
+
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ể. Làm xong → trả lời NGẮN GỌN bằng Markdown tóm tắt KẾT QUẢ (file đã đụng, phát hiện, lỗi nếu có). Không tán gẫu. Không hỏi lại cha — tự quyết với thông tin được cấp.
36
+
37
+ # NHIỆM VỤ
38
+ ${task}
39
+ ${context ? `\n# NGỮ CẢNH TỪ CHA\n${context}` : ""}`;
40
+ const history = [{ role: "user", content: sys }];
41
+ // Dùng chung meter của cha nếu được truyền vào → token sub-agent cộng dồn
42
+ // vào tổng phiên. Nếu không có thì tự tạo cục bộ (giữ tương thích cũ).
43
+ const meter = tokenMeter || new TokenMeter();
44
+ const before = { input: meter.input, output: meter.output };
45
+ onLog?.(`↳ sub-agent (depth=${depth}) bắt đầu: ${task.slice(0, 80)}${task.length > 80 ? "…" : ""}`);
46
+ const result = await runAgent({
47
+ history,
48
+ model,
49
+ signal,
50
+ tokenMeter: meter,
51
+ extraToolsDoc: spawnAgentToolsDoc(depth),
52
+ onTool: (name, input) => dispatchTool(name, input, depth),
53
+ onStatus: () => {},
54
+ onDelta: () => {},
55
+ onSteer: () => [],
56
+ });
57
+ const used = { input: meter.input - before.input, output: meter.output - before.output };
58
+ onLog?.(`↳ sub-agent (depth=${depth}) xong (↑${used.input} ↓${used.output})`);
59
+ // Trả về string sạch để cha (model) đọc dễ. Token đã cộng vào meter rồi.
60
+ return result;
61
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,70 @@
1
+ // Đếm token cục bộ bằng gpt-tokenizer (cl100k_base - tokenizer của GPT-4).
2
+ // CHÍNH XÁC cho GPT, XẤP XỈ cho Claude/Gemini/khác (sai số ~5-15%) — đủ để
3
+ // hiển thị mang tính tham khảo realtime trong CLI.
4
+ import { encode } from "gpt-tokenizer";
5
+
6
+ export function countTokens(text) {
7
+ if (!text) return 0;
8
+ try {
9
+ return encode(String(text)).length;
10
+ } catch {
11
+ // Fallback heuristic nếu encoder lỗi: ~4 ký tự / token.
12
+ return Math.ceil(String(text).length / 4);
13
+ }
14
+ }
15
+
16
+ // Đếm token cho cả mảng messages dạng {role, content}. Cộng overhead nhỏ
17
+ // (~4 token/message cho role + format) để gần đúng cách provider tính prompt.
18
+ export function countMessages(messages = []) {
19
+ let n = 0;
20
+ for (const m of messages) {
21
+ n += countTokens(m?.content || "") + 4;
22
+ }
23
+ return n;
24
+ }
25
+
26
+ // Bộ đếm cộng dồn cho 1 phiên: input (prompt gửi đi) + output (text stream về).
27
+ // Hỗ trợ cộng dồn theo delta để hiển thị realtime trong lúc stream.
28
+ export class TokenMeter {
29
+ constructor() {
30
+ this.input = 0;
31
+ this.output = 0;
32
+ this._outBuf = ""; // gom delta để đếm theo batch (đỡ tốn CPU)
33
+ this._outBufN = 0; // số token đã đếm từ _outBuf (để cộng dồn chính xác)
34
+ }
35
+ addInput(n) {
36
+ this.input += Math.max(0, n | 0);
37
+ }
38
+ // Mỗi delta text từ stream: gom vào buffer, định kỳ encode lại để cập nhật
39
+ // số token. Encode toàn buffer thay vì delta riêng lẻ → chính xác hơn (BPE
40
+ // gộp các byte qua ranh giới delta).
41
+ pushOutputDelta(text) {
42
+ if (!text) return;
43
+ this._outBuf += text;
44
+ // Re-encode toàn bộ buffer hiện tại; cập nhật delta vào this.output.
45
+ const total = countTokens(this._outBuf);
46
+ if (total > this._outBufN) {
47
+ this.output += total - this._outBufN;
48
+ this._outBufN = total;
49
+ }
50
+ }
51
+ // Kết thúc một lượt output → reset buffer (bắt đầu lượt mới).
52
+ endOutput() {
53
+ this._outBuf = "";
54
+ this._outBufN = 0;
55
+ }
56
+ get total() {
57
+ return this.input + this.output;
58
+ }
59
+ // Định dạng ngắn để hiển thị ở status bar: "↑1.2k ↓340 (1.5k)".
60
+ format() {
61
+ const fmt = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n));
62
+ return `↑${fmt(this.input)} ↓${fmt(this.output)} (${fmt(this.total)})`;
63
+ }
64
+ reset() {
65
+ this.input = 0;
66
+ this.output = 0;
67
+ this._outBuf = "";
68
+ this._outBufN = 0;
69
+ }
70
+ }
package/src/tools.js CHANGED
@@ -348,6 +348,10 @@ export function describe(name, input) {
348
348
  return input.id != null ? `xem tiến trình nền #${input.id}` : "liệt kê tiến trình nền";
349
349
  case "kill_bg":
350
350
  return `dừng tiến trình nền #${input.id}`;
351
+ case "spawn_agent":
352
+ return `↳ sub-agent: ${String(input.task || "").slice(0, 80)}`;
353
+ case "spawn_agents":
354
+ return `↳ ${(input.tasks || []).length} sub-agent song song`;
351
355
  default:
352
356
  return name;
353
357
  }