@noobdemon/noob-cli 1.11.0 → 1.12.0
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/CHANGELOG.md +41 -0
- package/package.json +1 -1
- package/src/agent.js +7 -4
- package/src/api.js +87 -14
- package/src/diff.js +152 -0
- package/src/repl/commands/prompts.js +45 -0
- package/src/repl/permission.js +116 -0
- package/src/repl/state.js +33 -0
- package/src/repl/utils.js +64 -0
- package/src/repl/workflow-commands.js +3 -0
- package/src/repl.js +372 -202
- package/src/tokens.js +2 -2
- package/src/tools.js +11 -3
- package/src/ui.js +92 -5
- package/src/workflow-runs.js +222 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
Tất cả thay đổi đáng kể của `@noobdemon/noob-cli` được ghi vào file này.
|
|
4
4
|
|
|
5
|
+
## [1.12.0] - 2026-06-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Diff preview trước khi approve `edit_file`/`write_file`** (`src/diff.js`): permission prompt hiện unified diff màu (`-` đỏ, `+` xanh, context dim) thay vì tóm tắt raw. `edit_file` đọc file gốc + locate `old_string` → diff vùng thay đổi với context xung quanh. `write_file` overwrite → diff cũ vs mới; file mới → `(file MỚI · N dòng)` xanh top 20 dòng. Bắt model bịa nội dung sớm.
|
|
9
|
+
- **Workflow resume + per-run journal** (`src/workflow-runs.js`): mỗi `/workflow run` ghi journal vào `<cwd>/.noob/workflow-runs/<id>.json` (mỗi sub-agent task: hash crc32(task+context+model), status, result). Ctrl+C / mất mạng giữa fan-out → `/workflow resume <id>` skip task đã done, chỉ chạy lại pending. 3 sub-command mới: `/workflow runs|log|resume`.
|
|
10
|
+
- **Quota usage trong status bar** (`src/api.js` + `src/ui.js`): meta line append `· 4.7k/5k req` cạnh token meter, màu theo % used (dim <70%, vàng 70-85%, đỏ >85%). `/api/usage` cache TTL 90s + background refresh — không spam gateway. Cache bust khi `/login`/`/logout`. Plan admin skip hoàn toàn.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Render markdown trong stream mode** (`src/ui.js` + `src/repl.js`): trước đây streaming path in raw `**bold**`, `` `code` ``, `## heading` không màu. Giờ `makeStreamPrinter` flush theo dòng hoàn chỉnh, render inline:
|
|
14
|
+
- `**bold**` → vàng `#fbbf24` đậm (nhãn `**Vấn đề/cơ hội:**` đập vào mắt)
|
|
15
|
+
- `` `inline code` `` → xanh lá `#34d399` (path/file.ts:23 tách rõ)
|
|
16
|
+
- Heading phân tầng: `#` brand gradient, `##` tím, `###` cyan, `####+` xám sáng
|
|
17
|
+
- Bullet `*`/`-` → `•` xanh accent; code fence (```lang ... ```) pass-through raw
|
|
18
|
+
- **Refactor `src/repl.js` −8.1%** (102KB → 93.8KB): tách 4 module mới với dependency injection rõ ràng — `src/repl/permission.js` (askPermission/askAddRoot/askWorkflowAgentMode), `src/repl/commands/prompts.js` (runImprove/runKarpathy), `src/repl/state.js` (createState factory), `src/repl/utils.js` (shortCwd/relTime/truncate/fmtK/fmtTime/preview/firstLine/shortPath). Mỗi module test được riêng bằng mock object thay vì bám closure.
|
|
19
|
+
- **Context window tăng 300k → 2M tokens** (`src/tokens.js`) + tinh chỉnh ngưỡng auto-compact (`src/repl.js` + `src/agent.js`):
|
|
20
|
+
- Ngưỡng warning kéo xuống cho context lớn: 40% (800k) nhắc nhẹ, 60% (1.2M) cảnh báo mạnh, 75% (1.5M) auto-compact. Trước đây 60/70/80% → với 2M context sẽ để tới 1.6M mới compact, quá trễ vì mỗi lượt cuối có thể ăn 200k+ tokens.
|
|
21
|
+
- `SUMMARIZE_THRESHOLD_CHARS` 1M → 6M chars (~1.5M tokens), match với mốc 75% auto-compact.
|
|
22
|
+
- `keepTail` tăng 4/8 → 16/24 messages: với phiên dài 2M tokens, giữ 4 message cuối là quá ít — model mất hết context tool result gần nhất (vd chuỗi edit_file + run_command đang dở).
|
|
23
|
+
|
|
24
|
+
## [1.11.2] - 2026-06-10
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **Hết tình trạng "timeout nhiều hơn done"** — task chưa xong đã ngủm: chuyển backend gateway sang upstream mới ổn định hơn (upstream cũ hay cắt stream giữa chừng ở phút thứ 4-5 với câu trả lời dài). User không phải đổi gì, chạy `noob update` là dùng được.
|
|
28
|
+
- **Thấy progress khi model đang nghĩ lâu**: gateway giờ phát status `"đang nghĩ… (Ns)"` mỗi 30s khi upstream im lặng → khỏi tưởng treo và Ctrl+C oan. Heartbeat connection cũng đẩy về 10s liên tục để Cloudflare không đóng socket.
|
|
29
|
+
- **CLI tự bắt được khi gateway chết im lặng** (`src/api.js`): idle timeout 60s — nếu không nhận được byte nào (kể cả keepalive) trong 60s thì coi như chết, retry lại thay vì treo vô tận.
|
|
30
|
+
- **Chống vòng lặp "nối tiếp vô hạn"** (`src/api.js`): `maxContinues` cap ở 5 lần thay vì `Infinity`. Trước đây nếu upstream cứ trả truncated, CLI nối tiếp mãi → user thấy treo.
|
|
31
|
+
|
|
32
|
+
### Chore
|
|
33
|
+
- ESLint ignore `worker/**` (sub-project Cloudflare Worker riêng).
|
|
34
|
+
|
|
35
|
+
## [1.11.1] - 2026-06-10
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Câu trả lời không còn bị cắt cụt khi viết về web search / SEO** (`src/api.js`): regex dọn block `## Web Search Results` chỉ chạy ở `/search` mode. Trước đây nó chạy luôn ở chat → nếu bạn đang viết content/SEO có heading dạng đó, phần còn lại của câu trả lời bị nuốt mất.
|
|
39
|
+
- **SSE stream bền hơn với reverse proxy CRLF**: chuẩn hoá `\r\n` → `\n` trước khi parse, tránh dòng SSE bị bỏ sót khi gateway trả line-ending hỗn tạp.
|
|
40
|
+
- **Cờ `--insecure-tls` áp dụng đúng thời điểm** (`src/api.js`): bỏ lệnh áp dụng top-level (chạy trước parse argv vì ESM hoist). Giờ chỉ áp dụng đúng một lần từ `bin/noob.js` sau khi đọc argv.
|
|
41
|
+
- **Không còn `MaxListenersExceededWarning` khi chạy test / dynamic import** (`src/tools.js`): handler cleanup tiến trình nền (`exit` + `SIGTERM`) có guard chống đăng ký kép.
|
|
42
|
+
|
|
43
|
+
### Chore
|
|
44
|
+
- **`.gitignore` / `.npmignore`**: bổ sung `*.bak`, `*.swp`, `Thumbs.db`, `Desktop.ini`, lockfile backup của npm/yarn/pnpm — defence-in-depth, không ảnh hưởng nội dung package đã publish.
|
|
45
|
+
|
|
5
46
|
## [1.11.0] - 2026-06-10
|
|
6
47
|
|
|
7
48
|
### Fixed
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -146,7 +146,7 @@ const LOOP_DETECT_THRESHOLD = 2;
|
|
|
146
146
|
const MAX_PROMPT_CHARS = 1200000; // ~300k tokens (ngang context window) — compact() KHÔNG chạy trước auto-compact 80% (240k token) của repl.js
|
|
147
147
|
// Khi history vượt ngưỡng này, gọi model phụ tóm tắt các lượt cũ thay vì cắt cụt
|
|
148
148
|
// → giữ được "trí nhớ dài hạn" trong phiên mà không nổ context.
|
|
149
|
-
const SUMMARIZE_THRESHOLD_CHARS =
|
|
149
|
+
const SUMMARIZE_THRESHOLD_CHARS = 6000000; // ~1.5M tokens (75% window) — summarize chỉ chạy sau auto-compact 75% với CONTEXT_WINDOW=2M
|
|
150
150
|
|
|
151
151
|
// HARD GOAL block (do /goal <text> set): chèn ngay sau memoryBlock, attention
|
|
152
152
|
// cao. Mục đích — chống 3 failure mode bài "dynamic workflows" của Anthropic
|
|
@@ -258,9 +258,12 @@ export async function maybeSummarize(history, { model, signal, force = false } =
|
|
|
258
258
|
if (!history?.length) return false;
|
|
259
259
|
const totalChars = history.reduce((s, m) => s + (m.content?.length || 0), 0);
|
|
260
260
|
if (!force && totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
|
|
261
|
-
// Giữ
|
|
262
|
-
//
|
|
263
|
-
|
|
261
|
+
// Giữ tail nguyên vẹn; tóm tắt phần trước.
|
|
262
|
+
// Với CONTEXT_WINDOW = 2M tokens, tail cần đủ lớn để giữ context tool result
|
|
263
|
+
// gần nhất (vd 10 lượt cuối có thể là chuỗi edit_file + run_command đang dở).
|
|
264
|
+
// force (gọi từ /compact hoặc auto-compact 75%): giữ 16 tail.
|
|
265
|
+
// non-force: giữ 24 tail (rộng tay hơn vì phiên rất dài mới trigger).
|
|
266
|
+
const keepTail = force ? 16 : 24;
|
|
264
267
|
if (history.length <= keepTail + 2) return false;
|
|
265
268
|
// Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
|
|
266
269
|
const head = history.slice(0, history.length - keepTail);
|
package/src/api.js
CHANGED
|
@@ -45,8 +45,10 @@ export function applyInsecureTLS() {
|
|
|
45
45
|
'\x1b[33m⚠ NOOB_INSECURE_TLS=1: TLS verification DISABLED for this process. MITM-vulnerable. Unset when done.\x1b[0m'
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
|
-
//
|
|
49
|
-
|
|
48
|
+
// KHÔNG gọi applyInsecureTLS() ở top-level: vì ESM hoist, lệnh này sẽ chạy
|
|
49
|
+
// TRƯỚC khi bin/noob.js kịp parse `--insecure-tls` và set env. bin/noob.js đã
|
|
50
|
+
// gọi applyInsecureTLS() sau khi parse argv — đó là điểm duy nhất.
|
|
51
|
+
// Nếu env NOOB_INSECURE_TLS=1 đã có sẵn từ shell, bin/noob.js cũng sẽ áp dụng.
|
|
50
52
|
|
|
51
53
|
function authHeaders() {
|
|
52
54
|
const h = { 'Content-Type': 'application/json' };
|
|
@@ -98,7 +100,13 @@ async function parseError(resp) {
|
|
|
98
100
|
// Unlimited.surf & similar proxies inject web search results as XML/markdown blocks
|
|
99
101
|
// into the SSE stream. These get appended as regular text and confuse the AI model
|
|
100
102
|
// when seen in subsequent turns (it thinks they are prompt injection attempts).
|
|
101
|
-
|
|
103
|
+
//
|
|
104
|
+
// QUAN TRỌNG: chỉ chạy stripping markdown-heading khi mode === 'search'. Với
|
|
105
|
+
// chat/merge, người dùng có thể đang viết về chính chủ đề "Web Search Results"
|
|
106
|
+
// (vd nội dung SEO/content) — regex tham sẽ nuốt mất câu trả lời thật.
|
|
107
|
+
// Các tag XML/bracket/plain-marker thì hiếm khi xuất hiện tự nhiên nên vẫn an
|
|
108
|
+
// toàn để strip ở mọi mode.
|
|
109
|
+
function cleanResponseText(text, mode = 'chat') {
|
|
102
110
|
if (!text) return text;
|
|
103
111
|
let cleaned = text;
|
|
104
112
|
// XML/SGML style: <web_search_results>...</web_search_results>
|
|
@@ -110,11 +118,15 @@ function cleanResponseText(text) {
|
|
|
110
118
|
// Plain text markers: web_search_results ... web_search_results_end
|
|
111
119
|
cleaned = cleaned.replace(/web_search_results[\s\S]*?web_search_results_end/gi, '');
|
|
112
120
|
cleaned = cleaned.replace(/web_search_summary[\s\S]*?web_search_summary_end/gi, '');
|
|
113
|
-
// Markdown headings:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
// Markdown headings: chỉ áp dụng cho search mode (nguồn duy nhất bị inject
|
|
122
|
+
// dạng heading bởi proxy). Lookahead bám sát: dừng tại heading kế tiếp HOẶC
|
|
123
|
+
// 2 dòng trống liên tiếp (đoạn văn mới) — tránh nuốt phần còn lại của câu trả lời.
|
|
124
|
+
if (mode === 'search') {
|
|
125
|
+
cleaned = cleaned.replace(
|
|
126
|
+
/^#{1,3}\s+Web\s+Search\s+(Results|Summary)\b[^\n]*\n[\s\S]*?(?=^#{1,3}\s|\n\n\n|$)/gim,
|
|
127
|
+
''
|
|
128
|
+
);
|
|
129
|
+
}
|
|
118
130
|
return cleaned.trim();
|
|
119
131
|
}
|
|
120
132
|
|
|
@@ -153,7 +165,12 @@ export async function stream({
|
|
|
153
165
|
onDelta,
|
|
154
166
|
onReasoning,
|
|
155
167
|
onStatus,
|
|
156
|
-
|
|
168
|
+
// Cap continuation: trước đây Infinity → khi upstream cứ trả truncated liên
|
|
169
|
+
// tục (effort:high bị Vercel cắt 300s mỗi lần, hoặc CF wall-clock cắt fetch
|
|
170
|
+
// upstream), CLI nối tiếp vô hạn → user thấy như treo. 5 lần là rộng rãi cho
|
|
171
|
+
// câu trả lời rất dài + vài lần bị cắt; nhiều hơn nữa = upstream/gateway
|
|
172
|
+
// hỏng thật, dừng tốt hơn.
|
|
173
|
+
maxContinues = 5,
|
|
157
174
|
}) {
|
|
158
175
|
const endpoint =
|
|
159
176
|
mode === 'search' ? '/api/search' : mode === 'merge' ? '/api/merge' : '/api/chat';
|
|
@@ -213,7 +230,7 @@ export async function stream({
|
|
|
213
230
|
}
|
|
214
231
|
|
|
215
232
|
return {
|
|
216
|
-
text: cleanResponseText(fullText.trim()),
|
|
233
|
+
text: cleanResponseText(fullText.trim(), mode),
|
|
217
234
|
reasoning: reasoning.trim(),
|
|
218
235
|
finishReason: lastFinishReason,
|
|
219
236
|
};
|
|
@@ -275,6 +292,17 @@ async function streamOnce({
|
|
|
275
292
|
let sawDone = false; // thấy {done} = stream kết thúc tử tế (không bị cắt)
|
|
276
293
|
let truncated = false; // gateway báo upstream bị cắt giữa chừng
|
|
277
294
|
|
|
295
|
+
// Idle timeout client-side: gateway phát keepalive (`: keepalive\n\n`) mỗi 10s.
|
|
296
|
+
// Nếu 60s liên tiếp không nhận được gì (kể cả keepalive comment), nghĩa là
|
|
297
|
+
// gateway/CF connection đã chết im lặng. Throw retryable thay vì treo vô tận.
|
|
298
|
+
const IDLE_MS = 60000;
|
|
299
|
+
let lastByteAt = Date.now();
|
|
300
|
+
const idleTimer = setInterval(() => {
|
|
301
|
+
if (Date.now() - lastByteAt > IDLE_MS) {
|
|
302
|
+
ctrl.abort(new Error('idle_timeout'));
|
|
303
|
+
}
|
|
304
|
+
}, 5000);
|
|
305
|
+
|
|
278
306
|
// Một dòng SSE → cập nhật text/reasoning. Tách ra để dùng lại khi flush dòng cuối.
|
|
279
307
|
const processLine = (rawLine) => {
|
|
280
308
|
const line = rawLine.trim();
|
|
@@ -320,7 +348,13 @@ async function streamOnce({
|
|
|
320
348
|
while (true) {
|
|
321
349
|
const { done, value } = await reader.read();
|
|
322
350
|
if (done) break;
|
|
351
|
+
lastByteAt = Date.now(); // bất kỳ byte nào (kể cả keepalive comment) → reset idle timer
|
|
323
352
|
buf += decoder.decode(value, { stream: true });
|
|
353
|
+
// Chuẩn hoá CRLF→LF: một số reverse proxy (Cloudflare, nginx config khác)
|
|
354
|
+
// gửi line endings \r\n. processLine() có trim() nên \r trailing tự rụng,
|
|
355
|
+
// nhưng nếu chunk biên giới rơi đúng giữa \r và \n thì vẫn ổn — đây là
|
|
356
|
+
// safety belt cho trường hợp \r đơn lẻ (rất hiếm nhưng có thấy thực tế).
|
|
357
|
+
buf = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
324
358
|
let nl;
|
|
325
359
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
326
360
|
processLine(buf.slice(0, nl));
|
|
@@ -336,21 +370,60 @@ async function streamOnce({
|
|
|
336
370
|
|
|
337
371
|
return { text, reasoning, truncated };
|
|
338
372
|
} catch (err) {
|
|
373
|
+
// Phân biệt user abort (Ctrl+C — signal cha) với idle_timeout (ctrl.abort
|
|
374
|
+
// bên trong khi gateway im lặng quá lâu). Cả hai cùng làm fetch throw
|
|
375
|
+
// AbortError nên check signal cha trước.
|
|
339
376
|
if (signal?.aborted) throw err; // người dùng bấm Ctrl+C → huỷ thật, không nối tiếp
|
|
340
|
-
|
|
341
|
-
//
|
|
377
|
+
const isIdle = ctrl.signal.aborted && err?.name === 'AbortError';
|
|
378
|
+
// Rớt mạng giữa chừng (không phải huỷ user): với chat, nếu đã có chữ thì
|
|
379
|
+
// trả phần đã nhận + cờ truncated để lớp trên nối tiếp.
|
|
342
380
|
if (mode === 'chat' && text) return { text, reasoning, truncated: true };
|
|
381
|
+
// Chưa có chữ nào và là idle_timeout → throw lỗi retryable rõ ràng (thay
|
|
382
|
+
// vì AbortError chung chung) để streamWithRetry biết cần thử lại.
|
|
383
|
+
if (isIdle) {
|
|
384
|
+
throw new ApiError('idle_timeout: gateway im lặng quá lâu', {
|
|
385
|
+
code: 'idle_timeout',
|
|
386
|
+
retryable: true,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
343
389
|
throw err;
|
|
344
390
|
} finally {
|
|
391
|
+
clearInterval(idleTimer);
|
|
345
392
|
signal?.removeEventListener('abort', onUserAbort);
|
|
346
393
|
}
|
|
347
394
|
}
|
|
348
395
|
|
|
396
|
+
// Cache /api/usage trong 90s để gọi từ status bar không spam gateway. Key cache
|
|
397
|
+
// theo apiKey — đổi key (login khác) thì cache bust ngay. Bypass cache bằng
|
|
398
|
+
// usage({ force: true }) cho /usage command (user gõ tay → muốn realtime).
|
|
399
|
+
let _usageCache = { key: null, at: 0, data: null };
|
|
400
|
+
const USAGE_CACHE_MS = 90_000;
|
|
401
|
+
|
|
349
402
|
/** Fetch the current key's quota/usage from the gateway (no request consumed). */
|
|
350
|
-
export async function usage() {
|
|
403
|
+
export async function usage({ force = false } = {}) {
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const key = config.apiKey || '';
|
|
406
|
+
if (!force && _usageCache.data && _usageCache.key === key && now - _usageCache.at < USAGE_CACHE_MS) {
|
|
407
|
+
return _usageCache.data;
|
|
408
|
+
}
|
|
351
409
|
const resp = await fetch(config.gatewayUrl + '/api/usage', { headers: authHeaders() });
|
|
352
410
|
if (!resp.ok) throw await parseError(resp);
|
|
353
|
-
|
|
411
|
+
const data = await resp.json();
|
|
412
|
+
_usageCache = { key, at: now, data };
|
|
413
|
+
return data;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Trả về usage đã cache mà KHÔNG fetch (cho status bar render sync). Null nếu chưa có. */
|
|
417
|
+
export function cachedUsage() {
|
|
418
|
+
const key = config.apiKey || '';
|
|
419
|
+
if (!_usageCache.data || _usageCache.key !== key) return null;
|
|
420
|
+
if (Date.now() - _usageCache.at > USAGE_CACHE_MS) return null;
|
|
421
|
+
return _usageCache.data;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Bust cache (gọi sau khi đổi key / logout). */
|
|
425
|
+
export function resetUsageCache() {
|
|
426
|
+
_usageCache = { key: null, at: 0, data: null };
|
|
354
427
|
}
|
|
355
428
|
|
|
356
429
|
export { ApiError };
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Minimal line-based unified diff cho permission preview. KHÔNG phải implementation
|
|
2
|
+
// đầy đủ của Myers — chỉ cần LCS + emit context để show user trước khi approve.
|
|
3
|
+
//
|
|
4
|
+
// Mục tiêu: user thấy CHÍNH XÁC dòng nào bị xoá / thêm, không bị model bịa nội dung.
|
|
5
|
+
// Output dạng unified diff (` @@`, `-`, `+`, ` `), colorize bằng chalk ở caller.
|
|
6
|
+
//
|
|
7
|
+
// Performance: O(M*N) LCS table. M, N ≤ ~vài trăm dòng cho 1 edit_file → đủ nhanh.
|
|
8
|
+
// File lớn hơn (write_file overwrite 1k+ dòng) → caller truncate trước khi gọi.
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
// LCS table giữa 2 mảng lines. Trả về matrix (M+1) x (N+1).
|
|
12
|
+
function lcsTable(a, b) {
|
|
13
|
+
const m = a.length;
|
|
14
|
+
const n = b.length;
|
|
15
|
+
const t = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
|
|
16
|
+
for (let i = 1; i <= m; i++) {
|
|
17
|
+
for (let j = 1; j <= n; j++) {
|
|
18
|
+
if (a[i - 1] === b[j - 1]) t[i][j] = t[i - 1][j - 1] + 1;
|
|
19
|
+
else t[i][j] = t[i - 1][j] >= t[i][j - 1] ? t[i - 1][j] : t[i][j - 1];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return t;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Backtrack LCS → mảng các op { type: 'eq'|'del'|'add', text }.
|
|
26
|
+
// Output theo thứ tự xuất hiện trong file (top-down).
|
|
27
|
+
function diffLines(a, b) {
|
|
28
|
+
const t = lcsTable(a, b);
|
|
29
|
+
const ops = [];
|
|
30
|
+
let i = a.length;
|
|
31
|
+
let j = b.length;
|
|
32
|
+
while (i > 0 && j > 0) {
|
|
33
|
+
if (a[i - 1] === b[j - 1]) {
|
|
34
|
+
ops.push({ type: 'eq', text: a[i - 1] });
|
|
35
|
+
i--; j--;
|
|
36
|
+
} else if (t[i - 1][j] >= t[i][j - 1]) {
|
|
37
|
+
ops.push({ type: 'del', text: a[i - 1] });
|
|
38
|
+
i--;
|
|
39
|
+
} else {
|
|
40
|
+
ops.push({ type: 'add', text: b[j - 1] });
|
|
41
|
+
j--;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
while (i > 0) { ops.push({ type: 'del', text: a[--i] }); }
|
|
45
|
+
while (j > 0) { ops.push({ type: 'add', text: b[--j] }); }
|
|
46
|
+
return ops.reverse();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Group ops thành hunks (cụm thay đổi + context). `context` = số dòng eq trước/sau
|
|
50
|
+
// mỗi cụm del/add. Mặc định 3 — chuẩn unified diff.
|
|
51
|
+
function groupHunks(ops, context = 3) {
|
|
52
|
+
const hunks = [];
|
|
53
|
+
let cur = null;
|
|
54
|
+
let trailingEq = 0;
|
|
55
|
+
for (let k = 0; k < ops.length; k++) {
|
|
56
|
+
const op = ops[k];
|
|
57
|
+
if (op.type === 'eq') {
|
|
58
|
+
if (!cur) {
|
|
59
|
+
// Chưa có hunk đang mở → giữ trong sliding window context để mở hunk kế tiếp.
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
cur.ops.push(op);
|
|
63
|
+
trailingEq++;
|
|
64
|
+
if (trailingEq > context * 2) {
|
|
65
|
+
// Đã đủ context sau → đóng hunk, trim eq thừa.
|
|
66
|
+
const trimCount = trailingEq - context;
|
|
67
|
+
cur.ops.splice(cur.ops.length - trimCount, trimCount);
|
|
68
|
+
hunks.push(cur);
|
|
69
|
+
cur = null;
|
|
70
|
+
trailingEq = 0;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
trailingEq = 0;
|
|
74
|
+
if (!cur) {
|
|
75
|
+
// Mở hunk mới: thêm context trước (eq trong window vừa rồi).
|
|
76
|
+
cur = { ops: [] };
|
|
77
|
+
// Lấy `context` eq lùi từ vị trí hiện tại.
|
|
78
|
+
const start = Math.max(0, k - context);
|
|
79
|
+
for (let p = start; p < k; p++) {
|
|
80
|
+
if (ops[p].type === 'eq') cur.ops.push(ops[p]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
cur.ops.push(op);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (cur) {
|
|
87
|
+
// Trim trailing eq nếu vượt context.
|
|
88
|
+
while (cur.ops.length > 0 && cur.ops[cur.ops.length - 1].type === 'eq') {
|
|
89
|
+
let trail = 0;
|
|
90
|
+
for (let p = cur.ops.length - 1; p >= 0 && cur.ops[p].type === 'eq'; p--) trail++;
|
|
91
|
+
if (trail > context) cur.ops.splice(cur.ops.length - (trail - context), trail - context);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
hunks.push(cur);
|
|
95
|
+
}
|
|
96
|
+
return hunks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Render unified diff với màu. `label` là tiêu đề (vd path file).
|
|
100
|
+
// `maxLines` cap tổng số dòng render (mặc định 60) — diff quá dài hiện cuối `... +N dòng`.
|
|
101
|
+
export function renderUnifiedDiff(oldText, newText, { label = '', context = 3, maxLines = 60 } = {}) {
|
|
102
|
+
const a = oldText.split('\n');
|
|
103
|
+
const b = newText.split('\n');
|
|
104
|
+
const ops = diffLines(a, b);
|
|
105
|
+
const hunks = groupHunks(ops, context);
|
|
106
|
+
const out = [];
|
|
107
|
+
out.push(chalk.dim(' ┌─ ' + label));
|
|
108
|
+
if (!hunks.length) {
|
|
109
|
+
out.push(chalk.dim(' │ ') + chalk.dim('(không có thay đổi)'));
|
|
110
|
+
out.push(chalk.dim(' └─'));
|
|
111
|
+
return out.join('\n');
|
|
112
|
+
}
|
|
113
|
+
let printed = 0;
|
|
114
|
+
let truncated = 0;
|
|
115
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
116
|
+
if (h > 0) out.push(chalk.dim(' │ ' + chalk.dim('@@')));
|
|
117
|
+
for (const op of hunks[h].ops) {
|
|
118
|
+
if (printed >= maxLines) {
|
|
119
|
+
truncated++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const text = String(op.text).slice(0, 200);
|
|
123
|
+
let line;
|
|
124
|
+
if (op.type === 'add') line = chalk.green('+ ') + chalk.green(text);
|
|
125
|
+
else if (op.type === 'del') line = chalk.red('- ') + chalk.red(text);
|
|
126
|
+
else line = chalk.dim(' ') + chalk.dim(text);
|
|
127
|
+
out.push(chalk.dim(' │ ') + line);
|
|
128
|
+
printed++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (truncated) {
|
|
132
|
+
out.push(chalk.dim(' │ ') + chalk.dim(`… +${truncated} dòng nữa (truncated)`));
|
|
133
|
+
}
|
|
134
|
+
out.push(chalk.dim(' └─'));
|
|
135
|
+
return out.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Render preview cho file mới (write_file path chưa tồn tại).
|
|
139
|
+
// Hiển thị top N dòng + count tổng. Không có concept diff.
|
|
140
|
+
export function renderNewFilePreview(content, { label = '', maxLines = 20 } = {}) {
|
|
141
|
+
const lines = String(content || '').split('\n');
|
|
142
|
+
const head = lines.slice(0, maxLines);
|
|
143
|
+
const more = lines.length - head.length;
|
|
144
|
+
const out = [];
|
|
145
|
+
out.push(chalk.dim(' ┌─ ' + label + ' ') + chalk.green(`(file MỚI · ${lines.length} dòng)`));
|
|
146
|
+
for (const l of head) {
|
|
147
|
+
out.push(chalk.dim(' │ ') + chalk.green('+ ') + chalk.green(l.slice(0, 200)));
|
|
148
|
+
}
|
|
149
|
+
if (more > 0) out.push(chalk.dim(' │ ') + chalk.dim(`… +${more} dòng nữa`));
|
|
150
|
+
out.push(chalk.dim(' └─'));
|
|
151
|
+
return out.join('\n');
|
|
152
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Slash commands chạy 1 prompt cố định qua handle() + persist().
|
|
2
|
+
// Tách khỏi src/repl.js — cả 2 cùng pattern, đơn giản, không động state phức tạp.
|
|
3
|
+
//
|
|
4
|
+
// Caller truyền:
|
|
5
|
+
// - config: để check apiKey
|
|
6
|
+
// - c, t: color + i18n
|
|
7
|
+
// - handle: async function (prompt) → đẩy 1 lượt user tới agent loop
|
|
8
|
+
// - persist: lưu session sau lượt
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* /improve [hint] — model rà soát workspace & đề xuất tính năng/cải tiến.
|
|
12
|
+
* KHÔNG sửa code, chỉ phân tích & đề xuất.
|
|
13
|
+
*/
|
|
14
|
+
export async function runImprove(arg, { config, c, t, handle, persist }) {
|
|
15
|
+
if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
|
|
16
|
+
const focus = arg
|
|
17
|
+
? `\nNgười dùng nhấn mạnh: "${arg}". Ưu tiên theo hướng đó nhưng vẫn nêu gợi ý quan trọng khác.`
|
|
18
|
+
: '';
|
|
19
|
+
const prompt = `Đóng vai senior engineer & product reviewer. KHẢO SÁT workspace hiện tại và đề xuất TÍNH NĂNG / CẢI TIẾN cho dự án.${focus}\n\nQUY TRÌNH (dùng tool, không nói suông):\n1. list_dir thư mục gốc để nắm cấu trúc.\n2. Đọc README.md, package.json, noob.md, CHANGELOG.md (nếu có) để hiểu mục đích & trạng thái.\n3. list_dir/glob các thư mục mã chính. KHÔNG đọc hết file — chỉ đủ để nắm kiến trúc.\n4. grep TODO/FIXME/HACK/XXX để biết chỗ tác giả đã ghi nhận.\n5. Ghi nhận thiếu test/lint/CI nếu có.\n\nSAU KHẢO SÁT, viết báo cáo Markdown TIẾNG VIỆT theo cấu trúc:\n\n## Tóm tắt dự án\n2–4 dòng: làm gì, tech gì, trạng thái.\n\n## Điểm mạnh hiện tại\n3–6 gạch đầu dòng.\n\n## Gợi ý cải thiện\n5–10 đề xuất, MỖI cái:\n### N. <Tên>\n- **Vấn đề/cơ hội:** quan sát cụ thể (kèm tên_file:dòng nếu được).\n- **Đề xuất:** mô tả tính năng/cải tiến.\n- **Lợi ích:** UX/hiệu năng/độ tin cậy/mở rộng.\n- **Công sức:** S (vài giờ) / M (1–2 ngày) / L (>2 ngày).\n- **Ưu tiên:** P0 / P1 / P2.\n\n## Đề xuất ưu tiên hàng đầu\n1–3 mục P0 nên làm trước, kèm lý do.\n\nQUY TẮC: bám observation từ code thật, KHÔNG gợi ý chung chung, thẳng thắn không nịnh, KHÔNG sửa code, KHÔNG ghi noob.md.`;
|
|
20
|
+
console.log(c.tool(' ✨ ' + t.improveRunning));
|
|
21
|
+
await handle(prompt);
|
|
22
|
+
persist();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* /karpathy [path] — bắt noob tự rà soát code theo 4 nguyên tắc Karpathy.
|
|
27
|
+
* Không có path → soát các file đã đổi trong phiên (model thấy qua FILES CHANGED).
|
|
28
|
+
*/
|
|
29
|
+
export async function runKarpathy(arg, { config, c, t, handle, persist }) {
|
|
30
|
+
if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
|
|
31
|
+
const target = arg
|
|
32
|
+
? `file/đường dẫn: ${arg}`
|
|
33
|
+
: 'các file bạn đã tạo/sửa trong phiên này (xem mục FILES CHANGED)';
|
|
34
|
+
const prompt = `Đóng vai reviewer khó tính. Rà soát ${target} theo 4 nguyên tắc code của Karpathy.
|
|
35
|
+
ĐỌC nội dung file thật bằng read_file trước — KHÔNG dựa vào trí nhớ.
|
|
36
|
+
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":
|
|
37
|
+
1. THINK FIRST — giả định ẩn nào chưa nêu? chỗ nào thiếu kiểm chứng?
|
|
38
|
+
2. KEEP IT SIMPLE — over-engineer, abstraction thừa, lồng quá sâu, hàm quá dài?
|
|
39
|
+
3. SURGICAL — thay đổi lạc đề, refactor tiện tay, đổi style/format vô cớ?
|
|
40
|
+
4. VERIFIABLE GOAL — mục tiêu có kiểm chứng được? đã chạy build/test chưa?
|
|
41
|
+
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.`;
|
|
42
|
+
console.log(c.tool(' ⚖ Karpathy check…'));
|
|
43
|
+
await handle(prompt);
|
|
44
|
+
persist();
|
|
45
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Permission prompts (y/n/a) cho tool calls, add-root, và workflow agent mode.
|
|
2
|
+
//
|
|
3
|
+
// Tách khỏi src/repl.js (refactor 1.11.4+): 3 hàm này dùng chung pattern
|
|
4
|
+
// "paste-spam protection" — nếu user paste/gõ nhầm tin nhắn lạ (dòng dài > 3 char,
|
|
5
|
+
// không khớp y/n/a) vào đúng lúc prompt hiện, thay vì coi như từ chối, ta XẾP
|
|
6
|
+
// HÀNG tin đó (pending) và hỏi lại. Đây là thủ phạm cũ làm CLI "tự tắt" hoặc
|
|
7
|
+
// quyết định nhầm trước đây.
|
|
8
|
+
//
|
|
9
|
+
// Mỗi hàm:
|
|
10
|
+
// - tui.setBusy(false) trước prompt (tắt spinner để prompt nổi bật).
|
|
11
|
+
// - try/finally khôi phục busy=true với label thinking.
|
|
12
|
+
// - stdin đóng (raw == null) → return 'n' (mặc định an toàn).
|
|
13
|
+
//
|
|
14
|
+
// Caller phải truyền:
|
|
15
|
+
// - tui: TUI instance (setBusy, ask qua chính TUI hoặc inject `ask` riêng).
|
|
16
|
+
// - ask: hàm async đọc 1 dòng từ stdin (đã có sẵn trong repl.js).
|
|
17
|
+
// - pending: mảng tin nhắn xếp hàng dùng chung với REPL loop.
|
|
18
|
+
// - c: color palette (chalk hex).
|
|
19
|
+
// - t: i18n strings.
|
|
20
|
+
// - truncate: helper cắt chuỗi dài.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hỏi quyền chung cho 1 tool call. Trả về 'y' | 'n' | 'a'.
|
|
24
|
+
* 'a' = auto-approve mọi lần gọi tool name này trong phiên còn lại.
|
|
25
|
+
*/
|
|
26
|
+
export async function askPermission(name, { tui, ask, pending, c, t, truncate }) {
|
|
27
|
+
tui.setBusy(false);
|
|
28
|
+
console.log(
|
|
29
|
+
c.tool(' ⏸ Cần quyền: ' + name) +
|
|
30
|
+
c.dim(' — gõ y (đồng ý) / n (từ chối) / a (luôn cho phép)')
|
|
31
|
+
);
|
|
32
|
+
try {
|
|
33
|
+
while (true) {
|
|
34
|
+
const raw = await ask(
|
|
35
|
+
c.tool(' cho phép? ') + c.dim('[y] có / [n] không / [a] luôn ' + name + ' › ')
|
|
36
|
+
);
|
|
37
|
+
if (raw == null) return 'n';
|
|
38
|
+
const a = raw.trim().toLowerCase();
|
|
39
|
+
if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
|
|
40
|
+
if (a === 'n' || a === 'no' || a === 'không') return 'n';
|
|
41
|
+
if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
|
|
42
|
+
if (raw.trim().length > 3) {
|
|
43
|
+
pending.push(raw);
|
|
44
|
+
console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
|
|
45
|
+
}
|
|
46
|
+
console.log(c.dim(' ' + t.permRetry));
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
tui.setBusy(true, t.thinking);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hỏi user có muốn add folder ngoài workspace cho 1 tool call cụ thể.
|
|
55
|
+
* `targetPath` = path tool đang muốn truy cập (để user thấy rõ context).
|
|
56
|
+
* Trả về 'y' | 'n' | 'a'.
|
|
57
|
+
*/
|
|
58
|
+
export async function askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate }) {
|
|
59
|
+
tui.setBusy(false);
|
|
60
|
+
console.log(c.tool(' ⏸ Cần cấp quyền folder: ') + c.accent(root));
|
|
61
|
+
console.log(c.dim(' (model muốn truy cập: ' + targetPath + ')'));
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const raw = await ask(
|
|
65
|
+
c.tool(' cho phép? ') +
|
|
66
|
+
c.dim('[y] thêm vào scope lần này / [a] luôn thêm / [n] từ chối › ')
|
|
67
|
+
);
|
|
68
|
+
if (raw == null) return 'n';
|
|
69
|
+
const a = raw.trim().toLowerCase();
|
|
70
|
+
if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
|
|
71
|
+
if (a === 'n' || a === 'no' || a === 'không') return 'n';
|
|
72
|
+
if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
|
|
73
|
+
if (raw.trim().length > 3) {
|
|
74
|
+
pending.push(raw);
|
|
75
|
+
console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
|
|
76
|
+
}
|
|
77
|
+
console.log(c.dim(' → gõ y / n / a'));
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
tui.setBusy(true, t.thinking);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Hỏi user có muốn bật agent mode để chạy /workflow. Chỉ y/n (Enter=yes).
|
|
86
|
+
* Trả về 'y' | 'n'.
|
|
87
|
+
*/
|
|
88
|
+
export async function askWorkflowAgentMode({ tui, ask, pending, c, t, truncate }) {
|
|
89
|
+
tui.setBusy(false);
|
|
90
|
+
console.log(
|
|
91
|
+
c.tool(
|
|
92
|
+
' ' +
|
|
93
|
+
(t.workflowAgentAskHint ||
|
|
94
|
+
'🎼 /workflow cần spawn sub-agent — agent mode hiện đang TẮT.')
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
try {
|
|
98
|
+
while (true) {
|
|
99
|
+
const raw = await ask(
|
|
100
|
+
c.tool(' bật agent mode và chạy workflow? ') +
|
|
101
|
+
c.dim('[y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ')
|
|
102
|
+
);
|
|
103
|
+
if (raw == null) return 'n';
|
|
104
|
+
const a = raw.trim().toLowerCase();
|
|
105
|
+
if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
|
|
106
|
+
if (a === 'n' || a === 'no' || a === 'không') return 'n';
|
|
107
|
+
if (raw.trim().length > 3) {
|
|
108
|
+
pending.push(raw);
|
|
109
|
+
console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
|
|
110
|
+
}
|
|
111
|
+
console.log(c.dim(' → gõ y hoặc n'));
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
tui.setBusy(true, t.thinking);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// State phiên REPL — factory pure, không side effect.
|
|
2
|
+
//
|
|
3
|
+
// Tách khỏi src/repl.js (refactor): trước đây inline trong startRepl(). Tách ra
|
|
4
|
+
// để (1) test được riêng — pass opts vào, assert shape; (2) thấy ngay shape state
|
|
5
|
+
// đầy đủ thay vì cuộn 50 dòng init.
|
|
6
|
+
//
|
|
7
|
+
// Lưu ý: state chỉ chứa primitive + collection rỗng. Mọi reference động (model,
|
|
8
|
+
// tokenMeter, abort, session) vẫn ở startRepl scope — vì chúng có lifecycle khác
|
|
9
|
+
// state và cần closure binding với tui/api.
|
|
10
|
+
import { findModel, DEFAULT_MODEL } from '../models.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} opts - CLI flags từ bin/noob.js
|
|
14
|
+
* @param {Object} config - config singleton (đọc model mặc định + yoloDefault)
|
|
15
|
+
* @returns {Object} state phiên — mutable, share giữa REPL loop + commands
|
|
16
|
+
*/
|
|
17
|
+
export function createState(opts = {}, config) {
|
|
18
|
+
return {
|
|
19
|
+
model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
|
|
20
|
+
mode: 'chat', // chat | merge | search
|
|
21
|
+
history: [],
|
|
22
|
+
autoApprove: new Set(),
|
|
23
|
+
yolo: !!opts.yolo || config.yoloDefault,
|
|
24
|
+
ultra: false, // /ultra chế độ tự hành đang chạy
|
|
25
|
+
agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
|
|
26
|
+
goal: null, // HARD GOAL set qua /goal — inject vào mọi prompt
|
|
27
|
+
loop: null, // /loop — {intervalMs, intervalStr, task, timer, ticks, startedAt} | null
|
|
28
|
+
extraRoots: [], // /add-dir — UX display only; source of truth ở tools.js
|
|
29
|
+
_longSessionWarned: false, // đã in cảnh báo phiên dài chưa (reset khi /clear)
|
|
30
|
+
todos: [], // [{text, done}] — todo list parse từ model output, render trên status bar
|
|
31
|
+
workflowRun: null, // {id, data, path} — active workflow run journal (resume + log)
|
|
32
|
+
};
|
|
33
|
+
}
|