@noobdemon/noob-cli 1.11.1 → 1.12.1

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/tokens.js CHANGED
@@ -57,8 +57,8 @@ 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 (300k tokens). Dùng để tính % usage realtime.
61
- export const CONTEXT_WINDOW = 300000;
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;
62
62
 
63
63
  export class TokenMeter {
64
64
  constructor() {
package/src/ui.js CHANGED
@@ -58,6 +58,24 @@ export function modelBadge(model) {
58
58
  // * option `href` không nên bọc ngoặc — wrapper tự thêm `(...)`.
59
59
  const BULLET = c.accent('•');
60
60
 
61
+ // Heading scale theo level — marked-terminal gọi `heading` với (text, level).
62
+ // h1 → brand gradient (chỉ dùng cho firstHeading thực ra, nhưng giữ fallback).
63
+ // h2 → tím đậm (section chính, vd `## Đề xuất ưu tiên hàng đầu`).
64
+ // h3 → cyan đậm (sub-section, vd `### 10. Sub-project ...`).
65
+ // h4+ → xám sáng đậm để vẫn nổi nhưng không cướp spotlight của h2/h3.
66
+ const HEADING_BY_LEVEL = {
67
+ 1: (s) => brand(s),
68
+ 2: chalk.hex('#a78bfa').bold,
69
+ 3: chalk.hex('#06b6d4').bold,
70
+ 4: chalk.hex('#9ca3af').bold,
71
+ 5: chalk.hex('#9ca3af').bold,
72
+ 6: chalk.hex('#9ca3af').bold,
73
+ };
74
+ function headingStyler(text, level) {
75
+ const fn = HEADING_BY_LEVEL[level] || HEADING_BY_LEVEL[2];
76
+ return fn(text);
77
+ }
78
+
61
79
  marked.use(
62
80
  markedTerminal(
63
81
  {
@@ -66,11 +84,15 @@ marked.use(
66
84
  tab: 2,
67
85
  showSectionPrefix: false,
68
86
  firstHeading: (s) => brand(s),
69
- heading: chalk.hex('#a78bfa').bold,
87
+ heading: headingStyler,
70
88
  blockquote: chalk.hex('#6b7280').italic,
71
- strong: chalk.bold,
89
+ // **bold** → vàng đậm để các nhãn `**Vấn đề/cơ hội:**`, `**Đề xuất:**`,
90
+ // `**Lợi ích:**`, `**P0**`, `**#1 ...**` đập vào mắt thay vì lẫn prose.
91
+ strong: chalk.hex('#fbbf24').bold,
72
92
  em: chalk.italic,
73
- codespan: chalk.bgHex('#1f2937').hex('#fbbf24'),
93
+ // `inline code` (backtick): xanh lá pastel không bg — tách rõ khỏi prose
94
+ // mà không chói như highlight bg cũ. Dùng cho path:line, tên file, tên hàm.
95
+ codespan: chalk.hex('#34d399'),
74
96
  hr: () => rule(),
75
97
  link: chalk.hex('#06b6d4').underline,
76
98
  href: chalk.hex('#9ca3af'),
@@ -80,17 +102,82 @@ marked.use(
80
102
  )
81
103
  );
82
104
 
83
- // Post-process: đổi bullet `*` thành `•` màu accent, thêm viền `│` cho block code (4-space indent).
105
+ // Post-process:
106
+ // 1. Bullet `*` → `•` màu accent.
107
+ // 2. Block code (4-space indent) → viền trái `│`.
108
+ // 3. Inline `**bold**` và backtick `code` BÊN TRONG list item: marked-terminal
109
+ // v7 + marked v15 không chạy inline parser cho list children → ta tự parse
110
+ // sau cùng. Chạy SAU bước 1 để bullet đã đổi rồi.
111
+ const INLINE_BOLD = chalk.hex('#fbbf24').bold;
112
+ const INLINE_CODE = chalk.hex('#34d399');
113
+ export function renderInline(text) {
114
+ // `code` trước (để `**foo**` trong code không bị parse). Backtick không được
115
+ // chứa newline; cho phép escape \` nếu cần.
116
+ let out = text.replace(/`([^`\n]+)`/g, (_, code) => INLINE_CODE(code));
117
+ // **bold** — non-greedy, không chứa newline. Cho phép nội dung là `code` đã ANSI.
118
+ out = out.replace(/\*\*([^*\n]+?)\*\*/g, (_, b) => INLINE_BOLD(b));
119
+ return out;
120
+ }
121
+
122
+ // Style heading prefix theo level cho stream printer (không qua marked).
123
+ // `## foo` → ANSI styled foo, `### bar` → khác cấp. Trả về null nếu không phải heading.
124
+ const HEADING_STYLE_BY_LEVEL = {
125
+ 1: brand,
126
+ 2: chalk.hex('#a78bfa').bold,
127
+ 3: chalk.hex('#06b6d4').bold,
128
+ 4: chalk.hex('#9ca3af').bold,
129
+ 5: chalk.hex('#9ca3af').bold,
130
+ 6: chalk.hex('#9ca3af').bold,
131
+ };
132
+ export function renderHeadingLine(line) {
133
+ const m = line.match(/^(#{1,6})\s+(.+)$/);
134
+ if (!m) return null;
135
+ const level = m[1].length;
136
+ const text = m[2];
137
+ const fn = HEADING_STYLE_BY_LEVEL[level] || HEADING_STYLE_BY_LEVEL[2];
138
+ return fn(renderInline(text));
139
+ }
140
+
141
+ // Đổi bullet đầu dòng `* `, `- `, `+ ` → `• ` màu accent. Numbered list giữ nguyên.
142
+ export function renderBulletPrefix(line) {
143
+ return line.replace(/^( *)[*\-+] /, (_, sp) => sp + BULLET + ' ');
144
+ }
84
145
  function prettify(s) {
85
146
  return s
86
147
  .replace(/^( *)\* /gm, (_, sp) => sp + BULLET + ' ')
87
- .replace(/^ {4}(.*)$/gm, (_, rest) => c.dim('│ ') + rest);
148
+ .replace(/^ {4}(.*)$/gm, (_, rest) => c.dim('│ ') + rest)
149
+ .split('\n')
150
+ .map(renderInline)
151
+ .join('\n');
88
152
  }
89
153
 
90
154
  export function renderMarkdown(md) {
91
155
  return prettify(marked.parse(md || '')).trimEnd();
92
156
  }
93
157
 
158
+ // Format quota usage cho status bar: "4.7k/5k req" với màu theo % used.
159
+ // < 70% → dim (xám)
160
+ // 70-85% → tool (vàng cam)
161
+ // > 85% → err (đỏ)
162
+ // Plan admin (limit = -1 hoặc 0 với unlimited flag) → trả null (không hiện).
163
+ function fmtK(n) {
164
+ if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
165
+ return String(n);
166
+ }
167
+ export function formatQuota(u) {
168
+ if (!u || !u.ok) return null;
169
+ if (u.plan === 'admin') return null; // unlimited — đỡ rác status bar.
170
+ const limit = Number(u.limit);
171
+ const remaining = Number(u.remaining);
172
+ if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return null;
173
+ const used = Math.max(0, limit - remaining);
174
+ const pct = (used / limit) * 100;
175
+ const label = `${fmtK(remaining)}/${fmtK(limit)} req`;
176
+ if (pct > 85) return c.err(label);
177
+ if (pct > 70) return c.tool(label);
178
+ return c.dim(label);
179
+ }
180
+
94
181
  export function box(content, title, color = '#a78bfa') {
95
182
  return boxen(content, {
96
183
  title,
@@ -0,0 +1,222 @@
1
+ // Workflow run journal — resume sau interrupt + per-run log.
2
+ //
3
+ // Mục đích: khi workflow fan-out N sub-agent rồi user Ctrl+C / mất mạng giữa
4
+ // chừng, lần chạy lại (/workflow resume <id>) skip sub-agent đã DONE, chỉ chạy
5
+ // lại sub-agent PENDING/FAILED → tiết kiệm token đã trả.
6
+ //
7
+ // Vị trí: <cwd>/.noob/workflow-runs/<timestamp>-<name>.json (per-workspace,
8
+ // không global — workflow context phụ thuộc repo đang làm).
9
+ //
10
+ // Shape:
11
+ // {
12
+ // id: "2026-06-11T15-30-22-deep-research",
13
+ // name: "deep-research" | null, // builtin/saved name nếu có
14
+ // workflowPrompt: "...", // prompt đã gửi cho parent agent
15
+ // startedAt: ISO, updatedAt: ISO,
16
+ // status: "running" | "done" | "interrupted" | "failed",
17
+ // tasks: [ // mỗi sub-agent là 1 entry
18
+ // {
19
+ // hash: "crc32 hex", // crc32(task + context + model)
20
+ // task: str, context: str, model: str,
21
+ // status: "pending" | "done" | "failed",
22
+ // startedAt: ISO?, finishedAt: ISO?,
23
+ // result: str?, // text trả về (cached cho resume)
24
+ // error: str?,
25
+ // },
26
+ // ...
27
+ // ]
28
+ // }
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ const DIR_NAME = path.join('.noob', 'workflow-runs');
33
+
34
+ function runsDir(cwd = process.cwd()) {
35
+ return path.join(cwd, DIR_NAME);
36
+ }
37
+
38
+ function ensureDir(cwd) {
39
+ try {
40
+ fs.mkdirSync(runsDir(cwd), { recursive: true });
41
+ } catch {}
42
+ }
43
+
44
+ // crc32 stdlib-free — đủ tốt cho dedup task hash (không phải crypto).
45
+ // Polynomial 0xEDB88320, table dựng 1 lần.
46
+ let _crcTable = null;
47
+ function crcTable() {
48
+ if (_crcTable) return _crcTable;
49
+ const t = new Uint32Array(256);
50
+ for (let i = 0; i < 256; i++) {
51
+ let c = i;
52
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
53
+ t[i] = c >>> 0;
54
+ }
55
+ _crcTable = t;
56
+ return t;
57
+ }
58
+ export function crc32(str) {
59
+ const t = crcTable();
60
+ let c = 0xffffffff;
61
+ for (let i = 0; i < str.length; i++) c = t[(c ^ str.charCodeAt(i)) & 0xff] ^ (c >>> 8);
62
+ return ((c ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
63
+ }
64
+
65
+ // Hash 1 sub-agent task. Bao gồm task + context + model — đổi bất kỳ field nào
66
+ // → hash khác → resume sẽ chạy lại thay vì hit cache cũ.
67
+ export function hashTask({ task, context, model }) {
68
+ return crc32(JSON.stringify({ task: task || '', context: context || '', model: model || '' }));
69
+ }
70
+
71
+ // Tên file an toàn — chỉ [a-z0-9-_], thay ký tự không hợp lệ bằng `_`.
72
+ function sanitizeId(s) {
73
+ return String(s || '').toLowerCase().replace(/[^a-z0-9_-]/g, '_').slice(0, 80);
74
+ }
75
+
76
+ function makeId(name) {
77
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
78
+ const safeName = sanitizeId(name || 'adhoc');
79
+ return `${ts}-${safeName}`;
80
+ }
81
+
82
+ function filePathFor(id, cwd) {
83
+ return path.join(runsDir(cwd), id + '.json');
84
+ }
85
+
86
+ // Tạo run mới. Trả về { id, path, data }.
87
+ export function createRun({ name = null, workflowPrompt = '', cwd = process.cwd() } = {}) {
88
+ ensureDir(cwd);
89
+ const id = makeId(name);
90
+ const now = new Date().toISOString();
91
+ const data = {
92
+ id,
93
+ name,
94
+ workflowPrompt,
95
+ startedAt: now,
96
+ updatedAt: now,
97
+ status: 'running',
98
+ tasks: [],
99
+ };
100
+ const fp = filePathFor(id, cwd);
101
+ fs.writeFileSync(fp, JSON.stringify(data, null, 2), 'utf8');
102
+ return { id, path: fp, data };
103
+ }
104
+
105
+ // Đọc run theo id. Trả về { ok, data, path } hoặc { ok: false, error }.
106
+ export function loadRun(id, cwd = process.cwd()) {
107
+ const fp = filePathFor(sanitizeId(id), cwd);
108
+ if (!fs.existsSync(fp)) return { ok: false, error: 'not_found' };
109
+ try {
110
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
111
+ return { ok: true, data, path: fp };
112
+ } catch (e) {
113
+ return { ok: false, error: e.message };
114
+ }
115
+ }
116
+
117
+ // Ghi đè run. Internal — caller phải đã có data đầy đủ.
118
+ function writeRun(data, cwd = process.cwd()) {
119
+ data.updatedAt = new Date().toISOString();
120
+ const fp = filePathFor(data.id, cwd);
121
+ try {
122
+ fs.writeFileSync(fp, JSON.stringify(data, null, 2), 'utf8');
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ // Tra hash task trong journal hiện tại. Nếu đã DONE → return cached result.
130
+ // Nếu PENDING/FAILED hoặc chưa có → return null (caller phải chạy thật).
131
+ export function lookupTaskResult(data, hash) {
132
+ if (!data || !Array.isArray(data.tasks)) return null;
133
+ const entry = data.tasks.find((tk) => tk.hash === hash);
134
+ if (!entry) return null;
135
+ if (entry.status === 'done' && typeof entry.result === 'string') return entry.result;
136
+ return null;
137
+ }
138
+
139
+ // Ghi nhận task BẮT ĐẦU chạy. Tạo entry mới với status=pending nếu chưa có.
140
+ // Trả về entry (mutable reference vào data.tasks).
141
+ export function recordTaskStart(data, { hash, task, context, model }, cwd) {
142
+ let entry = data.tasks.find((tk) => tk.hash === hash);
143
+ if (!entry) {
144
+ entry = { hash, task, context: context || '', model: model || '', status: 'pending' };
145
+ data.tasks.push(entry);
146
+ }
147
+ entry.status = 'pending';
148
+ entry.startedAt = new Date().toISOString();
149
+ delete entry.error;
150
+ writeRun(data, cwd);
151
+ return entry;
152
+ }
153
+
154
+ // Ghi nhận task XONG. Lưu result để resume hit cache.
155
+ export function recordTaskDone(data, hash, result, cwd) {
156
+ const entry = data.tasks.find((tk) => tk.hash === hash);
157
+ if (!entry) return;
158
+ entry.status = 'done';
159
+ entry.finishedAt = new Date().toISOString();
160
+ entry.result = String(result == null ? '' : result);
161
+ delete entry.error;
162
+ writeRun(data, cwd);
163
+ }
164
+
165
+ // Ghi nhận task FAILED. Resume sẽ chạy lại.
166
+ export function recordTaskFailed(data, hash, error, cwd) {
167
+ const entry = data.tasks.find((tk) => tk.hash === hash);
168
+ if (!entry) return;
169
+ entry.status = 'failed';
170
+ entry.finishedAt = new Date().toISOString();
171
+ entry.error = String(error?.message || error || 'unknown');
172
+ writeRun(data, cwd);
173
+ }
174
+
175
+ // Đóng run với status cuối. 'done' / 'interrupted' / 'failed'.
176
+ export function closeRun(data, status, cwd) {
177
+ data.status = status;
178
+ writeRun(data, cwd);
179
+ }
180
+
181
+ // Liệt kê run gần đây nhất (mặc định 20). Sort theo updatedAt desc.
182
+ export function listRuns(limit = 20, cwd = process.cwd()) {
183
+ const dir = runsDir(cwd);
184
+ if (!fs.existsSync(dir)) return [];
185
+ let files;
186
+ try {
187
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
188
+ } catch {
189
+ return [];
190
+ }
191
+ const out = [];
192
+ for (const f of files) {
193
+ try {
194
+ const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
195
+ const total = data.tasks?.length || 0;
196
+ const done = (data.tasks || []).filter((tk) => tk.status === 'done').length;
197
+ out.push({
198
+ id: data.id,
199
+ name: data.name,
200
+ status: data.status,
201
+ updatedAt: data.updatedAt,
202
+ startedAt: data.startedAt,
203
+ total,
204
+ done,
205
+ });
206
+ } catch {}
207
+ }
208
+ out.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
209
+ return out.slice(0, limit);
210
+ }
211
+
212
+ // Xoá run theo id. Trả về true nếu xoá được.
213
+ export function deleteRun(id, cwd = process.cwd()) {
214
+ const fp = filePathFor(sanitizeId(id), cwd);
215
+ if (!fs.existsSync(fp)) return false;
216
+ try {
217
+ fs.unlinkSync(fp);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }