@noobdemon/noob-cli 1.12.0 → 1.12.2

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.
@@ -0,0 +1,168 @@
1
+ // Agent tool dispatcher — xử lý spawn_agent / spawn_agents (sub-agent recursion +
2
+ // workflow journal cache) hoặc forward sang execTool cho các tool thường.
3
+ //
4
+ // Tách khỏi src/repl.js (v1.12.x) để:
5
+ // - giảm closure-coupling trong startRepl (rule noob.md "pure logic tách khỏi
6
+ // closure để testable")
7
+ // - smoke test 6 nhánh chính bằng mock thay vì E2E spawn process
8
+ //
9
+ // Pattern dùng: factory createAgentDispatcher(deps) trả về function dispatchTool.
10
+ // Factory được gọi MỖI turn trong handle() vì abort/printer được rebind theo turn —
11
+ // không cache singleton ở scope startRepl.
12
+
13
+ import chalk from 'chalk';
14
+ import { runSubAgent as defaultRunSubAgent, MAX_SUBAGENT_DEPTH } from '../subagent.js';
15
+ import { findModel as defaultFindModel } from '../models.js';
16
+ import * as defaultJournal from '../workflow-runs.js';
17
+ import { t } from '../i18n.js';
18
+
19
+ /**
20
+ * Tạo dispatcher cho 1 turn agent.
21
+ *
22
+ * @param {object} deps
23
+ * @param {object} deps.state — state object (cần state.agentMode, state.model, state.workflowRun)
24
+ * @param {AbortController} deps.abort — controller hiện tại của turn (đọc abort.signal)
25
+ * @param {object} deps.tokenMeter — TokenMeter instance, forward xuống sub-agent
26
+ * @param {function} deps.stopSpin — dừng spinner UI trước khi log
27
+ * @param {function} deps.startSpin — khởi động lại spinner sau log
28
+ * @param {function} deps.execTool — async (name, input) → {allow, result} cho tool thường
29
+ * @param {function} [deps.runSubAgent] — (chỉ dùng cho test) override sub-agent runner
30
+ * @param {function} [deps.findModel] — (chỉ dùng cho test) override model resolver
31
+ * @param {object} [deps.journal] — (chỉ dùng cho test) override workflow journal helpers
32
+ * @returns {function} dispatchTool(name, input, depth=0) → {allow, result}
33
+ */
34
+ export function createAgentDispatcher(deps) {
35
+ const { state, abort, tokenMeter, stopSpin, startSpin, execTool } = deps;
36
+ // Test injection points: production luôn dùng default; smoke test pass mock.
37
+ const runSubAgent = deps.runSubAgent || defaultRunSubAgent;
38
+ const findModel = deps.findModel || defaultFindModel;
39
+ const j = deps.journal || defaultJournal;
40
+ const hashWorkflowTask = j.hashTask;
41
+ const lookupWorkflowTaskResult = j.lookupTaskResult;
42
+ const recordWorkflowTaskStart = j.recordTaskStart;
43
+ const recordWorkflowTaskDone = j.recordTaskDone;
44
+ const recordWorkflowTaskFailed = j.recordTaskFailed;
45
+
46
+ const dispatchTool = async (name, input, depth = 0) => {
47
+ // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
48
+ // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
49
+ if (name === 'spawn_agent' || name === 'spawn_agents') {
50
+ if (!state.agentMode)
51
+ return {
52
+ allow: true,
53
+ result: 'ERROR: agent mode đang TẮT — gõ /agent on để bật trước khi spawn.',
54
+ };
55
+ if (depth >= MAX_SUBAGENT_DEPTH)
56
+ return {
57
+ allow: true,
58
+ result: `ERROR: đã đạt depth tối đa (${MAX_SUBAGENT_DEPTH}) — không spawn thêm.`,
59
+ };
60
+ const tasks =
61
+ name === 'spawn_agent' ? [input] : Array.isArray(input?.agents) ? input.agents : [];
62
+ if (!tasks.length) return { allow: true, result: 'ERROR: thiếu task cho sub-agent.' };
63
+ stopSpin();
64
+ console.log(
65
+ chalk.hex('#8b5cf6')(
66
+ ` ⊕ spawn ${tasks.length} sub-agent (depth ${depth + 1}/${MAX_SUBAGENT_DEPTH})`
67
+ )
68
+ );
69
+ startSpin(t.thinking);
70
+ try {
71
+ const runData = state.workflowRun?.data || null;
72
+ const results = await Promise.all(
73
+ tasks.map((task, i) => {
74
+ // Per-sub-agent model routing: task.model có thể là id model hoặc tên thân thiện.
75
+ // findModel() resolve cả hai; nếu không match thì fallback model của cha.
76
+ let subModel = state.model.id;
77
+ let modelTag = '';
78
+ if (task?.model) {
79
+ const m = findModel(task.model);
80
+ if (m) {
81
+ subModel = m.id;
82
+ modelTag = ` [${m.name}]`;
83
+ } else
84
+ modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
85
+ }
86
+ const taskBody = task?.task || task?.prompt || '';
87
+ const taskCtx = task?.context || '';
88
+ // Workflow journal: nếu đang trong run + task đã done lần trước → return
89
+ // cached result, tiết kiệm token. Hash = crc32(task+ctx+model).
90
+ if (runData) {
91
+ const hash = hashWorkflowTask({ task: taskBody, context: taskCtx, model: subModel });
92
+ const cached = lookupWorkflowTaskResult(runData, hash);
93
+ if (cached !== null) {
94
+ stopSpin();
95
+ console.log(
96
+ chalk.hex('#8b5cf6')(
97
+ ` ⊘ sub-agent #${i + 1}${modelTag} skip — đã done trong run trước (cached)`
98
+ )
99
+ );
100
+ startSpin(t.thinking);
101
+ return Promise.resolve(
102
+ `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
103
+ );
104
+ }
105
+ recordWorkflowTaskStart(runData, {
106
+ hash,
107
+ task: taskBody,
108
+ context: taskCtx,
109
+ model: subModel,
110
+ });
111
+ // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
112
+ return runSubAgent({
113
+ task: taskBody,
114
+ context: taskCtx,
115
+ depth: depth + 1,
116
+ model: subModel,
117
+ signal: abort.signal,
118
+ tokenMeter,
119
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
120
+ onLog: (msg) => {
121
+ stopSpin();
122
+ console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
123
+ startSpin(t.thinking);
124
+ },
125
+ })
126
+ .then((r) => {
127
+ recordWorkflowTaskDone(runData, hash, r);
128
+ return `── sub-agent #${i + 1}${modelTag} ──\n${r}`;
129
+ })
130
+ .catch((e) => {
131
+ recordWorkflowTaskFailed(runData, hash, e);
132
+ return `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`;
133
+ });
134
+ }
135
+ // Không có active workflow run → behavior cũ.
136
+ return runSubAgent({
137
+ task: taskBody,
138
+ context: taskCtx,
139
+ depth: depth + 1,
140
+ model: subModel,
141
+ signal: abort.signal,
142
+ tokenMeter,
143
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
144
+ onLog: (msg) => {
145
+ stopSpin();
146
+ console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
147
+ startSpin(t.thinking);
148
+ },
149
+ })
150
+ .then((r) => `── sub-agent #${i + 1}${modelTag} ──\n${r}`)
151
+ .catch(
152
+ (e) => `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`
153
+ );
154
+ })
155
+ );
156
+ return { allow: true, result: results.join('\n\n') };
157
+ } catch (err) {
158
+ return { allow: true, result: 'ERROR sub-agent: ' + (err?.message || String(err)) };
159
+ }
160
+ }
161
+ stopSpin();
162
+ const res = await execTool(name, input);
163
+ startSpin(t.thinking);
164
+ return res;
165
+ };
166
+
167
+ return dispatchTool;
168
+ }
@@ -20,30 +20,42 @@
20
20
  // - truncate: helper cắt chuỗi dài.
21
21
 
22
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.
23
+ * Hỏi quyền chung cho 1 tool call. Trả về 'y' | 'n' | 'a' | 't' | 'f'.
24
+ * 'y' = đồng ý lần này
25
+ * 'n' = từ chối
26
+ * 'a' = always — auto-approve tool này tới hết phiên
27
+ * 't' = this turn — auto-approve tool này tới hết turn hiện tại
28
+ * 'f' = this file — auto-approve mọi tool destructive trên path này tới hết phiên
29
+ * (chỉ hiện khi có `targetPath`, vd edit_file/write_file)
30
+ *
31
+ * `targetPath` (optional) = path file đang bị thao tác — nếu có, hiện kèm option `f`.
25
32
  */
26
- export async function askPermission(name, { tui, ask, pending, c, t, truncate }) {
33
+ export async function askPermission(name, { tui, ask, pending, c, t, truncate, targetPath } = {}) {
27
34
  tui.setBusy(false);
28
- console.log(
29
- c.tool(' Cần quyền: ' + name) +
30
- c.dim(' — y (đồng ý) / n (từ chối) / a (luôn cho phép)')
31
- );
35
+ const hasFile = typeof targetPath === 'string' && targetPath.length > 0;
36
+ const headerHint = hasFile
37
+ ? ' — y (đồng ý) / n (từ chối) / a (luôn cho phép) / t (turn này) / f (file này)'
38
+ : ' — y (đồng ý) / n (từ chối) / a (luôn cho phép) / t (turn này)';
39
+ console.log(c.tool(' ⏸ Cần quyền: ' + name) + c.dim(headerHint));
40
+ if (hasFile) console.log(c.dim(' file: ' + targetPath));
41
+ const promptHint = hasFile
42
+ ? '[y] có / [n] không / [a] luôn ' + name + ' / [t] hết turn / [f] file này › '
43
+ : '[y] có / [n] không / [a] luôn ' + name + ' / [t] hết turn › ';
32
44
  try {
33
45
  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
- );
46
+ const raw = await ask(c.tool(' cho phép? ') + c.dim(promptHint));
37
47
  if (raw == null) return 'n';
38
48
  const a = raw.trim().toLowerCase();
39
49
  if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
40
50
  if (a === 'n' || a === 'no' || a === 'không') return 'n';
41
51
  if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
52
+ if (a === 't' || a === 'turn') return 't';
53
+ if (hasFile && (a === 'f' || a === 'file')) return 'f';
42
54
  if (raw.trim().length > 3) {
43
55
  pending.push(raw);
44
56
  console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
45
57
  }
46
- console.log(c.dim(' ' + t.permRetry));
58
+ console.log(c.dim(' ' + (t.permRetryExtended || t.permRetry)));
47
59
  }
48
60
  } finally {
49
61
  tui.setBusy(true, t.thinking);
package/src/repl/state.js CHANGED
@@ -19,7 +19,9 @@ export function createState(opts = {}, config) {
19
19
  model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
20
20
  mode: 'chat', // chat | merge | search
21
21
  history: [],
22
- autoApprove: new Set(),
22
+ autoApprove: new Set(), // tool name → 'a' (always, phiên)
23
+ autoApproveTurn: new Set(), // tool name → 't' (this turn, reset sau mỗi runAgent)
24
+ autoApproveFile: new Set(), // 'name:absPath' → 'f' (this file, phiên)
23
25
  yolo: !!opts.yolo || config.yoloDefault,
24
26
  ultra: false, // /ultra chế độ tự hành đang chạy
25
27
  agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
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
+ }