@noobdemon/noob-cli 1.0.6 → 1.0.8
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/src/agent.js +76 -8
- package/src/api.js +73 -41
- package/src/i18n.js +5 -2
- package/src/repl.js +168 -43
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import { stream } from "./api.js";
|
|
2
3
|
import { t } from "./i18n.js";
|
|
3
4
|
|
|
@@ -32,27 +33,86 @@ Available tools:
|
|
|
32
33
|
- JSON in the tool block must be valid: escape newlines as \\n inside string values.
|
|
33
34
|
- LANGUAGE: Always write your prose answers to the user in Vietnamese (tiếng Việt), unless the user explicitly writes in another language. Keep code, file paths, commands, and tool JSON unchanged.
|
|
34
35
|
|
|
36
|
+
# Coding principles (Karpathy) — apply to EVERY code change
|
|
37
|
+
1. THINK FIRST: state the key assumptions before you code. If a requirement is ambiguous or a step is hard to reverse, ask ONE sharp question instead of guessing.
|
|
38
|
+
2. KEEP IT SIMPLE: write the simplest thing that works. No speculative abstractions, no extra flags/config/layers "for later". Prefer deleting code to adding it.
|
|
39
|
+
3. SURGICAL: change only what the task needs. No drive-by refactors, renames, reformatting, or comment churn in unrelated code.
|
|
40
|
+
4. VERIFIABLE GOAL: decide how you'll know it works, then check it (run the build/test, read the output). Report what you verified — and honestly state what you did NOT verify.
|
|
41
|
+
|
|
35
42
|
# Example interaction
|
|
36
43
|
## USER
|
|
37
|
-
|
|
44
|
+
do the tests pass?
|
|
38
45
|
## ASSISTANT
|
|
39
46
|
\`\`\`tool
|
|
40
|
-
{"name": "run_command", "input": {"command": "
|
|
47
|
+
{"name": "run_command", "input": {"command": "npm test"}}
|
|
41
48
|
\`\`\`
|
|
42
49
|
## TOOL RESULT (run_command)
|
|
43
|
-
|
|
50
|
+
12 passing (340ms)
|
|
44
51
|
[exit code 0]
|
|
45
52
|
## ASSISTANT
|
|
46
|
-
|
|
53
|
+
Có — cả 12 test đều pass.
|
|
47
54
|
|
|
48
55
|
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.`;
|
|
49
56
|
|
|
50
57
|
const MAX_STEPS = 30;
|
|
58
|
+
const MAX_PROMPT_CHARS = 80000; // ngân sách ký tự cho phần hội thoại gửi lên model
|
|
59
|
+
|
|
60
|
+
// Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
|
|
61
|
+
// khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
|
|
62
|
+
// (PowerShell) báo lỗi.
|
|
63
|
+
function runtimeContext() {
|
|
64
|
+
const isWin = process.platform === "win32";
|
|
65
|
+
const lines = [
|
|
66
|
+
"# ENVIRONMENT",
|
|
67
|
+
`- OS: ${process.platform} (${os.release()})`,
|
|
68
|
+
`- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
|
|
69
|
+
`- Current working directory: ${process.cwd()}`,
|
|
70
|
+
];
|
|
71
|
+
if (isWin) {
|
|
72
|
+
lines.push(
|
|
73
|
+
"- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.",
|
|
74
|
+
" Use: Get-Content (not cat), Get-ChildItem (not ls), Select-String (not grep),",
|
|
75
|
+
" (Get-Content f | Measure-Object -Line) (not wc -l). Paths use backslashes.",
|
|
76
|
+
"- Prefer the dedicated tools (read_file / list_dir / grep / glob) over shell commands;",
|
|
77
|
+
" they are cross-platform. Use run_command mainly for builds/tests/installs.",
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
lines.push("- Prefer the dedicated tools (read_file/list_dir/grep/glob) over shell when possible.");
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Lược ngữ cảnh để không vượt context khi phiên dài. KHÔNG đụng vào history thật
|
|
86
|
+
// (vẫn lưu/đầy đủ để resume) — chỉ thu gọn BẢN SAO dùng cho prompt.
|
|
87
|
+
function compact(history, budget) {
|
|
88
|
+
const len = (m) => (m.content || "").length + 24;
|
|
89
|
+
let total = history.reduce((s, m) => s + len(m), 0);
|
|
90
|
+
if (total <= budget) return history;
|
|
91
|
+
const out = history.map((m) => ({ ...m }));
|
|
92
|
+
// (1) Rút gọn các TOOL RESULT cũ trước (giữ 5 cái gần nhất) — đây là phần phình
|
|
93
|
+
// nhất (đọc file lớn) và model đã xử lý xong rồi.
|
|
94
|
+
const toolIdx = out.map((m, i) => (m.role === "tool" ? i : -1)).filter((i) => i >= 0);
|
|
95
|
+
for (const i of toolIdx.slice(0, Math.max(0, toolIdx.length - 5))) {
|
|
96
|
+
if (total <= budget) break;
|
|
97
|
+
const before = len(out[i]);
|
|
98
|
+
out[i].content = "[kết quả công cụ cũ đã được lược bớt để tiết kiệm ngữ cảnh]";
|
|
99
|
+
total -= before - len(out[i]);
|
|
100
|
+
}
|
|
101
|
+
if (total <= budget) return out;
|
|
102
|
+
// (2) Vẫn dài → bỏ các lượt cũ nhất, giữ USER đầu tiên (mô tả nhiệm vụ gốc) +
|
|
103
|
+
// 12 message gần nhất.
|
|
104
|
+
const firstUser = out.findIndex((m) => m.role === "user");
|
|
105
|
+
const head = firstUser >= 0 ? [out[firstUser]] : [];
|
|
106
|
+
const tailStart = Math.max(firstUser + 1, out.length - 12);
|
|
107
|
+
const elided = { role: "tool", name: "context", content: "[… các lượt trước đã được lược bớt …]" };
|
|
108
|
+
return [...head, elided, ...out.slice(tailStart)];
|
|
109
|
+
}
|
|
51
110
|
|
|
52
111
|
// The proxy is stateless, so we serialize the whole transcript into one prompt.
|
|
53
112
|
function buildPrompt(history) {
|
|
54
|
-
const
|
|
55
|
-
|
|
113
|
+
const msgs = compact(history, MAX_PROMPT_CHARS);
|
|
114
|
+
const parts = [SYSTEM, "", runtimeContext(), "", "=".repeat(60), "# CONVERSATION", ""];
|
|
115
|
+
for (const m of msgs) {
|
|
56
116
|
if (m.role === "user") parts.push(`## USER\n${m.content}`);
|
|
57
117
|
else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
|
|
58
118
|
else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
|
|
@@ -95,11 +155,19 @@ export function parseToolCall(text) {
|
|
|
95
155
|
* @param {(msg:string)=>void} opts.onStatus thinking/streaming status
|
|
96
156
|
* @returns {Promise<string>} the final assistant answer (no tool block)
|
|
97
157
|
*/
|
|
98
|
-
export async function runAgent({ history, model, signal, onTool, onStatus }) {
|
|
158
|
+
export async function runAgent({ history, model, signal, onTool, onStatus, onDelta }) {
|
|
99
159
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
100
160
|
const prompt = buildPrompt(history);
|
|
101
161
|
onStatus?.("thinking");
|
|
102
|
-
|
|
162
|
+
onDelta?.({ type: "step-start" });
|
|
163
|
+
const { text } = await stream({
|
|
164
|
+
mode: "chat",
|
|
165
|
+
model,
|
|
166
|
+
message: prompt,
|
|
167
|
+
signal,
|
|
168
|
+
onDelta: (d) => onDelta?.({ type: "delta", text: d }),
|
|
169
|
+
});
|
|
170
|
+
onDelta?.({ type: "step-end" });
|
|
103
171
|
history.push({ role: "assistant", content: text });
|
|
104
172
|
|
|
105
173
|
const call = parseToolCall(text);
|
package/src/api.js
CHANGED
|
@@ -43,57 +43,89 @@ async function parseError(resp) {
|
|
|
43
43
|
* Stream a chat/merge/search request from the gateway.
|
|
44
44
|
* @returns {Promise<{text:string, reasoning:string}>}
|
|
45
45
|
*/
|
|
46
|
-
export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus }) {
|
|
46
|
+
export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000 }) {
|
|
47
47
|
const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
|
|
48
48
|
const body = mode === "search" ? { query: message } : mode === "merge" ? { message } : { message, model };
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
// Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
|
|
51
|
+
// huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
|
|
52
|
+
// của người dùng (Ctrl+C). Phân biệt 2 trường hợp qua cờ `timedOut`.
|
|
53
|
+
const ctrl = new AbortController();
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
const onUserAbort = () => ctrl.abort();
|
|
56
|
+
signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
57
|
+
let idle;
|
|
58
|
+
const arm = () => {
|
|
59
|
+
clearTimeout(idle);
|
|
60
|
+
idle = setTimeout(() => {
|
|
61
|
+
timedOut = true;
|
|
62
|
+
ctrl.abort();
|
|
63
|
+
}, idleMs);
|
|
64
|
+
};
|
|
58
65
|
|
|
59
|
-
const reader = resp.body.getReader();
|
|
60
|
-
const decoder = new TextDecoder();
|
|
61
|
-
let buf = "";
|
|
62
66
|
let text = "";
|
|
63
67
|
let reasoning = "";
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
69
|
+
// Một dòng SSE → cập nhật text/reasoning. Tách ra để dùng lại khi flush dòng cuối.
|
|
70
|
+
const processLine = (rawLine) => {
|
|
71
|
+
const line = rawLine.trim();
|
|
72
|
+
if (!line.startsWith("data:")) return;
|
|
73
|
+
const data = line.slice(5).trim();
|
|
74
|
+
if (!data) return;
|
|
75
|
+
let p;
|
|
76
|
+
try {
|
|
77
|
+
p = JSON.parse(data);
|
|
78
|
+
} catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (p.status && onStatus) onStatus(p.status);
|
|
82
|
+
if (p.delta) {
|
|
83
|
+
text += p.delta;
|
|
84
|
+
onDelta?.(p.delta);
|
|
85
|
+
}
|
|
86
|
+
if (p.reasoning) {
|
|
87
|
+
reasoning = p.reasoning;
|
|
88
|
+
onReasoning?.(p.reasoning);
|
|
89
|
+
if (p.answer) text = p.answer;
|
|
90
|
+
}
|
|
91
|
+
if (p.error) throw new ApiError(p.error, {});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
arm();
|
|
96
|
+
const resp = await fetch(config.gatewayUrl + endpoint, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: authHeaders(),
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: ctrl.signal,
|
|
101
|
+
});
|
|
102
|
+
if (!resp.ok || !resp.body) throw await parseError(resp);
|
|
103
|
+
|
|
104
|
+
const reader = resp.body.getReader();
|
|
105
|
+
const decoder = new TextDecoder();
|
|
106
|
+
let buf = "";
|
|
107
|
+
while (true) {
|
|
108
|
+
const { done, value } = await reader.read();
|
|
109
|
+
arm(); // có hoạt động → gia hạn idle
|
|
110
|
+
if (done) break;
|
|
111
|
+
buf += decoder.decode(value, { stream: true });
|
|
112
|
+
let nl;
|
|
113
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
114
|
+
processLine(buf.slice(0, nl));
|
|
115
|
+
buf = buf.slice(nl + 1);
|
|
91
116
|
}
|
|
92
|
-
if (p.error) throw new ApiError(p.error, { status: resp.status });
|
|
93
117
|
}
|
|
94
|
-
|
|
118
|
+
buf += decoder.decode(); // flush decoder
|
|
119
|
+
if (buf.trim()) processLine(buf); // dòng SSE cuối không có '\n' — đừng bỏ sót
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
return { text: text.trim(), reasoning: reasoning.trim() };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (timedOut) throw new ApiError("Kết nối tới máy chủ quá thời gian chờ (treo).", { code: "timeout" });
|
|
124
|
+
throw err;
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(idle);
|
|
127
|
+
signal?.removeEventListener("abort", onUserAbort);
|
|
128
|
+
}
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
/** Fetch the current key's quota/usage from the gateway (no request consumed). */
|
package/src/i18n.js
CHANGED
|
@@ -11,6 +11,9 @@ export const t = {
|
|
|
11
11
|
pressAgainToExit: "nhấn Ctrl+C lần nữa để thoát",
|
|
12
12
|
running: "đang chạy…",
|
|
13
13
|
denied: "đã từ chối",
|
|
14
|
+
queued: (n, txt) => `⏎ đã xếp hàng [${n}] · gửi khi model xong: ${txt}`,
|
|
15
|
+
queueCleared: (n) => `(đã xoá ${n} tin đang xếp hàng)`,
|
|
16
|
+
permRetry: "→ gõ y (đồng ý) · n (từ chối) · a (luôn cho phép)",
|
|
14
17
|
|
|
15
18
|
// auth
|
|
16
19
|
notLoggedIn:
|
|
@@ -61,8 +64,8 @@ export const t = {
|
|
|
61
64
|
cmdVersion: "/version /v xem version hiện tại + trạng thái yolo",
|
|
62
65
|
cmdExit: "/exit /quit thoát",
|
|
63
66
|
tip1: "• Mô tả việc cần làm; noob sẽ đọc/sửa file & chạy lệnh giúp bạn.",
|
|
64
|
-
tip2: "•
|
|
65
|
-
tip3: "• Ctrl+C 1 lần = dừng lượt
|
|
67
|
+
tip2: "• Đang chạy vẫn gõ tiếp được — tin sẽ xếp hàng & tự gửi khi model xong.",
|
|
68
|
+
tip3: "• Shift+Tab: bật/tắt yolo nhanh. Ctrl+C 1 lần = dừng lượt, 2 lần = thoát.",
|
|
66
69
|
|
|
67
70
|
// misc
|
|
68
71
|
yoloOn: "⚠ yolo BẬT — tự động duyệt mọi thao tác sửa file & chạy lệnh",
|
package/src/repl.js
CHANGED
|
@@ -44,9 +44,12 @@ export async function startRepl(opts = {}) {
|
|
|
44
44
|
let closed = false; // đã ngừng đọc vĩnh viễn
|
|
45
45
|
let exiting = false; // ta chủ động thoát (/exit, Ctrl+C ×2)
|
|
46
46
|
let lastPrompt = ""; // áp lại sau khi dựng lại interface
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Hàng đợi tin nhắn (giống Claude Code): khi model đang chạy, gõ thêm câu hỏi
|
|
48
|
+
// → xếp vào `pending`; xong turn, main loop tự lấy câu kế tiếp gửi lên. CHỈ
|
|
49
|
+
// main loop tiêu thụ `pending`. Câu trả lời permission đi qua `waiter` riêng,
|
|
50
|
+
// nên tin xếp hàng KHÔNG bị nhầm thành câu trả lời "cho phép?".
|
|
51
|
+
const pending = [];
|
|
52
|
+
let waiter = null; // resolver đang chờ một dòng tươi (prompt / permission)
|
|
50
53
|
let closeAt = 0;
|
|
51
54
|
|
|
52
55
|
function deliver(line) {
|
|
@@ -54,9 +57,11 @@ export async function startRepl(opts = {}) {
|
|
|
54
57
|
const w = waiter;
|
|
55
58
|
waiter = null;
|
|
56
59
|
w(line);
|
|
57
|
-
|
|
58
|
-
queue.push(line); // type-ahead / buffered — không bao giờ mất
|
|
60
|
+
return;
|
|
59
61
|
}
|
|
62
|
+
// Không ai đang hỏi → đây là tin xếp hàng cho lượt kế tiếp.
|
|
63
|
+
pending.push(line);
|
|
64
|
+
if (process.stdin.isTTY) console.log(c.dim(" " + t.queued(pending.length, truncate(line, 60))));
|
|
60
65
|
}
|
|
61
66
|
function endInput() {
|
|
62
67
|
closed = true;
|
|
@@ -70,31 +75,41 @@ export async function startRepl(opts = {}) {
|
|
|
70
75
|
const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
|
|
71
76
|
r.on("line", deliver);
|
|
72
77
|
r.on("close", () => {
|
|
73
|
-
if (exiting
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
if (++closeBurst > 8) return endInput(); // stdin mất thật → khỏi quay vòng
|
|
80
|
-
// 'close' bất thường: dựng lại interface, hiện lại prompt; reader vẫn chờ.
|
|
81
|
-
rl = buildRl();
|
|
82
|
-
if (lastPrompt) {
|
|
83
|
-
rl.setPrompt(lastPrompt);
|
|
84
|
-
rl.prompt();
|
|
78
|
+
if (exiting) return endInput(); // ta chủ động thoát
|
|
79
|
+
// EOF THẬT: stdin đã end/destroy (Ctrl+Z trên Windows, Ctrl+D trên *nix),
|
|
80
|
+
// hoặc stdin không phải TTY (piped) đã đọc hết. Chỉ lúc đó mới dừng.
|
|
81
|
+
if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
|
|
82
|
+
return endInput();
|
|
85
83
|
}
|
|
84
|
+
// 'close' BẤT THƯỜNG trên một TTY còn sống (tranh chấp console khi
|
|
85
|
+
// paste/đa dòng, tiến trình con, v.v.) → KHÔNG BAO GIỜ thoát. Dựng lại
|
|
86
|
+
// interface và hiện lại prompt; reader đang chờ vẫn được giữ nguyên. Nếu
|
|
87
|
+
// close dồn dập thì hoãn 50ms để khỏi quay CPU — nhưng vẫn sống.
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const fast = now - closeAt < 50;
|
|
90
|
+
closeAt = now;
|
|
91
|
+
const rebuild = () => {
|
|
92
|
+
rl = buildRl();
|
|
93
|
+
if (lastPrompt) {
|
|
94
|
+
rl.setPrompt(lastPrompt);
|
|
95
|
+
rl.prompt();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (fast) setTimeout(rebuild, 50);
|
|
99
|
+
else rebuild();
|
|
86
100
|
});
|
|
87
101
|
return r;
|
|
88
102
|
}
|
|
89
103
|
rl = buildRl();
|
|
90
104
|
|
|
91
105
|
function nextLine() {
|
|
92
|
-
if (queue.length) return Promise.resolve(queue.shift());
|
|
93
106
|
if (closed) return Promise.resolve(null);
|
|
94
107
|
return new Promise((res) => (waiter = res));
|
|
95
108
|
}
|
|
109
|
+
// Dòng "tươi": dùng cho prompt chính VÀ cho permission. KHÔNG đụng tới
|
|
110
|
+
// `pending` (hàng đợi tin nhắn) — chỉ main loop mới rút từ `pending`.
|
|
96
111
|
function ask(prompt) {
|
|
97
|
-
if (closed
|
|
112
|
+
if (closed) return Promise.resolve(null);
|
|
98
113
|
lastPrompt = prompt;
|
|
99
114
|
rl.setPrompt(prompt);
|
|
100
115
|
rl.prompt();
|
|
@@ -124,6 +139,11 @@ export async function startRepl(opts = {}) {
|
|
|
124
139
|
if (abort) {
|
|
125
140
|
abort.abort();
|
|
126
141
|
abort = null;
|
|
142
|
+
if (pending.length) {
|
|
143
|
+
const n = pending.length;
|
|
144
|
+
pending.length = 0; // ngắt lượt → xoá luôn tin đang xếp hàng
|
|
145
|
+
console.log(c.dim(" " + t.queueCleared(n)));
|
|
146
|
+
}
|
|
127
147
|
console.log(c.err("\n ✗ " + t.interrupted));
|
|
128
148
|
return; // the main loop will redraw the prompt
|
|
129
149
|
}
|
|
@@ -260,9 +280,16 @@ export async function startRepl(opts = {}) {
|
|
|
260
280
|
|
|
261
281
|
// Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
|
|
262
282
|
while (true) {
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
|
|
283
|
+
let input;
|
|
284
|
+
if (pending.length) {
|
|
285
|
+
// Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
|
|
286
|
+
input = (pending.shift() ?? "").trim();
|
|
287
|
+
if (process.stdin.isTTY && input) console.log(promptStr() + input);
|
|
288
|
+
} else {
|
|
289
|
+
const raw = await ask(promptStr());
|
|
290
|
+
if (raw == null) break; // stdin fully closed and drained
|
|
291
|
+
input = raw.trim();
|
|
292
|
+
}
|
|
266
293
|
if (!input) continue;
|
|
267
294
|
// Bọc cả lượt: một lỗi trong xử lý lệnh/agent không được phép thoát ra
|
|
268
295
|
// ngoài vòng lặp (sẽ rơi vào .catch ở bin/noob.js → process.exit(1) =
|
|
@@ -297,48 +324,77 @@ export async function startRepl(opts = {}) {
|
|
|
297
324
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
|
|
298
325
|
spinner.text = c.dim(`${label}… ${elapsed}s`);
|
|
299
326
|
};
|
|
327
|
+
const stopSpin = () => {
|
|
328
|
+
if (timer) {
|
|
329
|
+
clearInterval(timer);
|
|
330
|
+
timer = null;
|
|
331
|
+
}
|
|
332
|
+
if (spinner.isSpinning) spinner.stop();
|
|
333
|
+
};
|
|
334
|
+
const startSpin = (label) => {
|
|
335
|
+
if (!spinner.isSpinning) spinner.start();
|
|
336
|
+
if (!timer) timer = setInterval(() => tick(label), 200);
|
|
337
|
+
};
|
|
300
338
|
|
|
301
339
|
try {
|
|
302
340
|
if (state.mode !== "chat") {
|
|
303
|
-
|
|
304
|
-
|
|
341
|
+
const name = state.mode === "search" ? "Tìm web" : "Merge AI";
|
|
342
|
+
const label = state.mode === "search" ? t.searching : t.merging;
|
|
343
|
+
startSpin(label);
|
|
344
|
+
const printer = makeStreamPrinter(name, "#f59e0b");
|
|
305
345
|
const { text: answer } = await stream({
|
|
306
346
|
mode: state.mode,
|
|
307
347
|
message: text,
|
|
308
348
|
signal: abort.signal,
|
|
309
|
-
onStatus: (s) =>
|
|
349
|
+
onStatus: (s) => {
|
|
350
|
+
if (!printer.started) spinner.text = c.dim(" " + s);
|
|
351
|
+
},
|
|
352
|
+
onDelta: (d) => {
|
|
353
|
+
stopSpin();
|
|
354
|
+
printer.push(d);
|
|
355
|
+
},
|
|
310
356
|
});
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
printAnswer(answer,
|
|
357
|
+
stopSpin();
|
|
358
|
+
printer.flush();
|
|
359
|
+
if (!printer.started) printAnswer(answer, name, "#f59e0b");
|
|
314
360
|
return;
|
|
315
361
|
}
|
|
316
362
|
|
|
317
363
|
state.history.push({ role: "user", content: text });
|
|
318
|
-
|
|
319
|
-
|
|
364
|
+
startSpin(t.thinking);
|
|
365
|
+
let printer = null;
|
|
320
366
|
|
|
321
367
|
const answer = await runAgent({
|
|
322
368
|
history: state.history,
|
|
323
369
|
model: state.model.id,
|
|
324
370
|
signal: abort.signal,
|
|
325
371
|
onStatus: () => tick(t.thinking),
|
|
372
|
+
onDelta: (ev) => {
|
|
373
|
+
if (ev.type === "step-start") {
|
|
374
|
+
printer = makeStreamPrinter(state.model.name, providerColor(state.model.provider));
|
|
375
|
+
} else if (ev.type === "delta") {
|
|
376
|
+
if (printer.suppressing) return printer.push(ev.text); // nuốt tool JSON → để spinner chạy
|
|
377
|
+
stopSpin();
|
|
378
|
+
printer.push(ev.text);
|
|
379
|
+
if (printer.suppressing) startSpin(t.thinking); // vừa chuyển sang soạn tool
|
|
380
|
+
} else if (ev.type === "step-end") {
|
|
381
|
+
printer?.flush();
|
|
382
|
+
}
|
|
383
|
+
},
|
|
326
384
|
onTool: async (name, input) => {
|
|
327
|
-
|
|
328
|
-
spinner.stop();
|
|
385
|
+
stopSpin();
|
|
329
386
|
const res = await execTool(name, input);
|
|
330
|
-
|
|
331
|
-
timer = setInterval(() => tick(t.thinking), 200);
|
|
387
|
+
startSpin(t.thinking);
|
|
332
388
|
return res;
|
|
333
389
|
},
|
|
334
390
|
});
|
|
335
391
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
392
|
+
stopSpin();
|
|
393
|
+
// Lượt cuối không stream ra gì (vd model suy luận trả 1 cục) → in fallback.
|
|
394
|
+
if ((!printer || !printer.started) && answer?.trim())
|
|
395
|
+
printAnswer(answer, state.model.name, providerColor(state.model.provider));
|
|
339
396
|
} catch (err) {
|
|
340
|
-
|
|
341
|
-
spinner.stop();
|
|
397
|
+
stopSpin();
|
|
342
398
|
if (err.name === "AbortError") return;
|
|
343
399
|
printError(err);
|
|
344
400
|
} finally {
|
|
@@ -355,11 +411,9 @@ export async function startRepl(opts = {}) {
|
|
|
355
411
|
else if (name === "edit_file") preview(`- ${truncate(input.old_string)}\n+ ${truncate(input.new_string)}`, input.path);
|
|
356
412
|
|
|
357
413
|
if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
|
|
358
|
-
const a =
|
|
359
|
-
.trim()
|
|
360
|
-
.toLowerCase();
|
|
414
|
+
const a = await askPermission(name);
|
|
361
415
|
if (a === "a") state.autoApprove.add(name);
|
|
362
|
-
else if (a
|
|
416
|
+
else if (a === "n") {
|
|
363
417
|
console.log(c.err(" " + t.denied));
|
|
364
418
|
return { allow: false };
|
|
365
419
|
}
|
|
@@ -378,6 +432,27 @@ export async function startRepl(opts = {}) {
|
|
|
378
432
|
}
|
|
379
433
|
}
|
|
380
434
|
|
|
435
|
+
// Đọc câu trả lời cho phép một cách CHẮC CHẮN. Chỉ chấp nhận y/n/a (hoặc
|
|
436
|
+
// Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
|
|
437
|
+
// bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
|
|
438
|
+
// "tự tắt" trước đây) — thì KHÔNG coi là từ chối: xếp nó vào hàng đợi tin
|
|
439
|
+
// nhắn rồi HỎI LẠI. Nhờ vậy không thao tác nào bị quyết định bởi rác.
|
|
440
|
+
async function askPermission(name) {
|
|
441
|
+
while (true) {
|
|
442
|
+
const raw = await ask(c.tool(" cho phép? ") + c.dim("[y] có / [n] không / [a] luôn " + name + " › "));
|
|
443
|
+
if (raw == null) return "n"; // stdin đóng thật
|
|
444
|
+
const a = raw.trim().toLowerCase();
|
|
445
|
+
if (a === "" || a === "y" || a === "yes" || a === "có") return "y";
|
|
446
|
+
if (a === "n" || a === "no" || a === "không") return "n";
|
|
447
|
+
if (a === "a" || a === "always" || a === "luôn") return "a";
|
|
448
|
+
if (raw.trim().length > 3) {
|
|
449
|
+
pending.push(raw); // tin nhắn lạc → xếp hàng, gửi sau khi xong lượt
|
|
450
|
+
console.log(c.dim(" " + t.queued(pending.length, truncate(raw, 60))));
|
|
451
|
+
}
|
|
452
|
+
console.log(c.dim(" " + t.permRetry));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
381
456
|
// ── slash commands ─────────────────────────────────────────────────────
|
|
382
457
|
async function command(input) {
|
|
383
458
|
const [cmd, ...rest] = input.slice(1).split(/\s+/);
|
|
@@ -540,6 +615,56 @@ function printAnswer(text, name, color) {
|
|
|
540
615
|
);
|
|
541
616
|
}
|
|
542
617
|
|
|
618
|
+
// In câu trả lời theo dòng token thời gian thực. Vì model emit lời + (tuỳ chọn)
|
|
619
|
+
// MỘT khối ```tool ở cuối, ta giấu mọi thứ từ ```tool trở đi (người dùng chỉ
|
|
620
|
+
// thấy phần lời + hoạt động công cụ riêng). Giữ lại đuôi vài ký tự để không in
|
|
621
|
+
// nửa vời "```to" trước khi kịp nhận ra đó là fence.
|
|
622
|
+
function makeStreamPrinter(name, color) {
|
|
623
|
+
let buf = "";
|
|
624
|
+
let printed = 0;
|
|
625
|
+
let suppress = false;
|
|
626
|
+
let started = false;
|
|
627
|
+
let header = false;
|
|
628
|
+
const HOLD = 8;
|
|
629
|
+
const write = (s) => {
|
|
630
|
+
if (!s) return;
|
|
631
|
+
if (!header) {
|
|
632
|
+
process.stdout.write("\n" + chalk.hex(color).bold(" ● " + name) + "\n ");
|
|
633
|
+
header = true;
|
|
634
|
+
}
|
|
635
|
+
process.stdout.write(s.replace(/\n/g, "\n "));
|
|
636
|
+
started = true;
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
get started() {
|
|
640
|
+
return started;
|
|
641
|
+
},
|
|
642
|
+
get suppressing() {
|
|
643
|
+
return suppress;
|
|
644
|
+
},
|
|
645
|
+
push(delta) {
|
|
646
|
+
buf += delta;
|
|
647
|
+
if (suppress) return;
|
|
648
|
+
const f = buf.indexOf("```tool");
|
|
649
|
+
if (f !== -1) {
|
|
650
|
+
write(buf.slice(printed, f));
|
|
651
|
+
printed = buf.length;
|
|
652
|
+
suppress = true;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const safeEnd = Math.max(printed, buf.length - HOLD);
|
|
656
|
+
if (safeEnd > printed) {
|
|
657
|
+
write(buf.slice(printed, safeEnd));
|
|
658
|
+
printed = safeEnd;
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
flush() {
|
|
662
|
+
if (!suppress && printed < buf.length) write(buf.slice(printed));
|
|
663
|
+
if (started) process.stdout.write("\n");
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
543
668
|
function printError(err) {
|
|
544
669
|
const map = {
|
|
545
670
|
missing_key: t.errMissingKey,
|