@noobdemon/noob-cli 1.7.6 → 1.7.8
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/package.json +1 -1
- package/skills/dynamic-workflows/SKILL.md +154 -0
- package/src/agent.js +118 -23
- package/src/api.js +51 -6
- package/src/i18n.js +35 -0
- package/src/models.js +14 -1
- package/src/repl.js +305 -7
- package/src/subagent.js +112 -53
- package/src/tokens.js +16 -0
- package/src/tools.js +46 -11
- package/src/tui.js +11 -5
- package/src/workflows.js +142 -0
package/src/tools.js
CHANGED
|
@@ -98,8 +98,15 @@ process.on("SIGTERM", () => {
|
|
|
98
98
|
process.exit(143);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
// Helper: throw nếu signal đã abort. Dùng ở đầu mỗi tool + giữa các vòng walk dài
|
|
102
|
+
// để tool fs (glob/grep) cũng phản ứng với Ctrl+C, không chỉ run_command.
|
|
103
|
+
function checkAbort(signal) {
|
|
104
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
105
|
+
}
|
|
106
|
+
|
|
101
107
|
export const TOOLS = {
|
|
102
|
-
async read_file({ path: p, offset, limit }) {
|
|
108
|
+
async read_file({ path: p, offset, limit }, { signal } = {}) {
|
|
109
|
+
checkAbort(signal);
|
|
103
110
|
const data = await fs.readFile(abs(p), "utf8");
|
|
104
111
|
let lines = data.split("\n");
|
|
105
112
|
const start = offset ? Math.max(0, offset - 1) : 0;
|
|
@@ -110,14 +117,16 @@ export const TOOLS = {
|
|
|
110
117
|
);
|
|
111
118
|
},
|
|
112
119
|
|
|
113
|
-
async write_file({ path: p, content }) {
|
|
120
|
+
async write_file({ path: p, content }, { signal } = {}) {
|
|
121
|
+
checkAbort(signal);
|
|
114
122
|
await fs.mkdir(path.dirname(abs(p)), { recursive: true });
|
|
115
123
|
await fs.writeFile(abs(p), content ?? "", "utf8");
|
|
116
124
|
const n = (content ?? "").split("\n").length;
|
|
117
125
|
return `Wrote ${n} line(s) to ${rel(abs(p))}`;
|
|
118
126
|
},
|
|
119
127
|
|
|
120
|
-
async edit_file({ path: p, old_string, new_string, replace_all }) {
|
|
128
|
+
async edit_file({ path: p, old_string, new_string, replace_all }, { signal } = {}) {
|
|
129
|
+
checkAbort(signal);
|
|
121
130
|
const file = abs(p);
|
|
122
131
|
const data = await fs.readFile(file, "utf8");
|
|
123
132
|
if (old_string === new_string) throw new Error("old_string and new_string are identical");
|
|
@@ -163,7 +172,8 @@ export const TOOLS = {
|
|
|
163
172
|
);
|
|
164
173
|
},
|
|
165
174
|
|
|
166
|
-
async list_dir({ path: p = "." }) {
|
|
175
|
+
async list_dir({ path: p = "." }, { signal } = {}) {
|
|
176
|
+
checkAbort(signal);
|
|
167
177
|
const dir = abs(p);
|
|
168
178
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
169
179
|
const rows = entries
|
|
@@ -173,10 +183,13 @@ export const TOOLS = {
|
|
|
173
183
|
return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => " " + r).join("\n"));
|
|
174
184
|
},
|
|
175
185
|
|
|
176
|
-
async glob({ pattern }) {
|
|
186
|
+
async glob({ pattern }, { signal } = {}) {
|
|
187
|
+
checkAbort(signal);
|
|
177
188
|
const hits = [];
|
|
178
189
|
const rx = globToRegExp(pattern);
|
|
179
190
|
const roots = listRoots();
|
|
191
|
+
// Check signal mỗi ~256 entries thay vì mỗi entry — đỡ overhead trên repo lớn.
|
|
192
|
+
let tickCounter = 0;
|
|
180
193
|
for (const root of roots) {
|
|
181
194
|
(function walk(dir) {
|
|
182
195
|
let ents;
|
|
@@ -186,6 +199,7 @@ export const TOOLS = {
|
|
|
186
199
|
return;
|
|
187
200
|
}
|
|
188
201
|
for (const e of ents) {
|
|
202
|
+
if ((++tickCounter & 0xff) === 0) checkAbort(signal);
|
|
189
203
|
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
|
|
190
204
|
const full = path.join(dir, e.name);
|
|
191
205
|
if (e.isDirectory()) walk(full);
|
|
@@ -198,11 +212,14 @@ export const TOOLS = {
|
|
|
198
212
|
return hits.length ? clip(hits.join("\n")) : "No files matched.";
|
|
199
213
|
},
|
|
200
214
|
|
|
201
|
-
async grep({ pattern, path: p, glob: g }) {
|
|
215
|
+
async grep({ pattern, path: p, glob: g }, { signal } = {}) {
|
|
216
|
+
checkAbort(signal);
|
|
202
217
|
const rx = new RegExp(pattern, "i");
|
|
203
218
|
const gRx = g ? globToRegExp(g) : null;
|
|
204
219
|
const out = [];
|
|
220
|
+
let tickCounter = 0;
|
|
205
221
|
function scanFile(full) {
|
|
222
|
+
if ((++tickCounter & 0xff) === 0) checkAbort(signal);
|
|
206
223
|
const disp = displayPath(full);
|
|
207
224
|
const relp = disp.split(path.sep).join("/");
|
|
208
225
|
if (gRx && !gRx.test(relp)) return;
|
|
@@ -250,7 +267,7 @@ export const TOOLS = {
|
|
|
250
267
|
return out.length ? clip(out.join("\n")) : "No matches.";
|
|
251
268
|
},
|
|
252
269
|
|
|
253
|
-
run_command({ command, timeout = 60000, background = false }) {
|
|
270
|
+
run_command({ command, timeout = 60000, background = false }, { signal } = {}) {
|
|
254
271
|
const isWin = process.platform === "win32";
|
|
255
272
|
const shell = isWin ? "powershell.exe" : "/bin/bash";
|
|
256
273
|
const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
|
|
@@ -289,19 +306,34 @@ export const TOOLS = {
|
|
|
289
306
|
const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
290
307
|
let out = "";
|
|
291
308
|
let timedOut = false;
|
|
309
|
+
let aborted = false;
|
|
292
310
|
const killer = setTimeout(() => {
|
|
293
311
|
timedOut = true;
|
|
294
|
-
child
|
|
312
|
+
killBgTree(child);
|
|
295
313
|
}, timeout);
|
|
314
|
+
// Ctrl+C trong lúc command đang chạy → kill cây tiến trình con (Windows
|
|
315
|
+
// dùng taskkill /T để diệt cả grand-children, vd npm spawn node).
|
|
316
|
+
const onAbort = () => {
|
|
317
|
+
aborted = true;
|
|
318
|
+
killBgTree(child);
|
|
319
|
+
};
|
|
320
|
+
if (signal) {
|
|
321
|
+
if (signal.aborted) onAbort();
|
|
322
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
323
|
+
}
|
|
296
324
|
child.stdout.on("data", (d) => (out += d));
|
|
297
325
|
child.stderr.on("data", (d) => (out += d));
|
|
298
326
|
child.on("error", (e) => {
|
|
299
327
|
clearTimeout(killer);
|
|
328
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
300
329
|
resolve(`Failed to start command: ${e.message}`);
|
|
301
330
|
});
|
|
302
331
|
child.on("close", (code) => {
|
|
303
332
|
clearTimeout(killer);
|
|
304
|
-
|
|
333
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
334
|
+
const tail = aborted
|
|
335
|
+
? `\n[aborted by user (Ctrl+C) — killed.]`
|
|
336
|
+
: timedOut
|
|
305
337
|
? `\n[timed out after ${Math.round(timeout / 1000)}s — killed. If this is a server or other long-running task, re-run with {"background": true} instead.]`
|
|
306
338
|
: `\n[exit code ${code}]`;
|
|
307
339
|
resolve(clip((out.trim() || "(no output)") + tail));
|
|
@@ -445,8 +477,11 @@ export function describe(name, input) {
|
|
|
445
477
|
}
|
|
446
478
|
}
|
|
447
479
|
|
|
448
|
-
export async function runTool(name, input) {
|
|
480
|
+
export async function runTool(name, input, opts = {}) {
|
|
449
481
|
const fn = TOOLS[name];
|
|
450
482
|
if (!fn) throw new Error(`Unknown tool: ${name}`);
|
|
451
|
-
|
|
483
|
+
const { signal } = opts;
|
|
484
|
+
// Pre-check: nếu user đã Ctrl+C trước khi tool kịp chạy, bail ngay.
|
|
485
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
486
|
+
return await fn(input || {}, { signal });
|
|
452
487
|
}
|
package/src/tui.js
CHANGED
|
@@ -163,14 +163,20 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
163
163
|
function topRow() {
|
|
164
164
|
if (liveOut) {
|
|
165
165
|
// Khi đang stream prose mà busy, ghép meta (elapsed+token) vào cuối liveOut
|
|
166
|
-
//
|
|
166
|
+
// CHỈ KHI liveOut đủ ngắn để cả dòng (liveOut + meta) chắc chắn fit trong
|
|
167
|
+
// 1 dòng terminal. Nếu liveOut quá dài → ưu tiên prose, bỏ meta lượt này
|
|
168
|
+
// (tránh terminal wrap làm dòng tạm kẹt lại trong prose vĩnh viễn — xem
|
|
169
|
+
// Note "token meter chèn vào prose" trong noob.md).
|
|
167
170
|
if (busy && busyMeta) {
|
|
168
171
|
const meta = c.dim(" · " + busyMeta);
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
+
const metaLen = visLen(meta);
|
|
173
|
+
const liveLen = visLen(liveOut);
|
|
174
|
+
if (liveLen + metaLen <= cols()) {
|
|
175
|
+
return liveOut + meta;
|
|
176
|
+
}
|
|
177
|
+
// liveOut đã dài: hiện prose nguyên trạng, bỏ meta để tránh wrap.
|
|
172
178
|
}
|
|
173
|
-
return liveOut.slice(0, cols());
|
|
179
|
+
return liveOut.length > cols() ? liveOut.slice(0, cols()) : liveOut;
|
|
174
180
|
}
|
|
175
181
|
const spin = FRAMES[frame % FRAMES.length];
|
|
176
182
|
// Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
|
package/src/workflows.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
// CRUD workflow đã lưu. Cảm hứng từ tweet_dump.txt L183–193 ("saving and sharing
|
|
6
|
+
// dynamic workflows"): user nhấn 's' để snapshot prompt template ra file rồi tái
|
|
7
|
+
// dùng. Map sang noob: lưu Markdown ở ~/.noob/workflows/<name>.md, format có
|
|
8
|
+
// front-matter YAML-lite + body là prompt template.
|
|
9
|
+
|
|
10
|
+
const DIR = path.join(os.homedir(), ".noob", "workflows");
|
|
11
|
+
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Tên file an toàn — chỉ cho phép [a-z0-9-_], chống path traversal.
|
|
17
|
+
function sanitizeName(name) {
|
|
18
|
+
if (!name || typeof name !== "string") return null;
|
|
19
|
+
const trimmed = name.trim().toLowerCase();
|
|
20
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(trimmed)) return null;
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function filePath(name) {
|
|
25
|
+
const safe = sanitizeName(name);
|
|
26
|
+
if (!safe) return null;
|
|
27
|
+
return path.join(DIR, safe + ".md");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse front-matter cực tối giản: --- ... --- ở đầu file, key: value mỗi dòng.
|
|
31
|
+
function parseFile(raw) {
|
|
32
|
+
const meta = {};
|
|
33
|
+
let body = raw;
|
|
34
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
35
|
+
if (m) {
|
|
36
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
37
|
+
const kv = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
|
|
38
|
+
if (kv) meta[kv[1]] = kv[2].trim();
|
|
39
|
+
}
|
|
40
|
+
body = m[2];
|
|
41
|
+
}
|
|
42
|
+
return { meta, body };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function serialize(meta, body) {
|
|
46
|
+
const lines = ["---"];
|
|
47
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
48
|
+
lines.push(`${k}: ${String(v).replace(/\r?\n/g, " ")}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push("---");
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push(body);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Lưu workflow. Trả về { ok, path?, error? }.
|
|
57
|
+
export function saveWorkflow(name, prompt, opts = {}) {
|
|
58
|
+
const safe = sanitizeName(name);
|
|
59
|
+
if (!safe) return { ok: false, error: "invalid_name" };
|
|
60
|
+
if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
|
|
61
|
+
return { ok: false, error: "empty_prompt" };
|
|
62
|
+
}
|
|
63
|
+
ensureDir();
|
|
64
|
+
const fp = path.join(DIR, safe + ".md");
|
|
65
|
+
const meta = {
|
|
66
|
+
name: safe,
|
|
67
|
+
created: opts.created || new Date().toISOString(),
|
|
68
|
+
updated: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
if (opts.description) meta.description = opts.description;
|
|
71
|
+
try {
|
|
72
|
+
fs.writeFileSync(fp, serialize(meta, prompt.trim()), "utf8");
|
|
73
|
+
return { ok: true, path: fp };
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { ok: false, error: e.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Đọc workflow. Trả về { ok, name, prompt, meta, path } hoặc { ok: false }.
|
|
80
|
+
export function loadWorkflow(name) {
|
|
81
|
+
const fp = filePath(name);
|
|
82
|
+
if (!fp) return { ok: false, error: "invalid_name" };
|
|
83
|
+
if (!fs.existsSync(fp)) return { ok: false, error: "not_found" };
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
86
|
+
const { meta, body } = parseFile(raw);
|
|
87
|
+
return { ok: true, name: sanitizeName(name), prompt: body.trim(), meta, path: fp };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: false, error: e.message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Liệt kê tất cả workflow đã lưu. Trả về mảng { name, description?, updated? }.
|
|
94
|
+
export function listWorkflows() {
|
|
95
|
+
ensureDir();
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(DIR);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const f of entries) {
|
|
104
|
+
if (!f.endsWith(".md")) continue;
|
|
105
|
+
const name = f.slice(0, -3);
|
|
106
|
+
if (!sanitizeName(name)) continue;
|
|
107
|
+
try {
|
|
108
|
+
const raw = fs.readFileSync(path.join(DIR, f), "utf8");
|
|
109
|
+
const { meta } = parseFile(raw);
|
|
110
|
+
out.push({
|
|
111
|
+
name,
|
|
112
|
+
description: meta.description || "",
|
|
113
|
+
updated: meta.updated || "",
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
out.push({ name, description: "", updated: "" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Sort theo updated desc (mới nhất lên đầu), fallback alphabet.
|
|
120
|
+
out.sort((a, b) => {
|
|
121
|
+
if (a.updated && b.updated) return b.updated.localeCompare(a.updated);
|
|
122
|
+
return a.name.localeCompare(b.name);
|
|
123
|
+
});
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Xoá workflow. Trả về { ok, error? }.
|
|
128
|
+
export function deleteWorkflow(name) {
|
|
129
|
+
const fp = filePath(name);
|
|
130
|
+
if (!fp) return { ok: false, error: "invalid_name" };
|
|
131
|
+
if (!fs.existsSync(fp)) return { ok: false, error: "not_found" };
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(fp);
|
|
134
|
+
return { ok: true };
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return { ok: false, error: e.message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function workflowsDir() {
|
|
141
|
+
return DIR;
|
|
142
|
+
}
|