@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.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/agent.js +5 -0
- package/src/api.js +48 -0
- package/src/i18n.js +28 -1
- package/src/kg.js +300 -0
- package/src/prompts/system.md +57 -0
- package/src/repl/agent-dispatch.js +168 -0
- package/src/repl/permission.js +23 -11
- package/src/repl/state.js +3 -1
- package/src/repl/todos.js +65 -24
- package/src/repl.js +271 -152
- package/src/sessions.js +89 -0
- package/src/skills.js +59 -0
- package/src/tools.js +56 -0
- package/src/tui.js +21 -3
- package/src/ui.js +86 -84
|
@@ -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
|
+
}
|
package/src/repl/permission.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Quan trọng: chỉ parse list todo CUỐI cùng — assistant message GẦN NHẤT có
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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ữ
|
|
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
|
|
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
|
+
}
|