@noobdemon/noob-cli 1.12.1 → 1.12.3

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/tools.js CHANGED
@@ -2,6 +2,13 @@ import fs from 'node:fs/promises';
2
2
  import fssync from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
+ import {
6
+ createEntities as kgCreateEntities,
7
+ createRelations as kgCreateRelations,
8
+ addObservations as kgAddObservations,
9
+ searchNodes as kgSearchNodes,
10
+ formatGraphPretty as kgFormat,
11
+ } from './kg.js';
5
12
 
6
13
  const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
7
14
  const cwd = () => process.cwd();
@@ -511,6 +518,47 @@ export const TOOLS = {
511
518
  bg.delete(id);
512
519
  return `Killed background process #${id} (${p.command}).`;
513
520
  },
521
+
522
+ // Knowledge graph tools — KHÔNG xin permission (user chọn tự do).
523
+ // Storage: <cwd>/.noob/kg.jsonl. Logic ở src/kg.js.
524
+ async kg_search({ query }, { signal } = {}) {
525
+ if (signal?.aborted) throw new Error('aborted');
526
+ const g = await kgSearchNodes(String(query || ''));
527
+ if (!g.entities.length && !g.relations.length) return `(không có kết quả cho "${query}")`;
528
+ return kgFormat(g);
529
+ },
530
+
531
+ async kg_add({ entities }, { signal } = {}) {
532
+ if (signal?.aborted) throw new Error('aborted');
533
+ if (!Array.isArray(entities) || entities.length === 0)
534
+ throw new Error('kg_add: entities phải là mảng không rỗng');
535
+ const created = await kgCreateEntities(entities);
536
+ if (!created.length) return 'Không có entity mới (đã trùng tên với entity sẵn có)';
537
+ return `Đã thêm ${created.length} entity: ${created.map((e) => e.name).join(', ')}`;
538
+ },
539
+
540
+ async kg_link({ relations }, { signal } = {}) {
541
+ if (signal?.aborted) throw new Error('aborted');
542
+ if (!Array.isArray(relations) || relations.length === 0)
543
+ throw new Error('kg_link: relations phải là mảng không rỗng');
544
+ const created = await kgCreateRelations(relations);
545
+ if (!created.length) return 'Không có relation mới (đã trùng triple from+to+type)';
546
+ return (
547
+ `Đã thêm ${created.length} relation:\n` +
548
+ created.map((r) => ` ${r.from} --${r.relationType}--> ${r.to}`).join('\n')
549
+ );
550
+ },
551
+
552
+ async kg_obs({ entityName, contents }, { signal } = {}) {
553
+ if (signal?.aborted) throw new Error('aborted');
554
+ if (!entityName) throw new Error('kg_obs: thiếu entityName');
555
+ if (!Array.isArray(contents) || contents.length === 0)
556
+ throw new Error('kg_obs: contents phải là mảng không rỗng');
557
+ const out = await kgAddObservations([{ entityName, contents }]);
558
+ const added = out[0]?.addedObservations || [];
559
+ if (!added.length) return `Không có observation mới cho '${entityName}' (đã trùng)`;
560
+ return `Đã thêm ${added.length} observation cho '${entityName}':\n` + added.map((o) => ` - ${o}`).join('\n');
561
+ },
514
562
  };
515
563
 
516
564
  // Khớp old_string theo KHỐI DÒNG, bỏ qua khác biệt CRLF và khoảng trắng CUỐI
@@ -631,6 +679,14 @@ export function describe(name, input) {
631
679
  return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
632
680
  case 'spawn_agents':
633
681
  return `↳ ${(input.tasks || []).length} sub-agent song song`;
682
+ case 'kg_search':
683
+ return `kg search "${input.query || ''}"`;
684
+ case 'kg_add':
685
+ return `kg add ${(input.entities || []).length} entity`;
686
+ case 'kg_link':
687
+ return `kg link ${(input.relations || []).length} relation`;
688
+ case 'kg_obs':
689
+ return `kg obs '${input.entityName || ''}' (${(input.contents || []).length} fact)`;
634
690
  default:
635
691
  return name;
636
692
  }
package/src/tui.js CHANGED
@@ -75,7 +75,9 @@ function wrapText(text, width, maxLines) {
75
75
  }
76
76
  return lines.map(close);
77
77
  }
78
- const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
78
+ // Ember pulse: nhịp lửa lan ra/thu vào, ấm hơn Braille mặc định mà vẫn nhẹ.
79
+ // 8 frame chia 2 pha: lan rộng (·∘○◌◍) → thu lại. Phù hợp accent ember.
80
+ const FRAMES = ['·', '∘', '○', '◌', '◍', '◌', '○', '∘'];
79
81
  function truncStr(s, max) {
80
82
  if (!s) return '';
81
83
  return s.length > max ? s.slice(0, max - 1) + '…' : s;
@@ -789,10 +791,26 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
789
791
  draw();
790
792
  },
791
793
  read(label) {
794
+ // Lưu label CŨ trước khi đổi sang prompt mới (vd 'cho phép? [y/n]').
795
+ // Sau khi user trả lời xong (waiter resolve), restore lại label cũ +
796
+ // redraw — nếu không, dòng prompt tạm sẽ stick trên thanh nhập đến hết
797
+ // turn (mọi draw() sau đều render label tạm).
798
+ const prevLabel = promptLabel;
792
799
  if (label != null) promptLabel = label;
793
- if (queue.length) return Promise.resolve(queue.shift());
800
+ if (queue.length) {
801
+ const v = queue.shift();
802
+ promptLabel = prevLabel;
803
+ draw();
804
+ return Promise.resolve(v);
805
+ }
794
806
  draw();
795
- return new Promise((res) => (waiter = res));
807
+ return new Promise((res) => {
808
+ waiter = (val) => {
809
+ promptLabel = prevLabel;
810
+ draw();
811
+ res(val);
812
+ };
813
+ });
796
814
  },
797
815
  close() {
798
816
  try {
package/src/ui.js CHANGED
@@ -1,81 +1,95 @@
1
1
  import chalk from 'chalk';
2
2
  import gradient from 'gradient-string';
3
3
  import boxen from 'boxen';
4
- import { supportsLanguage } from 'cli-highlight';
5
4
  import { marked } from 'marked';
6
5
  import { markedTerminal } from 'marked-terminal';
7
6
  import { PROVIDERS, providerColor } from './models.js';
8
7
  import { t } from './i18n.js';
9
8
 
10
- const BRAND = ['#a78bfa', '#3b82f6', '#06b6d4'];
11
- const brand = gradient(BRAND);
12
-
9
+ // ── THEME: "Terminal Ember" ─────────────────────────────────────────────────
10
+ // Một accent thật mạnh (ember-orange) trên nền warm-gray. KHÔNG dùng
11
+ // purple/blue/cyan gradient — đó là AI-slop cliché. Đây là CLI lập trình:
12
+ // muốn cảm giác xưởng rèn / lò than về đêm, không phải landing page SaaS.
13
+ //
14
+ // ember (accent) #ff7a45 — tia lửa: prompt cursor, badge, link, brand mark
15
+ // amber (warn) #f0a868 — tool call, cảnh báo nhẹ, inline code
16
+ // moss (ok) #84a98c — success xanh xám trầm, không neon
17
+ // rust (err) #c0524b — đỏ rỉ, không chói
18
+ // user (prose) #e8e3d8 — trắng ngà (paper), prose chính
19
+ // ai (model) #f0a868 — câu thoại model = amber để phân biệt user
20
+ // dim (mute) #6b6359 — warm gray, KHÔNG xám lạnh #6b7280
13
21
  export const c = {
14
- dim: chalk.hex('#6b7280'),
15
- user: chalk.hex('#a78bfa'),
16
- ai: chalk.hex('#06b6d4'),
17
- tool: chalk.hex('#f59e0b'),
18
- ok: chalk.hex('#10b981'),
19
- err: chalk.hex('#ef4444'),
20
- accent: chalk.hex('#3b82f6'),
22
+ dim: chalk.hex('#6b6359'),
23
+ user: chalk.hex('#e8e3d8'),
24
+ ai: chalk.hex('#f0a868'),
25
+ tool: chalk.hex('#f0a868'),
26
+ ok: chalk.hex('#84a98c'),
27
+ err: chalk.hex('#c0524b'),
28
+ accent: chalk.hex('#ff7a45'),
21
29
  };
22
30
 
31
+ // Single-tone "gradient" giữ tương thích API gradient-string nội bộ. Banner
32
+ // dùng 2-stop ember→amber rất hẹp để không thành rainbow.
33
+ const brandGrad = gradient(['#ff7a45', '#f0a868']);
34
+ const brand = (s) => brandGrad(s);
35
+
23
36
  const term = () => process.stdout.columns || 80;
24
37
 
38
+ // ── Banner ─────────────────────────────────────────────────────────────────
39
+ // Wordmark heavy box-drawing 3 dòng — mảnh hơn block-letter ASCII thông thường,
40
+ // có cấu trúc rõ (gạch ngang heavy ┏━┓ ┗━┛). Sidebar ember ▌ ở mép trái làm
41
+ // "signature" — đập vào mắt ngay khi nhìn. Meta line dưới: ver · tagline,
42
+ // phân cách bằng ◆ ember nhỏ thay vì dấu · xám.
25
43
  export function banner() {
26
- const art = [
27
- ' ███╗ ██╗ ██████╗ ██████╗ ██████╗ ',
28
- ' ████╗ ██║██╔═══██╗██╔═══██╗██╔══██╗',
29
- ' ██╔██╗ ██║██║ ██║██║ ██║██████╔╝',
30
- ' ██║╚██╗██║██║ ██║██║ ██║██╔══██╗',
31
- ' ██║ ╚████║╚██████╔╝╚██████╔╝██████╔╝',
32
- ' ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ',
33
- ].join('\n');
34
- console.log('\n' + brand.multiline(art));
35
- console.log(c.dim(' ') + brand('Noob Demon') + c.dim(' · ' + t.tagline + '\n'));
44
+ const sidebar = chalk.hex('#ff7a45')('▌');
45
+ const wordmark = [
46
+ '┏┓╻┏━┓┏━┓┏┓ ',
47
+ '┃┗┫┃ ┃┃ ┃┣┻┓',
48
+ '╹ ╹┗━┛┗━┛┗━┛',
49
+ ].map((row) => sidebar + ' ' + brand(row)).join('\n');
50
+ const diamond = chalk.hex('#ff7a45')('◆');
51
+ const tag = chalk.hex('#e8e3d8').italic(t.tagline);
52
+ const meta = ' ' + c.dim('agentic terminal') + ' ' + diamond + ' ' + tag;
53
+ console.log('\n' + wordmark);
54
+ console.log(meta + '\n');
36
55
  }
37
56
 
38
57
  function rule(label = '') {
39
58
  const w = Math.min(term(), 100);
40
- if (!label) return c.dim(''.repeat(w));
41
- const head = `── ${label} `;
42
- return c.dim(head + ''.repeat(Math.max(0, w - head.length)));
59
+ if (!label) return c.dim(''.repeat(w));
60
+ const head = `┄┄ ${label} `;
61
+ return c.dim(head + ''.repeat(Math.max(0, w - head.length)));
43
62
  }
44
63
 
45
64
  export function modelBadge(model) {
46
65
  const color = providerColor(model.provider);
47
66
  const prov = PROVIDERS[model.provider]?.name || model.provider;
48
- return chalk.hex(color)('●') + ' ' + chalk.bold(model.name) + c.dim(` (${prov} · ${model.tier})`);
67
+ return (
68
+ chalk.hex(color)('◆') +
69
+ ' ' +
70
+ chalk.hex('#e8e3d8').bold(model.name) +
71
+ c.dim(` ${prov} · ${model.tier}`)
72
+ );
49
73
  }
50
74
 
51
75
  // ── Markdown → ANSI ────────────────────────────────────────────────────────
52
- // Dùng marked + marked-terminal. Một vài lưu ý từ source marked-terminal:
53
- // * option `code` STYLE fallback cho code block, không phải callback render.
54
- // Body code block đã được cli-highlight xử lý sẵn → ta truyền highlightOptions
55
- // để tô màu, và post-process để thêm viền trái `│`.
56
- // * option `listitem` chạy AFTER bullet `*` được prepend, nên KHÔNG thêm `•` ở đây.
57
- // Đổi bullet ở bước post-process.
58
- // * option `href` không nên bọc ngoặc — wrapper tự thêm `(...)`.
59
- const BULLET = c.accent('•');
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.
76
+ // Heading scale: h1 brand, h2 ember, h3 amber, h4+ dim đậm. TẤT CẢ trong cùng
77
+ // family ember-amber không nhảy hue (lavender→cyan) như cũ.
66
78
  const HEADING_BY_LEVEL = {
67
79
  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,
80
+ 2: chalk.hex('#ff7a45').bold,
81
+ 3: chalk.hex('#f0a868').bold,
82
+ 4: chalk.hex('#bfb6a8').bold,
83
+ 5: chalk.hex('#bfb6a8').bold,
84
+ 6: chalk.hex('#bfb6a8').bold,
73
85
  };
74
86
  function headingStyler(text, level) {
75
87
  const fn = HEADING_BY_LEVEL[level] || HEADING_BY_LEVEL[2];
76
88
  return fn(text);
77
89
  }
78
90
 
91
+ const BULLET = c.accent('›');
92
+
79
93
  marked.use(
80
94
  markedTerminal(
81
95
  {
@@ -85,49 +99,38 @@ marked.use(
85
99
  showSectionPrefix: false,
86
100
  firstHeading: (s) => brand(s),
87
101
  heading: headingStyler,
88
- blockquote: chalk.hex('#6b7280').italic,
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 lẫn prose.
91
- strong: chalk.hex('#fbbf24').bold,
92
- em: chalk.italic,
93
- // `inline code` (backtick): xanh pastel không bg tách rõ khỏi prose
94
- // 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'),
102
+ blockquote: chalk.hex('#bfb6a8').italic,
103
+ // **bold** = ember sáng chỉ MỘT accent xuyên suốt prose. Nhãn quan trọng
104
+ // ("P0", "Đề xuất:", "#1 ...") tự nổi không cần thêm hue.
105
+ strong: chalk.hex('#ff7a45').bold,
106
+ em: chalk.hex('#e8e3d8').italic,
107
+ // `inline code` (backtick): amber pastel. Tách khỏi prose paper-white
108
+ // không đụng accent ember (giữ ember cho điểm nhấn THẬT).
109
+ codespan: chalk.hex('#f0a868'),
96
110
  hr: () => rule(),
97
- link: chalk.hex('#06b6d4').underline,
98
- href: chalk.hex('#9ca3af'),
99
- code: chalk.hex('#f59e0b'),
111
+ link: chalk.hex('#ff7a45').underline,
112
+ href: chalk.hex('#bfb6a8'),
113
+ code: chalk.hex('#f0a868'),
100
114
  },
101
115
  { ignoreIllegals: true }
102
116
  )
103
117
  );
104
118
 
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');
119
+ const INLINE_BOLD = chalk.hex('#ff7a45').bold;
120
+ const INLINE_CODE = chalk.hex('#f0a868');
113
121
  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
122
  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
123
  out = out.replace(/\*\*([^*\n]+?)\*\*/g, (_, b) => INLINE_BOLD(b));
119
124
  return out;
120
125
  }
121
126
 
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
127
  const HEADING_STYLE_BY_LEVEL = {
125
128
  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,
129
+ 2: chalk.hex('#ff7a45').bold,
130
+ 3: chalk.hex('#f0a868').bold,
131
+ 4: chalk.hex('#bfb6a8').bold,
132
+ 5: chalk.hex('#bfb6a8').bold,
133
+ 6: chalk.hex('#bfb6a8').bold,
131
134
  };
132
135
  export function renderHeadingLine(line) {
133
136
  const m = line.match(/^(#{1,6})\s+(.+)$/);
@@ -138,47 +141,64 @@ export function renderHeadingLine(line) {
138
141
  return fn(renderInline(text));
139
142
  }
140
143
 
141
- // Đổi bullet đầu dòng `* `, `- `, `+ ` `• ` màu accent. Numbered list giữ nguyên.
144
+ // Bullet đầu dòng `*`, `-`, `+``›` ember. Không dùng `•` (AI default).
142
145
  export function renderBulletPrefix(line) {
143
146
  return line.replace(/^( *)[*\-+] /, (_, sp) => sp + BULLET + ' ');
144
147
  }
145
148
  function prettify(s) {
146
149
  return s
147
150
  .replace(/^( *)\* /gm, (_, sp) => sp + BULLET + ' ')
148
- .replace(/^ {4}(.*)$/gm, (_, rest) => c.dim('│ ') + rest)
149
151
  .split('\n')
150
152
  .map(renderInline)
151
153
  .join('\n');
152
154
  }
153
155
 
156
+ // Strip common leading whitespace từ mọi dòng non-empty. Cần thiết vì user hay
157
+ // paste markdown bị indent (vd copy từ list item, từ code block) → CommonMark
158
+ // treat indent ≥4 space là code block → bảng/heading/list không parse.
159
+ function dedent(md) {
160
+ if (!md) return md;
161
+ const lines = md.split('\n');
162
+ let min = Infinity;
163
+ for (const line of lines) {
164
+ if (!line.trim()) continue;
165
+ const m = line.match(/^( *)/);
166
+ const indent = m ? m[1].length : 0;
167
+ if (indent < min) min = indent;
168
+ if (min === 0) break;
169
+ }
170
+ if (!Number.isFinite(min) || min === 0) return md;
171
+ const re = new RegExp(`^ {${min}}`, 'gm');
172
+ return md.replace(re, '');
173
+ }
174
+
154
175
  export function renderMarkdown(md) {
155
- return prettify(marked.parse(md || '')).trimEnd();
176
+ return prettify(marked.parse(dedent(md || ''))).trimEnd();
156
177
  }
157
178
 
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).
179
+ // ── formatQuota ────────────────────────────────────────────────────────────
180
+ // Cùng palette: dim → amber → ember → rust theo % used. Không nhảy hue lạ.
163
181
  function fmtK(n) {
164
182
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
165
183
  return String(n);
166
184
  }
167
185
  export function formatQuota(u) {
168
186
  if (!u || !u.ok) return null;
169
- if (u.plan === 'admin') return null; // unlimited — đỡ rác status bar.
187
+ if (u.plan === 'admin') return null;
170
188
  const limit = Number(u.limit);
171
189
  const remaining = Number(u.remaining);
172
190
  if (!Number.isFinite(limit) || !Number.isFinite(remaining) || limit <= 0) return null;
173
191
  const used = Math.max(0, limit - remaining);
174
192
  const pct = (used / limit) * 100;
175
- const label = `${fmtK(remaining)}/${fmtK(limit)} req`;
193
+ const label = `${fmtK(remaining)}/${fmtK(limit)}`;
176
194
  if (pct > 85) return c.err(label);
177
- if (pct > 70) return c.tool(label);
195
+ if (pct > 70) return c.accent(label);
196
+ if (pct > 50) return c.tool(label);
178
197
  return c.dim(label);
179
198
  }
180
199
 
181
- export function box(content, title, color = '#a78bfa') {
200
+ // ── Box ────────────────────────────────────────────────────────────────────
201
+ export function box(content, title, color = '#ff7a45') {
182
202
  return boxen(content, {
183
203
  title,
184
204
  titleAlignment: 'left',