@noobdemon/noob-cli 1.0.7 → 1.0.9

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.0.7",
3
+ "version": "1.0.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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
- how many lines are in app.js?
44
+ do the tests pass?
38
45
  ## ASSISTANT
39
46
  \`\`\`tool
40
- {"name": "run_command", "input": {"command": "wc -l app.js"}}
47
+ {"name": "run_command", "input": {"command": "npm test"}}
41
48
  \`\`\`
42
49
  ## TOOL RESULT (run_command)
43
- 42 app.js
50
+ 12 passing (340ms)
44
51
  [exit code 0]
45
52
  ## ASSISTANT
46
- app.js has 42 lines.
53
+ 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 parts = [SYSTEM, "", "=".repeat(60), "# CONVERSATION", ""];
55
- for (const m of history) {
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
- const { text } = await stream({ mode: "chat", model, message: prompt, signal });
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
- const resp = await fetch(config.gatewayUrl + endpoint, {
51
- method: "POST",
52
- headers: authHeaders(),
53
- body: JSON.stringify(body),
54
- signal,
55
- });
56
-
57
- if (!resp.ok || !resp.body) throw await parseError(resp);
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
- while (true) {
66
- const { done, value } = await reader.read();
67
- if (done) break;
68
- buf += decoder.decode(value, { stream: true });
69
- let nl;
70
- while ((nl = buf.indexOf("\n")) !== -1) {
71
- const line = buf.slice(0, nl).trim();
72
- buf = buf.slice(nl + 1);
73
- if (!line.startsWith("data:")) continue;
74
- const data = line.slice(5).trim();
75
- if (!data) continue;
76
- let p;
77
- try {
78
- p = JSON.parse(data);
79
- } catch {
80
- continue;
81
- }
82
- if (p.status && onStatus) onStatus(p.status);
83
- if (p.delta) {
84
- text += p.delta;
85
- onDelta?.(p.delta);
86
- }
87
- if (p.reasoning) {
88
- reasoning = p.reasoning;
89
- onReasoning?.(p.reasoning);
90
- if (p.answer) text = p.answer;
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
- return { text: text.trim(), reasoning: reasoning.trim() };
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/repl.js CHANGED
@@ -133,6 +133,14 @@ export async function startRepl(opts = {}) {
133
133
  }
134
134
  }
135
135
 
136
+ // NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
137
+ // "event loop cạn"). 'beforeExit' nổ = loop cạn (stdin chết). 'exit' = thoát.
138
+ if (process.env.NOOB_DEBUG === "1") {
139
+ process.stderr.write(` [debug] isTTY=${process.stdin.isTTY} platform=${process.platform} node=${process.version}\n`);
140
+ process.on("beforeExit", (code) => process.stderr.write(` [debug] beforeExit code=${code} — EVENT LOOP CẠN (stdin chết)\n`));
141
+ process.on("exit", (code) => process.stderr.write(` [debug] exit code=${code} closed=${closed} exiting=${exiting}\n`));
142
+ }
143
+
136
144
  let abort = null; // active turn controller
137
145
  let sigintArmed = false;
138
146
  process.on("SIGINT", () => {
@@ -317,55 +325,89 @@ export async function startRepl(opts = {}) {
317
325
  return;
318
326
  }
319
327
  abort = new AbortController();
320
- const spinner = ora({ color: "magenta", spinner: "dots" });
328
+ // discardStdin:false TỐI QUAN TRỌNG. Mặc định ora chiếm stdin (raw mode +
329
+ // pause) để "nuốt" input khi quay. Trên Windows nó KHÔNG khôi phục sạch khi
330
+ // stop → stdin chết → prompt "cho phép?" hiện ra rồi event loop cạn → CLI tự
331
+ // out; và Ctrl+C không thành SIGINT (phải spam mới thoát). Tắt hẳn để ora
332
+ // đừng đụng stdin — readline tự quản.
333
+ const spinner = ora({ color: "magenta", spinner: "dots", discardStdin: false });
321
334
  const t0 = Date.now();
322
335
  let timer = null;
323
336
  const tick = (label) => {
324
337
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
325
338
  spinner.text = c.dim(`${label}… ${elapsed}s`);
326
339
  };
340
+ const stopSpin = () => {
341
+ if (timer) {
342
+ clearInterval(timer);
343
+ timer = null;
344
+ }
345
+ if (spinner.isSpinning) spinner.stop();
346
+ };
347
+ const startSpin = (label) => {
348
+ if (!spinner.isSpinning) spinner.start();
349
+ if (!timer) timer = setInterval(() => tick(label), 200);
350
+ };
327
351
 
328
352
  try {
329
353
  if (state.mode !== "chat") {
330
- spinner.start();
331
- timer = setInterval(() => tick(state.mode === "search" ? t.searching : t.merging), 200);
354
+ const name = state.mode === "search" ? "Tìm web" : "Merge AI";
355
+ const label = state.mode === "search" ? t.searching : t.merging;
356
+ startSpin(label);
357
+ const printer = makeStreamPrinter(name, "#f59e0b");
332
358
  const { text: answer } = await stream({
333
359
  mode: state.mode,
334
360
  message: text,
335
361
  signal: abort.signal,
336
- onStatus: (s) => (spinner.text = c.dim(" " + s)),
362
+ onStatus: (s) => {
363
+ if (!printer.started) spinner.text = c.dim(" " + s);
364
+ },
365
+ onDelta: (d) => {
366
+ stopSpin();
367
+ printer.push(d);
368
+ },
337
369
  });
338
- clearInterval(timer);
339
- spinner.stop();
340
- printAnswer(answer, state.mode === "search" ? "Tìm web" : "Merge AI", "#f59e0b");
370
+ stopSpin();
371
+ printer.flush();
372
+ if (!printer.started) printAnswer(answer, name, "#f59e0b");
341
373
  return;
342
374
  }
343
375
 
344
376
  state.history.push({ role: "user", content: text });
345
- spinner.start();
346
- timer = setInterval(() => tick(t.thinking), 200);
377
+ startSpin(t.thinking);
378
+ let printer = null;
347
379
 
348
380
  const answer = await runAgent({
349
381
  history: state.history,
350
382
  model: state.model.id,
351
383
  signal: abort.signal,
352
384
  onStatus: () => tick(t.thinking),
385
+ onDelta: (ev) => {
386
+ if (ev.type === "step-start") {
387
+ printer = makeStreamPrinter(state.model.name, providerColor(state.model.provider));
388
+ } else if (ev.type === "delta") {
389
+ if (printer.suppressing) return printer.push(ev.text); // nuốt tool JSON → để spinner chạy
390
+ stopSpin();
391
+ printer.push(ev.text);
392
+ if (printer.suppressing) startSpin(t.thinking); // vừa chuyển sang soạn tool
393
+ } else if (ev.type === "step-end") {
394
+ printer?.flush();
395
+ }
396
+ },
353
397
  onTool: async (name, input) => {
354
- clearInterval(timer);
355
- spinner.stop();
398
+ stopSpin();
356
399
  const res = await execTool(name, input);
357
- spinner.start();
358
- timer = setInterval(() => tick(t.thinking), 200);
400
+ startSpin(t.thinking);
359
401
  return res;
360
402
  },
361
403
  });
362
404
 
363
- clearInterval(timer);
364
- spinner.stop();
365
- printAnswer(answer, state.model.name, providerColor(state.model.provider));
405
+ stopSpin();
406
+ // Lượt cuối không stream ra gì (vd model suy luận trả 1 cục) → in fallback.
407
+ if ((!printer || !printer.started) && answer?.trim())
408
+ printAnswer(answer, state.model.name, providerColor(state.model.provider));
366
409
  } catch (err) {
367
- clearInterval(timer);
368
- spinner.stop();
410
+ stopSpin();
369
411
  if (err.name === "AbortError") return;
370
412
  printError(err);
371
413
  } finally {
@@ -390,7 +432,7 @@ export async function startRepl(opts = {}) {
390
432
  }
391
433
  }
392
434
 
393
- const sp = ora({ text: c.dim(" " + t.running), color: "yellow" }).start();
435
+ const sp = ora({ text: c.dim(" " + t.running), color: "yellow", discardStdin: false }).start();
394
436
  try {
395
437
  const result = await runTool(name, input);
396
438
  sp.stop();
@@ -527,7 +569,7 @@ export async function startRepl(opts = {}) {
527
569
 
528
570
  async function showUsage() {
529
571
  if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
530
- const sp = ora({ text: c.dim(" ..."), color: "magenta" }).start();
572
+ const sp = ora({ text: c.dim(" ..."), color: "magenta", discardStdin: false }).start();
531
573
  try {
532
574
  const u = await usage();
533
575
  sp.stop();
@@ -586,6 +628,56 @@ function printAnswer(text, name, color) {
586
628
  );
587
629
  }
588
630
 
631
+ // In câu trả lời theo dòng token thời gian thực. Vì model emit lời + (tuỳ chọn)
632
+ // MỘT khối ```tool ở cuối, ta giấu mọi thứ từ ```tool trở đi (người dùng chỉ
633
+ // 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
634
+ // nửa vời "```to" trước khi kịp nhận ra đó là fence.
635
+ function makeStreamPrinter(name, color) {
636
+ let buf = "";
637
+ let printed = 0;
638
+ let suppress = false;
639
+ let started = false;
640
+ let header = false;
641
+ const HOLD = 8;
642
+ const write = (s) => {
643
+ if (!s) return;
644
+ if (!header) {
645
+ process.stdout.write("\n" + chalk.hex(color).bold(" ● " + name) + "\n ");
646
+ header = true;
647
+ }
648
+ process.stdout.write(s.replace(/\n/g, "\n "));
649
+ started = true;
650
+ };
651
+ return {
652
+ get started() {
653
+ return started;
654
+ },
655
+ get suppressing() {
656
+ return suppress;
657
+ },
658
+ push(delta) {
659
+ buf += delta;
660
+ if (suppress) return;
661
+ const f = buf.indexOf("```tool");
662
+ if (f !== -1) {
663
+ write(buf.slice(printed, f));
664
+ printed = buf.length;
665
+ suppress = true;
666
+ return;
667
+ }
668
+ const safeEnd = Math.max(printed, buf.length - HOLD);
669
+ if (safeEnd > printed) {
670
+ write(buf.slice(printed, safeEnd));
671
+ printed = safeEnd;
672
+ }
673
+ },
674
+ flush() {
675
+ if (!suppress && printed < buf.length) write(buf.slice(printed));
676
+ if (started) process.stdout.write("\n");
677
+ },
678
+ };
679
+ }
680
+
589
681
  function printError(err) {
590
682
  const map = {
591
683
  missing_key: t.errMissingKey,