@noobdemon/noob-cli 1.7.2 → 1.7.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 +1 -1
- package/src/agent.js +18 -3
- package/src/i18n.js +11 -0
- package/src/repl.js +137 -10
- package/src/tools.js +107 -43
- package/src/tui.js +36 -4
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { stream } from "./api.js";
|
|
3
3
|
import { loadMemory } from "./memory.js";
|
|
4
|
+
import { listRoots } from "./tools.js";
|
|
4
5
|
import { t } from "./i18n.js";
|
|
5
6
|
import { countTokens } from "./tokens.js";
|
|
6
7
|
|
|
@@ -92,6 +93,19 @@ function runtimeContext() {
|
|
|
92
93
|
`- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
|
|
93
94
|
`- Current working directory: ${process.cwd()}`,
|
|
94
95
|
];
|
|
96
|
+
// Extra roots cấp qua /add-dir: model PHẢI biết để chủ động dùng (đọc/list/grep).
|
|
97
|
+
// Không liệt kê ở đây → model không 'thấy' thư mục đó tồn tại dù tools layer
|
|
98
|
+
// đã accept path.
|
|
99
|
+
try {
|
|
100
|
+
const roots = listRoots();
|
|
101
|
+
const extras = roots.slice(1); // [0] là cwd
|
|
102
|
+
if (extras.length) {
|
|
103
|
+
lines.push(
|
|
104
|
+
`- Extra roots cấp quyền qua /add-dir (tool path nằm trong các thư mục này cũng hợp lệ — dùng path tuyệt đối khi gọi tool):`,
|
|
105
|
+
);
|
|
106
|
+
for (const r of extras) lines.push(` • ${r}`);
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
95
109
|
if (isWin) {
|
|
96
110
|
lines.push(
|
|
97
111
|
"- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.",
|
|
@@ -137,12 +151,13 @@ function compact(history, budget) {
|
|
|
137
151
|
// lượt cũ thành một message system gọn (giữ quyết định, file đã sửa, lý do,
|
|
138
152
|
// việc dở) rồi thay phần đầu history bằng tóm tắt đó. Mutates `history` in place.
|
|
139
153
|
// Trả về true nếu có tóm tắt (để caller persist phiên ngay).
|
|
140
|
-
export async function maybeSummarize(history, { model, signal }) {
|
|
154
|
+
export async function maybeSummarize(history, { model, signal, force = false } = {}) {
|
|
141
155
|
if (!history?.length) return false;
|
|
142
156
|
const totalChars = history.reduce((s, m) => s + (m.content?.length || 0), 0);
|
|
143
|
-
if (totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
|
|
157
|
+
if (!force && totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
|
|
144
158
|
// Giữ 8 message cuối nguyên vẹn; tóm tắt phần trước.
|
|
145
|
-
|
|
159
|
+
// Khi force (gọi từ /compact), giữ ít tail hơn để tóm tắt mạnh hơn.
|
|
160
|
+
const keepTail = force ? 4 : 8;
|
|
146
161
|
if (history.length <= keepTail + 2) return false;
|
|
147
162
|
// Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
|
|
148
163
|
const head = history.slice(0, history.length - keepTail);
|
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
|
-
|
|
637
|
+
// Tách label (status) và 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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
//
|
|
634
|
-
|
|
673
|
+
// Status label đi qua tui.status; token+elapsed đi qua setMeta (vẫn hiện
|
|
674
|
+
// dù 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/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
|
-
|
|
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;
|
|
@@ -119,30 +176,35 @@ export const TOOLS = {
|
|
|
119
176
|
async glob({ pattern }) {
|
|
120
177
|
const hits = [];
|
|
121
178
|
const rx = globToRegExp(pattern);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
ents
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
|
137
198
|
return hits.length ? clip(hits.join("\n")) : "No files matched.";
|
|
138
199
|
},
|
|
139
200
|
|
|
140
|
-
async grep({ pattern, path: p
|
|
201
|
+
async grep({ pattern, path: p, glob: g }) {
|
|
141
202
|
const rx = new RegExp(pattern, "i");
|
|
142
203
|
const gRx = g ? globToRegExp(g) : null;
|
|
143
204
|
const out = [];
|
|
144
205
|
function scanFile(full) {
|
|
145
|
-
const
|
|
206
|
+
const disp = displayPath(full);
|
|
207
|
+
const relp = disp.split(path.sep).join("/");
|
|
146
208
|
if (gRx && !gRx.test(relp)) return;
|
|
147
209
|
let txt;
|
|
148
210
|
try {
|
|
@@ -155,33 +217,35 @@ export const TOOLS = {
|
|
|
155
217
|
if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
|
|
156
218
|
});
|
|
157
219
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
220
|
+
function walkDir(dir) {
|
|
221
|
+
let ents;
|
|
222
|
+
try {
|
|
223
|
+
ents = fssync.readdirSync(dir, { withFileTypes: true });
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const e of ents) {
|
|
228
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
|
|
229
|
+
const full = path.join(dir, e.name);
|
|
230
|
+
if (e.isDirectory()) {
|
|
231
|
+
walkDir(full);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
scanFile(full);
|
|
235
|
+
}
|
|
164
236
|
}
|
|
165
|
-
|
|
166
|
-
|
|
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);
|
|
167
240
|
} else {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
|
|
177
|
-
const full = path.join(dir, e.name);
|
|
178
|
-
if (e.isDirectory()) {
|
|
179
|
-
walk(full);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
scanFile(full);
|
|
183
|
-
}
|
|
184
|
-
})(abs(p));
|
|
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));
|
|
185
249
|
}
|
|
186
250
|
return out.length ? clip(out.join("\n")) : "No matches.";
|
|
187
251
|
},
|
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
|
|
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)
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
if (liveOut) {
|
|
165
|
+
// Khi đang stream prose mà 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();
|