@noobdemon/noob-cli 1.1.0 → 1.1.4

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.1.0",
3
+ "version": "1.1.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -55,7 +55,7 @@ Có — cả 12 test đều pass.
55
55
 
56
56
  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.`;
57
57
 
58
- const MAX_STEPS = 30;
58
+ const MAX_STEPS = 300;
59
59
  const MAX_PROMPT_CHARS = 80000; // ngân sách ký tự cho phần hội thoại gửi lên model
60
60
 
61
61
  // Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
@@ -177,8 +177,13 @@ export function parseToolCall(text) {
177
177
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
178
178
  * @returns {Promise<string>} the final assistant answer (no tool block)
179
179
  */
180
- export async function runAgent({ history, model, signal, onTool, onStatus, onDelta }) {
180
+ export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer }) {
181
181
  for (let step = 0; step < MAX_STEPS; step++) {
182
+ // Steering: tin nhắn người dùng gõ GIỮA CHỪNG được chèn vào hội thoại TRƯỚC
183
+ // lần gọi model kế tiếp → model thấy và điều chỉnh ngay trong cùng task.
184
+ const steer = onSteer?.() || [];
185
+ for (const msg of steer) history.push({ role: "user", content: msg });
186
+
182
187
  const prompt = buildPrompt(history);
183
188
  onStatus?.("thinking");
184
189
  onDelta?.({ type: "step-start" });
package/src/i18n.js CHANGED
@@ -13,6 +13,9 @@ export const t = {
13
13
  denied: "đã từ chối",
14
14
  queued: (n, txt) => `⏎ đã xếp hàng [${n}] · gửi khi model xong: ${txt}`,
15
15
  queueCleared: (n) => `(đã xoá ${n} tin đang xếp hàng)`,
16
+ steerHint: "💬 Gõ + Enter bất cứ lúc nào để chèn ý cho AI giữa chừng (không ngắt task đang chạy).",
17
+ steerWillInject: (txt) => `💬 sẽ chèn cho AI ở bước tới: ${txt}`,
18
+ steerInject: (txt) => `💬 đã chèn cho AI: ${txt}`,
16
19
  permRetry: "→ gõ y (đồng ý) · n (từ chối) · a (luôn cho phép)",
17
20
 
18
21
  // auth
@@ -54,6 +57,7 @@ export const t = {
54
57
  cmdSearch: "/search bật/tắt chế độ tìm web",
55
58
  cmdChat: "/chat quay lại chế độ chat thường",
56
59
  cmdYolo: "/yolo bật/tắt tự duyệt (hoặc nhấn Shift+Tab)",
60
+ cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
57
61
  cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
58
62
  cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
59
63
  cmdSessions: "/sessions liệt kê các phiên đã lưu",
package/src/repl.js CHANGED
@@ -59,9 +59,11 @@ export async function startRepl(opts = {}) {
59
59
  w(line);
60
60
  return;
61
61
  }
62
- // Không ai đang hỏi → đây là tin xếp hàng cho lượt kế tiếp.
62
+ // Không ai đang hỏi → tin xếp hàng. Nếu đang chạy task → sẽ CHÈN cho AI
63
+ // bước kế tiếp (steering). Nếu rảnh → gửi như lượt mới khi tới phiên.
63
64
  pending.push(line);
64
- if (process.stdin.isTTY) console.log(c.dim(" " + t.queued(pending.length, truncate(line, 60))));
65
+ if (process.stdin.isTTY)
66
+ console.log(abort ? c.user(" " + t.steerWillInject(truncate(line, 60))) : c.dim(" " + t.queued(pending.length, truncate(line, 60))));
65
67
  }
66
68
  function endInput() {
67
69
  closed = true;
@@ -74,6 +76,10 @@ export async function startRepl(opts = {}) {
74
76
  function buildRl() {
75
77
  const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
76
78
  r.on("line", deliver);
79
+ // readline ở chế độ terminal phát 'SIGINT' của RIÊNG nó (không cho process
80
+ // SIGINT chạy) — nối vào cùng một handler. interrupt() được hoist nên gọi
81
+ // được dù khai báo phía dưới.
82
+ r.on("SIGINT", () => interrupt());
77
83
  r.on("close", () => {
78
84
  if (exiting) return endInput(); // ta chủ động thoát
79
85
  // EOF THẬT: stdin đã end/destroy (Ctrl+Z trên Windows, Ctrl+D trên *nix),
@@ -122,7 +128,10 @@ export async function startRepl(opts = {}) {
122
128
  try {
123
129
  readline.emitKeypressEvents(process.stdin);
124
130
  process.stdin.on("keypress", (_str, key) => {
125
- if (!key || key.name !== "tab" || !key.shift) return;
131
+ if (!key) return;
132
+ // Ctrl+C dạng phím thô — ở raw mode, Ctrl+C KHÔNG tự thành SIGINT nữa.
133
+ if (key.ctrl && key.name === "c") return interrupt();
134
+ if (key.name !== "tab" || !key.shift) return;
126
135
  state.yolo = !state.yolo;
127
136
  console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
128
137
  rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
@@ -143,7 +152,19 @@ export async function startRepl(opts = {}) {
143
152
 
144
153
  let abort = null; // active turn controller
145
154
  let sigintArmed = false;
146
- process.on("SIGINT", () => {
155
+ let sigintTimer = null;
156
+ let lastInterrupt = 0;
157
+
158
+ // Ctrl+C — một đường xử lý duy nhất, gọi từ 3 nguồn (xem bên dưới) vì readline
159
+ // ở chế độ terminal/raw NUỐT SIGINT của process trên Windows. Debounce 80ms để
160
+ // gộp các nguồn trùng nhau thành MỘT lần nhấn.
161
+ // • đang chạy task → 1 lần = NGỪNG task (không thoát)
162
+ // • đang rảnh → 1 lần = đếm 1.5s; thêm 1 lần trong 1.5s = THOÁT CLI
163
+ function interrupt() {
164
+ const now = Date.now();
165
+ if (now - lastInterrupt < 80) return; // gộp SIGINT + 'SIGINT' của rl + keypress
166
+ lastInterrupt = now;
167
+
147
168
  if (abort) {
148
169
  abort.abort();
149
170
  abort = null;
@@ -153,17 +174,33 @@ export async function startRepl(opts = {}) {
153
174
  console.log(c.dim(" " + t.queueCleared(n)));
154
175
  }
155
176
  console.log(c.err("\n ✗ " + t.interrupted));
156
- return; // the main loop will redraw the prompt
177
+ sigintArmed = false;
178
+ if (sigintTimer) {
179
+ clearTimeout(sigintTimer);
180
+ sigintTimer = null;
181
+ }
182
+ if (!closed) rl.prompt(true);
183
+ return;
157
184
  }
185
+
158
186
  if (sigintArmed) {
187
+ // lần 2 trong cửa sổ 1.5s → thoát
188
+ if (sigintTimer) clearTimeout(sigintTimer);
189
+ exiting = true;
190
+ persist();
159
191
  console.log(c.dim("\n " + t.bye));
160
192
  process.exit(0);
161
193
  }
194
+ // lần 1 → vũ trang, đếm 1.5s
162
195
  sigintArmed = true;
163
196
  console.log(c.dim("\n " + t.pressAgainToExit));
164
- setTimeout(() => (sigintArmed = false), 2000);
165
- rl.prompt(true);
166
- });
197
+ sigintTimer = setTimeout(() => {
198
+ sigintArmed = false;
199
+ sigintTimer = null;
200
+ }, 1500);
201
+ if (!closed) rl.prompt(true);
202
+ }
203
+ process.on("SIGINT", interrupt);
167
204
 
168
205
  // Đừng để một lỗi bất ngờ làm "tự động tắt" CLI. Nguyên nhân hay gặp:
169
206
  // tiến trình cập nhật nền (spawn npm) phát sự kiện 'error' không ai bắt,
@@ -242,6 +279,24 @@ export async function startRepl(opts = {}) {
242
279
  }
243
280
  const startFresh = () => (session = sessions.newSession({ cwd: process.cwd(), model: state.model.id }));
244
281
 
282
+ // /karpathy [path] — bắt noob tự rà soát code theo 4 nguyên tắc Karpathy.
283
+ // Không có path → soát các file đã đổi trong phiên (model thấy qua FILES CHANGED).
284
+ async function runKarpathy(arg) {
285
+ if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
286
+ const target = arg ? `file/đường dẫn: ${arg}` : "các file bạn đã tạo/sửa trong phiên này (xem mục FILES CHANGED)";
287
+ const prompt = `Đóng vai reviewer khó tính. Rà soát ${target} theo 4 nguyên tắc code của Karpathy.
288
+ ĐỌC nội dung file thật bằng read_file trước — KHÔNG dựa vào trí nhớ.
289
+ Với MỖI nguyên tắc, cho verdict (✅ đạt / ⚠️ cảnh báo / ❌ vi phạm) + phát hiện cụ thể kèm "tên_file:dòng":
290
+ 1. THINK FIRST — giả định ẩn nào chưa nêu? chỗ nào thiếu kiểm chứng?
291
+ 2. KEEP IT SIMPLE — over-engineer, abstraction thừa, lồng quá sâu, hàm quá dài?
292
+ 3. SURGICAL — thay đổi lạc đề, refactor tiện tay, đổi style/format vô cớ?
293
+ 4. VERIFIABLE GOAL — mục tiêu có kiểm chứng được? đã chạy build/test chưa?
294
+ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng thắn, KHÔNG nịnh.`;
295
+ console.log(c.tool(" ⚖ Karpathy check…"));
296
+ await handle(prompt);
297
+ persist();
298
+ }
299
+
245
300
  banner();
246
301
  printStatus(state);
247
302
  if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
@@ -374,6 +429,10 @@ export async function startRepl(opts = {}) {
374
429
  }
375
430
 
376
431
  state.history.push({ role: "user", content: text });
432
+ if (process.stdin.isTTY && !state.steerHintShown) {
433
+ console.log(c.dim(" " + t.steerHint));
434
+ state.steerHintShown = true;
435
+ }
377
436
  startSpin(t.thinking);
378
437
  let printer = null;
379
438
 
@@ -382,6 +441,14 @@ export async function startRepl(opts = {}) {
382
441
  model: state.model.id,
383
442
  signal: abort.signal,
384
443
  onStatus: () => tick(t.thinking),
444
+ onSteer: () => {
445
+ if (!pending.length) return [];
446
+ const msgs = pending.splice(0);
447
+ stopSpin(); // in sạch dòng chèn rồi cho spinner chạy lại
448
+ for (const msg of msgs) console.log(c.user(" " + t.steerInject(truncate(msg, 70))));
449
+ startSpin(t.thinking);
450
+ return msgs;
451
+ },
385
452
  onDelta: (ev) => {
386
453
  if (ev.type === "step-start") {
387
454
  printer = makeStreamPrinter(state.model.name, providerColor(state.model.provider));
@@ -496,6 +563,11 @@ export async function startRepl(opts = {}) {
496
563
  state.yolo = !state.yolo;
497
564
  console.log((state.yolo ? c.err : c.ok)(" " + (state.yolo ? t.yoloOn : t.yoloOff)));
498
565
  break;
566
+ case "karpathy":
567
+ case "kcheck":
568
+ case "kc":
569
+ await runKarpathy(arg);
570
+ break;
499
571
  case "login":
500
572
  doLogin(arg);
501
573
  break;
@@ -718,6 +790,7 @@ function printHelp() {
718
790
  " " + t.cmdSearch,
719
791
  " " + t.cmdChat,
720
792
  " " + t.cmdYolo,
793
+ " " + t.cmdKarpathy,
721
794
  " " + t.cmdLogin,
722
795
  " " + t.cmdLogout,
723
796
  " " + t.cmdUsage,
package/src/tools.js CHANGED
@@ -39,47 +39,44 @@ export const TOOLS = {
39
39
  const file = abs(p);
40
40
  const data = await fs.readFile(file, "utf8");
41
41
  if (old_string === new_string) throw new Error("old_string and new_string are identical");
42
+ const useCRLF = data.includes("\r\n");
43
+ const adapt = (s) => {
44
+ const lf = s.replace(/\r\n/g, "\n");
45
+ return useCRLF ? lf.replace(/\n/g, "\r\n") : lf;
46
+ };
47
+ // Thay không diễn giải $&/$1… (split/slice, không dùng String.replace).
48
+ const applyExact = (cand, newS) => {
49
+ if (replace_all) return data.split(cand).join(newS);
50
+ const i = data.indexOf(cand);
51
+ return data.slice(0, i) + newS + data.slice(i + cand.length);
52
+ };
42
53
 
43
- let oldS = old_string;
44
- let newS = new_string;
45
- let count = data.split(oldS).length - 1;
46
-
47
- // Khoan dung CRLF: file Windows thường dùng \r\n, nhưng model hay gửi \n →
48
- // khớp hụt. Điều chỉnh kiểu xuống dòng của old/new cho khớp file RỒI thay
49
- // trên data gốc (file giữ nguyên vẹn).
50
- if (count === 0) {
51
- const useCRLF = data.includes("\r\n");
52
- const adapt = (s) => {
53
- const lf = s.replace(/\r\n/g, "\n");
54
- return useCRLF ? lf.replace(/\n/g, "\r\n") : lf;
55
- };
56
- const a = adapt(oldS);
57
- const c2 = data.split(a).length - 1;
58
- if (c2 > 0) {
59
- oldS = a;
60
- newS = adapt(newS);
61
- count = c2;
62
- }
54
+ // Tier 1 + 2: khớp NGUYÊN VĂN, rồi khớp sau khi chỉnh CRLF cho hợp file.
55
+ for (const cand of old_string === adapt(old_string) ? [old_string] : [old_string, adapt(old_string)]) {
56
+ const count = data.split(cand).length - 1;
57
+ if (count === 0) continue;
58
+ if (count > 1 && !replace_all)
59
+ throw new Error(`old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`);
60
+ await fs.writeFile(file, applyExact(cand, cand === old_string ? new_string : adapt(new_string)), "utf8");
61
+ return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
63
62
  }
64
63
 
65
- if (count === 0)
66
- throw new Error(
67
- `old_string not found in ${rel(file)}. Read the file again with read_file and copy the target text EXACTLY — keep its indentation/whitespace and DROP the line-number prefix that read_file prints.`,
68
- );
69
- if (count > 1 && !replace_all)
70
- throw new Error(`old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`);
71
-
72
- // split/join (không dùng String.replace) để chuỗi thay thế chứa $&, $1… KHÔNG
73
- // bị diễn giải đặc biệt — bảo toàn nguyên văn code.
74
- let next;
75
- if (replace_all) {
76
- next = data.split(oldS).join(newS);
77
- } else {
78
- const i = data.indexOf(oldS);
79
- next = data.slice(0, i) + newS + data.slice(i + oldS.length);
64
+ // Tier 3: khoan dung khoảng trắng CUỐI dòng + CRLF — khớp theo khối DÒNG.
65
+ // (Thụt đầu dòng vẫn phải khớp → không bao giờ sửa nhầm chỗ.)
66
+ const m = matchByLines(data, old_string);
67
+ if (m && m.count > 1 && !replace_all)
68
+ throw new Error(`old_string khớp ${m.count} chỗ (sau khi bỏ qua khoảng trắng cuối dòng) trong ${rel(file)}; thêm dòng ngữ cảnh hoặc dùng replace_all`);
69
+ if (m && m.count === 1) {
70
+ const next = data.slice(0, m.start) + adapt(new_string) + data.slice(m.end);
71
+ await fs.writeFile(file, next, "utf8");
72
+ return `Edited ${rel(file)} (1 replacement(s))`;
80
73
  }
81
- await fs.writeFile(file, next, "utf8");
82
- return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
74
+
75
+ // Không thấy lỗi GIÀU THÔNG TIN: cho model thấy đúng byte trong file để sửa.
76
+ throw new Error(
77
+ `old_string not found in ${rel(file)}. Copy the target text EXACTLY (indentation/whitespace included, NO line-number prefix).` +
78
+ nearbyContext(data, old_string),
79
+ );
83
80
  },
84
81
 
85
82
  async list_dir({ path: p = "." }) {
@@ -174,6 +171,71 @@ export const TOOLS = {
174
171
  },
175
172
  };
176
173
 
174
+ // Khớp old_string theo KHỐI DÒNG, bỏ qua khác biệt CRLF và khoảng trắng CUỐI
175
+ // dòng. Trả {count, start, end} (offset ký tự trong data) khi khớp đúng 1 chỗ,
176
+ // {count>1} khi nhập nhằng, hoặc null khi không thấy.
177
+ function matchByLines(data, oldStr) {
178
+ const trim = (l) => l.replace(/\r$/, "").replace(/[ \t]+$/, "");
179
+ const fileLines = data.split("\n");
180
+ const oldLines = oldStr.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
181
+ if (!oldLines.length) return null;
182
+ const nFile = fileLines.map(trim);
183
+ const nOld = oldLines.map(trim);
184
+ const offsets = [];
185
+ let acc = 0;
186
+ for (const l of fileLines) {
187
+ offsets.push(acc);
188
+ acc += l.length + 1; // +1 cho '\n' đã tách
189
+ }
190
+ const hits = [];
191
+ for (let i = 0; i + nOld.length <= nFile.length; i++) {
192
+ let ok = true;
193
+ for (let j = 0; j < nOld.length; j++)
194
+ if (nFile[i + j] !== nOld[j]) {
195
+ ok = false;
196
+ break;
197
+ }
198
+ if (ok) hits.push(i);
199
+ }
200
+ if (hits.length === 0) return null;
201
+ if (hits.length > 1) return { count: hits.length };
202
+ const i = hits[0];
203
+ const last = i + nOld.length - 1;
204
+ return { count: 1, start: offsets[i], end: offsets[last] + fileLines[last].length };
205
+ }
206
+
207
+ // Khi không khớp: in vùng file gần dòng giống nhất, dạng JSON-escaped để model
208
+ // thấy rõ tab/space → sửa old_string cho khớp ngay lần sau.
209
+ function nearbyContext(data, oldStr) {
210
+ const want = (oldStr.replace(/\r\n/g, "\n").split("\n").find((l) => l.trim()) || "").trim();
211
+ if (!want) return "";
212
+ const lines = data.replace(/\r\n/g, "\n").split("\n");
213
+ // Dòng giống nhất = tiền tố chung dài nhất với `want` (sau khi trim).
214
+ const commonPrefix = (a, b) => {
215
+ let i = 0;
216
+ const n = Math.min(a.length, b.length);
217
+ while (i < n && a[i] === b[i]) i++;
218
+ return i;
219
+ };
220
+ let hit = -1;
221
+ let best = 0;
222
+ for (let i = 0; i < lines.length; i++) {
223
+ const s = commonPrefix(lines[i].trim(), want);
224
+ if (s > best) {
225
+ best = s;
226
+ hit = i;
227
+ }
228
+ }
229
+ if (hit < 0 || best < 6) return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
230
+ const a = Math.max(0, hit - 2);
231
+ const b = Math.min(lines.length, hit + 3);
232
+ const snippet = lines
233
+ .slice(a, b)
234
+ .map((l, k) => ` ${a + k + 1}: ${JSON.stringify(l)}`)
235
+ .join("\n");
236
+ return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}`;
237
+ }
238
+
177
239
  function globToRegExp(glob) {
178
240
  let rx = "";
179
241
  for (let i = 0; i < glob.length; i++) {