@noobdemon/noob-cli 1.12.16 → 1.12.18
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 +14 -0
- package/package.json +1 -1
- package/src/agent.js +11 -1
- package/src/api.js +4 -0
- package/src/repl.js +32 -8
- package/src/tui.js +52 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.18] - 2026-06-25
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Cải thiện system prompt cho coding flow: inspect đúng chỗ, patch hẹp, verify nhanh và final gọn hơn để model code mượt, ít vòng lặp hơn.
|
|
9
|
+
|
|
10
|
+
## [1.12.17] - 2026-06-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Image input support** (`src/tui.js`, `src/repl.js`, `src/api.js`, `src/agent.js`, `worker/src/worker.js`): rich TTY trên Windows hỗ trợ `Alt+V` để lấy ảnh từ clipboard, hiện chip `[pasted image #1]`, rồi gửi kèm payload `image: "data:image/png;base64,..."` qua gateway. CLI cũng tự đính kèm ảnh đầu tiên khi user nhắc `@file.png|jpg|jpeg|webp|gif` (giới hạn 8MB). Worker validate data URL và forward `image` sang Railway (`{message, model, image}`); fallback cũ giữ `{message, model}`.
|
|
14
|
+
|
|
15
|
+
### Verified
|
|
16
|
+
- `npm test` 100/100 pass.
|
|
17
|
+
- Railway `/chat` smoke với `image` data URL base64 trả mô tả ảnh thành công.
|
|
18
|
+
|
|
5
19
|
## [1.12.15] - 2026-06-16
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -36,7 +36,7 @@ Available tools (each is self-contained; pick the SMALLEST tool that answers the
|
|
|
36
36
|
Context is finite. Don't slurp the whole repo up front. Discover information progressively: list_dir/glob to map → grep to locate → read_file (with offset+limit for big files) to inspect only what matters. Each tool result spends your attention budget — make every call earn it. When a tool returns a huge blob, extract the few facts you need, then move on; don't re-read it later (the result stays in history).
|
|
37
37
|
|
|
38
38
|
# Rules
|
|
39
|
-
- TODO-BASED EXECUTION: For any multi-step task (3+ actions), you MUST call \`write_todos\` FIRST with all items done:false, then call it AGAIN after every completed step with that item flipped to done:true (resend the full list). NEVER write markdown \`- [ ]\` lines — the runtime parses \`write_todos\` calls, not markdown. Your response is NOT finished until all items are done:true. The ONLY valid reason to stop is: (a) all items done, or (b) you are WAITING for a user reply. If you just got a tool result
|
|
39
|
+
- TODO-BASED EXECUTION: For any multi-step task (3+ actions), you MUST call \`write_todos\` FIRST with all items done:false, then call it AGAIN after every completed step with that item flipped to done:true (resend the full list). NEVER write markdown \`- [ ]\` lines — the runtime parses \`write_todos\` calls, not markdown. Your response is NOT finished until all items are done:true. The ONLY valid reason to stop is: (a) all items done and sensible verification is complete, or (b) you are WAITING for a user reply. If you just got a tool result and unfinished work remains, continue with the next tool — do NOT output a premature summary, do NOT ask "what next". After write_file/edit_file returns, call write_todos to tick the just-finished item, then immediately start the next.
|
|
40
40
|
- GROUND TRUTH = real TOOL RESULTs in this conversation, not your memory or what you intended to do. A file changed only if a write_file/edit_file result confirms it (see the FILES CHANGED list). A test passed / build succeeded / command worked only if a run_command result above shows it. Never narrate outcomes you didn't observe; if you haven't checked, say so and check now (read_file / list_dir / run the command). Before any "done/summary" reply, reconcile every file and result you're about to claim against the actual tool results above — if it isn't there, you didn't do it yet.
|
|
41
41
|
- VERIFY BEFORE DISMISSING: never declare a TOOL RESULT "fake", "spurious", "injected", "unrelated", or "from a previous turn" without first verifying with a fresh tool call. If a result looks off (unexpected content, output you didn't ask for, weird command), your DEFAULT is: treat it as REAL runtime output, then run a small verification (read_file the affected path, grep for the symbol, list_dir, re-run the command) to confirm actual state. Only after the verification tool result contradicts the suspicious one may you call it stale/leftover — and even then, work from the FRESH result, never from your guess. Trusting your own skepticism over the runtime is the same over-confidence bug as hallucinating success: both substitute memory for evidence.
|
|
42
42
|
- Investigate before editing: read the relevant files first; never invent file contents.
|
|
@@ -66,6 +66,12 @@ Context is finite. Don't slurp the whole repo up front. Discover information pro
|
|
|
66
66
|
3. SURGICAL: change only what the task needs. No drive-by refactors, renames, reformatting, or comment churn in unrelated code.
|
|
67
67
|
4. VERIFIABLE GOAL: decide how you'll know it works, then check it (run the build/test, read the output). Report what you verified — and honestly state what you did NOT verify.
|
|
68
68
|
|
|
69
|
+
# Coding workflow — default for implementation tasks
|
|
70
|
+
1. Inspect first: list/glob/grep/read only the files needed to understand the change.
|
|
71
|
+
2. Patch narrowly: edit the smallest relevant block; avoid new helpers/files unless they clearly reduce code now.
|
|
72
|
+
3. Verify narrowly: run the fastest relevant test/lint/build command. If it fails, use the output as ground truth and fix once before broadening scope.
|
|
73
|
+
4. Finish cleanly: final answer states the changed files and the exact verification result. Do not include tool-call JSON, long plans, or speculative next steps.
|
|
74
|
+
|
|
69
75
|
# Example interaction
|
|
70
76
|
## USER
|
|
71
77
|
do the tests pass?
|
|
@@ -556,6 +562,7 @@ function extractJsonObject(s, from) {
|
|
|
556
562
|
export async function runAgent({
|
|
557
563
|
history,
|
|
558
564
|
model,
|
|
565
|
+
image,
|
|
559
566
|
signal,
|
|
560
567
|
onTool,
|
|
561
568
|
onStatus,
|
|
@@ -608,6 +615,7 @@ export async function runAgent({
|
|
|
608
615
|
const { text, finishReason } = await streamWithRetry({
|
|
609
616
|
model,
|
|
610
617
|
message,
|
|
618
|
+
image,
|
|
611
619
|
system,
|
|
612
620
|
signal,
|
|
613
621
|
tokenMeter,
|
|
@@ -756,6 +764,7 @@ export async function runAgent({
|
|
|
756
764
|
async function streamWithRetry({
|
|
757
765
|
model,
|
|
758
766
|
message,
|
|
767
|
+
image,
|
|
759
768
|
system,
|
|
760
769
|
signal,
|
|
761
770
|
tokenMeter,
|
|
@@ -771,6 +780,7 @@ async function streamWithRetry({
|
|
|
771
780
|
mode: 'chat',
|
|
772
781
|
model,
|
|
773
782
|
message,
|
|
783
|
+
image,
|
|
774
784
|
system,
|
|
775
785
|
signal,
|
|
776
786
|
effort,
|
package/src/api.js
CHANGED
|
@@ -157,6 +157,7 @@ function hasUnclosedToolBlock(text) {
|
|
|
157
157
|
export async function stream({
|
|
158
158
|
mode = 'chat',
|
|
159
159
|
message,
|
|
160
|
+
image,
|
|
160
161
|
model,
|
|
161
162
|
system,
|
|
162
163
|
conversation,
|
|
@@ -186,6 +187,7 @@ export async function stream({
|
|
|
186
187
|
endpoint,
|
|
187
188
|
mode,
|
|
188
189
|
message: prompt,
|
|
190
|
+
image,
|
|
189
191
|
model,
|
|
190
192
|
system,
|
|
191
193
|
conversation,
|
|
@@ -262,6 +264,7 @@ async function streamOnce({
|
|
|
262
264
|
endpoint,
|
|
263
265
|
mode,
|
|
264
266
|
message,
|
|
267
|
+
image,
|
|
265
268
|
model,
|
|
266
269
|
system,
|
|
267
270
|
conversation,
|
|
@@ -278,6 +281,7 @@ async function streamOnce({
|
|
|
278
281
|
else if (mode === 'merge') body = { message };
|
|
279
282
|
else {
|
|
280
283
|
body = { message, model, remember: true, memoryToken: getMemoryToken() };
|
|
284
|
+
if (image) body.image = image;
|
|
281
285
|
if (system) body.customInstructions = system;
|
|
282
286
|
if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
|
|
283
287
|
if (effort) body.effort = effort;
|
package/src/repl.js
CHANGED
|
@@ -101,6 +101,24 @@ import {
|
|
|
101
101
|
} from './repl/utils.js';
|
|
102
102
|
import { createAgentDispatcher } from './repl/agent-dispatch.js';
|
|
103
103
|
import { createBgRegistry } from './workflow-bg.js';
|
|
104
|
+
|
|
105
|
+
const IMAGE_MIME = {
|
|
106
|
+
'.png': 'image/png',
|
|
107
|
+
'.jpg': 'image/jpeg',
|
|
108
|
+
'.jpeg': 'image/jpeg',
|
|
109
|
+
'.webp': 'image/webp',
|
|
110
|
+
'.gif': 'image/gif',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function imageDataUrl(file) {
|
|
114
|
+
if (!file) return null;
|
|
115
|
+
const mime = IMAGE_MIME[path.extname(file).toLowerCase()];
|
|
116
|
+
if (!mime) return null;
|
|
117
|
+
const abs = path.resolve(process.cwd(), file);
|
|
118
|
+
const stat = fs.statSync(abs);
|
|
119
|
+
if (stat.size > 8 * 1024 * 1024) throw new Error('Ảnh quá lớn (>8MB): ' + file);
|
|
120
|
+
return `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
|
|
121
|
+
}
|
|
104
122
|
export async function startRepl(opts = {}) {
|
|
105
123
|
const state = createState(opts, config);
|
|
106
124
|
const tokenMeter = new TokenMeter();
|
|
@@ -1453,23 +1471,25 @@ NGUYÊN TẮC:
|
|
|
1453
1471
|
let input;
|
|
1454
1472
|
if (pending.length) {
|
|
1455
1473
|
// Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
|
|
1456
|
-
input = (pending.shift() ?? '').trim();
|
|
1474
|
+
input = { text: (pending.shift() ?? '').trim(), images: [] };
|
|
1457
1475
|
} else {
|
|
1458
1476
|
const raw = await ask(promptStr(false));
|
|
1459
1477
|
if (raw == null) break; // stdin fully closed and drained
|
|
1460
|
-
input = raw.trim();
|
|
1478
|
+
input = typeof raw === 'string' ? { text: raw.trim(), images: [] } : raw;
|
|
1479
|
+
input.text = String(input.text || '').trim();
|
|
1480
|
+
input.images = Array.isArray(input.images) ? input.images : [];
|
|
1461
1481
|
}
|
|
1462
|
-
if (!input) continue;
|
|
1482
|
+
if (!input.text) continue;
|
|
1463
1483
|
// Bọc cả lượt: một lỗi trong xử lý lệnh/agent không được phép thoát ra
|
|
1464
1484
|
// ngoài vòng lặp (sẽ rơi vào .catch ở bin/noob.js → process.exit(1) =
|
|
1465
1485
|
// "tự động tắt"). Bắt ở đây, in lỗi, rồi tiếp tục vòng lặp.
|
|
1466
1486
|
try {
|
|
1467
|
-
if (input.startsWith('/')) {
|
|
1468
|
-
const done = await command(input);
|
|
1487
|
+
if (input.text.startsWith('/')) {
|
|
1488
|
+
const done = await command(input.text);
|
|
1469
1489
|
if (done) break;
|
|
1470
1490
|
continue;
|
|
1471
1491
|
}
|
|
1472
|
-
await handle(input);
|
|
1492
|
+
await handle(input.text, { images: input.images });
|
|
1473
1493
|
persist(); // lưu sau mỗi lượt → resume được kể cả khi tắt đột ngột
|
|
1474
1494
|
} catch (err) {
|
|
1475
1495
|
printError(err);
|
|
@@ -1481,7 +1501,7 @@ NGUYÊN TẮC:
|
|
|
1481
1501
|
process.exit(0);
|
|
1482
1502
|
|
|
1483
1503
|
// ── turn handler ─────────────────────────────────────────────────────────
|
|
1484
|
-
async function handle(text) {
|
|
1504
|
+
async function handle(text, opts = {}) {
|
|
1485
1505
|
if (!config.apiKey) {
|
|
1486
1506
|
console.log(c.tool(' ' + t.notLoggedIn));
|
|
1487
1507
|
return;
|
|
@@ -1546,9 +1566,12 @@ NGUYÊN TẮC:
|
|
|
1546
1566
|
}
|
|
1547
1567
|
|
|
1548
1568
|
const files = mentionedFiles(text);
|
|
1569
|
+
const image =
|
|
1570
|
+
opts.images?.[0] ||
|
|
1571
|
+
imageDataUrl(files.find((f) => IMAGE_MIME[path.extname(f).toLowerCase()]));
|
|
1549
1572
|
const content = files.length
|
|
1550
1573
|
? text +
|
|
1551
|
-
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.]`
|
|
1574
|
+
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.${image ? ' Ảnh đầu tiên đã được đính kèm cho model vision.' : ''}]`
|
|
1552
1575
|
: text;
|
|
1553
1576
|
state.history.push({ role: 'user', content });
|
|
1554
1577
|
// Update terminal title với session name (trích từ message đầu).
|
|
@@ -1592,6 +1615,7 @@ NGUYÊN TẮC:
|
|
|
1592
1615
|
const answer = await runAgent({
|
|
1593
1616
|
history: state.history,
|
|
1594
1617
|
model: state.model.id,
|
|
1618
|
+
image,
|
|
1595
1619
|
signal: abort.signal,
|
|
1596
1620
|
tokenMeter,
|
|
1597
1621
|
goal: state.goal,
|
package/src/tui.js
CHANGED
|
@@ -7,11 +7,32 @@
|
|
|
7
7
|
// Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
|
|
8
8
|
// (đọc dòng đơn giản, in thẳng) để khỏi vỡ ở terminal lạ / pipe / CI.
|
|
9
9
|
import readline from 'node:readline';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { c } from './ui.js';
|
|
11
12
|
|
|
12
13
|
const ESC = '\x1b';
|
|
13
14
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
15
|
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
16
|
+
function readClipboardImageDataUrl() {
|
|
17
|
+
if (process.platform !== 'win32') return null;
|
|
18
|
+
try {
|
|
19
|
+
const script =
|
|
20
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;' +
|
|
21
|
+
'$img=[Windows.Forms.Clipboard]::GetImage();' +
|
|
22
|
+
'if($null -eq $img){exit 2};' +
|
|
23
|
+
'$ms=New-Object IO.MemoryStream;' +
|
|
24
|
+
'$img.Save($ms,[Drawing.Imaging.ImageFormat]::Png);' +
|
|
25
|
+
'[Convert]::ToBase64String($ms.ToArray())';
|
|
26
|
+
const b64 = execFileSync('powershell.exe', ['-NoProfile', '-STA', '-Command', script], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: 5000,
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
}).trim();
|
|
31
|
+
return b64 ? `data:image/png;base64,${b64}` : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
15
36
|
// Trả index trong `text` mà tại đó vị trí VISUAL đạt `targetVis`. Bỏ qua toàn
|
|
16
37
|
// bộ ANSI escape sequence khi đếm. Dùng cho soft-wrap khi text có màu.
|
|
17
38
|
function findVisPos(text, targetVis) {
|
|
@@ -207,6 +228,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
207
228
|
// vị trí con trỏ → hỗ trợ ←/→/Home/End/Delete, chèn & xoá GIỮA dòng.
|
|
208
229
|
let cells = [];
|
|
209
230
|
let cur = 0;
|
|
231
|
+
let imageSeq = 0;
|
|
210
232
|
let waiter = null;
|
|
211
233
|
const queue = [];
|
|
212
234
|
|
|
@@ -254,8 +276,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
254
276
|
? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) + '…'
|
|
255
277
|
: firstLine;
|
|
256
278
|
};
|
|
257
|
-
const
|
|
279
|
+
const imageLabel = (x) => `[pasted image #${x.n}]`;
|
|
280
|
+
const cellStr = (x) => {
|
|
281
|
+
if (x.image) return imageLabel(x);
|
|
282
|
+
return x.paste !== undefined ? x.paste : x.c;
|
|
283
|
+
};
|
|
258
284
|
const cellPlain = (x) => {
|
|
285
|
+
if (x.image) return imageLabel(x);
|
|
259
286
|
if (x.paste === undefined) return x.c;
|
|
260
287
|
const preview = pastePreview(x.paste);
|
|
261
288
|
return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
@@ -264,6 +291,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
264
291
|
const coloredInput = () =>
|
|
265
292
|
cells
|
|
266
293
|
.map((x) => {
|
|
294
|
+
if (x.image) return c.dim(imageLabel(x));
|
|
267
295
|
if (x.paste === undefined) return x.c;
|
|
268
296
|
const preview = pastePreview(x.paste);
|
|
269
297
|
const label = preview
|
|
@@ -568,6 +596,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
568
596
|
histPos = null;
|
|
569
597
|
} else pushText(content); // paste 1 dòng = gõ thẳng
|
|
570
598
|
}
|
|
599
|
+
function pushImage(image) {
|
|
600
|
+
imageSeq += 1;
|
|
601
|
+
cells.splice(cur, 0, { image, n: imageSeq });
|
|
602
|
+
cur += 1;
|
|
603
|
+
histPos = null;
|
|
604
|
+
}
|
|
605
|
+
function pasteClipboardImage() {
|
|
606
|
+
const image = readClipboardImageDataUrl();
|
|
607
|
+
if (image) pushImage(image);
|
|
608
|
+
else w('\x07');
|
|
609
|
+
draw();
|
|
610
|
+
}
|
|
571
611
|
function backspace() {
|
|
572
612
|
if (cur > 0) {
|
|
573
613
|
cells.splice(cur - 1, 1);
|
|
@@ -582,7 +622,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
582
622
|
}
|
|
583
623
|
}
|
|
584
624
|
// null = ô dán (coi như ranh giới từ); ngược lại trả ký tự của ô.
|
|
585
|
-
const charAt = (k) =>
|
|
625
|
+
const charAt = (k) =>
|
|
626
|
+
cells[k] && cells[k].paste === undefined && !cells[k].image ? cells[k].c : null;
|
|
586
627
|
function moveWordLeft() {
|
|
587
628
|
while (cur > 0 && charAt(cur - 1) === ' ') cur -= 1;
|
|
588
629
|
if (cur > 0 && charAt(cur - 1) === null) return void (cur -= 1); // qua 1 chip
|
|
@@ -639,6 +680,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
639
680
|
|
|
640
681
|
function submit() {
|
|
641
682
|
const full = fullText();
|
|
683
|
+
const images = cells.filter((x) => x.image).map((x) => x.image);
|
|
642
684
|
const echo = echoUserBlock();
|
|
643
685
|
if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
|
|
644
686
|
submitHistory.push(full);
|
|
@@ -654,9 +696,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
654
696
|
if (waiter) {
|
|
655
697
|
const wr = waiter;
|
|
656
698
|
waiter = null;
|
|
657
|
-
wr(full);
|
|
658
|
-
} else if (onLine) onLine(full);
|
|
659
|
-
else queue.push(full);
|
|
699
|
+
wr(images.length ? { text: full, images } : full);
|
|
700
|
+
} else if (onLine) onLine(images.length ? { text: full, images } : full);
|
|
701
|
+
else queue.push(images.length ? { text: full, images } : full);
|
|
660
702
|
}
|
|
661
703
|
|
|
662
704
|
let carry = '';
|
|
@@ -702,6 +744,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
702
744
|
}
|
|
703
745
|
const ch = s[i];
|
|
704
746
|
if (ch === ESC) {
|
|
747
|
+
if (rest[1] === 'v' || rest[1] === 'V') {
|
|
748
|
+
pasteClipboardImage();
|
|
749
|
+
i += 2;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
705
752
|
// Một chuỗi CSI hoàn chỉnh: \x1b[ … <chữ/~> hoặc SS3: \x1bO<chữ>.
|
|
706
753
|
const csi = rest.match(/^\x1b\[[0-9;]*[~A-Za-z]/) || rest.match(/^\x1bO[A-Za-z]/);
|
|
707
754
|
if (!csi) {
|