@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/src/repl.js CHANGED
@@ -6,7 +6,7 @@ import { createTui } from './tui.js';
6
6
  import { runAgent, maybeSummarize, buildSystem, buildUserMessage } from './agent.js';
7
7
  import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from './subagent.js';
8
8
  import { TokenMeter, countMessages, CONTEXT_WINDOW, countTokens } from './tokens.js';
9
- import { stream, usage, ApiError, resetMemoryToken } from './api.js';
9
+ import { stream, usage, cachedUsage, resetUsageCache, ApiError, resetMemoryToken } from './api.js';
10
10
  import {
11
11
  runTool,
12
12
  describe,
@@ -18,7 +18,17 @@ import {
18
18
  nearestExistingDir,
19
19
  } from './tools.js';
20
20
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from './models.js';
21
- import { c, banner, modelBadge, renderMarkdown, box } from './ui.js';
21
+ import { c, banner, modelBadge, renderMarkdown, renderInline, renderHeadingLine, renderBulletPrefix, box, formatQuota } from './ui.js';
22
+ import { renderUnifiedDiff, renderNewFilePreview } from './diff.js';
23
+ import {
24
+ askPermission as _askPermission,
25
+ askAddRoot as _askAddRoot,
26
+ askWorkflowAgentMode as _askWorkflowAgentMode,
27
+ } from './repl/permission.js';
28
+ import {
29
+ runImprove as _runImprove,
30
+ runKarpathy as _runKarpathy,
31
+ } from './repl/commands/prompts.js';
22
32
  import { config } from './config.js';
23
33
  import { loadMemory, memoryPath, memoryStats } from './memory.js';
24
34
  import { t } from './i18n.js';
@@ -26,6 +36,17 @@ import { checkLatest, runUpdate, CURRENT } from './update.js';
26
36
  import * as sessions from './sessions.js';
27
37
  import { loadSkill, listSkills } from './skills.js';
28
38
  import { saveWorkflow, loadWorkflow } from './workflows.js';
39
+ import {
40
+ createRun as createWorkflowRun,
41
+ loadRun as loadWorkflowRun,
42
+ listRuns as listWorkflowRuns,
43
+ closeRun as closeWorkflowRun,
44
+ hashTask as hashWorkflowTask,
45
+ lookupTaskResult as lookupWorkflowTaskResult,
46
+ recordTaskStart as recordWorkflowTaskStart,
47
+ recordTaskDone as recordWorkflowTaskDone,
48
+ recordTaskFailed as recordWorkflowTaskFailed,
49
+ } from './workflow-runs.js';
29
50
  import { getBuiltinWorkflow, loadBuiltinPrompt } from './workflows-builtin.js';
30
51
  import { SLASH, completeInput, mentionedFiles } from './repl/complete.js';
31
52
  import { parseTodosFromHistory } from './repl/todos.js';
@@ -45,22 +66,12 @@ import {
45
66
  workflowLoad as _workflowLoad,
46
67
  workflowDelete as _workflowDelete,
47
68
  } from './repl/workflow-commands.js';
69
+ import { createState } from './repl/state.js';
70
+ import {
71
+ shortCwd, shortPath, relTime, firstLine, truncate, fmtTime, fmtK, preview,
72
+ } from './repl/utils.js';
48
73
  export async function startRepl(opts = {}) {
49
- const state = {
50
- model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
51
- mode: 'chat', // chat | merge | search
52
- history: [],
53
- autoApprove: new Set(),
54
- yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
55
- ultra: false, // chế độ tự hành (self-quest) đang chạy?
56
- agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
57
- goal: null, // HARD GOAL (set qua /goal <text>) — inject vào mọi prompt tới khi /goal clear
58
- loop: null, // /loop — {intervalMs, intervalStr, task, timer, ticks, startedAt} hoặc null
59
- extraRoots: [], // thư mục ngoài cwd được /add-dir cấp quyền (UX display only;
60
- // source of truth là extraRoots trong src/tools.js)
61
- _longSessionWarned: false, // đã in cảnh báo phiên dài chưa (reset khi /clear)
62
- todos: [], // [{text, done}] — todo list parse từ model output, render trên status bar
63
- };
74
+ const state = createState(opts, config);
64
75
  const tokenMeter = new TokenMeter();
65
76
 
66
77
  // Set terminal title bar — hiện trên CMD/PowerShell.
@@ -367,7 +378,7 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
367
378
  // match quan trọng: `help` / `?` / `patterns` / `builtins` / `list|ls` /
368
379
  // `load` / `delete|rm` / `save` / `run`. Ad-hoc default = phần còn lại.
369
380
  const m = trimmed.match(
370
- /^(help|\?|patterns|builtins|list|ls|load|delete|rm|save|run)\b\s*([\s\S]*)$/i
381
+ /^(help|\?|patterns|builtins|list|ls|load|delete|rm|save|run|runs|log|resume)\b\s*([\s\S]*)$/i
371
382
  );
372
383
  if (m) {
373
384
  const sub = m[1].toLowerCase();
@@ -380,6 +391,9 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
380
391
  if (sub === 'delete' || sub === 'rm') return workflowDelete(rest);
381
392
  if (sub === 'save') return workflowSave(rest);
382
393
  if (sub === 'run') return workflowRun(rest);
394
+ if (sub === 'runs') return workflowRuns();
395
+ if (sub === 'log') return workflowLog(rest);
396
+ if (sub === 'resume') return workflowResume(rest);
383
397
  }
384
398
  // Default: ad-hoc workflow (giữ behavior cũ — model design workflow từ request).
385
399
  await workflowExecute(trimmed);
@@ -388,39 +402,12 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
388
402
  // Hỏi quyền bật agent mode để chạy workflow. CHỈ chấp nhận y/n (Enter = yes).
389
403
  // Nếu nhận dòng lạ & dài (paste nhầm tin nhắn) → xếp hàng + hỏi lại (y hệt
390
404
  // askPermission / askAddRoot) để user khỏi phải gõ lại /workflow.
391
- async function askWorkflowAgentMode() {
392
- tui.setBusy(false);
393
- console.log(
394
- c.tool(
395
- ' ' +
396
- (t.workflowAgentAskHint || '🎼 /workflow cần spawn sub-agent — agent mode hiện đang TẮT.')
397
- )
398
- );
399
- try {
400
- while (true) {
401
- const raw = await ask(
402
- c.tool(' bật agent mode và chạy workflow? ') +
403
- c.dim('[y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ')
404
- );
405
- if (raw == null) return 'n'; // stdin đóng thật
406
- const a = raw.trim().toLowerCase();
407
- if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
408
- if (a === 'n' || a === 'no' || a === 'không') return 'n';
409
- if (raw.trim().length > 3) {
410
- pending.push(raw);
411
- console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
412
- }
413
- console.log(c.dim(' → gõ y hoặc n'));
414
- }
415
- } finally {
416
- tui.setBusy(true, t.thinking);
417
- }
418
- }
405
+ const askWorkflowAgentMode = () => _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
419
406
 
420
407
  // Chạy thật workflow prompt — chia sẻ giữa ad-hoc và `run <name>`.
421
408
  // `builtInName` (optional): nếu có thì SKIP loadSkill dynamic-workflows (prompt
422
409
  // built-in đã hardcode pattern + step cụ thể rồi, không cần model design lại).
423
- async function workflowExecute(userRequest, { builtInName = null } = {}) {
410
+ async function workflowExecute(userRequest, { builtInName = null, resumeRun = null } = {}) {
424
411
  let prompt;
425
412
  if (builtInName) {
426
413
  // Built-in prompt đã đầy đủ PLAN + 4 bước thực thi — KHÔNG wrap thêm skill.
@@ -472,8 +459,57 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
472
459
  c.tool(' ✓ ' + (t.workflowAgentEnabled || 'đã bật agent mode cho workflow này.'))
473
460
  );
474
461
  }
462
+ // Mở workflow run journal — track mỗi sub-agent task để resume sau interrupt.
463
+ // resumeRun: data đã load sẵn từ /workflow resume <id> → tái dùng id cũ.
464
+ // Còn lại: tạo run mới (ad-hoc hoặc built-in/saved).
465
+ try {
466
+ if (resumeRun) {
467
+ state.workflowRun = { id: resumeRun.id, data: resumeRun, path: null };
468
+ // Đánh dấu lại running (trước đó là interrupted/failed).
469
+ resumeRun.status = 'running';
470
+ const doneCount = (resumeRun.tasks || []).filter((tk) => tk.status === 'done').length;
471
+ console.log(
472
+ c.tool(
473
+ ' 🎼 resume run ' +
474
+ c.accent(resumeRun.id) +
475
+ c.dim(` · ${doneCount}/${(resumeRun.tasks || []).length} task đã done sẽ được skip\n`)
476
+ )
477
+ );
478
+ } else {
479
+ const run = createWorkflowRun({
480
+ name: builtInName || 'adhoc',
481
+ workflowPrompt: prompt,
482
+ });
483
+ state.workflowRun = run;
484
+ console.log(c.dim(' 📓 workflow run journal: ' + run.id));
485
+ }
486
+ } catch (e) {
487
+ // Journal lỗi (vd EACCES /.noob/) → vẫn chạy workflow như cũ, mất tính năng resume thôi.
488
+ state.workflowRun = null;
489
+ console.log(c.dim(' (không khởi tạo được workflow journal: ' + (e?.message || e) + ')'));
490
+ }
475
491
  console.log(c.tool(' 🎼 ' + (t.workflowRunning || 'Dynamic workflow running…')));
476
- await handle(prompt);
492
+ try {
493
+ await handle(prompt);
494
+ // handle() xong sạch → đóng journal với status done.
495
+ if (state.workflowRun) closeWorkflowRun(state.workflowRun.data, 'done');
496
+ } catch (err) {
497
+ // Interrupt (Ctrl+C) hoặc lỗi khác → đánh dấu interrupted, journal vẫn đầy đủ để resume.
498
+ if (state.workflowRun) {
499
+ const isAbort = err?.message === 'aborted' || err?.name === 'AbortError';
500
+ closeWorkflowRun(state.workflowRun.data, isAbort ? 'interrupted' : 'failed');
501
+ console.log(
502
+ c.tool(
503
+ ' 📓 run ' +
504
+ c.accent(state.workflowRun.id) +
505
+ c.dim(' đã lưu — resume bằng `/workflow resume ' + state.workflowRun.id + '`')
506
+ )
507
+ );
508
+ }
509
+ throw err;
510
+ } finally {
511
+ state.workflowRun = null;
512
+ }
477
513
  persist();
478
514
  }
479
515
 
@@ -662,39 +698,83 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
662
698
  await workflowExecute(userRequest);
663
699
  }
664
700
 
665
- // /improve [hint]model soát workspace & đề xuất tính năng/cải tiến.
666
- // KHÔNG sửa code, chỉ phân tích & đề xuất.
667
- async function runImprove(arg) {
668
- if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
669
- const focus = arg
670
- ? `\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.`
671
- : '';
672
- 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.`;
673
- console.log(c.tool(' ' + t.improveRunning));
674
- await handle(prompt);
675
- persist();
701
+ // /workflow runslist các workflow run journal trong workspace hiện tại.
702
+ function workflowRuns() {
703
+ const items = listWorkflowRuns(20);
704
+ if (!items.length) {
705
+ console.log(c.dim(' (chưa workflow run nào trong workspace này)'));
706
+ console.log(c.dim(' Run đầu tiên được tạo khi bạn /workflow <yêu cầu> hoặc /workflow run <name>.'));
707
+ return;
708
+ }
709
+ console.log('\n' + chalk.bold(' ' + (t.workflowRunsTitle || '📓 Workflow runs (workspace này)')));
710
+ for (const r of items) {
711
+ const statusColor =
712
+ r.status === 'done' ? c.ok :
713
+ r.status === 'interrupted' ? c.tool :
714
+ r.status === 'failed' ? c.err :
715
+ c.accent;
716
+ console.log(
717
+ c.dim(' ') +
718
+ c.accent(r.id.padEnd(40)) +
719
+ statusColor(r.status.padEnd(13)) +
720
+ c.dim(`${r.done}/${r.total} task done · ${relTime(r.updatedAt)}`)
721
+ );
722
+ }
723
+ console.log(
724
+ c.dim('\n /workflow log <id> xem chi tiết\n /workflow resume <id> chạy lại, skip task đã done\n')
725
+ );
676
726
  }
677
727
 
678
- // /karpathy [path]bắt noob tự soát code theo 4 nguyên tắc Karpathy.
679
- // Không có path → soát các file đã đổi trong phiên (model thấy qua FILES CHANGED).
680
- async function runKarpathy(arg) {
681
- if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
682
- const target = arg
683
- ? `file/đường dẫn: ${arg}`
684
- : 'các file bạn đã tạo/sửa trong phiên này (xem mục FILES CHANGED)';
685
- const prompt = `Đóng vai reviewer khó tính. Rà soát ${target} theo 4 nguyên tắc code của Karpathy.
686
- ĐỌC nội dung file thật bằng read_file trước — KHÔNG dựa vào trí nhớ.
687
- 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":
688
- 1. THINK FIRST giả định ẩn nào chưa nêu? chỗ nào thiếu kiểm chứng?
689
- 2. KEEP IT SIMPLE — over-engineer, abstraction thừa, lồng quá sâu, hàm quá dài?
690
- 3. SURGICAL — thay đổi lạc đề, refactor tiện tay, đổi style/format vô cớ?
691
- 4. VERIFIABLE GOAL mục tiêu có kiểm chứng được? đã chạy build/test chưa?
692
- 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.`;
693
- console.log(c.tool(' ⚖ Karpathy check…'));
694
- await handle(prompt);
695
- persist();
728
+ // /workflow log <id> xem chi tiết 1 run: prompt, tasks, kết quả mỗi sub-agent.
729
+ function workflowLog(arg) {
730
+ if (!arg) return console.log(c.err(' Cách dùng: /workflow log <id> (xem id bằng /workflow runs)'));
731
+ const r = loadWorkflowRun(arg);
732
+ if (!r.ok) return console.log(c.err(' Không tìm thấy run: ' + arg));
733
+ const d = r.data;
734
+ console.log('\n' + chalk.bold(' 📓 ' + d.id));
735
+ console.log(c.dim(` name: ${d.name || '(adhoc)'} · status: ${d.status} · started: ${relTime(d.startedAt)} · updated: ${relTime(d.updatedAt)}`));
736
+ console.log(c.dim(' workflow prompt: ' + truncate(d.workflowPrompt || '', 120)));
737
+ if (!d.tasks?.length) {
738
+ console.log(c.dim(' (chưa sub-agent task nào được ghi nhận)'));
739
+ return;
740
+ }
741
+ console.log('\n' + c.dim(' ── sub-agent tasks ──'));
742
+ d.tasks.forEach((tk, i) => {
743
+ const statusColor =
744
+ tk.status === 'done' ? c.ok :
745
+ tk.status === 'failed' ? c.err :
746
+ c.tool;
747
+ console.log(
748
+ c.dim(` #${i + 1} `) +
749
+ statusColor(tk.status.padEnd(8)) +
750
+ c.dim(tk.hash + ' ') +
751
+ c.accent(truncate(tk.task, 80))
752
+ );
753
+ if (tk.context) console.log(c.dim(' context: ' + truncate(tk.context, 80)));
754
+ if (tk.model) console.log(c.dim(' model: ' + tk.model));
755
+ if (tk.result) console.log(c.dim(' result: ' + truncate(tk.result.replace(/\s+/g, ' '), 120)));
756
+ if (tk.error) console.log(c.err(' error: ' + truncate(tk.error, 120)));
757
+ });
758
+ console.log('');
696
759
  }
697
760
 
761
+ // /workflow resume <id> — chạy lại workflow với cùng prompt; dispatchTool tự
762
+ // hit cache trong journal cho mọi task đã done lần trước → tiết kiệm token.
763
+ async function workflowResume(arg) {
764
+ if (!arg) return console.log(c.err(' Cách dùng: /workflow resume <id> (xem id bằng /workflow runs)'));
765
+ const r = loadWorkflowRun(arg);
766
+ if (!r.ok) return console.log(c.err(' Không tìm thấy run: ' + arg));
767
+ const d = r.data;
768
+ if (!d.workflowPrompt) return console.log(c.err(' Run này không có workflow prompt — không thể resume.'));
769
+ if (d.status === 'done') {
770
+ console.log(c.tool(' ⚠ run đã ở trạng thái DONE — vẫn resume nhưng có thể chạy lại task mới nếu model spawn khác.'));
771
+ }
772
+ await workflowExecute(d.workflowPrompt, { resumeRun: d });
773
+ }
774
+
775
+ const runImprove = (arg) => _runImprove(arg, { config, c, t, handle, persist });
776
+ const runKarpathy = (arg) => _runKarpathy(arg, { config, c, t, handle, persist });
777
+
698
778
  // ── ULTRA: chế độ tự hành (self-quest) ────────────────────────────────────
699
779
  // Constants + helpers thuần + prompt templates đã tách sang src/repl/ultra.js.
700
780
  // Phần state-heavy (runUltra loop) ở dưới giữ ở đây vì cần closure handle/persist/state.
@@ -1062,6 +1142,22 @@ NGUYÊN TẮC:
1062
1142
  .catch(() => {});
1063
1143
  }
1064
1144
 
1145
+ // Prefetch usage để status bar có quota ngay từ phút đầu. Cache TTL 90s ở
1146
+ // api.js → status bar tick mỗi giây chỉ đọc cachedUsage() sync, không gọi
1147
+ // gateway. Refresh nền mỗi 90s để số luôn tươi mà không spam.
1148
+ // Chạy hoàn toàn non-blocking; lỗi (offline, bad key) → bỏ qua, status bar
1149
+ // chỉ thiếu phần quota tail, không ảnh hưởng phần còn lại.
1150
+ let usageRefreshTimer = null;
1151
+ const refreshUsage = () => {
1152
+ if (!config.apiKey) return;
1153
+ usage({ force: true }).catch(() => {});
1154
+ };
1155
+ if (config.apiKey) {
1156
+ refreshUsage();
1157
+ usageRefreshTimer = setInterval(refreshUsage, 90_000);
1158
+ if (typeof usageRefreshTimer.unref === 'function') usageRefreshTimer.unref();
1159
+ }
1160
+
1065
1161
  // Khôi phục phiên theo cờ dòng lệnh, hoặc mở phiên mới.
1066
1162
  if (opts.continue) {
1067
1163
  const s = sessions.latest(process.cwd()); // phiên gần nhất CỦA workspace này
@@ -1120,6 +1216,7 @@ NGUYÊN TẮC:
1120
1216
  }
1121
1217
  }
1122
1218
  exiting = true;
1219
+ if (usageRefreshTimer) clearInterval(usageRefreshTimer);
1123
1220
  tui.close();
1124
1221
  process.exit(0);
1125
1222
 
@@ -1140,7 +1237,9 @@ NGUYÊN TẮC:
1140
1237
  // Nhờ vậy người dùng LUÔN thấy đồng hồ + token đang chạy, kể cả khi treo chờ y/n.
1141
1238
  const tickMeta = () => {
1142
1239
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
1143
- tui.setMeta(`${elapsed}s · ${tokenMeter.formatWithPct()}`);
1240
+ const quota = formatQuota(cachedUsage());
1241
+ const tail = quota ? ` · ${quota}` : '';
1242
+ tui.setMeta(`${elapsed}s · ${tokenMeter.formatWithPct()}${tail}`);
1144
1243
  };
1145
1244
  const tick = (label) => {
1146
1245
  tui.status(c.dim(`${label}…`));
@@ -1238,6 +1337,7 @@ NGUYÊN TẮC:
1238
1337
  );
1239
1338
  startSpin(t.thinking);
1240
1339
  try {
1340
+ const runData = state.workflowRun?.data || null;
1241
1341
  const results = await Promise.all(
1242
1342
  tasks.map((task, i) => {
1243
1343
  // Per-sub-agent model routing: task.model có thể là id model hoặc tên thân thiện.
@@ -1252,10 +1352,59 @@ NGUYÊN TẮC:
1252
1352
  } else
1253
1353
  modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
1254
1354
  }
1255
- // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
1355
+ const taskBody = task?.task || task?.prompt || '';
1356
+ const taskCtx = task?.context || '';
1357
+ // Workflow journal: nếu đang trong run + task đã done lần trước → return
1358
+ // cached result, tiết kiệm token. Hash = crc32(task+ctx+model).
1359
+ if (runData) {
1360
+ const hash = hashWorkflowTask({ task: taskBody, context: taskCtx, model: subModel });
1361
+ const cached = lookupWorkflowTaskResult(runData, hash);
1362
+ if (cached !== null) {
1363
+ stopSpin();
1364
+ console.log(
1365
+ chalk.hex('#8b5cf6')(
1366
+ ` ⊘ sub-agent #${i + 1}${modelTag} skip — đã done trong run trước (cached)`
1367
+ )
1368
+ );
1369
+ startSpin(t.thinking);
1370
+ return Promise.resolve(
1371
+ `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
1372
+ );
1373
+ }
1374
+ recordWorkflowTaskStart(runData, {
1375
+ hash,
1376
+ task: taskBody,
1377
+ context: taskCtx,
1378
+ model: subModel,
1379
+ });
1380
+ // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
1381
+ return runSubAgent({
1382
+ task: taskBody,
1383
+ context: taskCtx,
1384
+ depth: depth + 1,
1385
+ model: subModel,
1386
+ signal: abort.signal,
1387
+ tokenMeter,
1388
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
1389
+ onLog: (msg) => {
1390
+ stopSpin();
1391
+ console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
1392
+ startSpin(t.thinking);
1393
+ },
1394
+ })
1395
+ .then((r) => {
1396
+ recordWorkflowTaskDone(runData, hash, r);
1397
+ return `── sub-agent #${i + 1}${modelTag} ──\n${r}`;
1398
+ })
1399
+ .catch((e) => {
1400
+ recordWorkflowTaskFailed(runData, hash, e);
1401
+ return `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`;
1402
+ });
1403
+ }
1404
+ // Không có active workflow run → behavior cũ.
1256
1405
  return runSubAgent({
1257
- task: task?.task || task?.prompt || '',
1258
- context: task?.context || '',
1406
+ task: taskBody,
1407
+ context: taskCtx,
1259
1408
  depth: depth + 1,
1260
1409
  model: subModel,
1261
1410
  signal: abort.signal,
@@ -1335,15 +1484,18 @@ NGUYÊN TẮC:
1335
1484
  abort = null;
1336
1485
  tui.setBusy(false);
1337
1486
  // Auto-compact dựa trên context tokens thay vì chars.
1338
- // 80% context window (160k tokens) auto compact.
1339
- // 70% (140k tokens) → cảnh báo mạnh.
1340
- // 60% (120k tokens) → nhắc nhẹ.
1487
+ // Với CONTEXT_WINDOW = 2M tokens (xem src/tokens.js):
1488
+ // 75% (1.5M tokens) → auto compact
1489
+ // 60% (1.2M tokens) → cảnh báo mạnh
1490
+ // 40% (800k tokens) → nhắc nhẹ
1491
+ // Ngưỡng kéo xuống vì model context dài hiện tại để 80% mới compact thì
1492
+ // mỗi lượt cuối đã ăn 200k+ tokens — auto-compact sớm hơn giữ phiên mượt.
1341
1493
  try {
1342
1494
  const totalTokens = countMessages(state.history);
1343
1495
  const k = Math.round(totalTokens / 1000);
1344
1496
  const pct = Math.round((totalTokens / CONTEXT_WINDOW) * 100);
1345
- // Mốc 3 (≥80% — 160k tokens): TỰ ĐỘNG compact.
1346
- if (totalTokens >= CONTEXT_WINDOW * 0.8 && !state._autoCompacting) {
1497
+ // Mốc 3 (≥75% — 1.5M tokens): TỰ ĐỘNG compact.
1498
+ if (totalTokens >= CONTEXT_WINDOW * 0.75 && !state._autoCompacting) {
1347
1499
  state._autoCompacting = true;
1348
1500
  console.log(c.accent(` ⚡ ${t.autoCompactTrigger(k)} (${pct}% context)`));
1349
1501
  tui.setBusy(true, t.compactRunning);
@@ -1371,12 +1523,12 @@ NGUYÊN TẮC:
1371
1523
  } finally {
1372
1524
  state._autoCompacting = false;
1373
1525
  }
1374
- } else if (totalTokens >= CONTEXT_WINDOW * 0.7) {
1375
- // Mốc 2 (≥70% — 140k tokens): cảnh báo mạnh.
1526
+ } else if (totalTokens >= CONTEXT_WINDOW * 0.6) {
1527
+ // Mốc 2 (≥60% — 1.2M tokens): cảnh báo mạnh.
1376
1528
  console.log(c.err(` ⚠ ${t.veryLongSession(k)} (${pct}% context)`));
1377
1529
  state._longSessionWarned = true;
1378
- } else if (totalTokens >= CONTEXT_WINDOW * 0.6 && !state._longSessionWarned) {
1379
- // Mốc 1 (≥60% — 120k tokens): nhắc nhẹ một lần.
1530
+ } else if (totalTokens >= CONTEXT_WINDOW * 0.4 && !state._longSessionWarned) {
1531
+ // Mốc 1 (≥40% — 800k tokens): nhắc nhẹ một lần.
1380
1532
  console.log(c.dim(` ⓘ ${t.longSession(k)} (${pct}% context)`));
1381
1533
  state._longSessionWarned = true;
1382
1534
  }
@@ -1393,9 +1545,52 @@ NGUYÊN TẮC:
1393
1545
  const color = name === 'run_command' ? '#ef4444' : '#f59e0b';
1394
1546
  console.log('\n' + chalk.hex(color)(' ⚙ ' + name) + c.dim(' ' + desc));
1395
1547
 
1396
- if (name === 'write_file' && input.content) preview(input.content, input.path);
1397
- else if (name === 'edit_file')
1398
- preview(`- ${truncate(input.old_string)}\n+ ${truncate(input.new_string)}`, input.path);
1548
+ // Diff preview show user CHÍNH XÁC dòng nào bị xoá/thêm trước khi approve.
1549
+ // Tránh tin model mù: bắt được hallucinate nội dung sớm.
1550
+ if (name === 'edit_file' && typeof input.old_string === 'string' && typeof input.new_string === 'string') {
1551
+ try {
1552
+ const filePath = path.resolve(process.cwd(), input.path || '');
1553
+ let oldText = input.old_string;
1554
+ let newText = input.new_string;
1555
+ // Cố gắng đọc file gốc + locate old_string để show diff CỦA CẢ FILE
1556
+ // (vài dòng context xung quanh). Nếu old_string không match đúng (vd model
1557
+ // dùng escape khác), fallback về diff trực tiếp old_string vs new_string.
1558
+ if (fs.existsSync(filePath)) {
1559
+ const fileContent = fs.readFileSync(filePath, 'utf8');
1560
+ const idx = fileContent.indexOf(input.old_string);
1561
+ if (idx !== -1) {
1562
+ // Full file before + after thay đổi → diff sẽ render đúng hunk
1563
+ // có vài dòng context xung quanh sửa đổi (chuẩn unified diff).
1564
+ oldText = fileContent;
1565
+ if (input.replace_all) {
1566
+ newText = fileContent.split(input.old_string).join(input.new_string);
1567
+ } else {
1568
+ const before = fileContent.slice(0, idx);
1569
+ const after = fileContent.slice(idx + input.old_string.length);
1570
+ newText = before + input.new_string + after;
1571
+ }
1572
+ }
1573
+ }
1574
+ console.log(renderUnifiedDiff(oldText, newText, { label: input.path || '(unknown path)' }));
1575
+ } catch (e) {
1576
+ // Fallback nếu đọc file lỗi: ít nhất show old vs new raw.
1577
+ console.log(renderUnifiedDiff(input.old_string, input.new_string, { label: input.path || '(unknown path)' }));
1578
+ }
1579
+ } else if (name === 'write_file' && typeof input.content === 'string') {
1580
+ try {
1581
+ const filePath = path.resolve(process.cwd(), input.path || '');
1582
+ if (fs.existsSync(filePath)) {
1583
+ // File đã tồn tại → write_file là OVERWRITE → show diff old vs new.
1584
+ const oldContent = fs.readFileSync(filePath, 'utf8');
1585
+ console.log(renderUnifiedDiff(oldContent, input.content, { label: input.path + ' (OVERWRITE)', maxLines: 60 }));
1586
+ } else {
1587
+ // File mới → preview top 20 dòng + tổng số dòng.
1588
+ console.log(renderNewFilePreview(input.content, { label: input.path || '(unknown path)', maxLines: 20 }));
1589
+ }
1590
+ } catch {
1591
+ console.log(renderNewFilePreview(input.content, { label: input.path || '(unknown path)', maxLines: 20 }));
1592
+ }
1593
+ }
1399
1594
 
1400
1595
  if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
1401
1596
  const a = await askPermission(name);
@@ -1450,66 +1645,14 @@ NGUYÊN TẮC:
1450
1645
  // Hỏi user có muốn cấp quyền folder ngoài workspace cho tool call hiện tại
1451
1646
  // hay không. Trả về "y" | "n" | "a" (luôn). y = chỉ lần này; a = auto-approve
1452
1647
  // mọi add-root trong phiên. Cùng style retry với askPermission (lạc → queue).
1453
- async function askAddRoot(root, targetPath) {
1454
- tui.setBusy(false);
1455
- console.log(c.tool(' ⏸ Cần cấp quyền folder: ') + c.accent(root));
1456
- console.log(c.dim(' (model muốn truy cập: ' + targetPath + ')'));
1457
- try {
1458
- while (true) {
1459
- const raw = await ask(
1460
- c.tool(' cho phép? ') +
1461
- c.dim('[y] thêm vào scope lần này / [a] luôn thêm / [n] từ chối › ')
1462
- );
1463
- if (raw == null) return 'n';
1464
- const a = raw.trim().toLowerCase();
1465
- if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
1466
- if (a === 'n' || a === 'no' || a === 'không') return 'n';
1467
- if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
1468
- if (raw.trim().length > 3) {
1469
- pending.push(raw);
1470
- console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
1471
- }
1472
- console.log(c.dim(' → gõ y / n / a'));
1473
- }
1474
- } finally {
1475
- tui.setBusy(true, t.thinking);
1476
- }
1477
- }
1648
+ const askAddRoot = (root, targetPath) => _askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate });
1478
1649
 
1479
1650
  // Đọc câu trả lời cho phép một cách CHẮC CHẮN. Chỉ chấp nhận y/n/a (hoặc
1480
1651
  // Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
1481
1652
  // bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
1482
1653
  // "tự tắt" trước đây) — thì KHÔNG coi là từ chối: xếp nó vào hàng đợi tin
1483
1654
  // nhắn rồi HỎI LẠI. Nhờ vậy không thao tác nào bị quyết định bởi rác.
1484
- async function askPermission(name) {
1485
- // Tắt spinner "đang chạy" trong lúc chờ duyệt. Nếu để nguyên, thanh dưới hiện
1486
- // "đang chạy · Ctrl+C để dừng" → người dùng tưởng đang bận, không biết phải gõ
1487
- // y/n nên lượt TREO. Báo bằng 1 dòng cố định (vào scrollback, không bị vẽ đè)
1488
- // + bỏ spinner để prompt nổi bật. finally khôi phục trạng thái chạy.
1489
- tui.setBusy(false);
1490
- console.log(
1491
- c.tool(' ⏸ Cần quyền: ' + name) + c.dim(' — gõ y (đồng ý) / n (từ chối) / a (luôn cho phép)')
1492
- );
1493
- try {
1494
- while (true) {
1495
- const raw = await ask(
1496
- c.tool(' cho phép? ') + c.dim('[y] có / [n] không / [a] luôn ' + name + ' › ')
1497
- );
1498
- if (raw == null) return 'n'; // stdin đóng thật
1499
- const a = raw.trim().toLowerCase();
1500
- if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
1501
- if (a === 'n' || a === 'no' || a === 'không') return 'n';
1502
- if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
1503
- if (raw.trim().length > 3) {
1504
- pending.push(raw); // tin nhắn lạc → xếp hàng, gửi sau khi xong lượt
1505
- console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
1506
- }
1507
- console.log(c.dim(' ' + t.permRetry));
1508
- }
1509
- } finally {
1510
- tui.setBusy(true, t.thinking); // khôi phục "đang chạy" cho phần còn lại của lượt
1511
- }
1512
- }
1655
+ const askPermission = (name) => _askPermission(name, { tui, ask, pending, c, t, truncate });
1513
1656
 
1514
1657
  // ── slash commands ─────────────────────────────────────────────────────
1515
1658
  async function command(input) {
@@ -1638,6 +1781,7 @@ NGUYÊN TẮC:
1638
1781
  break;
1639
1782
  case 'logout':
1640
1783
  config.clearKey();
1784
+ resetUsageCache();
1641
1785
  console.log(c.ok(' ' + t.loggedOut));
1642
1786
  break;
1643
1787
  case 'usage':
@@ -1770,6 +1914,7 @@ NGUYÊN TẮC:
1770
1914
  function doLogin(key) {
1771
1915
  if (!key) return console.log(c.err(' ' + t.needKeyArg));
1772
1916
  config.setKey(key);
1917
+ resetUsageCache();
1773
1918
  console.log(c.ok(' ✓ ') + c.dim(t.loginSaved(config.path)));
1774
1919
  showUsage().catch(() => {});
1775
1920
  }
@@ -1778,7 +1923,7 @@ NGUYÊN TẮC:
1778
1923
  if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
1779
1924
  tui.status(c.dim(' ...'));
1780
1925
  try {
1781
- const u = await usage();
1926
+ const u = await usage({ force: true });
1782
1927
  tui.status(null);
1783
1928
  if (!u.ok) return printError(new ApiError(t.errInvalidKey, { code: u.error }));
1784
1929
  printUsage(u);
@@ -1857,13 +2002,6 @@ NGUYÊN TẮC:
1857
2002
  }
1858
2003
 
1859
2004
  // ── presentation helpers ───────────────────────────────────────────────────
1860
- function fmtK(n) {
1861
- return n >= 1000000
1862
- ? (n / 1000000).toFixed(1) + 'M'
1863
- : n >= 1000
1864
- ? (n / 1000).toFixed(1) + 'k'
1865
- : String(n);
1866
- }
1867
2005
  function printAnswer(text, name, color) {
1868
2006
  if (!text?.trim()) return;
1869
2007
  console.log('\n' + chalk.hex(color).bold(' ● ' + name));
@@ -1875,26 +2013,61 @@ function printAnswer(text, name, color) {
1875
2013
  );
1876
2014
  }
1877
2015
 
1878
- // In câu trả lời theo dòng token thời gian thực. model emit lời + (tuỳ chọn)
1879
- // MỘT khối ```tool cuối, ta giấu mọi thứ từ ```tool trở đi (người dùng chỉ
1880
- // thấy phần lời + hoạt động công cụ riêng). Giữ lại đuôi vài ký tự để không in
1881
- // nửa vời "```to" trước khi kịp nhận ra đó fence.
2016
+ // Stream printer với inline markdown rendering. Chiến lược:
2017
+ // - Tích đầy đủ MỘT dòng (\n) rồi mới render & write vậy `**bold**`, backtick,
2018
+ // heading `## foo` không bị cắt giữa chừng giữa các token.
2019
+ // - Trên dòng đang dở (chưa thấy \n), giữ trong buffer; cuối cùng `flush()` xử lý.
2020
+ // - Block code fence (```lang ... ```) bypass renderer: in nguyên xi giữa cặp ```.
2021
+ // - ```tool trở đi: nuốt sạch (sẽ render riêng qua tool dispatcher).
2022
+ function renderStreamLine(line, inCodeFence) {
2023
+ if (inCodeFence) return line; // code fence body: in raw, không parse markdown.
2024
+ const heading = renderHeadingLine(line);
2025
+ if (heading !== null) return heading;
2026
+ return renderInline(renderBulletPrefix(line));
2027
+ }
1882
2028
  function makeStreamPrinter(name, color) {
1883
- let buf = '';
1884
- let printed = 0;
2029
+ let buf = ''; // toàn bộ delta đã nhận (chưa cắt)
2030
+ let printed = 0; // offset đã in trong buf
1885
2031
  let suppress = false;
1886
2032
  let started = false;
1887
2033
  let header = false;
1888
- const HOLD = 8;
1889
- const write = (s) => {
2034
+ let inCodeFence = false;
2035
+ const HOLD = 8; // giữ đuôi 8 char để phát hiện sớm "```tool"
2036
+ const emit = (s) => {
1890
2037
  if (!s) return;
1891
2038
  if (!header) {
1892
2039
  process.stdout.write('\n' + chalk.hex(color).bold(' ● ' + name) + '\n ');
1893
2040
  header = true;
1894
2041
  }
2042
+ // s có thể chứa \n từ render line + leading newline gốc. Thay \n → \n để indent.
1895
2043
  process.stdout.write(s.replace(/\n/g, '\n '));
1896
2044
  started = true;
1897
2045
  };
2046
+ // Render & emit từ printed → end. Chỉ flush các dòng đã HOÀN CHỈNH (có \n).
2047
+ // Dòng cuối chưa có \n: chừa lại trong buf cho lần push tiếp theo.
2048
+ const flushCompleteLines = (end) => {
2049
+ const slice = buf.slice(printed, end);
2050
+ if (!slice) return;
2051
+ const lastNl = slice.lastIndexOf('\n');
2052
+ if (lastNl === -1) return; // chưa có dòng nào hoàn chỉnh trong khoảng này
2053
+ const complete = slice.slice(0, lastNl + 1); // bao gồm \n cuối
2054
+ printed += lastNl + 1;
2055
+ // Render từng dòng (split giữ \n)
2056
+ const lines = complete.split('\n');
2057
+ // split với chuỗi kết thúc \n → mảng cuối là '' — bỏ qua
2058
+ const rendered = lines.map((ln, i) => {
2059
+ if (i === lines.length - 1 && ln === '') return '';
2060
+ // Toggle code fence khi gặp ``` ở đầu dòng (allow indent).
2061
+ const fenceMatch = ln.match(/^\s*```/);
2062
+ if (fenceMatch) {
2063
+ const out = renderStreamLine(ln, inCodeFence);
2064
+ inCodeFence = !inCodeFence;
2065
+ return out;
2066
+ }
2067
+ return renderStreamLine(ln, inCodeFence);
2068
+ }).join('\n');
2069
+ emit(rendered);
2070
+ };
1898
2071
  return {
1899
2072
  get started() {
1900
2073
  return started;
@@ -1907,19 +2080,49 @@ function makeStreamPrinter(name, color) {
1907
2080
  if (suppress) return;
1908
2081
  const f = buf.indexOf('```tool');
1909
2082
  if (f !== -1) {
1910
- write(buf.slice(printed, f));
2083
+ // Flush mọi thứ TRƯỚC ```tool (full lines + phần dở của dòng cuối cũng in luôn
2084
+ // vì sắp suppress, không cần chừa buffer nữa).
2085
+ const beforeTool = buf.slice(printed, f);
2086
+ if (beforeTool) {
2087
+ // Render từng dòng kể cả dòng dở cuối
2088
+ const parts = beforeTool.split('\n');
2089
+ const rendered = parts.map((ln) => {
2090
+ const fenceMatch = ln.match(/^\s*```/);
2091
+ if (fenceMatch) {
2092
+ const out = renderStreamLine(ln, inCodeFence);
2093
+ inCodeFence = !inCodeFence;
2094
+ return out;
2095
+ }
2096
+ return renderStreamLine(ln, inCodeFence);
2097
+ }).join('\n');
2098
+ emit(rendered);
2099
+ }
1911
2100
  printed = buf.length;
1912
2101
  suppress = true;
1913
2102
  return;
1914
2103
  }
2104
+ // Chỉ flush các dòng đã hoàn chỉnh; chừa đuôi HOLD char để phát hiện ```tool.
1915
2105
  const safeEnd = Math.max(printed, buf.length - HOLD);
1916
- if (safeEnd > printed) {
1917
- write(buf.slice(printed, safeEnd));
1918
- printed = safeEnd;
1919
- }
2106
+ if (safeEnd > printed) flushCompleteLines(safeEnd);
1920
2107
  },
1921
2108
  flush() {
1922
- if (!suppress && printed < buf.length) write(buf.slice(printed));
2109
+ if (!suppress && printed < buf.length) {
2110
+ // Render phần còn lại (kể cả dòng cuối chưa có \n).
2111
+ const tail = buf.slice(printed);
2112
+ const parts = tail.split('\n');
2113
+ const rendered = parts.map((ln, i) => {
2114
+ // Toggle fence
2115
+ const fenceMatch = ln.match(/^\s*```/);
2116
+ if (fenceMatch) {
2117
+ const out = renderStreamLine(ln, inCodeFence);
2118
+ inCodeFence = !inCodeFence;
2119
+ return out;
2120
+ }
2121
+ return renderStreamLine(ln, inCodeFence);
2122
+ }).join('\n');
2123
+ emit(rendered);
2124
+ printed = buf.length;
2125
+ }
1923
2126
  if (started) process.stdout.write('\n');
1924
2127
  },
1925
2128
  };
@@ -2027,37 +2230,4 @@ function listModels() {
2027
2230
  console.log('\n' + lines.join('\n') + c.dim('\n\n ' + t.modelListHint) + '\n');
2028
2231
  }
2029
2232
 
2030
- const shortCwd = () => {
2031
- const p = process.cwd();
2032
- return p.length > 48 ? '…' + p.slice(-47) : p;
2033
- };
2034
- const shortPath = (p = '') => (p.length > 30 ? '…' + p.slice(-29) : p);
2035
- const relTime = (ts) => {
2036
- const m = Math.round((Date.now() - ts) / 60000);
2037
- if (m < 1) return 'vừa xong';
2038
- if (m < 60) return m + ' phút trước';
2039
- const h = Math.round(m / 60);
2040
- if (h < 24) return h + ' giờ trước';
2041
- return Math.round(h / 24) + ' ngày trước';
2042
- };
2043
- const firstLine = (s) => (s.split('\n')[0] || '').slice(0, 100);
2044
- const truncate = (s = '', n = 120) => (s.length > n ? s.slice(0, n) + '…' : s).replace(/\n/g, '⏎');
2045
- const fmtTime = (iso) => {
2046
- try {
2047
- return new Date(iso).toLocaleString('vi-VN');
2048
- } catch {
2049
- return iso;
2050
- }
2051
- };
2052
2233
 
2053
- function preview(content, label) {
2054
- const lines = content.split('\n').slice(0, 12);
2055
- const more = content.split('\n').length - lines.length;
2056
- console.log(
2057
- c.dim(' ┌─ ' + (label || '')) +
2058
- '\n' +
2059
- lines.map((l) => c.dim(' │ ') + l.slice(0, 110)).join('\n') +
2060
- (more > 0 ? c.dim(`\n │ … +${more} dòng nữa`) : '') +
2061
- c.dim('\n └─')
2062
- );
2063
- }