@noobdemon/noob-cli 1.11.1 → 1.12.1

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 CHANGED
@@ -2,6 +2,45 @@
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.1] - 2026-06-11
6
+
7
+ ### Fixed
8
+ - **TDZ ReferenceError khi workflow / tool out-of-scope ask permission** (`src/repl.js`): 3 helper `askPermission`/`askAddRoot`/`askWorkflowAgentMode` trước đây khai báo `const` rải rác (line ~405/1648/1655) — SAU chỗ gọi đầu tiên trong scope `startRepl` → JS Temporal Dead Zone throw `Cannot access 'askAddRoot' before initialization`. Exception bị `uncaughtException` handler nuốt im lặng → user gõ `y` mà flow chết, workflow approve xong không chạy. Fix: gom 3 const lên line 147 ngay sau `function ask`, trước mọi function khác. Verify bằng E2E smoke spawn bin thật (`scripts/smoke-tdz.mjs`, 3/3 PASS).
9
+ - **Todo progress bar không reset khi chuyển task** (`src/repl/todos.js`): parser cũ scan toàn lịch sử rồi dedupe theo text → todo task A vẫn đếm vào bar dù model đã viết list mới cho task B (bar 5/10 nhưng reply chỉ có 3 todo). Fix: scan reverse, dừng ở assistant message **đầu tiên** có ≥1 dòng `- [ ]`/`- [x]` — coi đó là "todo list hiện tại", bỏ qua mọi message trước. Tự nhiên reset khi model viết list mới.
10
+
11
+ ### Changed
12
+ - **Todo parser hardening** (`src/repl/todos.js`): skip dòng nằm trong code fence ` ``` ` (paste README/snippet có checkbox không bị bắt nhầm), chấp nhận cả `[X]` viết hoa (cờ `/i` cho cả `RE_DONE` và `RE_TODO`), bỏ qua message có content kiểu non-string (multimodal array).
13
+
14
+ ## [1.12.0] - 2026-06-11
15
+
16
+ ### Added
17
+ - **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.
18
+ - **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`.
19
+ - **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.
20
+
21
+ ### Changed
22
+ - **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:
23
+ - `**bold**` → vàng `#fbbf24` đậm (nhãn `**Vấn đề/cơ hội:**` đập vào mắt)
24
+ - `` `inline code` `` → xanh lá `#34d399` (path/file.ts:23 tách rõ)
25
+ - Heading phân tầng: `#` brand gradient, `##` tím, `###` cyan, `####+` xám sáng
26
+ - Bullet `*`/`-` → `•` xanh accent; code fence (```lang ... ```) pass-through raw
27
+ - **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.
28
+ - **Context window tăng 300k → 2M tokens** (`src/tokens.js`) + tinh chỉnh ngưỡng auto-compact (`src/repl.js` + `src/agent.js`):
29
+ - 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.
30
+ - `SUMMARIZE_THRESHOLD_CHARS` 1M → 6M chars (~1.5M tokens), match với mốc 75% auto-compact.
31
+ - `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ở).
32
+
33
+ ## [1.11.2] - 2026-06-10
34
+
35
+ ### Fixed
36
+ - **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.
37
+ - **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.
38
+ - **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.
39
+ - **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.
40
+
41
+ ### Chore
42
+ - ESLint ignore `worker/**` (sub-project Cloudflare Worker riêng).
43
+
5
44
  ## [1.11.1] - 2026-06-10
6
45
 
7
46
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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 = 1000000; // ~250k tokens (83% window) — summarize chỉ chạy sau auto-compact 80%
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ữ 8 message cuối nguyên vẹn; tóm tắt phần trước.
262
- // Khi force (gọi từ /compact), giữ ít tail hơn để tóm tắt mạnh hơn.
263
- const keepTail = force ? 4 : 8;
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
@@ -165,7 +165,12 @@ export async function stream({
165
165
  onDelta,
166
166
  onReasoning,
167
167
  onStatus,
168
- maxContinues = Infinity,
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,
169
174
  }) {
170
175
  const endpoint =
171
176
  mode === 'search' ? '/api/search' : mode === 'merge' ? '/api/merge' : '/api/chat';
@@ -287,6 +292,17 @@ async function streamOnce({
287
292
  let sawDone = false; // thấy {done} = stream kết thúc tử tế (không bị cắt)
288
293
  let truncated = false; // gateway báo upstream bị cắt giữa chừng
289
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
+
290
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.
291
307
  const processLine = (rawLine) => {
292
308
  const line = rawLine.trim();
@@ -332,6 +348,7 @@ async function streamOnce({
332
348
  while (true) {
333
349
  const { done, value } = await reader.read();
334
350
  if (done) break;
351
+ lastByteAt = Date.now(); // bất kỳ byte nào (kể cả keepalive comment) → reset idle timer
335
352
  buf += decoder.decode(value, { stream: true });
336
353
  // Chuẩn hoá CRLF→LF: một số reverse proxy (Cloudflare, nginx config khác)
337
354
  // gửi line endings \r\n. processLine() có trim() nên \r trailing tự rụng,
@@ -353,21 +370,60 @@ async function streamOnce({
353
370
 
354
371
  return { text, reasoning, truncated };
355
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.
356
376
  if (signal?.aborted) throw err; // người dùng bấm Ctrl+C → huỷ thật, không nối tiếp
357
- // Rớt mạng giữa chừng (không phải huỷ): với chat, nếu đã có
358
- // chữ thì trả phần đã nhận + cờ truncated để lớp trên nối tiếp.
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 đã chữ thì
379
+ // trả phần đã nhận + cờ truncated để lớp trên nối tiếp.
359
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
+ }
360
389
  throw err;
361
390
  } finally {
391
+ clearInterval(idleTimer);
362
392
  signal?.removeEventListener('abort', onUserAbort);
363
393
  }
364
394
  }
365
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
+
366
402
  /** Fetch the current key's quota/usage from the gateway (no request consumed). */
367
- 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
+ }
368
409
  const resp = await fetch(config.gatewayUrl + '/api/usage', { headers: authHeaders() });
369
410
  if (!resp.ok) throw await parseError(resp);
370
- return await resp.json();
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 };
371
427
  }
372
428
 
373
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
+ }
package/src/repl/todos.js CHANGED
@@ -1,38 +1,79 @@
1
- // Parse danh sách todo từ history hội thoại. Scan các assistant message tìm
2
- // pattern markdown `- [ ] task` và `- [x] task` (case-insensitive cho dấu x).
3
- // Pure function: chỉ phụ thuộc input `history`, không closure state.
1
+ // Parse danh sách todo từ history hội thoại.
4
2
  //
5
- // Dedupe: nếu cùng một text xuất hiện nhiều lần (model lặp todo qua các lượt),
6
- // giữ trạng thái CUỐI cùng phản ánh state hiện tại của model.
3
+ // Quan trọng: chỉ parse list todo CUỐI cùng assistant message GẦN NHẤT
4
+ // ít nhất 1 dòng `- [ ]` hoặc `- [x]` được coi là "todo list hiện tại". Mọi
5
+ // message trước đó bị bỏ qua.
6
+ //
7
+ // Lý do: trước đây scan toàn lịch sử rồi dedupe theo text → todo từ task CŨ
8
+ // vẫn đếm vào progress bar dù model đã chuyển sang task mới. Triệu chứng:
9
+ // bar 5/10 nhưng reply mới nhất chỉ có 3 todo. Cách mới tự nhiên reset khi
10
+ // model viết todo list mới, không bao giờ trộn todo từ 2 task khác nhau.
11
+ //
12
+ // Edge case đã xử lý:
13
+ // - Code fence ``` (cả với info string như ```md): bỏ qua dòng bên trong,
14
+ // tránh paste README/snippet có checkbox bị parser bắt nhầm thành todo.
15
+ // - Cả `[x]` và `[X]` (viết hoa) đều coi là done.
16
+ // - Multi-line message lặp cùng todo text: giữ trạng thái CUỐI.
17
+ //
18
+ // Pure function: chỉ phụ thuộc input `history`.
7
19
 
8
20
  /**
9
21
  * @typedef {{ text: string, done: boolean }} TodoItem
10
22
  */
11
23
 
24
+ // Regex tách riêng cho rõ ràng + dễ test. Cả 2 dùng cờ `i` để chấp nhận `[X]`
25
+ // (viết hoa) — markdown checkbox không phân biệt hoa thường trong thực tế.
26
+ const RE_DONE = /^\s*-\s*\[x\]\s+(.+)/i;
27
+ const RE_TODO = /^\s*-\s*\[\s?\]\s+(.+)/i;
28
+ // Code fence: ``` đầu dòng, có thể kèm info string (vd ```js, ```md).
29
+ const RE_FENCE = /^\s*```/;
30
+
12
31
  /**
13
- * Trích todo list từ history.
14
- * @param {Array<{role: string, content: any}>} history
32
+ * Parse các dòng todo trong 1 message content. Trả về [] nếu không có dòng nào.
33
+ * - Bỏ qua dòng nằm trong code fence (``` ... ```).
34
+ * - Trong cùng 1 message, nếu cùng text xuất hiện 2 lần (vd model lặp khi
35
+ * format), giữ trạng thái CUỐI — phản ánh ý định gần nhất.
36
+ * @param {string} content
15
37
  * @returns {TodoItem[]}
16
38
  */
17
- export function parseTodosFromHistory(history) {
18
- const todos = [];
19
- for (const m of history) {
20
- if (m.role !== 'assistant' || typeof m.content !== 'string') continue;
21
- const lines = m.content.split('\n');
22
- for (const line of lines) {
23
- const doneMatch = line.match(/^[\s]*-\s*\[x\]\s+(.+)/i);
24
- if (doneMatch) {
25
- todos.push({ text: doneMatch[1].trim(), done: true });
26
- continue;
27
- }
28
- const todoMatch = line.match(/^[\s]*-\s*\[\s?\]\s+(.+)/);
29
- if (todoMatch) {
30
- todos.push({ text: todoMatch[1].trim(), done: false });
31
- }
39
+ function parseTodosFromMessage(content) {
40
+ const out = [];
41
+ let inFence = false;
42
+ for (const line of content.split('\n')) {
43
+ if (RE_FENCE.test(line)) {
44
+ inFence = !inFence;
45
+ continue;
32
46
  }
47
+ if (inFence) continue;
48
+ const m1 = line.match(RE_DONE);
49
+ if (m1) {
50
+ out.push({ text: m1[1].trim(), done: true });
51
+ continue;
52
+ }
53
+ const m2 = line.match(RE_TODO);
54
+ if (m2) out.push({ text: m2[1].trim(), done: false });
33
55
  }
34
- // Dedupe: giữ item CUỐI cùng cho mỗi text (model thể lặp todo qua các lượt).
56
+ // Dedupe trong cùng message: giữ entry CUỐI cho mỗi text. Map<1 entry rẻ
57
+ // như không có — không cần micro-opt skip khi out.length<=1.
35
58
  const seen = new Map();
36
- for (const t of todos) seen.set(t.text, t);
59
+ for (const it of out) seen.set(it.text, it);
37
60
  return [...seen.values()];
38
61
  }
62
+
63
+ /**
64
+ * Trích todo list từ history. Chỉ trả về todo từ assistant message GẦN NHẤT
65
+ * chứa ít nhất 1 dòng todo. Mọi message trước đó bị bỏ qua.
66
+ * Bỏ qua message có content kiểu non-string (vd multimodal array) — chưa hỗ trợ.
67
+ * @param {Array<{role: string, content: any}>} history
68
+ * @returns {TodoItem[]}
69
+ */
70
+ export function parseTodosFromHistory(history) {
71
+ if (!Array.isArray(history)) return [];
72
+ for (let i = history.length - 1; i >= 0; i--) {
73
+ const m = history[i];
74
+ if (!m || m.role !== 'assistant' || typeof m.content !== 'string') continue;
75
+ const parsed = parseTodosFromMessage(m.content);
76
+ if (parsed.length) return parsed;
77
+ }
78
+ return [];
79
+ }