@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.12.16",
3
+ "version": "1.12.18",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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, you MUST continue — do NOT output a summary, do NOT ask "what next", do NOT stop. After write_file/edit_file returns, call write_todos to tick the just-finished item, then immediately start the next.
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 cellStr = (x) => (x.paste !== undefined ? x.paste : x.c);
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) => (cells[k] && cells[k].paste === undefined ? cells[k].c : null);
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) {