@noobdemon/noob-cli 1.7.1 → 1.7.3

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/bin/noob.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { startRepl } from "../src/repl.js";
3
3
  import { config } from "../src/config.js";
4
- import { usage, ApiError } from "../src/api.js";
4
+ import { usage, ApiError, applyInsecureTLS } from "../src/api.js";
5
5
  import { c } from "../src/ui.js";
6
6
  import { t } from "../src/i18n.js";
7
7
  import { checkLatest, runUpdate, CURRENT } from "../src/update.js";
@@ -27,6 +27,10 @@ for (let i = 0; i < argv.length; i++) {
27
27
 
28
28
  const sub = positional[0];
29
29
 
30
+ // Áp dụng --insecure-tls SAU khi parse argv (ESM import hoist khiến top-level
31
+ // check trong api.js chạy trước khi flag được set).
32
+ applyInsecureTLS();
33
+
30
34
  // ── subcommands ──────────────────────────────────────────────────────────
31
35
  if (sub === "login") {
32
36
  const key = positional[1];
@@ -38,12 +42,20 @@ if (sub === "login") {
38
42
  console.log(c.ok("✓ ") + t.loginSaved(config.path));
39
43
  try {
40
44
  const u = await usage();
41
- if (u.ok) console.log(c.ok(t.loginOk({ pro: "Pro", proplus: "Pro+", admin: "Admin", trial: "Trial" }[u.plan] || u.plan)));
42
- else console.log(c.err(" " + t.errInvalidKey));
43
- } catch {
44
- /* network — key still saved */
45
+ if (u.ok) {
46
+ console.log(c.ok(t.loginOk({ pro: "Pro", proplus: "Pro+", admin: "Admin", trial: "Trial" }[u.plan] || u.plan)));
47
+ process.exit(0);
48
+ } else {
49
+ // Key đã lưu xuống đĩa nhưng gateway từ chối — exit != 0 để user biết, script CI cũng bắt được.
50
+ console.log(c.err("✗ " + t.errInvalidKey));
51
+ process.exit(2);
52
+ }
53
+ } catch (err) {
54
+ // Mạng/gateway down: giữ key (user có thể đang offline), nhưng KHÔNG im lặng.
55
+ console.log(c.err("⚠ không verify được key (mạng/gateway): " + (err?.message || err)));
56
+ console.log(c.dim(" key vẫn được lưu — chạy `noob usage` khi có mạng để xác nhận."));
57
+ process.exit(3);
45
58
  }
46
- process.exit(0);
47
59
  } else if (sub === "logout") {
48
60
  config.clearKey();
49
61
  console.log(c.ok(t.loggedOut));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -17,21 +17,22 @@ To call a tool, emit EXACTLY ONE fenced code block tagged \`tool\` containing a
17
17
 
18
18
  Then STOP and wait — the runtime executes the tool and replies with a TOOL RESULT. Use one tool per step. When the task is complete (or you are only answering a question), reply normally in Markdown with NO tool block.
19
19
 
20
- Available tools:
21
- - read_file {"path": str, "offset"?: int, "limit"?: int} — read a file. The "N " line-number prefix in the output is DISPLAY ONLY — it is NOT part of the file; never copy it into edit_file.
22
- - write_file {"path": str, "content": str} — create/overwrite a file
20
+ Available tools (each is self-contained; pick the SMALLEST tool that answers the question):
21
+ - read_file {"path": str, "offset"?: int, "limit"?: int} — read a file. Default reads whole file. For files you suspect are LARGE (>500 lines), first check size via list_dir/glob, then read with offset+limit (e.g. 200 lines at a time) instead of slurping. The "N " line-number prefix in output is DISPLAY ONLY — never copy it into edit_file.
22
+ - write_file {"path": str, "content": str} — create/overwrite a file. Use ONLY for new files or full rewrites; otherwise prefer edit_file.
23
23
  - edit_file {"path": str, "old_string": str, "new_string": str, "replace_all"?: bool} — exact string replace. old_string must match the file's RAW text byte-for-byte (indentation/whitespace included, NO line-number prefix) and be unique unless replace_all. If a replace fails, re-read the file and copy the exact text.
24
- - list_dir {"path"?: str} — list a directory
25
- - glob {"pattern": str} — find files by glob (supports ** and *)
26
- - grep {"pattern": str, "path"?: str, "glob"?: str} — regex search file contents
27
- - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. A foreground command is killed after ~60s (override with "timeout" ms). For anything long-running or that never exits on its own — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: it starts the process, returns immediately, and keeps running WITHOUT blocking the next steps. Never start a server in the foreground (it will hang then be killed).
28
- - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll this after starting a server to confirm it came up)
29
- - kill_bg {"id": int} — stop a background process started with run_command background:true
24
+ - list_dir {"path"?: str} — list a directory. Use to map an unfamiliar project before reading anything.
25
+ - glob {"pattern": str} — find files by glob (supports ** and *). Use when you know the filename pattern but not its location.
26
+ - grep {"pattern": str, "path"?: str, "glob"?: str} — regex search file contents. Use to LOCATE code before reading; cheaper than reading whole files when hunting a symbol/string.
27
+ - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. Foreground commands are killed after ~60s (override with "timeout" ms). For long-running processes — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: starts the process, returns immediately, keeps running WITHOUT blocking next steps. Never start a server in the foreground (it will hang then be killed).
28
+ - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll after starting a server to confirm it came up).
29
+ - kill_bg {"id": int} — stop a background process started with run_command background:true.
30
+
31
+ # Retrieval strategy (just-in-time, not bulk)
32
+ Context is finite. Don't slurp the whole repo up front. Discover information progressively: list_dir/glob to map → grep to locate → read_file (with offset+limit for big files) to inspect only what matters. Each tool result spends your attention budget — make every call earn it. When a tool returns a huge blob, extract the few facts you need, then move on; don't re-read it later (the result stays in history).
30
33
 
31
34
  # Rules
32
- - GROUND TRUTH = the filesystem, NOT your memory of this chat. A file was created/changed ONLY if a write_file/edit_file TOOL RESULT confirms it (see the FILES CHANGED list). Saying "I created/updated X" in prose does NOT change any file you must emit the tool call. If the user says a file is missing or asks its state, read_file/list_dir to check reality first; never claim a file "was reverted" or "should be there" from memory.
33
- - NO FABRICATED VERIFICATION. Never claim a result you did not actually get from a real TOOL RESULT in THIS conversation. Do NOT say "tests pass" / "300/300" / "verified" / "done" / "100%" / "it works" unless a run_command TOOL RESULT above truly shows it. Do NOT reference or quote output that does not appear above — if you have not run the check, say so and RUN it; never narrate a result you only imagine.
34
- - BEFORE any "finished/summary" message, RE-CHECK reality against the FILES CHANGED list: every file you are about to say you created/edited MUST appear there. If it is not there, you did NOT write it — emit the write_file/edit_file (or list_dir/read_file to confirm) NOW instead of claiming completion. When in doubt, list_dir/read_file the workspace and verify before asserting — do not assert from memory.
35
+ - GROUND TRUTH = real TOOL RESULTs in this conversation, not your memory or what you intended to do. A file changed only if a write_file/edit_file result confirms it (see the FILES CHANGED list). A test passed / build succeeded / command worked only if a run_command result above shows it. Never narrate outcomes you didn't observe; if you haven't checked, say so and check now (read_file / list_dir / run the command). Before any "done/summary" reply, reconcile every file and result you're about to claim against the actual tool results above — if it isn't there, you didn't do it yet.
35
36
  - Investigate before editing: read the relevant files first; never invent file contents.
36
37
  - Make the smallest change that fully solves the task. Match the surrounding code style.
37
38
  - Prefer edit_file over write_file for existing files.
@@ -136,12 +137,13 @@ function compact(history, budget) {
136
137
  // lượt cũ thành một message system gọn (giữ quyết định, file đã sửa, lý do,
137
138
  // việc dở) rồi thay phần đầu history bằng tóm tắt đó. Mutates `history` in place.
138
139
  // Trả về true nếu có tóm tắt (để caller persist phiên ngay).
139
- export async function maybeSummarize(history, { model, signal }) {
140
+ export async function maybeSummarize(history, { model, signal, force = false } = {}) {
140
141
  if (!history?.length) return false;
141
142
  const totalChars = history.reduce((s, m) => s + (m.content?.length || 0), 0);
142
- if (totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
143
+ if (!force && totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
143
144
  // Giữ 8 message cuối nguyên vẹn; tóm tắt phần trước.
144
- const keepTail = 8;
145
+ // Khi force (gọi từ /compact), giữ ít tail hơn để tóm tắt mạnh hơn.
146
+ const keepTail = force ? 4 : 8;
145
147
  if (history.length <= keepTail + 2) return false;
146
148
  // Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
147
149
  const head = history.slice(0, history.length - keepTail);
package/src/api.js CHANGED
@@ -5,9 +5,19 @@ import { config } from "./config.js";
5
5
 
6
6
  // Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
7
7
  // revocation proxy. Off by default. Prefer fixing the trust store.
8
- if (process.env.NOOB_INSECURE_TLS === "1") {
8
+ // LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
9
+ // nếu chỉ check ở top-level, flag --insecure-tls set sau import sẽ không kịp.
10
+ let _tlsWarned = false;
11
+ export function applyInsecureTLS() {
12
+ if (process.env.NOOB_INSECURE_TLS !== "1") return;
9
13
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
14
+ if (_tlsWarned) return;
15
+ _tlsWarned = true;
16
+ // Cảnh báo rõ ràng: flag này tắt verify TLS TOÀN PROCESS. MITM-able.
17
+ console.warn("\x1b[33m⚠ NOOB_INSECURE_TLS=1: TLS verification DISABLED for this process. MITM-vulnerable. Unset when done.\x1b[0m");
10
18
  }
19
+ // Vẫn áp dụng ngay nếu env có sẵn từ shell (export NOOB_INSECURE_TLS=1).
20
+ applyInsecureTLS();
11
21
 
12
22
  function authHeaders() {
13
23
  const h = { "Content-Type": "application/json" };
package/src/i18n.js CHANGED
@@ -64,7 +64,9 @@ export const t = {
64
64
  cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
65
65
  cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
66
66
  cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
67
+ cmdCompact: "/compact tóm tắt phiên ngay để gọn ngữ cảnh (giữ trí nhớ dài hạn)",
67
68
  cmdMemory: "/memory xem bộ nhớ noob.md (/mem)",
69
+ cmdAddDir: "/add-dir <path> thêm thư mục ngoài cwd vào phạm vi tool (không arg = liệt kê)",
68
70
  cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
69
71
  cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
70
72
  cmdSessions: "/sessions liệt kê các phiên đã lưu",
@@ -111,6 +113,15 @@ export const t = {
111
113
  ultraNeedGoal: "Cần mục tiêu. Dùng: /ultra <mô tả mục tiêu>",
112
114
  ultraQuest: (n) => `tự nghĩ nhiệm vụ kế tiếp (vòng ${n})…`,
113
115
  learning: "đang chưng cất bài học vào noob.md…",
116
+ compactRunning: "đang tóm tắt phiên để gọn ngữ cảnh…",
117
+ compactEmpty: "Phiên còn trống — không có gì để tóm tắt.",
118
+ compactSkipped: "Phiên còn ngắn hoặc tóm tắt thất bại — bỏ qua.",
119
+ compactDone: (bMsgs, aMsgs, bK, aK, pct) => `Đã tóm tắt: ${bMsgs} → ${aMsgs} tin · ${bK}k → ${aK}k chars (giảm ${pct}%).`,
120
+ longSession: (k) => `Phiên dài (${k}k chars). Cân nhắc /compact để gọn ngữ cảnh (giữ trí nhớ) hoặc /clear để phiên mới hoàn toàn.`,
121
+ veryLongSession: (k) => `⚠ Phiên RẤT dài (${k}k chars) — model có thể chậm/lú. Khuyến nghị /compact ngay, hoặc /clear nếu task đã xong.`,
122
+ autoCompactTrigger: (k) => `Phiên đã đạt ${k}k chars — tự động tóm tắt để giữ model chạy mượt…`,
123
+ autoCompactDone: (bK, aK, pct) => `✓ Auto-compact: ${bK}k → ${aK}k chars (giảm ${pct}%). Trí nhớ dài hạn đã giữ lại trong session_summary.`,
124
+ autoCompactFail: "Auto-compact thất bại — bạn nên /clear hoặc /compact thủ công.",
114
125
  initRunning: "đang quét dự án & soạn noob.md…",
115
126
  initOverwriteWarn: (p) => `⚠ Đã có noob.md tại ${p}. /init sẽ ghi đè nội dung hiện tại.`,
116
127
  initOverwriteConfirm: "Ghi đè? gõ 'y' để xác nhận, phím khác để huỷ › ",
package/src/repl.js CHANGED
@@ -3,11 +3,11 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
5
  import { createTui } from "./tui.js";
6
- import { runAgent } from "./agent.js";
6
+ import { runAgent, maybeSummarize } from "./agent.js";
7
7
  import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
8
8
  import { TokenMeter } from "./tokens.js";
9
9
  import { stream, usage, ApiError } from "./api.js";
10
- import { runTool, describe, DESTRUCTIVE } from "./tools.js";
10
+ import { runTool, describe, DESTRUCTIVE, addRoot, listRoots } from "./tools.js";
11
11
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
12
12
  import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
13
13
  import { config } from "./config.js";
@@ -43,6 +43,7 @@ const SLASH = [
43
43
  { name: "/continue", desc: "tiếp tục phiên gần nhất" },
44
44
  { name: "/sessions", desc: "liệt kê phiên đã lưu" },
45
45
  { name: "/cwd", desc: "thư mục hiện tại" },
46
+ { name: "/add-dir", desc: "thêm thư mục ngoài cwd vào phạm vi" },
46
47
  { name: "/status", desc: "trạng thái" },
47
48
  { name: "/version", desc: "phiên bản" },
48
49
  { name: "/exit", desc: "thoát" },
@@ -132,6 +133,9 @@ export async function startRepl(opts = {}) {
132
133
  yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
133
134
  ultra: false, // chế độ tự hành (self-quest) đang chạy?
134
135
  agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
136
+ extraRoots: [], // thư mục ngoài cwd được /add-dir cấp quyền (UX display only;
137
+ // source of truth là extraRoots trong src/tools.js)
138
+ _longSessionWarned: false, // đã in cảnh báo phiên dài chưa (reset khi /clear)
135
139
  };
136
140
  const tokenMeter = new TokenMeter();
137
141
 
@@ -485,6 +489,35 @@ NGUYÊN TẮC:
485
489
  persist();
486
490
  }
487
491
 
492
+ // /compact — chủ động tóm tắt phiên ngay để gọn ngữ cảnh, giữ trí nhớ dài hạn.
493
+ // Khác /clear (xoá sạch) và khác auto-summarize (chỉ chạy khi vượt ngưỡng).
494
+ async function runCompact() {
495
+ if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
496
+ if (!state.history?.length) return console.log(c.dim(" " + t.compactEmpty));
497
+ const beforeChars = state.history.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : 0), 0);
498
+ const beforeMsgs = state.history.length;
499
+ console.log(c.tool(" 🗜 " + t.compactRunning));
500
+ tui.setBusy(true, t.compactRunning);
501
+ try {
502
+ const ok = await maybeSummarize(state.history, { model: state.model, force: true });
503
+ tui.setBusy(false);
504
+ if (!ok) {
505
+ console.log(c.dim(" " + t.compactSkipped));
506
+ return;
507
+ }
508
+ const afterChars = state.history.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : 0), 0);
509
+ const afterMsgs = state.history.length;
510
+ const saved = Math.max(0, beforeChars - afterChars);
511
+ const pct = beforeChars > 0 ? Math.round((saved / beforeChars) * 100) : 0;
512
+ console.log(c.ok(" ✓ " + t.compactDone(beforeMsgs, afterMsgs, Math.round(beforeChars / 1000), Math.round(afterChars / 1000), pct)));
513
+ state._longSessionWarned = false; // reset để có thể cảnh báo lại nếu lại phình
514
+ persist();
515
+ } catch (err) {
516
+ tui.setBusy(false);
517
+ printError(err);
518
+ }
519
+ }
520
+
488
521
  function showMemory() {
489
522
  const mem = loadMemory();
490
523
  if (!mem) return console.log(c.dim(" " + t.memoryEmpty(memoryPath())));
@@ -601,11 +634,17 @@ NGUYÊN TẮC:
601
634
  // spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
602
635
  const t0 = Date.now();
603
636
  let timer = null;
604
- const tick = (label) => {
637
+ // Tách label (status) meta (elapsed+tokens):
638
+ // - status có thể bị reset (vd. lúc xin permission, lúc đang in answer)
639
+ // - meta đi qua setMeta, vẫn hiện trong busy bar nền dù status null
640
+ // Nhờ vậy người dùng LUÔN thấy đồng hồ + token đang chạy, kể cả khi treo chờ y/n.
641
+ const tickMeta = () => {
605
642
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
606
- // Chèn token usage realtime (↑input ↓output) ngay cạnh spinner để người
607
- // dùng thấy được số token đang cộng dồn trong khi model stream.
608
- tui.status(c.dim(`${label}… ${elapsed}s · ${tokenMeter.format()}`));
643
+ tui.setMeta(`${elapsed}s · ${tokenMeter.format()}`);
644
+ };
645
+ const tick = (label) => {
646
+ tui.status(c.dim(`${label}…`));
647
+ tickMeta();
609
648
  };
610
649
  const stopSpin = () => {
611
650
  if (timer) {
@@ -613,9 +652,10 @@ NGUYÊN TẮC:
613
652
  timer = null;
614
653
  }
615
654
  tui.status(null);
655
+ // KHÔNG reset meta ở đây — để token+elapsed vẫn hiện qua setBusy/busyMeta
656
+ // cho tới khi setBusy(false) ở finally tự dọn.
616
657
  };
617
658
  const startSpin = (label) => {
618
- // (tui hiện status khi tick gọi)
619
659
  if (!timer) timer = setInterval(() => tick(label), 200);
620
660
  };
621
661
 
@@ -630,8 +670,10 @@ NGUYÊN TẮC:
630
670
  message: text,
631
671
  signal: abort.signal,
632
672
  onStatus: (s) => {
633
- // Kèm token meter để nhánh merge/search cũng thấy ↑input ↓output realtime.
634
- if (!printer.started) tui.status(c.dim(` ${s} · ${tokenMeter.format()}`));
673
+ // Status label đi qua tui.status; token+elapsed đi qua setMeta (vẫn hiện
674
+ // printer đã bắt đầu vì busyMeta nằm trên busy bar nền).
675
+ if (!printer.started) tui.status(c.dim(` ${s}`));
676
+ tickMeta();
635
677
  },
636
678
  onDelta: (d) => {
637
679
  stopSpin();
@@ -735,6 +777,55 @@ NGUYÊN TẮC:
735
777
  } finally {
736
778
  abort = null;
737
779
  tui.setBusy(false);
780
+ // Cảnh báo phiên dài: in một lần khi tổng chars vượt ~2× ngưỡng summarize
781
+ // (60k trong agent.js). Tự maybeSummarize đã chạy bên trong, nhưng người
782
+ // dùng có thể muốn /clear chủ động cho gọn ngữ cảnh + tốc độ.
783
+ try {
784
+ const totalChars = state.history.reduce(
785
+ (a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content || "").length),
786
+ 0,
787
+ );
788
+ const k = Math.round(totalChars / 1000);
789
+ // Mốc 3 (240k+): TỰ ĐỘNG compact — không hỏi, không chờ user. Mục tiêu là
790
+ // giữ model chạy mượt khi user mải làm việc, không để phiên phình mãi.
791
+ // Dùng cờ _autoCompacting chống re-entrant (nếu compact lâu, lượt sau
792
+ // tới trước khi xong thì bỏ qua).
793
+ if (totalChars > 240000 && !state._autoCompacting) {
794
+ state._autoCompacting = true;
795
+ console.log(c.accent(" ⚡ " + t.autoCompactTrigger(k)));
796
+ tui.setBusy(true, t.compactRunning);
797
+ try {
798
+ const ok = await maybeSummarize(state.history, { model: state.model, force: true });
799
+ tui.setBusy(false);
800
+ if (ok) {
801
+ const afterChars = state.history.reduce(
802
+ (a, m) => a + (typeof m.content === "string" ? m.content.length : 0),
803
+ 0,
804
+ );
805
+ const aK = Math.round(afterChars / 1000);
806
+ const pct = totalChars > 0 ? Math.round(((totalChars - afterChars) / totalChars) * 100) : 0;
807
+ console.log(c.ok(" " + t.autoCompactDone(k, aK, pct)));
808
+ state._longSessionWarned = false;
809
+ persist();
810
+ } else {
811
+ console.log(c.err(" " + t.autoCompactFail));
812
+ }
813
+ } catch (e) {
814
+ tui.setBusy(false);
815
+ console.log(c.err(" " + t.autoCompactFail));
816
+ } finally {
817
+ state._autoCompacting = false;
818
+ }
819
+ } else if (totalChars > 200000) {
820
+ // Mốc 2 (200k–240k): cảnh báo mạnh, in lại mỗi lượt.
821
+ console.log(c.err(" " + t.veryLongSession(k)));
822
+ state._longSessionWarned = true;
823
+ } else if (totalChars > 120000 && !state._longSessionWarned) {
824
+ // Mốc 1 (120k+): nhắc nhẹ một lần.
825
+ console.log(c.dim(" ⓘ " + t.longSession(k)));
826
+ state._longSessionWarned = true;
827
+ }
828
+ } catch {}
738
829
  }
739
830
  }
740
831
 
@@ -860,6 +951,9 @@ NGUYÊN TẮC:
860
951
  case "learn":
861
952
  await runLearn(arg);
862
953
  break;
954
+ case "compact":
955
+ await runCompact();
956
+ break;
863
957
  case "memory":
864
958
  case "mem":
865
959
  showMemory();
@@ -881,6 +975,7 @@ NGUYÊN TẮC:
881
975
  case "new":
882
976
  persist(); // giữ lại phiên cũ trên đĩa
883
977
  state.history = [];
978
+ state._longSessionWarned = false; // reset cờ cảnh báo phiên dài
884
979
  startFresh(); // phiên mới (phiên cũ vẫn resume được)
885
980
  if (!tui.tty) console.clear();
886
981
  banner();
@@ -908,6 +1003,28 @@ NGUYÊN TẮC:
908
1003
  case "cwd":
909
1004
  console.log(c.dim(" " + process.cwd()));
910
1005
  break;
1006
+ case "adddir":
1007
+ case "add-dir": {
1008
+ if (!arg) {
1009
+ // Không arg → liệt kê roots hiện tại (cwd + các thư mục đã /add-dir).
1010
+ const roots = listRoots();
1011
+ console.log(c.dim(" Phạm vi truy cập:"));
1012
+ for (const r of roots) {
1013
+ const isCwd = r === process.cwd();
1014
+ console.log(" " + (isCwd ? c.accent("• ") : c.ok("+ ")) + r + (isCwd ? c.dim(" (cwd)") : ""));
1015
+ }
1016
+ console.log(c.dim(" Dùng: /add-dir <đường-dẫn>"));
1017
+ break;
1018
+ }
1019
+ try {
1020
+ const full = addRoot(path.resolve(process.cwd(), arg));
1021
+ if (!state.extraRoots.includes(full)) state.extraRoots.push(full);
1022
+ console.log(c.ok(" ✓ ") + c.dim("đã thêm vào phạm vi: ") + full);
1023
+ } catch (e) {
1024
+ console.log(c.err(" ✗ ") + (e?.message || String(e)));
1025
+ }
1026
+ break;
1027
+ }
911
1028
  case "status":
912
1029
  printStatus(state);
913
1030
  break;
@@ -978,8 +1095,16 @@ NGUYÊN TẮC:
978
1095
  s.mode === "merge" ? c.tool("Merge AI") : s.mode === "search" ? c.accent("Tìm web") : modelBadge(s.model);
979
1096
  const key = config.apiKey ? c.ok(" 🔑") : c.err(" 🔒");
980
1097
  const yolo = s.yolo ? c.err(" ⚡ yolo: BẬT") : c.dim(" yolo: tắt");
1098
+ // Size phiên — màu đổi theo mức: dim < 60k, tool 60-120k, accent 120-200k, err > 200k.
1099
+ const totalChars = (s.history || []).reduce(
1100
+ (a, m) => a + (typeof m.content === "string" ? m.content.length : 0),
1101
+ 0,
1102
+ );
1103
+ const k = Math.round(totalChars / 1000);
1104
+ const sizeColor = totalChars > 200000 ? c.err : totalChars > 120000 ? c.accent : totalChars > 60000 ? c.tool : c.dim;
1105
+ const size = sizeColor(` ctx: ${k}k`);
981
1106
  console.log(
982
- " " + mode + key + yolo + c.dim(" v" + CURRENT) + c.dim(" thư mục: " + shortCwd()),
1107
+ " " + mode + key + yolo + size + c.dim(" v" + CURRENT) + c.dim(" thư mục: " + shortCwd()),
983
1108
  );
984
1109
  }
985
1110
  }
@@ -1093,7 +1218,9 @@ function printHelp() {
1093
1218
  " " + t.cmdKarpathy,
1094
1219
  " " + t.cmdUltra,
1095
1220
  " " + t.cmdLearn,
1221
+ " " + t.cmdCompact,
1096
1222
  " " + t.cmdMemory,
1223
+ " " + t.cmdAddDir,
1097
1224
  " " + t.cmdLogin,
1098
1225
  " " + t.cmdLogout,
1099
1226
  " " + t.cmdUsage,
package/src/subagent.js CHANGED
@@ -32,7 +32,13 @@ Ví dụ phân cấp: cha giao "build full app" → đẻ 1 sub-agent "build bac
32
32
  // Chạy một sub-agent. dispatchTool: hàm để thực thi tool con (chia sẻ với cha).
33
33
  // model: dùng chung model của cha. onLog: callback để log tiến độ ra UI cha.
34
34
  export async function runSubAgent({ task, context, model, signal, dispatchTool, depth = 1, onLog, tokenMeter }) {
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.
35
+ const sys = `Bạn là SUB-AGENT (depth=${depth}) được agent cha ủy thác MỘT nhiệm vụ cụ thể.
36
+
37
+ # Cách làm việc
38
+ - Tự quyết với thông tin được cấp + tự khám phá filesystem (list_dir/glob/grep/read_file). KHÔNG hỏi lại cha.
39
+ - History của bạn TÁCH BIỆT với cha. Cha CHỈ thấy chuỗi trả lời cuối của bạn → hãy là một bản tóm tắt cô đọng (mục tiêu 1–2k token): mọi file đã đụng, phát hiện then chốt, lỗi/cảnh báo, và các đầu mối cha cần để hành động tiếp. Bỏ chi tiết quá trình thừa.
40
+ - Làm điều nhỏ nhất giải quyết trọn vẹn nhiệm vụ. Không drive-by refactor.
41
+ - Verify khi hợp lý (chạy build/test/lint). Báo trung thực phần đã/chưa verify.
36
42
 
37
43
  # NHIỆM VỤ
38
44
  ${task}
package/src/tokens.js CHANGED
@@ -25,33 +25,64 @@ export function countMessages(messages = []) {
25
25
 
26
26
  // Bộ đếm cộng dồn cho 1 phiên: input (prompt gửi đi) + output (text stream về).
27
27
  // Hỗ trợ cộng dồn theo delta để hiển thị realtime trong lúc stream.
28
+ //
29
+ // Vấn đề cũ: pushOutputDelta encode TOÀN buffer mỗi delta → O(N²) trên stream dài.
30
+ // Fix: sliding window. BPE chỉ phụ thuộc context cục bộ (vài chục byte) nên ta
31
+ // chỉ cần encode đoạn TAIL gần đây, phần trước đó "commit" dứt khoát. Safety
32
+ // window đủ rộng (256 chars) để qua mọi ranh giới token thực tế của cl100k_base
33
+ // (token dài nhất ~ vài chục byte).
34
+ const TAIL_WINDOW = 256;
35
+
28
36
  export class TokenMeter {
29
37
  constructor() {
30
38
  this.input = 0;
31
39
  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)
40
+ // Phần đầu output đã "commit" không đụng vào nữa.
41
+ this._committedChars = 0; // số tự đã đẩy qua khỏi tail window
42
+ this._committedTokens = 0; // tổng token tương ứng đã cộng vào this.output
43
+ // Tail buffer: TAIL_WINDOW ký tự cuối, dùng để re-encode khi có delta mới.
44
+ this._tail = "";
45
+ this._tailTokens = 0; // token count hiện tại của _tail (đã cộng vào this.output)
34
46
  }
35
47
  addInput(n) {
36
48
  this.input += Math.max(0, n | 0);
37
49
  }
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 delta riêng lẻ chính xác hơn (BPE
40
- // gộp các byte qua ranh giới delta).
50
+ // Mỗi delta text từ stream: append vào tail, re-encode CHỈ tail (không toàn buffer).
51
+ // Nếu tail vượt 2×TAIL_WINDOW commit nửa đầu, giữ lại nửa sau.
41
52
  pushOutputDelta(text) {
42
53
  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;
54
+ this._tail += text;
55
+ // Re-encode tail cập nhật phần token của tail.
56
+ const newTailTokens = countTokens(this._tail);
57
+ this.output += newTailTokens - this._tailTokens;
58
+ this._tailTokens = newTailTokens;
59
+ // Nếu tail quá lớn, commit nửa đầu để giữ tail bounded.
60
+ // Cắt tại ranh giới whitespace gần TAIL_WINDOW — BPE align tốt ở đây nên
61
+ // sai số head+tail vs encode liền mạch ≈ 0 (thay vì +1 token mỗi cut).
62
+ if (this._tail.length > TAIL_WINDOW * 2) {
63
+ let cutAt = this._tail.length - TAIL_WINDOW;
64
+ // Lùi cutAt về ranh giới whitespace gần nhất (tối đa 64 chars).
65
+ const minCut = Math.max(0, cutAt - 64);
66
+ while (cutAt > minCut && !/\s/.test(this._tail[cutAt])) cutAt--;
67
+ const head = this._tail.slice(0, cutAt);
68
+ const newTail = this._tail.slice(cutAt);
69
+ const headTokens = countTokens(head);
70
+ const newTailTokensAfterCut = countTokens(newTail);
71
+ // Điều chỉnh this.output để giữ tổng nhất quán với re-encode rời.
72
+ const delta = headTokens + newTailTokensAfterCut - this._tailTokens;
73
+ this.output += delta;
74
+ this._committedChars += head.length;
75
+ this._committedTokens += headTokens;
76
+ this._tail = newTail;
77
+ this._tailTokens = newTailTokensAfterCut;
49
78
  }
50
79
  }
51
80
  // Kết thúc một lượt output → reset buffer (bắt đầu lượt mới).
52
81
  endOutput() {
53
- this._outBuf = "";
54
- this._outBufN = 0;
82
+ this._committedChars = 0;
83
+ this._committedTokens = 0;
84
+ this._tail = "";
85
+ this._tailTokens = 0;
55
86
  }
56
87
  get total() {
57
88
  return this.input + this.output;
@@ -64,7 +95,9 @@ export class TokenMeter {
64
95
  reset() {
65
96
  this.input = 0;
66
97
  this.output = 0;
67
- this._outBuf = "";
68
- this._outBufN = 0;
98
+ this._committedChars = 0;
99
+ this._committedTokens = 0;
100
+ this._tail = "";
101
+ this._tailTokens = 0;
69
102
  }
70
103
  }
package/src/tools.js CHANGED
@@ -5,8 +5,65 @@ import { spawn } from "node:child_process";
5
5
 
6
6
  const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
7
7
  const cwd = () => process.cwd();
8
- const abs = (p) => path.resolve(cwd(), p);
8
+
9
+ // Các thư mục ngoài cwd được user cấp quyền qua /add-dir. Path tool sẽ chấp
10
+ // nhận nếu nó nằm trong cwd HOẶC trong một extra root. Source of truth ở đây.
11
+ const extraRoots = new Set();
12
+ export function addRoot(p) {
13
+ if (!p) throw new Error("thiếu path");
14
+ const full = path.resolve(p);
15
+ let st;
16
+ try { st = fssync.statSync(full); } catch { throw new Error("không tồn tại: " + p); }
17
+ if (!st.isDirectory()) throw new Error("không phải thư mục: " + p);
18
+ extraRoots.add(full);
19
+ return full;
20
+ }
21
+ export function listRoots() {
22
+ return [cwd(), ...extraRoots];
23
+ }
24
+ function within(root, full) {
25
+ if (full === root) return true;
26
+ const rel = path.relative(root, full);
27
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
28
+ }
29
+
30
+ const abs = (p) => {
31
+ if (!p) return cwd();
32
+ // Path tuyệt đối: chấp nhận nếu nằm trong cwd hoặc một extra root, không thì
33
+ // ném lỗi rõ ràng (tools dưới sẽ propagate cho LLM).
34
+ if (path.isAbsolute(p)) {
35
+ const full = path.resolve(p);
36
+ if (within(cwd(), full)) return full;
37
+ for (const r of extraRoots) if (within(r, full)) return full;
38
+ throw new Error("path nằm ngoài phạm vi (cwd + /add-dir): " + p);
39
+ }
40
+ // Tương đối: ưu tiên cwd; nếu thoát cwd thì thử từng extra root.
41
+ const full = path.resolve(cwd(), p);
42
+ if (within(cwd(), full)) return full;
43
+ for (const r of extraRoots) {
44
+ const fr = path.resolve(r, p);
45
+ if (within(r, fr)) return fr;
46
+ }
47
+ throw new Error("path nằm ngoài phạm vi (cwd + /add-dir): " + p);
48
+ };
9
49
  const rel = (p) => path.relative(cwd(), p) || ".";
50
+ // Tên rút gọn để hiển thị: nếu path thuộc cwd → relative cwd; nếu thuộc một
51
+ // extra root → "<rootName>/<rel>" để user phân biệt được; còn lại fallback path tuyệt đối.
52
+ function displayPath(full) {
53
+ if (within(cwd(), full)) return path.relative(cwd(), full) || ".";
54
+ for (const r of extraRoots) {
55
+ if (within(r, full)) {
56
+ const sub = path.relative(r, full);
57
+ return sub ? path.basename(r) + path.sep + sub : path.basename(r);
58
+ }
59
+ }
60
+ return full;
61
+ }
62
+ function relFrom(root, full) {
63
+ return path.relative(root, full) || ".";
64
+ }
65
+ // Thư mục bỏ qua khi walk (glob/grep). node_modules + các thư mục build/cache phổ biến.
66
+ const SKIP_DIRS = new Set(["node_modules", ".next", "dist", "build", ".venv", "venv", "__pycache__", ".cache", ".turbo", ".parcel-cache", "target"]);
10
67
 
11
68
  function clip(s) {
12
69
  if (s.length <= MAX_OUT) return s;
@@ -29,8 +86,16 @@ function killBgTree(child) {
29
86
  } catch {}
30
87
  }
31
88
  // Đừng để tiến trình nền sống mồ côi sau khi CLI thoát.
32
- process.on("exit", () => {
89
+ // - 'exit' bắt được normal exit + process.exit() (gồm cả nhánh Ctrl+C lần 2 ở repl).
90
+ // - SIGTERM mặc định KHÔNG trigger 'exit' → process chết im, bg leak. Bắt riêng.
91
+ // - SIGINT đã có handler ở repl.js, không đụng vào để không phá UX "Ctrl+C lần 1 = abort".
92
+ function cleanupBg() {
33
93
  for (const p of bg.values()) killBgTree(p.child);
94
+ }
95
+ process.on("exit", cleanupBg);
96
+ process.on("SIGTERM", () => {
97
+ cleanupBg();
98
+ process.exit(143);
34
99
  });
35
100
 
36
101
  export const TOOLS = {
@@ -74,7 +139,9 @@ export const TOOLS = {
74
139
  if (count === 0) continue;
75
140
  if (count > 1 && !replace_all)
76
141
  throw new Error(`old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`);
77
- await fs.writeFile(file, applyExact(cand, cand === old_string ? new_string : adapt(new_string)), "utf8");
142
+ // LUÔN adapt new_string về line ending của file (kể cả khi cand match raw old_string),
143
+ // tránh tạo file mix CRLF/LF làm git/editor hiển thị diff lạ → user tưởng 'Edited nhưng không apply'.
144
+ await fs.writeFile(file, applyExact(cand, adapt(new_string)), "utf8");
78
145
  return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
79
146
  }
80
147
 
@@ -109,29 +176,48 @@ export const TOOLS = {
109
176
  async glob({ pattern }) {
110
177
  const hits = [];
111
178
  const rx = globToRegExp(pattern);
112
- (function walk(dir) {
113
- let ents;
114
- try {
115
- ents = fssync.readdirSync(dir, { withFileTypes: true });
116
- } catch {
117
- return;
118
- }
119
- for (const e of ents) {
120
- if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
121
- const full = path.join(dir, e.name);
122
- if (e.isDirectory()) walk(full);
123
- else if (rx.test(rel(full).split(path.sep).join("/"))) hits.push(rel(full));
124
- if (hits.length > 500) return;
125
- }
126
- })(cwd());
179
+ const roots = listRoots();
180
+ for (const root of roots) {
181
+ (function walk(dir) {
182
+ let ents;
183
+ try {
184
+ ents = fssync.readdirSync(dir, { withFileTypes: true });
185
+ } catch {
186
+ return;
187
+ }
188
+ for (const e of ents) {
189
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
190
+ const full = path.join(dir, e.name);
191
+ if (e.isDirectory()) walk(full);
192
+ else if (rx.test(relFrom(root, full).split(path.sep).join("/"))) hits.push(displayPath(full));
193
+ if (hits.length > 500) return;
194
+ }
195
+ })(root);
196
+ if (hits.length > 500) break;
197
+ }
127
198
  return hits.length ? clip(hits.join("\n")) : "No files matched.";
128
199
  },
129
200
 
130
- async grep({ pattern, path: p = ".", glob: g }) {
201
+ async grep({ pattern, path: p, glob: g }) {
131
202
  const rx = new RegExp(pattern, "i");
132
203
  const gRx = g ? globToRegExp(g) : null;
133
204
  const out = [];
134
- (function walk(dir) {
205
+ function scanFile(full) {
206
+ const disp = displayPath(full);
207
+ const relp = disp.split(path.sep).join("/");
208
+ if (gRx && !gRx.test(relp)) return;
209
+ let txt;
210
+ try {
211
+ txt = fssync.readFileSync(full, "utf8");
212
+ } catch {
213
+ return;
214
+ }
215
+ if (txt.includes("\u0000")) return; // skip binary files
216
+ txt.split("\n").forEach((l, idx) => {
217
+ if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
218
+ });
219
+ }
220
+ function walkDir(dir) {
135
221
  let ents;
136
222
  try {
137
223
  ents = fssync.readdirSync(dir, { withFileTypes: true });
@@ -139,26 +225,28 @@ export const TOOLS = {
139
225
  return;
140
226
  }
141
227
  for (const e of ents) {
142
- if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
228
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
143
229
  const full = path.join(dir, e.name);
144
230
  if (e.isDirectory()) {
145
- walk(full);
146
- continue;
147
- }
148
- const relp = rel(full).split(path.sep).join("/");
149
- if (gRx && !gRx.test(relp)) continue;
150
- let txt;
151
- try {
152
- txt = fssync.readFileSync(full, "utf8");
153
- } catch {
231
+ walkDir(full);
154
232
  continue;
155
233
  }
156
- if (txt.includes("\u0000")) continue; // skip binary files
157
- txt.split("\n").forEach((l, idx) => {
158
- if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
159
- });
234
+ scanFile(full);
160
235
  }
161
- })(abs(p));
236
+ }
237
+ // Không truyền path → quét cwd + tất cả extra roots. Có path → chỉ vùng đó.
238
+ if (p == null || p === "" || p === ".") {
239
+ for (const root of listRoots()) walkDir(root);
240
+ } else {
241
+ let st;
242
+ try {
243
+ st = fssync.statSync(abs(p));
244
+ } catch {
245
+ return "No matches.";
246
+ }
247
+ if (st.isFile()) scanFile(abs(p));
248
+ else walkDir(abs(p));
249
+ }
162
250
  return out.length ? clip(out.join("\n")) : "No matches.";
163
251
  },
164
252
 
package/src/tui.js CHANGED
@@ -54,6 +54,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
54
54
  print() {},
55
55
  status() {},
56
56
  setBusy() {},
57
+ setMeta() {},
57
58
  setPrompt() {},
58
59
  read() {
59
60
  if (queue.length) return Promise.resolve(queue.shift());
@@ -79,7 +80,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
79
80
  // LUÔN thấy rõ "đang chạy", không bị tưởng treo.
80
81
  let busy = false;
81
82
  let busyLabel = "";
82
- let busyStartedAt = 0; // mốc thời gian để hiển thị elapsed
83
+ let busyMeta = ""; // chuỗi phụ (elapsed + token) repl đẩy vào qua setMeta()
83
84
  let frame = 0;
84
85
  let frameTimer = null;
85
86
  let prevRows = 0;
@@ -160,9 +161,31 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
160
161
  return promptLabel + arr.join("");
161
162
  }
162
163
  function topRow() {
163
- if (liveOut) return liveOut.slice(0, cols());
164
- if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
165
- if (busy) return c.dim(FRAMES[frame % FRAMES.length] + " " + (busyLabel || "đang chạy") + " · Ctrl+C để dừng");
164
+ if (liveOut) {
165
+ // Khi đang stream prose busy, ghép meta (elapsed+token) vào cuối liveOut
166
+ // để user vẫn thấy phiên đang sống không bị che status bar.
167
+ if (busy && busyMeta) {
168
+ const meta = c.dim(" · " + busyMeta);
169
+ const budget = Math.max(0, cols() - visLen(meta));
170
+ const head = liveOut.length > budget ? liveOut.slice(0, budget) : liveOut;
171
+ return head + meta;
172
+ }
173
+ return liveOut.slice(0, cols());
174
+ }
175
+ const spin = FRAMES[frame % FRAMES.length];
176
+ // Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
177
+ // người dùng thấy phiên đang sống — kể cả lúc model im giữa các bước.
178
+ if (statusText) {
179
+ const meta = busy && busyMeta ? c.dim(" · " + busyMeta) : "";
180
+ const tail = busy ? c.dim(" · Ctrl+C để dừng") : "";
181
+ const line = c.dim(spin + " ") + statusText + meta + tail;
182
+ return line.length > cols() ? line.slice(0, cols()) : line;
183
+ }
184
+ if (busy) {
185
+ const meta = busyMeta ? " · " + busyMeta : "";
186
+ const line = c.dim(spin + " " + (busyLabel || "đang chạy") + meta + " · Ctrl+C để dừng");
187
+ return line.length > cols() ? line.slice(0, cols()) : line;
188
+ }
166
189
  return null;
167
190
  }
168
191
  function menuRows() {
@@ -455,8 +478,17 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
455
478
  setBusy(on, label) {
456
479
  busy = !!on;
457
480
  if (label != null) busyLabel = label;
481
+ if (!on) busyMeta = ""; // reset meta khi tắt busy để lượt sau không carry số cũ
458
482
  draw();
459
483
  },
484
+ setMeta(meta) {
485
+ // repl bơm chuỗi phụ (vd: "12s · ↑1.2k ↓340 (1.5k)") để status bar hiện
486
+ // realtime kể cả khi model im giữa các bước.
487
+ const next = meta || "";
488
+ if (next === busyMeta) return;
489
+ busyMeta = next;
490
+ if (busy) draw();
491
+ },
460
492
  setPrompt(label) {
461
493
  promptLabel = label || "";
462
494
  draw();