@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 +22 -0
- package/package.json +2 -2
- package/src/agent.js +1 -0
- package/src/models.js +4 -75
- package/src/repl/stream-printer.js +185 -0
- package/src/repl.js +7 -169
- package/src/tools.js +85 -1
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.
|
|
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 —
|
|
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-
|
|
31
|
-
name: 'Claude Opus 4.
|
|
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-
|
|
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,
|
|
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
|
|
2114
|
-
//
|
|
2115
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
|
|
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
|
+
' ': ' ',
|
|
189
|
+
'&': '&',
|
|
190
|
+
'<': '<',
|
|
191
|
+
'>': '>',
|
|
192
|
+
'"': '"',
|
|
193
|
+
''': "'",
|
|
194
|
+
''': "'",
|
|
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':
|