@noobdemon/noob-cli 1.0.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
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
@@ -324,48 +324,77 @@ export async function startRepl(opts = {}) {
324
324
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
325
325
  spinner.text = c.dim(`${label}… ${elapsed}s`);
326
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
+ };
327
338
 
328
339
  try {
329
340
  if (state.mode !== "chat") {
330
- spinner.start();
331
- timer = setInterval(() => tick(state.mode === "search" ? t.searching : t.merging), 200);
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");
332
345
  const { text: answer } = await stream({
333
346
  mode: state.mode,
334
347
  message: text,
335
348
  signal: abort.signal,
336
- onStatus: (s) => (spinner.text = c.dim(" " + s)),
349
+ onStatus: (s) => {
350
+ if (!printer.started) spinner.text = c.dim(" " + s);
351
+ },
352
+ onDelta: (d) => {
353
+ stopSpin();
354
+ printer.push(d);
355
+ },
337
356
  });
338
- clearInterval(timer);
339
- spinner.stop();
340
- printAnswer(answer, state.mode === "search" ? "Tìm web" : "Merge AI", "#f59e0b");
357
+ stopSpin();
358
+ printer.flush();
359
+ if (!printer.started) printAnswer(answer, name, "#f59e0b");
341
360
  return;
342
361
  }
343
362
 
344
363
  state.history.push({ role: "user", content: text });
345
- spinner.start();
346
- timer = setInterval(() => tick(t.thinking), 200);
364
+ startSpin(t.thinking);
365
+ let printer = null;
347
366
 
348
367
  const answer = await runAgent({
349
368
  history: state.history,
350
369
  model: state.model.id,
351
370
  signal: abort.signal,
352
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
+ },
353
384
  onTool: async (name, input) => {
354
- clearInterval(timer);
355
- spinner.stop();
385
+ stopSpin();
356
386
  const res = await execTool(name, input);
357
- spinner.start();
358
- timer = setInterval(() => tick(t.thinking), 200);
387
+ startSpin(t.thinking);
359
388
  return res;
360
389
  },
361
390
  });
362
391
 
363
- clearInterval(timer);
364
- spinner.stop();
365
- printAnswer(answer, state.model.name, providerColor(state.model.provider));
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));
366
396
  } catch (err) {
367
- clearInterval(timer);
368
- spinner.stop();
397
+ stopSpin();
369
398
  if (err.name === "AbortError") return;
370
399
  printError(err);
371
400
  } finally {
@@ -586,6 +615,56 @@ function printAnswer(text, name, color) {
586
615
  );
587
616
  }
588
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
+
589
668
  function printError(err) {
590
669
  const map = {
591
670
  missing_key: t.errMissingKey,