@noobdemon/noob-cli 1.12.5 → 1.12.7

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,14 @@
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.7] - 2026-06-12
6
+
7
+ ### Changed
8
+ - **Gỡ auto-compact, chuyển sang MANUAL only** (`src/repl.js` + `src/tokens.js` + `src/agent.js`): trước đây CLI tự gọi `maybeSummarize({force:true})` khi context đạt 75% — gián đoạn workflow giữa chừng và summary có thể mất chi tiết user cần. Giờ user toàn quyền quyết định khi nào tóm tắt bằng `/compact`. Chỉ còn 2 mốc CẢNH BÁO (không auto-action): **60% (120k tokens)** nhắc nhẹ một lần, **80% (160k tokens)** cảnh báo mạnh gợi ý gõ `/compact` trước khi provider reject ở ~200k. Đồng thời sửa bug `CONTEXT_WINDOW=2_000_000` → `200_000` (khớp Claude Opus 4.7 + GPT-4o); ngưỡng cũ 2M khiến 75% = 1.5M token không bao giờ chạm → user báo `/compact không hoạt động`. `SUMMARIZE_THRESHOLD_CHARS` 6M → 600k, `MAX_PROMPT_CHARS` 1.2M → 800k, `keepTail` 16/24 → 12/16 cho khớp window thực.
9
+
10
+ ### Added
11
+ - **Tool `write_todos`** (`src/repl/agent-dispatch.js` + `src/tools.js` + `src/agent.js`): tool ẢO để model declare structured todo list thay vì viết markdown `- [ ]`. Shape `{todos: [{text, done}]}` — REPLACE toàn bộ list mỗi lần gọi (no patch). Dispatcher intercept TRƯỚC `execTool`: set `state.todos` + `tui.setTodos` trực tiếp, set flag `state._todosFromTool=true` để `repl.js` skip parse markdown sau turn (tránh overwrite structured state). In compact box lần đầu, diff (chỉ dòng đổi) các lần sau. SYSTEM prompt rule TODO-BASED EXECUTION đã update: model PHẢI dùng `write_todos`, không viết markdown. Lý do: parser markdown cũ (`parseTodosFromHistory`) fragile khi model format sai (sai indent, dùng `*` thay `-`, thiếu space). Structured tool call → CLI render luôn đúng, progress bar trên status line cập nhật ngay. Stub trong `TOOLS.write_todos` làm fail-safe nếu lỡ qua `runTool` trực tiếp. Smoke `scripts/smoke-write-todos.mjs` 27/27 pass + regression `smoke-dispatch.mjs` 23/23 pass.
12
+
5
13
  ## [1.12.5] - 2026-06-12
6
14
 
7
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.12.5",
3
+ "version": "1.12.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -29,12 +29,13 @@ Available tools (each is self-contained; pick the SMALLEST tool that answers the
29
29
  - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. Foreground commands are killed after ~60s (override with "timeout" ms). For long-running processes — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: starts the process, returns immediately, keeps running WITHOUT blocking next steps. Never start a server in the foreground (it will hang then be killed).
30
30
  - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll after starting a server to confirm it came up).
31
31
  - kill_bg {"id": int} — stop a background process started with run_command background:true.
32
+ - write_todos {"todos": [{"text": str, "done": bool}, ...]} — declare/update structured TODO list. REPLACES the entire list every call (no patching individual items). To check an item off, resend the FULL list with done:true on that item. Use this INSTEAD of writing markdown \`- [ ]\` lines: the CLI renders it as a progress bar on the status line AND prints a compact box, no fragile markdown parsing. Call ONCE at the start of any multi-step task with all items done:false, then call AGAIN after each step with the just-finished item flipped to done:true.
32
33
 
33
34
  # Retrieval strategy (just-in-time, not bulk)
34
35
  Context is finite. Don't slurp the whole repo up front. Discover information progressively: list_dir/glob to map → grep to locate → read_file (with offset+limit for big files) to inspect only what matters. Each tool result spends your attention budget — make every call earn it. When a tool returns a huge blob, extract the few facts you need, then move on; don't re-read it later (the result stays in history).
35
36
 
36
37
  # Rules
37
- - TODO-BASED EXECUTION: For multi-step tasks, you MUST keep going until ALL items are "- [x]". NEVER stop mid-list. Flow: (1) write todo list, (2) start first item, (3) after EVERY tool result, check off the completed item AND IMMEDIATELY start the next unchecked item, (4) repeat until all done. Your response is NOT finished until ALL items are checked. The ONLY valid reason to stop is: (a) all items done, or (b) you are WAITING for a user reply. If you just got a tool result, you MUST continue — do NOT output a summary, do NOT ask "what next", do NOT stop. After write_file/edit_file returns, immediately do the next item.
38
+ - TODO-BASED EXECUTION: For any multi-step task (3+ actions), you MUST call \`write_todos\` FIRST with all items done:false, then call it AGAIN after every completed step with that item flipped to done:true (resend the full list). NEVER write markdown \`- [ ]\` lines the runtime parses \`write_todos\` calls, not markdown. Your response is NOT finished until all items are done:true. The ONLY valid reason to stop is: (a) all items done, or (b) you are WAITING for a user reply. If you just got a tool result, you MUST continue — do NOT output a summary, do NOT ask "what next", do NOT stop. After write_file/edit_file returns, call write_todos to tick the just-finished item, then immediately start the next.
38
39
  - GROUND TRUTH = real TOOL RESULTs in this conversation, not your memory or what you intended to do. A file changed only if a write_file/edit_file result confirms it (see the FILES CHANGED list). A test passed / build succeeded / command worked only if a run_command result above shows it. Never narrate outcomes you didn't observe; if you haven't checked, say so and check now (read_file / list_dir / run the command). Before any "done/summary" reply, reconcile every file and result you're about to claim against the actual tool results above — if it isn't there, you didn't do it yet.
39
40
  - VERIFY BEFORE DISMISSING: never declare a TOOL RESULT "fake", "spurious", "injected", "unrelated", or "from a previous turn" without first verifying with a fresh tool call. If a result looks off (unexpected content, output you didn't ask for, weird command), your DEFAULT is: treat it as REAL runtime output, then run a small verification (read_file the affected path, grep for the symbol, list_dir, re-run the command) to confirm actual state. Only after the verification tool result contradicts the suspicious one may you call it stale/leftover — and even then, work from the FRESH result, never from your guess. Trusting your own skepticism over the runtime is the same over-confidence bug as hallucinating success: both substitute memory for evidence.
40
41
  - Investigate before editing: read the relevant files first; never invent file contents.
@@ -145,10 +146,12 @@ const MAX_STEPS = 10000;
145
146
  // loop detection cũ bằng cách xen kẽ 2-3 tool call khác nhau.
146
147
  const LOOP_DETECT_WINDOW = 6;
147
148
  const LOOP_DETECT_THRESHOLD = 2;
148
- 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
149
+ const MAX_PROMPT_CHARS = 800000; // ~200k tokens (ngang CONTEXT_WINDOW) — compact() safety net cuối, repl.js auto-compact ở 75% (150k token) chạy trước.
149
150
  // 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
150
151
  // → giữ được "trí nhớ dài hạn" trong phiên mà không nổ context.
151
- const SUMMARIZE_THRESHOLD_CHARS = 6000000; // ~1.5M tokens (75% window) summarize chỉ chạy sau auto-compact 75% với CONTEXT_WINDOW=2M
152
+ // 600k chars 150k tokens = trùng ngưỡng auto-compact 75% của repl.js. Khi
153
+ // /compact thủ công hoặc auto-compact gọi với force=true thì ngưỡng này bị bypass.
154
+ const SUMMARIZE_THRESHOLD_CHARS = 600000;
152
155
 
153
156
  // HARD GOAL block (do /goal <text> set): chèn ngay sau memoryBlock, attention
154
157
  // cao. Mục đích — chống 3 failure mode bài "dynamic workflows" của Anthropic
@@ -261,11 +264,11 @@ export async function maybeSummarize(history, { model, signal, force = false } =
261
264
  const totalChars = history.reduce((s, m) => s + (m.content?.length || 0), 0);
262
265
  if (!force && totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
263
266
  // Giữ tail nguyên vẹn; tóm tắt phần trước.
264
- // Với CONTEXT_WINDOW = 2M tokens, tail cần đủ lớn để giữ context tool result
265
- // gần nhất (vd 10 lượt cuối có thể là chuỗi edit_file + run_command đang dở).
266
- // force (gọi từ /compact hoặc auto-compact 75%): giữ 16 tail.
267
- // non-force: giữ 24 tail (rộng tay hơn vì phiên rất dài mới trigger).
268
- const keepTail = force ? 16 : 24;
267
+ // Với CONTEXT_WINDOW = 200k tokens, tail cần đủ để giữ vài lượt tool result
268
+ // gần nhất (chuỗi edit_file + run_command đang dở).
269
+ // force (gọi từ /compact hoặc auto-compact 75%): giữ 12 tail.
270
+ // non-force: giữ 16 tail (rộng tay hơn vì phiên dài mới trigger).
271
+ const keepTail = force ? 12 : 16;
269
272
  if (history.length <= keepTail + 2) return false;
270
273
  // Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
271
274
  const head = history.slice(0, history.length - keepTail);
@@ -32,7 +32,7 @@ import { t } from '../i18n.js';
32
32
  * @returns {function} dispatchTool(name, input, depth=0) → {allow, result}
33
33
  */
34
34
  export function createAgentDispatcher(deps) {
35
- const { state, abort, tokenMeter, stopSpin, startSpin, execTool } = deps;
35
+ const { state, abort, tokenMeter, stopSpin, startSpin, execTool, tui, c } = deps;
36
36
  // Test injection points: production luôn dùng default; smoke test pass mock.
37
37
  const runSubAgent = deps.runSubAgent || defaultRunSubAgent;
38
38
  const findModel = deps.findModel || defaultFindModel;
@@ -44,6 +44,57 @@ export function createAgentDispatcher(deps) {
44
44
  const recordWorkflowTaskFailed = j.recordTaskFailed;
45
45
 
46
46
  const dispatchTool = async (name, input, depth = 0) => {
47
+ // write_todos: tool ẢO cập nhật state.todos + TUI trực tiếp. Không qua execTool
48
+ // vì không phải fs/shell — chỉ là cách model declare structured todo list thay
49
+ // vì viết markdown `- [ ]` (parser markdown fragile khi format sai). Mỗi lần
50
+ // gọi REPLACE toàn bộ list (không patch từng item — model gửi lại full list
51
+ // với done:true cho item vừa xong). State.todos được set NGAY → TUI render
52
+ // chính xác, không cần parse history.
53
+ if (name === 'write_todos') {
54
+ const todosIn = Array.isArray(input?.todos) ? input.todos : null;
55
+ if (!todosIn)
56
+ return { allow: true, result: 'ERROR: write_todos cần field "todos": [{text, done}].' };
57
+ const todos = todosIn
58
+ .filter((it) => it && typeof it.text === 'string' && it.text.trim())
59
+ .map((it) => ({ text: String(it.text).trim(), done: !!it.done }));
60
+ if (!todos.length)
61
+ return { allow: true, result: 'ERROR: todos rỗng — gửi ít nhất 1 item {text, done}.' };
62
+ const prev = Array.isArray(state.todos) ? state.todos : [];
63
+ const prevByText = new Map(prev.map((p) => [p.text, !!p.done]));
64
+ state.todos = todos;
65
+ // Flag: lượt này model đã dùng write_todos → repl skip parse markdown để
66
+ // không overwrite structured state bằng parser fragile. Reset đầu mỗi turn.
67
+ state._todosFromTool = true;
68
+ try { tui?.setTodos?.(todos); } catch {}
69
+ const done = todos.filter((t) => t.done).length;
70
+ // In compact: lần đầu (prev rỗng) hoặc list thay đổi tập text → in full.
71
+ // Nếu cùng tập text + chỉ khác trạng thái done → in diff (dòng vừa toggle).
72
+ const sameSet = prev.length === todos.length && todos.every((t) => prevByText.has(t.text));
73
+ stopSpin?.();
74
+ if (!sameSet) {
75
+ const lines = todos.map((t) => ' ' + (t.done ? '✓ ' : '☐ ') + t.text);
76
+ console.log((c?.tool || ((s) => s))(` 📋 todo (${done}/${todos.length})`));
77
+ console.log(lines.join('\n'));
78
+ } else {
79
+ // diff: in dòng có done thay đổi (cả false→true lẫn true→false).
80
+ const changes = todos.filter((t) => prevByText.get(t.text) !== t.done);
81
+ if (changes.length === 0) {
82
+ console.log((c?.dim || ((s) => s))(` 📋 todo (${done}/${todos.length}) · không đổi`));
83
+ } else {
84
+ console.log((c?.tool || ((s) => s))(` 📋 todo (${done}/${todos.length})`));
85
+ for (const ch of changes) {
86
+ const mark = ch.done ? '✓' : '☐';
87
+ console.log(' ' + mark + ' ' + ch.text);
88
+ }
89
+ }
90
+ }
91
+ startSpin?.();
92
+ return {
93
+ allow: true,
94
+ result: `Đã cập nhật ${todos.length} todo (${done} xong, ${todos.length - done} còn lại). Tiếp tục item chưa done; nếu tất cả done, kết thúc trả lời.`,
95
+ };
96
+ }
97
+
47
98
  // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
48
99
  // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
49
100
  if (name === 'spawn_agent' || name === 'spawn_agents') {
package/src/repl.js CHANGED
@@ -1418,7 +1418,7 @@ NGUYÊN TẮC:
1418
1418
  // src/repl/agent-dispatch.js (v1.12.x). Factory được gọi MỖI turn vì abort
1419
1419
  // được rebind trong handle() — không cache.
1420
1420
  const dispatchTool = createAgentDispatcher({
1421
- state, abort, tokenMeter, stopSpin, startSpin, execTool,
1421
+ state, abort, tokenMeter, stopSpin, startSpin, execTool, tui, c,
1422
1422
  });
1423
1423
 
1424
1424
  const answer = await runAgent({
@@ -1461,8 +1461,15 @@ NGUYÊN TẮC:
1461
1461
  printAnswer(answer, state.model.name, providerColor(state.model.provider));
1462
1462
 
1463
1463
  // Parse todo từ model output → render trên status bar.
1464
- state.todos = parseTodosFromHistory(state.history);
1465
- tui.setTodos(state.todos);
1464
+ // Nếu lượt này model đã gọi write_todos (state._todosFromTool = true),
1465
+ // state.todos + TUI đã được set trực tiếp trong dispatcher — SKIP parse
1466
+ // markdown để không overwrite structured state bằng parser fragile.
1467
+ if (state._todosFromTool) {
1468
+ state._todosFromTool = false; // reset cho turn sau
1469
+ } else {
1470
+ state.todos = parseTodosFromHistory(state.history);
1471
+ tui.setTodos(state.todos);
1472
+ }
1466
1473
  return answer; // vòng ULTRA cần text này để dò token hoàn thành
1467
1474
  } catch (err) {
1468
1475
  stopSpin();
@@ -1474,53 +1481,24 @@ NGUYÊN TẮC:
1474
1481
  // Reset turn-scoped auto-approve — chỉ áp dụng trong runAgent vừa rồi.
1475
1482
  // (autoApprove + autoApproveFile vẫn giữ nguyên cho phiên.)
1476
1483
  state.autoApproveTurn.clear();
1477
- // Auto-compact dựa trên context tokens thay chars.
1478
- // Với CONTEXT_WINDOW = 2M tokens (xem src/tokens.js):
1479
- // 75% (1.5M tokens) auto compact
1480
- // 60% (1.2M tokens) cảnh báo mạnh
1481
- // 40% (800k tokens) nhắc nhẹ
1482
- // Ngưỡng kéo xuống model context dài hiện tại để 80% mới compact thì
1483
- // mỗi lượt cuối đã ăn 200k+ tokens — auto-compact sớm hơn giữ phiên mượt.
1484
+ // [2026-06-12] GỠ AUTO-COMPACT user kiểm soát compact thủ công bằng /compact.
1485
+ // do: auto-compact gián đoạn workflow giữa chừng, summary có thể mất chi
1486
+ // tiết user cần. Giữ 2 mốc CẢNH BÁO (60% / 80%) để user biết khi nào nên
1487
+ // chạy /compact, nhưng KHÔNG tự động chạy nữa.
1488
+ // Với CONTEXT_WINDOW = 200k tokens:
1489
+ // 60% (120k) nhắc nhẹ một lần
1490
+ // 80% (160k) cảnh báo mạnh nên /compact ngay trước khi provider reject
1484
1491
  try {
1485
1492
  const totalTokens = countMessages(state.history);
1486
1493
  const k = Math.round(totalTokens / 1000);
1487
1494
  const pct = Math.round((totalTokens / CONTEXT_WINDOW) * 100);
1488
- // Mốc 3 (≥75% 1.5M tokens): TỰ ĐỘNG compact.
1489
- if (totalTokens >= CONTEXT_WINDOW * 0.75 && !state._autoCompacting) {
1490
- state._autoCompacting = true;
1491
- console.log(c.accent(` ⚡ ${t.autoCompactTrigger(k)} (${pct}% context)`));
1492
- tui.setBusy(true, t.compactRunning);
1493
- try {
1494
- const ok = await maybeSummarize(state.history, { model: state.model, force: true });
1495
- tui.setBusy(false);
1496
- if (ok) {
1497
- const afterTokens = countMessages(state.history);
1498
- const aK = Math.round(afterTokens / 1000);
1499
- const saved =
1500
- totalTokens > 0 ? Math.round(((totalTokens - afterTokens) / totalTokens) * 100) : 0;
1501
- console.log(
1502
- c.ok(
1503
- ` ${t.autoCompactDone(k, aK, saved)} (${Math.round((afterTokens / CONTEXT_WINDOW) * 100)}% context)`
1504
- )
1505
- );
1506
- state._longSessionWarned = false;
1507
- persist();
1508
- } else {
1509
- console.log(c.err(' ' + t.autoCompactFail));
1510
- }
1511
- } catch (e) {
1512
- tui.setBusy(false);
1513
- console.log(c.err(' ' + t.autoCompactFail));
1514
- } finally {
1515
- state._autoCompacting = false;
1516
- }
1517
- } else if (totalTokens >= CONTEXT_WINDOW * 0.6) {
1518
- // Mốc 2 (≥60% — 1.2M tokens): cảnh báo mạnh.
1519
- console.log(c.err(` ⚠ ${t.veryLongSession(k)} (${pct}% context)`));
1495
+ if (totalTokens >= CONTEXT_WINDOW * 0.8) {
1496
+ // Mốc 2 (≥80% 160k tokens): cảnh báo mạnh, gợi ý /compact ngay.
1497
+ console.log(c.err(` ⚠ ${t.veryLongSession(k)} (${pct}% context) — gõ /compact để tóm tắt, tránh provider reject ở ~200k.`));
1520
1498
  state._longSessionWarned = true;
1521
- } else if (totalTokens >= CONTEXT_WINDOW * 0.4 && !state._longSessionWarned) {
1522
- // Mốc 1 (≥40% — 800k tokens): nhắc nhẹ một lần.
1523
- console.log(c.dim(` ⓘ ${t.longSession(k)} (${pct}% context)`));
1499
+ } else if (totalTokens >= CONTEXT_WINDOW * 0.6 && !state._longSessionWarned) {
1500
+ // Mốc 1 (≥60% — 120k tokens): nhắc nhẹ một lần.
1501
+ console.log(c.dim(` ⓘ ${t.longSession(k)} (${pct}% context) — cân nhắc /compact nếu phiên còn dài.`));
1524
1502
  state._longSessionWarned = true;
1525
1503
  }
1526
1504
  } catch {}
package/src/tokens.js CHANGED
@@ -57,8 +57,12 @@ export function countMessages(messages = []) {
57
57
  // window đủ rộng (256 chars) để qua mọi ranh giới token thực tế của cl100k/o200k
58
58
  // (token dài nhất ~ vài chục byte).
59
59
  const TAIL_WINDOW = 256;
60
- // Context window tối đa của model (2M tokens). Dùng để tính % usage realtime.
61
- export const CONTEXT_WINDOW = 2_000_000;
60
+ // Context window tối đa của model. Đặt 200k tokens match Claude 3.5/Opus 4,
61
+ // GPT-4o, an toàn cho mọi model phổ biến qua gateway (Gemini 1M, DeepSeek
62
+ // 128k, Grok 128k...). Đặt cao hơn 200k là vô nghĩa: provider sẽ reject prompt
63
+ // TRƯỚC khi auto-compact của repl.js có cơ hội trigger → user thấy 'compact
64
+ // không hoạt động' dù logic compact vẫn đúng.
65
+ export const CONTEXT_WINDOW = 200_000;
62
66
 
63
67
  export class TokenMeter {
64
68
  constructor() {
package/src/tools.js CHANGED
@@ -519,6 +519,17 @@ export const TOOLS = {
519
519
  return `Killed background process #${id} (${p.command}).`;
520
520
  },
521
521
 
522
+ // write_todos là tool ẢO: dispatcher (src/repl/agent-dispatch.js) intercept TRƯỚC
523
+ // khi vào execTool, nên stub này thường không chạy. Giữ để fail-safe: nếu có
524
+ // code path nào lỡ gọi runTool('write_todos', ...) trực tiếp, ít nhất trả OK
525
+ // thay vì ném Unknown tool — và stub trả lỗi rõ hướng dẫn fix.
526
+ async write_todos({ todos }, { signal } = {}) {
527
+ if (signal?.aborted) throw new Error('aborted');
528
+ if (!Array.isArray(todos)) throw new Error('write_todos: todos phải là mảng');
529
+ const done = todos.filter((it) => it && it.done).length;
530
+ return `(stub) write_todos nhận ${todos.length} item (${done} done) — dispatcher đáng lẽ phải intercept trước; nếu thấy dòng này, báo bug.`;
531
+ },
532
+
522
533
  // Knowledge graph tools — KHÔNG xin permission (user chọn tự do).
523
534
  // Storage: <cwd>/.noob/kg.jsonl. Logic ở src/kg.js.
524
535
  async kg_search({ query }, { signal } = {}) {
@@ -679,6 +690,11 @@ export function describe(name, input) {
679
690
  return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
680
691
  case 'spawn_agents':
681
692
  return `↳ ${(input.tasks || []).length} sub-agent song song`;
693
+ case 'write_todos': {
694
+ const items = Array.isArray(input?.todos) ? input.todos : [];
695
+ const done = items.filter((it) => it && it.done).length;
696
+ return `todo ${done}/${items.length}`;
697
+ }
682
698
  case 'kg_search':
683
699
  return `kg search "${input.query || ''}"`;
684
700
  case 'kg_add':