@noobdemon/noob-cli 1.5.4 → 1.6.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 +6 -2
- package/src/agent.js +99 -9
- package/src/i18n.js +7 -0
- package/src/repl.js +61 -0
- package/src/subagent.js +56 -0
- package/src/tokens.js +70 -0
- package/src/tools.js +4 -0
- package/src/tui.js +2 -1
- package/src/ui.js +39 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noobdemon/noob-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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,7 +39,10 @@
|
|
|
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",
|
|
44
|
+
"marked": "^15.0.12",
|
|
45
|
+
"marked-terminal": "^7.3.0",
|
|
42
46
|
"ora": "^8.2.0"
|
|
43
47
|
}
|
|
44
48
|
}
|
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
|
-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
// Thứ tự CÓ 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
|
-
|
|
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) =>
|
|
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,7 +57,10 @@ 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)",
|
|
63
|
+
cmdInit: "/init quét dự án & tạo noob.md (tổng quan + quy ước, như Claude Code)",
|
|
61
64
|
cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
|
|
62
65
|
cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
|
|
63
66
|
cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
|
|
@@ -108,6 +111,10 @@ export const t = {
|
|
|
108
111
|
ultraNeedGoal: "Cần mục tiêu. Dùng: /ultra <mô tả mục tiêu>",
|
|
109
112
|
ultraQuest: (n) => `tự nghĩ nhiệm vụ kế tiếp (vòng ${n})…`,
|
|
110
113
|
learning: "đang chưng cất bài học vào noob.md…",
|
|
114
|
+
initRunning: "đang quét dự án & soạn noob.md…",
|
|
115
|
+
initOverwriteWarn: (p) => `⚠ Đã có noob.md tại ${p}. /init sẽ ghi đè nội dung hiện tại.`,
|
|
116
|
+
initOverwriteConfirm: "Ghi đè? gõ 'y' để xác nhận, phím khác để huỷ › ",
|
|
117
|
+
initCancel: "Huỷ /init — giữ nguyên noob.md.",
|
|
111
118
|
memoryEmpty: (p) => `Chưa có noob.md. noob sẽ tự tạo ở: ${p}`,
|
|
112
119
|
memoryStat: (n) => ` · ${n} dòng / ~200`,
|
|
113
120
|
|
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";
|
|
@@ -25,6 +27,7 @@ const SLASH = [
|
|
|
25
27
|
{ name: "/chat", desc: "chế độ chat thường" },
|
|
26
28
|
{ name: "/yolo", desc: "bật/tắt tự duyệt" },
|
|
27
29
|
{ name: "/auto-yolo", desc: "lưu yolo làm mặc định (cần xác nhận)" },
|
|
30
|
+
{ name: "/init", desc: "quét dự án & tạo noob.md" },
|
|
28
31
|
{ name: "/karpathy", desc: "rà soát code (Karpathy)" },
|
|
29
32
|
{ name: "/ultra", desc: "tự hành: tự nghĩ & làm nhiệm vụ" },
|
|
30
33
|
{ name: "/learn", desc: "chưng cất bài học vào noob.md" },
|
|
@@ -396,6 +399,58 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
|
|
|
396
399
|
state.ultra = false;
|
|
397
400
|
}
|
|
398
401
|
|
|
402
|
+
// /init — quét dự án & sinh noob.md tổng quan (giống `/init` của Claude Code).
|
|
403
|
+
// Nếu noob.md đã có: hỏi xác nhận ghi đè trước khi giao việc cho model.
|
|
404
|
+
async function runInit() {
|
|
405
|
+
if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
|
|
406
|
+
const mem = loadMemory();
|
|
407
|
+
if (mem) {
|
|
408
|
+
console.log(c.err(" " + t.initOverwriteWarn(memoryPath())));
|
|
409
|
+
const ans = ((await ask(c.tool(" " + t.initOverwriteConfirm))) ?? "").trim().toLowerCase();
|
|
410
|
+
if (ans !== "y" && ans !== "yes" && ans !== "có") {
|
|
411
|
+
console.log(c.dim(" " + t.initCancel));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const prompt = `Hãy KHỞI TẠO bộ nhớ dự án \`noob.md\` ở thư mục gốc (giống cách Claude Code chạy /init).
|
|
416
|
+
|
|
417
|
+
QUY TRÌNH (làm bằng tool, không nói suông):
|
|
418
|
+
1. list_dir thư mục gốc để nhìn tổng quan.
|
|
419
|
+
2. Đọc các file quan trọng nếu có: package.json, README.md, pyproject.toml, requirements.txt, Cargo.toml, go.mod, tsconfig.json, vite.config*, next.config*, Makefile, Dockerfile, .editorconfig, .eslintrc*, .prettierrc*.
|
|
420
|
+
3. Lướt qua thư mục mã nguồn chính (src/, lib/, app/…) bằng list_dir/glob để hiểu kiến trúc — đừng đọc hết, chỉ đủ để nắm cấu trúc.
|
|
421
|
+
4. Nếu repo có test/build script, ghi lại lệnh CHÍNH XÁC (ví dụ \`npm test\`, \`pytest\`, \`cargo build\`).
|
|
422
|
+
|
|
423
|
+
SAU ĐÓ ghi \`noob.md\` bằng write_file (ghi đè nếu đã có) với cấu trúc CHÍNH XÁC sau:
|
|
424
|
+
|
|
425
|
+
# noob.md
|
|
426
|
+
|
|
427
|
+
## Tổng quan
|
|
428
|
+
- 2–5 gạch đầu dòng: tên dự án, mục đích, ngôn ngữ/runtime, framework chính.
|
|
429
|
+
|
|
430
|
+
## Lệnh thường dùng
|
|
431
|
+
- build: <lệnh hoặc "chưa rõ">
|
|
432
|
+
- test: <lệnh>
|
|
433
|
+
- run/dev: <lệnh>
|
|
434
|
+
- lint/format: <lệnh nếu có>
|
|
435
|
+
|
|
436
|
+
## Kiến trúc
|
|
437
|
+
- 3–8 gạch đầu dòng mô tả các thư mục/module chính & vai trò.
|
|
438
|
+
|
|
439
|
+
## Rules
|
|
440
|
+
- (để trống hoặc thêm quy ước ĐÃ CHỐT rút ra từ config: ví dụ "dùng ES modules (type: module)", "Node >=18"…)
|
|
441
|
+
|
|
442
|
+
## Notes
|
|
443
|
+
- (để trống — sẽ tự bổ sung sau khi học thêm trong quá trình làm việc)
|
|
444
|
+
|
|
445
|
+
NGUYÊN TẮC:
|
|
446
|
+
- Chỉ ghi sự thật rút ra từ file thật. KHÔNG bịa lệnh/quy ước không có cơ sở.
|
|
447
|
+
- Ngắn gọn (~80–150 dòng), mỗi ý một gạch đầu dòng.
|
|
448
|
+
- Khi xong, in 1 đoạn tóm tắt rất ngắn về những gì đã ghi vào noob.md.`;
|
|
449
|
+
console.log(c.tool(" 📋 " + t.initRunning));
|
|
450
|
+
await handle(prompt);
|
|
451
|
+
persist();
|
|
452
|
+
}
|
|
453
|
+
|
|
399
454
|
// /learn [ghi chú] — bắt noob chưng cất điều đáng nhớ của phiên vào noob.md.
|
|
400
455
|
async function runLearn(arg) {
|
|
401
456
|
if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
|
|
@@ -731,6 +786,9 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
|
|
|
731
786
|
case "u":
|
|
732
787
|
await runUltra(arg);
|
|
733
788
|
break;
|
|
789
|
+
case "init":
|
|
790
|
+
await runInit();
|
|
791
|
+
break;
|
|
734
792
|
case "learn":
|
|
735
793
|
await runLearn(arg);
|
|
736
794
|
break;
|
|
@@ -960,7 +1018,10 @@ function printHelp() {
|
|
|
960
1018
|
" " + t.cmdSearch,
|
|
961
1019
|
" " + t.cmdChat,
|
|
962
1020
|
" " + t.cmdYolo,
|
|
1021
|
+
" " + t.cmdAgent,
|
|
1022
|
+
" " + t.cmdTokens,
|
|
963
1023
|
" " + t.cmdAutoYolo,
|
|
1024
|
+
" " + t.cmdInit,
|
|
964
1025
|
" " + t.cmdKarpathy,
|
|
965
1026
|
" " + t.cmdUltra,
|
|
966
1027
|
" " + t.cmdLearn,
|
package/src/subagent.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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 }) {
|
|
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
|
+
const meter = new TokenMeter();
|
|
42
|
+
onLog?.(`↳ sub-agent (depth=${depth}) bắt đầu: ${task.slice(0, 80)}${task.length > 80 ? "…" : ""}`);
|
|
43
|
+
const result = await runAgent({
|
|
44
|
+
history,
|
|
45
|
+
model,
|
|
46
|
+
signal,
|
|
47
|
+
tokenMeter: meter,
|
|
48
|
+
extraToolsDoc: spawnAgentToolsDoc(depth),
|
|
49
|
+
onTool: (name, input) => dispatchTool(name, input, depth),
|
|
50
|
+
onStatus: () => {},
|
|
51
|
+
onDelta: () => {},
|
|
52
|
+
onSteer: () => [],
|
|
53
|
+
});
|
|
54
|
+
onLog?.(`↳ sub-agent (depth=${depth}) xong (${meter.format()})`);
|
|
55
|
+
return { result, tokens: { input: meter.input, output: meter.output } };
|
|
56
|
+
}
|
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
|
}
|
package/src/tui.js
CHANGED
|
@@ -74,11 +74,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
74
74
|
|
|
75
75
|
let liveOut = ""; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
|
|
76
76
|
let statusText = null; // text spinner khi đang nghĩ
|
|
77
|
-
// `busy` = một lượt/tool ĐANG chạy. Hiện
|
|
77
|
+
// `busy` = một lượt/tool ĐANG chạy. Hiện status bar suốt lượt kể cả lúc
|
|
78
78
|
// statusText tạm trống (vd model ngừng phun token giữa các bước) → người dùng
|
|
79
79
|
// LUÔN thấy rõ "đang chạy", không bị tưởng treo.
|
|
80
80
|
let busy = false;
|
|
81
81
|
let busyLabel = "";
|
|
82
|
+
let busyStartedAt = 0; // mốc thời gian để hiển thị elapsed
|
|
82
83
|
let frame = 0;
|
|
83
84
|
let frameTimer = null;
|
|
84
85
|
let prevRows = 0;
|
package/src/ui.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import gradient from "gradient-string";
|
|
3
3
|
import boxen from "boxen";
|
|
4
|
-
import {
|
|
4
|
+
import { supportsLanguage } from "cli-highlight";
|
|
5
|
+
import { marked } from "marked";
|
|
6
|
+
import { markedTerminal } from "marked-terminal";
|
|
5
7
|
import { PROVIDERS, providerColor } from "./models.js";
|
|
6
8
|
import { t } from "./i18n.js";
|
|
7
9
|
|
|
@@ -47,60 +49,46 @@ export function modelBadge(model) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
// ── Markdown → ANSI ────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// Dùng marked + marked-terminal. Một vài lưu ý từ source marked-terminal:
|
|
53
|
+
// * option `code` là STYLE fallback cho code block, không phải callback render.
|
|
54
|
+
// Body code block đã được cli-highlight xử lý sẵn → ta truyền highlightOptions
|
|
55
|
+
// để tô màu, và post-process để thêm viền trái `│`.
|
|
56
|
+
// * option `listitem` chạy AFTER bullet `*` được prepend, nên KHÔNG thêm `•` ở đây.
|
|
57
|
+
// Đổi bullet ở bước post-process.
|
|
58
|
+
// * option `href` không nên bọc ngoặc — wrapper tự thêm `(...)`.
|
|
59
|
+
const BULLET = c.accent("•");
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
marked.use(
|
|
62
|
+
markedTerminal(
|
|
63
|
+
{
|
|
64
|
+
width: Math.min(term(), 100),
|
|
65
|
+
reflowText: true,
|
|
66
|
+
tab: 2,
|
|
67
|
+
showSectionPrefix: false,
|
|
68
|
+
firstHeading: (s) => brand(s),
|
|
69
|
+
heading: chalk.hex("#a78bfa").bold,
|
|
70
|
+
blockquote: chalk.hex("#6b7280").italic,
|
|
71
|
+
strong: chalk.bold,
|
|
72
|
+
em: chalk.italic,
|
|
73
|
+
codespan: chalk.bgHex("#1f2937").hex("#fbbf24"),
|
|
74
|
+
hr: () => rule(),
|
|
75
|
+
link: chalk.hex("#06b6d4").underline,
|
|
76
|
+
href: chalk.hex("#9ca3af"),
|
|
77
|
+
code: chalk.hex("#f59e0b"),
|
|
78
|
+
},
|
|
79
|
+
{ ignoreIllegals: true },
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Post-process: đổi bullet `*` thành `•` màu accent, thêm viền `│` cho block code (4-space indent).
|
|
84
|
+
function prettify(s) {
|
|
85
|
+
return s
|
|
86
|
+
.replace(/^( *)\* /gm, (_, sp) => sp + BULLET + " ")
|
|
87
|
+
.replace(/^ {4}(.*)$/gm, (_, rest) => c.dim("│ ") + rest);
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
export function renderMarkdown(md) {
|
|
72
|
-
|
|
73
|
-
const lines = md.split("\n");
|
|
74
|
-
let i = 0;
|
|
75
|
-
while (i < lines.length) {
|
|
76
|
-
const line = lines[i];
|
|
77
|
-
const fence = line.match(/^```(\w*)\s*$/);
|
|
78
|
-
if (fence) {
|
|
79
|
-
const lang = fence[1];
|
|
80
|
-
const buf = [];
|
|
81
|
-
i++;
|
|
82
|
-
while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
|
|
83
|
-
i++; // skip closing fence
|
|
84
|
-
out.push(renderCode(buf.join("\n"), lang));
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
let m;
|
|
88
|
-
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
|
|
89
|
-
out.push(brand(chalk.bold(m[2])));
|
|
90
|
-
} else if (/^\s*[-*+]\s+/.test(line)) {
|
|
91
|
-
out.push(line.replace(/^(\s*)[-*+]\s+/, (_, sp) => sp + c.accent(" • ")) .replace(/^(\s*\S+\s)(.*)$/, (_, pre, rest) => pre + inline(rest)));
|
|
92
|
-
} else if ((m = line.match(/^(\s*)(\d+)\.\s+(.*)$/))) {
|
|
93
|
-
out.push(m[1] + c.accent(` ${m[2]}. `) + inline(m[3]));
|
|
94
|
-
} else if (/^\s*>\s?/.test(line)) {
|
|
95
|
-
out.push(c.dim("┃ ") + c.dim(inline(line.replace(/^\s*>\s?/, ""))));
|
|
96
|
-
} else if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
|
97
|
-
out.push(rule());
|
|
98
|
-
} else {
|
|
99
|
-
out.push(inline(line));
|
|
100
|
-
}
|
|
101
|
-
i++;
|
|
102
|
-
}
|
|
103
|
-
return out.join("\n");
|
|
91
|
+
return prettify(marked.parse(md || "")).trimEnd();
|
|
104
92
|
}
|
|
105
93
|
|
|
106
94
|
export function box(content, title, color = "#a78bfa") {
|