@noobdemon/noob-cli 1.12.7 → 1.12.9

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 CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  Tất cả thay đổi đáng kể của `@noobdemon/noob-cli` được ghi vào file này.
4
4
 
5
+ ## [1.12.9] - 2026-06-13
6
+
7
+ ### Added
8
+ - **Tool `web_fetch`** (`src/tools.js` + `src/agent.js`): tool đọc cho model tải nội dung URL http/https. Shape `{url: str, raw?: bool}`. Dùng `fetch` native (Node ≥18), validate URL + chỉ cho http/https (reject `file://` và URL rác), timeout 30s, hủy được theo Ctrl+C (qua `signal`). Mặc định strip HTML → text đọc được (helper `htmlToText`: gỡ script/style/head, block tag → newline, gỡ tag, decode entity phổ biến, gọn whitespace); `raw:true` trả HTML thô; non-HTML (text/plain, JSON) trả nguyên văn. Output clip ở `MAX_OUT` như mọi tool. KHÔNG vào `DESTRUCTIVE` — read-only, không xin phép (giống `read_file`/`grep`). Khai báo trong SYSTEM prompt cạnh `read_file`. Giới hạn: strip bằng regex, trang SPA render client-side sẽ trả gần rỗng. Smoke `scripts/smoke-web-fetch.mjs` 13/13 pass (HTML strip, raw mode, text/plain, reject URL không hợp lệ/non-http/thiếu url, describe preview).
9
+
10
+ ### Changed
11
+ - **Tách stream printer khỏi `src/repl.js`** (`src/repl/stream-printer.js`, ~186 dòng): chuyển khối tự chứa `renderStreamBlock`/`makeStreamPrinter`/`renderStreamLine`/`looksLikeTableRow`/`TABLE_SEP_RE` sang module riêng — chỉ phụ thuộc 4 helper render từ `ui.js` + `chalk`, không kéo theo closure state của `startRepl`. `repl.js` giờ `import { makeStreamPrinter }` + `export { renderStreamBlock } from './repl/stream-printer.js'` (re-export giữ contract cho `scripts/smoke-table-stream2.mjs`). Gỡ 3 import thừa (`renderInline`/`renderHeadingLine`/`renderBulletPrefix`) khỏi `repl.js`. Status bar (`printStatus`) cố tình KHÔNG tách — khóa chặt vào >10 biến closure, tách pure không đáng. Verify: `smoke-table-stream2.mjs` 5/5 pass + `check-imports.mjs` OK.
12
+
13
+ ### Removed
14
+ - **Dọn file rác ở thư mục gốc**: xóa `body.json`, `body3.json`, `tweet_dump.txt` (artifact scratch, đều untracked trong git). Thêm `body*.json` vào `.gitignore` (`tweet_dump.txt` đã ignore từ trước) để tránh tái diễn.
15
+
16
+ ## [1.12.8] - 2026-06-13
17
+
18
+ ### Added
19
+ - **Claude Opus 4.8** (`gateway-claude-opus-4-8`, provider `anthropic`, tier `flagship`) — set làm `DEFAULT_MODEL` mới.
20
+
21
+ ### Removed
22
+ - **Rút catalog xuống 3 flagship** (`src/models.js`): chỉ còn `gateway-claude-opus-4-8`, `gateway-gpt-5-5`, `gateway-deepseek-v4-pro`. Gỡ toàn bộ 32 model cũ: o3/o3-mini/o4-mini/DeepSeek R1/Qwen QwQ (reasoning), GPT-5 Mini/Nano + Gemini Flash + DeepSeek V4 Flash + GPT-4.1 Mini/Nano (fast), Gemini 2.5/3/3.1 Pro, Grok 3/4, Qwen 3 Max, Kimi K2, Llama 3.3 70B, Claude Sonnet 4/4.6, Opus 4.1/4.5/4.6/4.7, GPT-5/5.1/5.3/5.4/4o/5 Online. `PROVIDERS` map còn 3 entry (`openai`/`anthropic`/`deepseek`).
23
+
24
+ ### Changed
25
+ - **Worker `claude-code-proxy`** (`worker/src/worker.js`): `mapModel` map "opus/claude/sonnet/haiku" → `opus-4-8`, "gpt/o3/o4" → `gpt-5-5`, "deepseek" → `deepseek-v4-pro`; fallback default → `opus-4-8`. `modelsList()` chỉ còn 3 id mới. Đã deploy (version `689b94d1`).
26
+
5
27
  ## [1.12.7] - 2026-06-12
6
28
 
7
29
  ### Changed
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.12.7",
3
+ "version": "1.12.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Trợ lý lập trình agentic trong terminal (kiểu Claude Code), tiếng Việt, dùng sức mạnh Noob Demon — 34 mô hình AI.",
7
+ "description": "Trợ lý lập trình agentic trong terminal (kiểu Claude Code), tiếng Việt, dùng sức mạnh Noob Demon — 3 mô hình flagship (Claude Opus 4.8, GPT-5.5, DeepSeek V4 Pro).",
8
8
  "type": "module",
9
9
  "bin": {
10
10
  "noob": "bin/noob.js"
package/src/agent.js CHANGED
@@ -21,6 +21,7 @@ Then STOP and wait — the runtime executes the tool and replies with a TOOL RES
21
21
 
22
22
  Available tools (each is self-contained; pick the SMALLEST tool that answers the question):
23
23
  - read_file {"path": str, "offset"?: int, "limit"?: int} — read a file. Default reads whole file. For files you suspect are LARGE (>500 lines), first check size via list_dir/glob, then read with offset+limit (e.g. 200 lines at a time) instead of slurping. The "N " line-number prefix in output is DISPLAY ONLY — never copy it into edit_file.
24
+ - web_fetch {"url": str, "raw"?: bool} — fetch an http/https URL and return its content as plain text (HTML stripped to readable text; set "raw":true for unstripped HTML). Use for docs, changelogs, API references, or any page the user links. Read-only, no permission needed. 30s timeout; output is clipped like other tools.
24
25
  - write_file {"path": str, "content": str} — create/overwrite a file. Use ONLY for new files or full rewrites; otherwise prefer edit_file.
25
26
  - edit_file {"path": str, "old_string": str, "new_string": str, "replace_all"?: bool} — exact string replace. old_string must match the file's RAW text byte-for-byte (indentation/whitespace included, NO line-number prefix) and be unique unless replace_all. If a replace fails, re-read the file and copy the exact text.
26
27
  - list_dir {"path"?: str} — list a directory. Use to map an unfamiliar project before reading anything.
package/src/models.js CHANGED
@@ -1,98 +1,27 @@
1
1
  // Model catalog supported by the Noob Demon gateway.
2
2
  export const MODELS = [
3
- { id: 'gateway-gpt-5', name: 'GPT-5', provider: 'openai', tier: 'flagship' },
4
- { id: 'gateway-gpt-5-1', name: 'GPT-5.1', provider: 'openai', tier: 'flagship' },
5
- { id: 'gateway-gpt-5-3', name: 'GPT-5.3', provider: 'openai', tier: 'flagship' },
6
- { id: 'gateway-gpt-5-4', name: 'GPT-5.4', provider: 'openai', tier: 'flagship' },
7
- { id: 'gateway-gpt-5-5', name: 'GPT-5.5', provider: 'openai', tier: 'flagship' },
8
- { id: 'gateway-gpt-o3', name: 'o3', provider: 'openai', tier: 'reasoning' },
9
- { id: 'gateway-gpt-o3-mini', name: 'o3 Mini', provider: 'openai', tier: 'reasoning' },
10
- { id: 'gateway-gpt-o4-mini', name: 'o4-mini', provider: 'openai', tier: 'reasoning' },
11
- { id: 'gateway-gpt-4o', name: 'GPT-4o', provider: 'openai', tier: 'standard' },
12
- { id: 'gateway-gpt-4-1-mini', name: 'GPT-4.1 Mini', provider: 'openai', tier: 'fast' },
13
- { id: 'gateway-gpt-4-1-nano', name: 'GPT-4.1 Nano', provider: 'openai', tier: 'fast' },
14
- { id: 'gateway-gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai', tier: 'fast' },
15
- { id: 'gateway-gpt-5-nano', name: 'GPT-5 Nano', provider: 'openai', tier: 'fast' },
16
- { id: 'gateway-gpt-5-online', name: 'GPT-5 Online', provider: 'openai', tier: 'standard' },
17
- {
18
- id: 'gateway-claude-opus-4-7',
19
- name: 'Claude Opus 4.7',
20
- provider: 'anthropic',
21
- tier: 'flagship',
22
- },
23
- {
24
- id: 'gateway-claude-opus-4-6',
25
- name: 'Claude Opus 4.6',
26
- provider: 'anthropic',
27
- tier: 'flagship',
28
- },
29
3
  {
30
- id: 'gateway-claude-opus-4-5',
31
- name: 'Claude Opus 4.5',
4
+ id: 'gateway-claude-opus-4-8',
5
+ name: 'Claude Opus 4.8',
32
6
  provider: 'anthropic',
33
7
  tier: 'flagship',
34
8
  },
35
- {
36
- id: 'gateway-claude-opus-4-1',
37
- name: 'Claude Opus 4.1',
38
- provider: 'anthropic',
39
- tier: 'standard',
40
- },
41
- {
42
- id: 'gateway-claude-sonnet-4',
43
- name: 'Claude Sonnet 4',
44
- provider: 'anthropic',
45
- tier: 'standard',
46
- },
47
- {
48
- id: 'gateway-claude-sonnet-4-6',
49
- name: 'Claude Sonnet 4.6',
50
- provider: 'anthropic',
51
- tier: 'standard',
52
- },
53
- { id: 'gateway-google-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', tier: 'flagship' },
54
- { id: 'gateway-gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', tier: 'flagship' },
55
- { id: 'gateway-gemini-3-1-pro', name: 'Gemini 3.1 Pro', provider: 'google', tier: 'flagship' },
56
- { id: 'gateway-gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', tier: 'fast' },
9
+ { id: 'gateway-gpt-5-5', name: 'GPT-5.5', provider: 'openai', tier: 'flagship' },
57
10
  {
58
11
  id: 'gateway-deepseek-v4-pro',
59
12
  name: 'DeepSeek V4 Pro',
60
13
  provider: 'deepseek',
61
14
  tier: 'flagship',
62
15
  },
63
- {
64
- id: 'gateway-deepseek-v4-flash',
65
- name: 'DeepSeek V4 Flash',
66
- provider: 'deepseek',
67
- tier: 'fast',
68
- },
69
- { id: 'gateway-deepseek-r1', name: 'DeepSeek R1', provider: 'deepseek', tier: 'reasoning' },
70
- { id: 'gateway-deepseek-v3', name: 'DeepSeek V3', provider: 'deepseek', tier: 'standard' },
71
- { id: 'gateway-grok-4', name: 'Grok 4', provider: 'xai', tier: 'flagship' },
72
- { id: 'gateway-grok-3', name: 'Grok 3', provider: 'xai', tier: 'standard' },
73
- { id: 'gateway-qwen-3-max', name: 'Qwen 3 Max', provider: 'alibaba', tier: 'standard' },
74
- { id: 'gateway-qwen-qwq-32b', name: 'Qwen QwQ 32B', provider: 'alibaba', tier: 'reasoning' },
75
- { id: 'gateway-deepinfra-kimi-k2', name: 'Kimi K2', provider: 'moonshot', tier: 'standard' },
76
- {
77
- id: 'gateway-llama-3-3-70b-versatile',
78
- name: 'Llama 3.3 70B',
79
- provider: 'meta',
80
- tier: 'standard',
81
- },
82
16
  ];
83
17
 
84
18
  export const PROVIDERS = {
85
19
  openai: { name: 'OpenAI', color: '#10a37f' },
86
20
  anthropic: { name: 'Anthropic', color: '#d97706' },
87
- google: { name: 'Google', color: '#3b82f6' },
88
21
  deepseek: { name: 'DeepSeek', color: '#06b6d4' },
89
- xai: { name: 'xAI', color: '#ef4444' },
90
- alibaba: { name: 'Alibaba', color: '#8b5cf6' },
91
- moonshot: { name: 'Moonshot', color: '#ec4899' },
92
- meta: { name: 'Meta', color: '#6366f1' },
93
22
  };
94
23
 
95
- export const DEFAULT_MODEL = 'gateway-claude-opus-4-7';
24
+ export const DEFAULT_MODEL = 'gateway-claude-opus-4-8';
96
25
 
97
26
  export function findModel(id) {
98
27
  if (!id || typeof id !== 'string') return undefined;
@@ -0,0 +1,185 @@
1
+ // Stream printer: render delta token của model thành ANSI line-by-line.
2
+ //
3
+ // Tách từ src/repl.js (mục "tách repl.js"). CHỈ phụ thuộc helper render từ
4
+ // ui.js + chalk — không đụng closure state của startRepl, nên tách sạch.
5
+ //
6
+ // Chiến lược (giữ NGUYÊN từ bản gốc):
7
+ // - Tích đầy đủ MỘT dòng (\n) rồi mới render & write — vậy `**bold**`, backtick,
8
+ // và heading `## foo` không bị cắt giữa chừng giữa các token.
9
+ // - Trên dòng đang dở (chưa thấy \n), giữ trong buffer; cuối cùng `flush()` xử lý.
10
+ // - Block code fence (```lang ... ```) bypass renderer: in nguyên xi giữa cặp ```.
11
+ // - ```tool trở đi: nuốt sạch (sẽ render riêng qua tool dispatcher).
12
+ // - Bảng markdown (header + separator |---|) cần parse đa dòng → gom thành
13
+ // table block, render qua marked.parse() 1 lần (renderStreamBlock).
14
+ // QUAN TRỌNG: stream line-by-line không tương thích với block elements
15
+ // đa dòng — flushCompleteLines phải HOLD các dòng cuối là table-row/sep
16
+ // candidate để chờ đủ data trước khi parse.
17
+ import chalk from 'chalk';
18
+ import {
19
+ renderMarkdown,
20
+ renderInline,
21
+ renderHeadingLine,
22
+ renderBulletPrefix,
23
+ } from '../ui.js';
24
+
25
+ function renderStreamLine(line, inCodeFence) {
26
+ if (inCodeFence) return line; // code fence body: in raw, không parse markdown.
27
+ const heading = renderHeadingLine(line);
28
+ if (heading !== null) return heading;
29
+ return renderInline(renderBulletPrefix(line));
30
+ }
31
+
32
+ // Dòng separator của bảng markdown: |---|---| hoặc |:---:|---:| v.v.
33
+ export const TABLE_SEP_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
34
+
35
+ // Dòng có ít nhất 1 pipe ở giữa (không tính pipe đầu/cuối) → ứng viên row của bảng.
36
+ function looksLikeTableRow(line) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed.startsWith('|') && !trimmed.includes('|')) return false;
39
+ // Cần ít nhất 1 ký tự `|` thực sự phân tách cột.
40
+ const stripped = trimmed.replace(/^\|/, '').replace(/\|$/, '');
41
+ return stripped.includes('|');
42
+ }
43
+
44
+ // Render block lines (mảng dòng KHÔNG có \n cuối) thành ANSI. Detect table block
45
+ // và parse qua marked; phần còn lại render line-by-line như cũ.
46
+ export function renderStreamBlock(lines, fenceState) {
47
+ const out = [];
48
+ let i = 0;
49
+ while (i < lines.length) {
50
+ const ln = lines[i];
51
+ // Code fence ưu tiên cao nhất — không đụng vào nội dung fence.
52
+ const fenceMatch = ln.match(/^\s*```/);
53
+ if (fenceMatch) {
54
+ out.push(renderStreamLine(ln, fenceState.inCodeFence));
55
+ fenceState.inCodeFence = !fenceState.inCodeFence;
56
+ i++;
57
+ continue;
58
+ }
59
+ if (fenceState.inCodeFence) {
60
+ out.push(ln);
61
+ i++;
62
+ continue;
63
+ }
64
+ // Detect table: dòng hiện tại là row + dòng kế tiếp là separator.
65
+ if (
66
+ looksLikeTableRow(ln) &&
67
+ i + 1 < lines.length &&
68
+ TABLE_SEP_RE.test(lines[i + 1])
69
+ ) {
70
+ // Gom hết các dòng row liên tiếp.
71
+ const tableLines = [ln, lines[i + 1]];
72
+ let j = i + 2;
73
+ while (j < lines.length && looksLikeTableRow(lines[j])) {
74
+ tableLines.push(lines[j]);
75
+ j++;
76
+ }
77
+ out.push(renderMarkdown(tableLines.join('\n')));
78
+ i = j;
79
+ continue;
80
+ }
81
+ out.push(renderStreamLine(ln, false));
82
+ i++;
83
+ }
84
+ return out.join('\n');
85
+ }
86
+
87
+ export function makeStreamPrinter(name, color) {
88
+ let buf = ''; // toàn bộ delta đã nhận (chưa cắt)
89
+ let printed = 0; // offset đã in trong buf
90
+ let suppress = false;
91
+ let started = false;
92
+ let header = false;
93
+ let inCodeFence = false;
94
+ const HOLD = 8; // giữ đuôi 8 char để phát hiện sớm "```tool"
95
+ const emit = (s) => {
96
+ if (!s) return;
97
+ if (!header) {
98
+ process.stdout.write('\n' + chalk.hex(color).bold(' ● ' + name) + '\n ');
99
+ header = true;
100
+ }
101
+ // s có thể chứa \n từ render line + leading newline gốc. Thay \n → \n để indent.
102
+ process.stdout.write(s.replace(/\n/g, '\n '));
103
+ started = true;
104
+ };
105
+ // Render & emit từ printed → end. Chỉ flush các dòng đã HOÀN CHỈNH (có \n).
106
+ // Dòng cuối chưa có \n: chừa lại trong buf cho lần push tiếp theo.
107
+ // Lùi mốc flush nếu các dòng cuối là table-row/separator candidate — chờ đủ
108
+ // data để gom thành table block parse 1 lần qua marked (xem renderStreamBlock).
109
+ const flushCompleteLines = (end) => {
110
+ const slice = buf.slice(printed, end);
111
+ if (!slice) return;
112
+ const lastNl = slice.lastIndexOf('\n');
113
+ if (lastNl === -1) return;
114
+ let complete = slice.slice(0, lastNl + 1);
115
+ let completeLines = complete.split('\n');
116
+ if (completeLines[completeLines.length - 1] === '') completeLines.pop();
117
+ let hold = 0;
118
+ while (
119
+ completeLines.length - hold > 0 &&
120
+ (looksLikeTableRow(completeLines[completeLines.length - 1 - hold]) ||
121
+ TABLE_SEP_RE.test(completeLines[completeLines.length - 1 - hold]))
122
+ ) {
123
+ hold++;
124
+ if (hold > 64) break;
125
+ }
126
+ if (hold > 0) {
127
+ const keptLines = completeLines.slice(0, completeLines.length - hold);
128
+ if (keptLines.length === 0) return; // toàn bộ slice là table candidate → chờ
129
+ complete = keptLines.join('\n') + '\n';
130
+ completeLines = keptLines;
131
+ }
132
+ printed += complete.length;
133
+ const fenceState = { inCodeFence };
134
+ const rendered = renderStreamBlock(completeLines, fenceState);
135
+ inCodeFence = fenceState.inCodeFence;
136
+ emit(rendered + '\n');
137
+ };
138
+ return {
139
+ get started() {
140
+ return started;
141
+ },
142
+ get suppressing() {
143
+ return suppress;
144
+ },
145
+ push(delta) {
146
+ buf += delta;
147
+ if (suppress) return;
148
+ const f = buf.indexOf('```tool');
149
+ if (f !== -1) {
150
+ // Flush mọi thứ TRƯỚC ```tool (full lines + phần dở của dòng cuối cũng in luôn
151
+ // vì sắp suppress, không cần chừa buffer nữa).
152
+ const beforeTool = buf.slice(printed, f);
153
+ if (beforeTool) {
154
+ const parts = beforeTool.split('\n');
155
+ // bỏ '' cuối nếu kết thúc bằng \n
156
+ if (parts[parts.length - 1] === '') parts.pop();
157
+ const fenceState = { inCodeFence };
158
+ const rendered = renderStreamBlock(parts, fenceState);
159
+ inCodeFence = fenceState.inCodeFence;
160
+ emit(rendered);
161
+ }
162
+ printed = buf.length;
163
+ suppress = true;
164
+ return;
165
+ }
166
+ // Chỉ flush các dòng đã hoàn chỉnh; chừa đuôi HOLD char để phát hiện ```tool.
167
+ const safeEnd = Math.max(printed, buf.length - HOLD);
168
+ if (safeEnd > printed) flushCompleteLines(safeEnd);
169
+ },
170
+ flush() {
171
+ if (!suppress && printed < buf.length) {
172
+ // Render phần còn lại (kể cả dòng cuối chưa có \n).
173
+ const tail = buf.slice(printed);
174
+ const parts = tail.split('\n');
175
+ if (parts[parts.length - 1] === '') parts.pop();
176
+ const fenceState = { inCodeFence };
177
+ const rendered = renderStreamBlock(parts, fenceState);
178
+ inCodeFence = fenceState.inCodeFence;
179
+ emit(rendered);
180
+ printed = buf.length;
181
+ }
182
+ if (started) process.stdout.write('\n');
183
+ },
184
+ };
185
+ }
package/src/repl.js CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  nearestExistingDir,
19
19
  } from './tools.js';
20
20
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from './models.js';
21
- import { c, banner, modelBadge, renderMarkdown, renderInline, renderHeadingLine, renderBulletPrefix, box, formatQuota } from './ui.js';
21
+ import { c, banner, modelBadge, renderMarkdown, box, formatQuota } from './ui.js';
22
+ import { makeStreamPrinter } from './repl/stream-printer.js';
22
23
  import { renderUnifiedDiff, renderNewFilePreview } from './diff.js';
23
24
  import {
24
25
  askPermission as _askPermission,
@@ -2110,174 +2111,11 @@ function printAnswer(text, name, color) {
2110
2111
  );
2111
2112
  }
2112
2113
 
2113
- // Stream printer với inline markdown rendering. Chiến lược:
2114
- // - Tích đầy đủ MỘT dòng (\n) rồi mới render & write — vậy `**bold**`, backtick,
2115
- // heading `## foo` không bị cắt giữa chừng giữa các token.
2116
- // - Trên dòng đang dở (chưa thấy \n), giữ trong buffer; cuối cùng `flush()` xử lý.
2117
- // - Block code fence (```lang ... ```) bypass renderer: in nguyên xi giữa cặp ```.
2118
- // - ```tool trở đi: nuốt sạch (sẽ render riêng qua tool dispatcher).
2119
- // - Bảng markdown (header + separator |---|) cần parse đa dòng → gom thành
2120
- // table block, render qua marked.parse() 1 lần (renderStreamBlock).
2121
- // QUAN TRỌNG: stream line-by-line không tương thích với block elements
2122
- // đa dòng — flushCompleteLines phải HOLD các dòng cuối là table-row/sep
2123
- // candidate để chờ đủ data trước khi parse.
2124
- function renderStreamLine(line, inCodeFence) {
2125
- if (inCodeFence) return line; // code fence body: in raw, không parse markdown.
2126
- const heading = renderHeadingLine(line);
2127
- if (heading !== null) return heading;
2128
- return renderInline(renderBulletPrefix(line));
2129
- }
2130
- // Dòng separator của bảng markdown: |---|---| hoặc |:---:|---:| v.v.
2131
- const TABLE_SEP_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
2132
- // Dòng có ít nhất 1 pipe ở giữa (không tính pipe đầu/cuối) → ứng viên row của bảng.
2133
- function looksLikeTableRow(line) {
2134
- const trimmed = line.trim();
2135
- if (!trimmed.startsWith('|') && !trimmed.includes('|')) return false;
2136
- // Cần ít nhất 1 ký tự `|` thực sự phân tách cột.
2137
- const stripped = trimmed.replace(/^\|/, '').replace(/\|$/, '');
2138
- return stripped.includes('|');
2139
- }
2140
- // Render block lines (mảng dòng KHÔNG có \n cuối) thành ANSI. Detect table block
2141
- // và parse qua marked; phần còn lại render line-by-line như cũ.
2142
- export function renderStreamBlock(lines, fenceState) {
2143
- const out = [];
2144
- let i = 0;
2145
- while (i < lines.length) {
2146
- const ln = lines[i];
2147
- // Code fence ưu tiên cao nhất — không đụng vào nội dung fence.
2148
- const fenceMatch = ln.match(/^\s*```/);
2149
- if (fenceMatch) {
2150
- out.push(renderStreamLine(ln, fenceState.inCodeFence));
2151
- fenceState.inCodeFence = !fenceState.inCodeFence;
2152
- i++;
2153
- continue;
2154
- }
2155
- if (fenceState.inCodeFence) {
2156
- out.push(ln);
2157
- i++;
2158
- continue;
2159
- }
2160
- // Detect table: dòng hiện tại là row + dòng kế tiếp là separator.
2161
- if (
2162
- looksLikeTableRow(ln) &&
2163
- i + 1 < lines.length &&
2164
- TABLE_SEP_RE.test(lines[i + 1])
2165
- ) {
2166
- // Gom hết các dòng row liên tiếp.
2167
- const tableLines = [ln, lines[i + 1]];
2168
- let j = i + 2;
2169
- while (j < lines.length && looksLikeTableRow(lines[j])) {
2170
- tableLines.push(lines[j]);
2171
- j++;
2172
- }
2173
- out.push(renderMarkdown(tableLines.join('\n')));
2174
- i = j;
2175
- continue;
2176
- }
2177
- out.push(renderStreamLine(ln, false));
2178
- i++;
2179
- }
2180
- return out.join('\n');
2181
- }
2182
- function makeStreamPrinter(name, color) {
2183
- let buf = ''; // toàn bộ delta đã nhận (chưa cắt)
2184
- let printed = 0; // offset đã in trong buf
2185
- let suppress = false;
2186
- let started = false;
2187
- let header = false;
2188
- let inCodeFence = false;
2189
- const HOLD = 8; // giữ đuôi 8 char để phát hiện sớm "```tool"
2190
- const emit = (s) => {
2191
- if (!s) return;
2192
- if (!header) {
2193
- process.stdout.write('\n' + chalk.hex(color).bold(' ● ' + name) + '\n ');
2194
- header = true;
2195
- }
2196
- // s có thể chứa \n từ render line + leading newline gốc. Thay \n → \n để indent.
2197
- process.stdout.write(s.replace(/\n/g, '\n '));
2198
- started = true;
2199
- };
2200
- // Render & emit từ printed → end. Chỉ flush các dòng đã HOÀN CHỈNH (có \n).
2201
- // Dòng cuối chưa có \n: chừa lại trong buf cho lần push tiếp theo.
2202
- // Lùi mốc flush nếu các dòng cuối là table-row/separator candidate — chờ đủ
2203
- // data để gom thành table block parse 1 lần qua marked (xem renderStreamBlock).
2204
- const flushCompleteLines = (end) => {
2205
- const slice = buf.slice(printed, end);
2206
- if (!slice) return;
2207
- const lastNl = slice.lastIndexOf('\n');
2208
- if (lastNl === -1) return;
2209
- let complete = slice.slice(0, lastNl + 1);
2210
- let completeLines = complete.split('\n');
2211
- if (completeLines[completeLines.length - 1] === '') completeLines.pop();
2212
- let hold = 0;
2213
- while (
2214
- completeLines.length - hold > 0 &&
2215
- (looksLikeTableRow(completeLines[completeLines.length - 1 - hold]) ||
2216
- TABLE_SEP_RE.test(completeLines[completeLines.length - 1 - hold]))
2217
- ) {
2218
- hold++;
2219
- if (hold > 64) break;
2220
- }
2221
- if (hold > 0) {
2222
- const keptLines = completeLines.slice(0, completeLines.length - hold);
2223
- if (keptLines.length === 0) return; // toàn bộ slice là table candidate → chờ
2224
- complete = keptLines.join('\n') + '\n';
2225
- completeLines = keptLines;
2226
- }
2227
- printed += complete.length;
2228
- const fenceState = { inCodeFence };
2229
- const rendered = renderStreamBlock(completeLines, fenceState);
2230
- inCodeFence = fenceState.inCodeFence;
2231
- emit(rendered + '\n');
2232
- };
2233
- return {
2234
- get started() {
2235
- return started;
2236
- },
2237
- get suppressing() {
2238
- return suppress;
2239
- },
2240
- push(delta) {
2241
- buf += delta;
2242
- if (suppress) return;
2243
- const f = buf.indexOf('```tool');
2244
- if (f !== -1) {
2245
- // Flush mọi thứ TRƯỚC ```tool (full lines + phần dở của dòng cuối cũng in luôn
2246
- // vì sắp suppress, không cần chừa buffer nữa).
2247
- const beforeTool = buf.slice(printed, f);
2248
- if (beforeTool) {
2249
- const parts = beforeTool.split('\n');
2250
- // bỏ '' cuối nếu kết thúc bằng \n
2251
- if (parts[parts.length - 1] === '') parts.pop();
2252
- const fenceState = { inCodeFence };
2253
- const rendered = renderStreamBlock(parts, fenceState);
2254
- inCodeFence = fenceState.inCodeFence;
2255
- emit(rendered);
2256
- }
2257
- printed = buf.length;
2258
- suppress = true;
2259
- return;
2260
- }
2261
- // Chỉ flush các dòng đã hoàn chỉnh; chừa đuôi HOLD char để phát hiện ```tool.
2262
- const safeEnd = Math.max(printed, buf.length - HOLD);
2263
- if (safeEnd > printed) flushCompleteLines(safeEnd);
2264
- },
2265
- flush() {
2266
- if (!suppress && printed < buf.length) {
2267
- // Render phần còn lại (kể cả dòng cuối chưa có \n).
2268
- const tail = buf.slice(printed);
2269
- const parts = tail.split('\n');
2270
- if (parts[parts.length - 1] === '') parts.pop();
2271
- const fenceState = { inCodeFence };
2272
- const rendered = renderStreamBlock(parts, fenceState);
2273
- inCodeFence = fenceState.inCodeFence;
2274
- emit(rendered);
2275
- printed = buf.length;
2276
- }
2277
- if (started) process.stdout.write('\n');
2278
- },
2279
- };
2280
- }
2114
+ // Stream printer (renderStreamBlock / makeStreamPrinter) đã tách sang
2115
+ // ./repl/stream-printer.js pure, chỉ phụ thuộc helper render từ ui.js.
2116
+ // Re-export renderStreamBlock để giữ contract smoke-table-stream2.mjs
2117
+ // (import { renderStreamBlock } from 'src/repl.js').
2118
+ export { renderStreamBlock } from './repl/stream-printer.js';
2281
2119
 
2282
2120
  function printError(err) {
2283
2121
  const map = {
package/src/tools.js CHANGED
@@ -172,12 +172,48 @@ function clip(s) {
172
172
  return s.slice(0, MAX_OUT) + `\n… [truncated, ${s.length - MAX_OUT} more chars]`;
173
173
  }
174
174
 
175
+ // Strip HTML → plain text cho web_fetch. Không phải parser đầy đủ — đủ để model
176
+ // đọc nội dung trang: bỏ script/style/head, đổi block tag → newline, gỡ tag còn
177
+ // lại, decode entity phổ biến, gọn dòng trống thừa.
178
+ function htmlToText(html) {
179
+ let s = html;
180
+ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
181
+ s = s.replace(/<(script|style|head|noscript|svg)\b[\s\S]*?<\/\1>/gi, ' ');
182
+ // Block-level tag → newline để giữ cấu trúc đoạn.
183
+ s = s.replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6]|ul|ol|table|blockquote)>/gi, '\n');
184
+ s = s.replace(/<br\s*\/?>/gi, '\n');
185
+ s = s.replace(/<[^>]+>/g, ' '); // gỡ mọi tag còn lại
186
+ // Decode entity phổ biến.
187
+ const ent = {
188
+ '&nbsp;': ' ',
189
+ '&amp;': '&',
190
+ '&lt;': '<',
191
+ '&gt;': '>',
192
+ '&quot;': '"',
193
+ '&#39;': "'",
194
+ '&apos;': "'",
195
+ };
196
+ s = s.replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, (m) => ent[m] || m);
197
+ s = s.replace(/&#(\d+);/g, (_, n) => {
198
+ try {
199
+ return String.fromCodePoint(Number(n));
200
+ } catch {
201
+ return '';
202
+ }
203
+ });
204
+ // Gọn whitespace: nhiều space → 1, nhiều dòng trống → tối đa 1 dòng trống.
205
+ s = s.replace(/[ \t]+/g, ' ');
206
+ s = s.replace(/ *\n */g, '\n');
207
+ s = s.replace(/\n{3,}/g, '\n\n');
208
+ return s;
209
+ }
210
+
175
211
  // Tools that mutate the filesystem or run code require user approval.
176
212
  export const DESTRUCTIVE = new Set(['write_file', 'edit_file', 'run_command']);
177
213
 
178
214
  /**
179
215
  * Tên các tool model có thể gọi.
180
- * @typedef {'read_file'|'write_file'|'edit_file'|'list_dir'|'glob'|'grep'|'run_command'|'bg_output'|'kill_bg'} ToolName
216
+ * @typedef {'read_file'|'web_fetch'|'write_file'|'edit_file'|'list_dir'|'glob'|'grep'|'run_command'|'bg_output'|'kill_bg'} ToolName
181
217
  */
182
218
 
183
219
  /**
@@ -196,6 +232,8 @@ export const DESTRUCTIVE = new Set(['write_file', 'edit_file', 'run_command']);
196
232
  * @property {number} [timeout] run_command: ms (default 60000)
197
233
  * @property {boolean} [background] run_command: spawn ngầm, trả về NGAY
198
234
  * @property {number} [id] bg_output/kill_bg
235
+ * @property {string} [url] web_fetch: URL http/https cần tải
236
+ * @property {boolean} [raw] web_fetch: true = trả HTML thô, không strip
199
237
  */
200
238
 
201
239
  /**
@@ -257,6 +295,50 @@ export const TOOLS = {
257
295
  );
258
296
  },
259
297
 
298
+ async web_fetch({ url, raw }, { signal } = {}) {
299
+ checkAbort(signal);
300
+ if (!url || typeof url !== 'string') throw new Error('web_fetch: thiếu "url" (string)');
301
+ let u;
302
+ try {
303
+ u = new URL(url);
304
+ } catch {
305
+ throw new Error(`web_fetch: URL không hợp lệ: ${url}`);
306
+ }
307
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
308
+ throw new Error(`web_fetch: chỉ hỗ trợ http/https (nhận ${u.protocol})`);
309
+ // Timeout 30s, hủy theo signal (Ctrl+C) lẫn timeout nội bộ.
310
+ const ctrl = new AbortController();
311
+ const onAbort = () => ctrl.abort();
312
+ if (signal) {
313
+ if (signal.aborted) ctrl.abort();
314
+ else signal.addEventListener('abort', onAbort, { once: true });
315
+ }
316
+ const timer = setTimeout(() => ctrl.abort(), 30000);
317
+ let res;
318
+ try {
319
+ res = await fetch(u, {
320
+ signal: ctrl.signal,
321
+ redirect: 'follow',
322
+ headers: { 'user-agent': 'noob-cli/web_fetch', accept: 'text/html,text/plain,*/*' },
323
+ });
324
+ } catch (e) {
325
+ if (ctrl.signal.aborted && !signal?.aborted)
326
+ throw new Error('web_fetch: quá thời gian (30s)');
327
+ if (signal?.aborted) throw new Error('aborted');
328
+ throw new Error(`web_fetch: không tải được ${u.href}: ${e.message}`);
329
+ } finally {
330
+ clearTimeout(timer);
331
+ signal?.removeEventListener?.('abort', onAbort);
332
+ }
333
+ const ct = res.headers.get('content-type') || '';
334
+ const body = await res.text();
335
+ const head = `${res.status} ${res.statusText} · ${ct || 'unknown type'} · ${u.href}\n\n`;
336
+ // raw:true hoặc không phải HTML → trả nguyên văn (đã clip). Ngược lại strip HTML.
337
+ const isHtml = /html/i.test(ct) || /^\s*<(!doctype|html)/i.test(body);
338
+ const text = raw || !isHtml ? body : htmlToText(body);
339
+ return clip(head + text.trim());
340
+ },
341
+
260
342
  async write_file({ path: p, content }, { signal } = {}) {
261
343
  checkAbort(signal);
262
344
  await fs.mkdir(path.dirname(abs(p)), { recursive: true });
@@ -670,6 +752,8 @@ export function describe(name, input) {
670
752
  switch (name) {
671
753
  case 'read_file':
672
754
  return `read ${input.path}`;
755
+ case 'web_fetch':
756
+ return `fetch ${input.url || ''}`;
673
757
  case 'write_file':
674
758
  return `write ${input.path} (${(input.content ?? '').split('\n').length} lines)`;
675
759
  case 'edit_file':