@noobdemon/noob-cli 1.10.20 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Client for the noob gateway (claude-code-proxy worker). The gateway validates
2
2
  // the API key against Supabase, enforces plan limits, and hides the real
3
3
  // upstream. The CLI only ever sees the gateway URL + the user's key.
4
- import { config } from "./config.js";
4
+ import { config } from './config.js';
5
5
 
6
6
  // ── memoryToken: per-session random token for upstream conversation state.
7
7
  // Browser sends something like "uuid_uuid" (two v4 UUIDs joined by _).
@@ -10,9 +10,9 @@ import { config } from "./config.js";
10
10
  let _sessionMemoryToken = null;
11
11
 
12
12
  function makeUUID() {
13
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
13
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
14
14
  const r = (Math.random() * 16) | 0;
15
- const v = c === "x" ? r : (r & 0x3) | 0x8;
15
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
16
16
  return v.toString(16);
17
17
  });
18
18
  }
@@ -36,26 +36,30 @@ export function resetMemoryToken() {
36
36
  // nếu chỉ check ở top-level, flag --insecure-tls set sau import sẽ không kịp.
37
37
  let _tlsWarned = false;
38
38
  export function applyInsecureTLS() {
39
- if (process.env.NOOB_INSECURE_TLS !== "1") return;
40
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
39
+ if (process.env.NOOB_INSECURE_TLS !== '1') return;
40
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
41
41
  if (_tlsWarned) return;
42
42
  _tlsWarned = true;
43
43
  // Cảnh báo rõ ràng: flag này tắt verify TLS TOÀN PROCESS. MITM-able.
44
- console.warn("\x1b[33m⚠ NOOB_INSECURE_TLS=1: TLS verification DISABLED for this process. MITM-vulnerable. Unset when done.\x1b[0m");
44
+ console.warn(
45
+ '\x1b[33m⚠ NOOB_INSECURE_TLS=1: TLS verification DISABLED for this process. MITM-vulnerable. Unset when done.\x1b[0m'
46
+ );
45
47
  }
46
- // Vẫn áp dụng ngay nếu env sẵn từ shell (export NOOB_INSECURE_TLS=1).
47
- applyInsecureTLS();
48
+ // KHÔNG gọi applyInsecureTLS() top-level: ESM hoist, lệnh này sẽ chạy
49
+ // TRƯỚC khi bin/noob.js kịp parse `--insecure-tls` và set env. bin/noob.js đã
50
+ // gọi applyInsecureTLS() sau khi parse argv — đó là điểm duy nhất.
51
+ // Nếu env NOOB_INSECURE_TLS=1 đã có sẵn từ shell, bin/noob.js cũng sẽ áp dụng.
48
52
 
49
53
  function authHeaders() {
50
- const h = { "Content-Type": "application/json" };
51
- if (config.apiKey) h["Authorization"] = "Bearer " + config.apiKey;
54
+ const h = { 'Content-Type': 'application/json' };
55
+ if (config.apiKey) h['Authorization'] = 'Bearer ' + config.apiKey;
52
56
  return h;
53
57
  }
54
58
 
55
59
  class ApiError extends Error {
56
60
  constructor(message, { status, code, reset_at, plan, retryable, partial } = {}) {
57
61
  super(message);
58
- this.name = "ApiError";
62
+ this.name = 'ApiError';
59
63
  this.status = status;
60
64
  this.code = code;
61
65
  this.reset_at = reset_at;
@@ -64,15 +68,15 @@ class ApiError extends Error {
64
68
  // nếu lỗi 4xx auth/bad-request (retry vô nghĩa). Tự suy ra khi không truyền.
65
69
  this.retryable = retryable ?? deriveRetryable({ status, code });
66
70
  // partial: phần text đã nhận được trước khi lỗi (cho phép caller continue).
67
- this.partial = partial || "";
71
+ this.partial = partial || '';
68
72
  }
69
73
  }
70
74
 
71
75
  // Phân loại lỗi gateway: 5xx + 408/429 (không phải plan limit) + timeout/network
72
76
  // → retryable. 4xx khác (401 auth, 400 bad request, 429 plan_limit) → KHÔNG retry.
73
77
  function deriveRetryable({ status, code }) {
74
- if (code === "timeout") return true;
75
- if (code === "plan_limit") return false;
78
+ if (code === 'timeout') return true;
79
+ if (code === 'plan_limit') return false;
76
80
  if (!status) return true; // không có status = network drop / fetch throw → retry
77
81
  if (status >= 500) return true;
78
82
  if (status === 408 || status === 429) return true;
@@ -96,7 +100,13 @@ async function parseError(resp) {
96
100
  // Unlimited.surf & similar proxies inject web search results as XML/markdown blocks
97
101
  // into the SSE stream. These get appended as regular text and confuse the AI model
98
102
  // when seen in subsequent turns (it thinks they are prompt injection attempts).
99
- function cleanResponseText(text) {
103
+ //
104
+ // QUAN TRỌNG: chỉ chạy stripping markdown-heading khi mode === 'search'. Với
105
+ // chat/merge, người dùng có thể đang viết về chính chủ đề "Web Search Results"
106
+ // (vd nội dung SEO/content) — regex tham sẽ nuốt mất câu trả lời thật.
107
+ // Các tag XML/bracket/plain-marker thì hiếm khi xuất hiện tự nhiên nên vẫn an
108
+ // toàn để strip ở mọi mode.
109
+ function cleanResponseText(text, mode = 'chat') {
100
110
  if (!text) return text;
101
111
  let cleaned = text;
102
112
  // XML/SGML style: <web_search_results>...</web_search_results>
@@ -108,8 +118,15 @@ function cleanResponseText(text) {
108
118
  // Plain text markers: web_search_results ... web_search_results_end
109
119
  cleaned = cleaned.replace(/web_search_results[\s\S]*?web_search_results_end/gi, '');
110
120
  cleaned = cleaned.replace(/web_search_summary[\s\S]*?web_search_summary_end/gi, '');
111
- // Markdown headings: ## Web Search Results / ## Web Search Summary (with content until next heading)
112
- cleaned = cleaned.replace(/^#{1,3}\s+Web\s+Search\s+(Results|Summary)\s*[\s\S]*?(?=^#{1,3}|\n# |$)/gim, '');
121
+ // Markdown headings: chỉ áp dụng cho search mode (nguồn duy nhất bị inject
122
+ // dạng heading bởi proxy). Lookahead bám sát: dừng tại heading kế tiếp HOẶC
123
+ // 2 dòng trống liên tiếp (đoạn văn mới) — tránh nuốt phần còn lại của câu trả lời.
124
+ if (mode === 'search') {
125
+ cleaned = cleaned.replace(
126
+ /^#{1,3}\s+Web\s+Search\s+(Results|Summary)\b[^\n]*\n[\s\S]*?(?=^#{1,3}\s|\n\n\n|$)/gim,
127
+ ''
128
+ );
129
+ }
113
130
  return cleaned.trim();
114
131
  }
115
132
 
@@ -137,58 +154,98 @@ function hasUnclosedToolBlock(text) {
137
154
  *
138
155
  * @returns {Promise<{text:string, reasoning:string}>}
139
156
  */
140
- export async function stream({ mode = "chat", message, model, system, conversation, effort, signal, onDelta, onReasoning, onStatus, maxContinues = Infinity }) {
141
- const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
157
+ export async function stream({
158
+ mode = 'chat',
159
+ message,
160
+ model,
161
+ system,
162
+ conversation,
163
+ effort,
164
+ signal,
165
+ onDelta,
166
+ onReasoning,
167
+ onStatus,
168
+ maxContinues = Infinity,
169
+ }) {
170
+ const endpoint =
171
+ mode === 'search' ? '/api/search' : mode === 'merge' ? '/api/merge' : '/api/chat';
142
172
 
143
- let fullText = "";
144
- let reasoning = "";
173
+ let fullText = '';
174
+ let reasoning = '';
145
175
  let prompt = message; // prompt gửi đi: lần đầu = nguyên bản, các lần sau = nối tiếp
146
- let lastFinishReason = "stop"; // stop | truncated | tool_unclosed | empty | network_drop
176
+ let lastFinishReason = 'stop'; // stop | truncated | tool_unclosed | empty | network_drop
147
177
  let emptyStreak = 0; // số lần liên tiếp stream rỗng (chống loop vô tận khi upstream chết hẳn)
148
178
 
149
179
  for (let attempt = 0; ; attempt++) {
150
- const r = await streamOnce({ endpoint, mode, message: prompt, model, system, conversation, effort, signal, onStatus, onDelta, onReasoning });
151
- fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
180
+ const r = await streamOnce({
181
+ endpoint,
182
+ mode,
183
+ message: prompt,
184
+ model,
185
+ system,
186
+ conversation,
187
+ effort,
188
+ signal,
189
+ onStatus,
190
+ onDelta,
191
+ onReasoning,
192
+ });
193
+ fullText = mode === 'chat' ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
152
194
  if (r.reasoning) reasoning = r.reasoning;
153
195
 
154
196
  // Phát hiện thêm: tool block mở mà chưa đóng → coi như bị cắt dù gateway báo done.
155
- const toolUnclosed = mode === "chat" && hasUnclosedToolBlock(fullText);
197
+ const toolUnclosed = mode === 'chat' && hasUnclosedToolBlock(fullText);
156
198
  const truncated = r.truncated || toolUnclosed;
157
199
 
158
- if (!truncated) { lastFinishReason = "stop"; break; }
159
- if (mode !== "chat") { lastFinishReason = "truncated"; break; }
200
+ if (!truncated) {
201
+ lastFinishReason = 'stop';
202
+ break;
203
+ }
204
+ if (mode !== 'chat') {
205
+ lastFinishReason = 'truncated';
206
+ break;
207
+ }
160
208
 
161
209
  // Đếm chuỗi rỗng: nếu 3 lần liên tiếp model trả rỗng → upstream chết hẳn, dừng.
162
210
  if (!r.text.trim()) {
163
211
  emptyStreak++;
164
- if (emptyStreak >= 3) { lastFinishReason = "empty"; break; }
212
+ if (emptyStreak >= 3) {
213
+ lastFinishReason = 'empty';
214
+ break;
215
+ }
165
216
  } else {
166
217
  emptyStreak = 0;
167
218
  }
168
219
 
169
220
  if (attempt >= maxContinues) {
170
- lastFinishReason = toolUnclosed ? "tool_unclosed" : "truncated";
221
+ lastFinishReason = toolUnclosed ? 'tool_unclosed' : 'truncated';
171
222
  break;
172
223
  }
173
224
  prompt = continuationPrompt(message, fullText);
174
225
  }
175
226
 
176
- return { text: cleanResponseText(fullText.trim()), reasoning: reasoning.trim(), finishReason: lastFinishReason };
227
+ return {
228
+ text: cleanResponseText(fullText.trim(), mode),
229
+ reasoning: reasoning.trim(),
230
+ finishReason: lastFinishReason,
231
+ };
177
232
  }
178
233
 
179
234
  // Dựng prompt "nối tiếp" khi câu trả lời bị cắt giữa chừng: gửi lại nguyên ngữ
180
235
  // cảnh gốc + phần model đã viết dở + yêu cầu viết TIẾP đúng chỗ dừng, không lặp.
181
236
  function continuationPrompt(message, partial) {
182
- const bar = "=".repeat(60);
237
+ const bar = '='.repeat(60);
183
238
  return (
184
239
  message +
185
- "\n\n" + bar +
186
- "\n## ASSISTANT (bị ngắt giữa chừng — phần trả lời dưới đây CHƯA hoàn tất)\n" +
240
+ '\n\n' +
241
+ bar +
242
+ '\n## ASSISTANT (bị ngắt giữa chừng — phần trả lời dưới đây CHƯA hoàn tất)\n' +
187
243
  partial +
188
- "\n\n" + bar +
189
- "\n# SYSTEM: Phần trả lời ngay trên bị mạng/timeout cắt ngang trước khi xong. " +
190
- "Hãy VIẾT TIẾP liền mạch từ ĐÚNG ký tự cuối cùng ở trên KHÔNG lặp lại hay diễn đạt lại bất kỳ chữ nào đã hiện, KHÔNG mở đầu lại, KHÔNG thêm lời dẫn. " +
191
- "Chỉ xuất phần CÒN LẠI. Nếu đang viết dở một khối tool thì hoàn tất đúng khối đó. Nếu thật ra đã xong, chỉ xuất một dấu cách rồi dừng."
244
+ '\n\n' +
245
+ bar +
246
+ '\n# SYSTEM: Phần trả lời ngay trên bị mạng/timeout cắt ngang trước khi xong. ' +
247
+ 'Hãy VIẾT TIẾP liền mạch từ ĐÚNG tự cuối cùng trên KHÔNG lặp lại hay diễn đạt lại bất kỳ chữ nào đã hiện, KHÔNG mở đầu lại, KHÔNG thêm lời dẫn. ' +
248
+ 'Chỉ xuất phần CÒN LẠI. Nếu đang viết dở một khối tool thì hoàn tất đúng khối đó. Nếu thật ra đã xong, chỉ xuất một dấu cách rồi dừng.'
192
249
  );
193
250
  }
194
251
 
@@ -196,12 +253,24 @@ function continuationPrompt(message, partial) {
196
253
  * One network attempt of the stream. Returns this attempt's accumulated text +
197
254
  * a `truncated` flag telling the caller whether the reply was cut short.
198
255
  */
199
- async function streamOnce({ endpoint, mode, message, model, system, conversation, effort, signal, onStatus, onDelta, onReasoning }) {
256
+ async function streamOnce({
257
+ endpoint,
258
+ mode,
259
+ message,
260
+ model,
261
+ system,
262
+ conversation,
263
+ effort,
264
+ signal,
265
+ onStatus,
266
+ onDelta,
267
+ onReasoning,
268
+ }) {
200
269
  // chat body: gửi system + conversation riêng để gateway forward đúng tới upstream.
201
270
  // Worker gateway (handleChat) + upstream đều nhận shape này.
202
271
  let body;
203
- if (mode === "search") body = { query: message };
204
- else if (mode === "merge") body = { message };
272
+ if (mode === 'search') body = { query: message };
273
+ else if (mode === 'merge') body = { message };
205
274
  else {
206
275
  body = { message, model, remember: true, memoryToken: getMemoryToken() };
207
276
  if (system) body.customInstructions = system;
@@ -211,17 +280,17 @@ async function streamOnce({ endpoint, mode, message, model, system, conversation
211
280
 
212
281
  const ctrl = new AbortController();
213
282
  const onUserAbort = () => ctrl.abort();
214
- signal?.addEventListener("abort", onUserAbort, { once: true });
283
+ signal?.addEventListener('abort', onUserAbort, { once: true });
215
284
 
216
- let text = "";
217
- let reasoning = "";
285
+ let text = '';
286
+ let reasoning = '';
218
287
  let sawDone = false; // thấy {done} = stream kết thúc tử tế (không bị cắt)
219
288
  let truncated = false; // gateway báo upstream bị cắt giữa chừng
220
289
 
221
290
  // Một dòng SSE → cập nhật text/reasoning. Tách ra để dùng lại khi flush dòng cuối.
222
291
  const processLine = (rawLine) => {
223
292
  const line = rawLine.trim();
224
- if (!line.startsWith("data:")) return;
293
+ if (!line.startsWith('data:')) return;
225
294
  const data = line.slice(5).trim();
226
295
  if (!data) return;
227
296
  let p;
@@ -243,14 +312,14 @@ async function streamOnce({ endpoint, mode, message, model, system, conversation
243
312
  if (p.truncated) truncated = true;
244
313
  // Handle {finish: true, reason: "stop"} — unlimited.surf & some proxies
245
314
  // send this as completion signal instead of (or in addition to) {done: true}
246
- if (p.finish === true && p.reason === "stop") sawDone = true;
315
+ if (p.finish === true && p.reason === 'stop') sawDone = true;
247
316
  if (p.done) sawDone = true;
248
317
  if (p.error) throw new ApiError(p.error, {});
249
318
  };
250
319
 
251
320
  try {
252
321
  const resp = await fetch(config.gatewayUrl + endpoint, {
253
- method: "POST",
322
+ method: 'POST',
254
323
  headers: authHeaders(),
255
324
  body: JSON.stringify(body),
256
325
  signal: ctrl.signal,
@@ -259,13 +328,18 @@ async function streamOnce({ endpoint, mode, message, model, system, conversation
259
328
 
260
329
  const reader = resp.body.getReader();
261
330
  const decoder = new TextDecoder();
262
- let buf = "";
331
+ let buf = '';
263
332
  while (true) {
264
333
  const { done, value } = await reader.read();
265
334
  if (done) break;
266
335
  buf += decoder.decode(value, { stream: true });
336
+ // Chuẩn hoá CRLF→LF: một số reverse proxy (Cloudflare, nginx config khác)
337
+ // gửi line endings \r\n. processLine() có trim() nên \r trailing tự rụng,
338
+ // nhưng nếu chunk biên giới rơi đúng giữa \r và \n thì vẫn ổn — đây là
339
+ // safety belt cho trường hợp \r đơn lẻ (rất hiếm nhưng có thấy thực tế).
340
+ buf = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
267
341
  let nl;
268
- while ((nl = buf.indexOf("\n")) !== -1) {
342
+ while ((nl = buf.indexOf('\n')) !== -1) {
269
343
  processLine(buf.slice(0, nl));
270
344
  buf = buf.slice(nl + 1);
271
345
  }
@@ -275,23 +349,23 @@ async function streamOnce({ endpoint, mode, message, model, system, conversation
275
349
 
276
350
  // Chat: gateway gửi {done} khi xong sạch. Stream EOF mà chưa thấy {done} dù
277
351
  // đã có chữ → kết nối/edge rớt giữa chừng → coi như bị cắt để nối tiếp.
278
- if (mode === "chat" && !sawDone && text) truncated = true;
352
+ if (mode === 'chat' && !sawDone && text) truncated = true;
279
353
 
280
354
  return { text, reasoning, truncated };
281
355
  } catch (err) {
282
356
  if (signal?.aborted) throw err; // người dùng bấm Ctrl+C → huỷ thật, không nối tiếp
283
357
  // Rớt mạng giữa chừng (không phải huỷ): với chat, nếu đã có
284
358
  // chữ thì trả phần đã nhận + cờ truncated để lớp trên nối tiếp.
285
- if (mode === "chat" && text) return { text, reasoning, truncated: true };
359
+ if (mode === 'chat' && text) return { text, reasoning, truncated: true };
286
360
  throw err;
287
361
  } finally {
288
- signal?.removeEventListener("abort", onUserAbort);
362
+ signal?.removeEventListener('abort', onUserAbort);
289
363
  }
290
364
  }
291
365
 
292
366
  /** Fetch the current key's quota/usage from the gateway (no request consumed). */
293
367
  export async function usage() {
294
- const resp = await fetch(config.gatewayUrl + "/api/usage", { headers: authHeaders() });
368
+ const resp = await fetch(config.gatewayUrl + '/api/usage', { headers: authHeaders() });
295
369
  if (!resp.ok) throw await parseError(resp);
296
370
  return await resp.json();
297
371
  }
package/src/config.js CHANGED
@@ -1,17 +1,17 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
4
 
5
5
  // Default gateway — the Noob Demon endpoint. The real upstream is hidden behind
6
6
  // it; the CLI only ever talks to this gateway.
7
- const DEFAULT_GATEWAY = "https://claude-code-proxy.lohieuky678.workers.dev";
7
+ const DEFAULT_GATEWAY = 'https://claude-code-proxy.lohieuky678.workers.dev';
8
8
 
9
- const DIR = path.join(os.homedir(), ".noob");
10
- const FILE = path.join(DIR, "config.json");
9
+ const DIR = path.join(os.homedir(), '.noob');
10
+ const FILE = path.join(DIR, 'config.json');
11
11
 
12
12
  function read() {
13
13
  try {
14
- return JSON.parse(fs.readFileSync(FILE, "utf8"));
14
+ return JSON.parse(fs.readFileSync(FILE, 'utf8'));
15
15
  } catch {
16
16
  return {};
17
17
  }
@@ -20,24 +20,24 @@ function read() {
20
20
  function write(cfg) {
21
21
  try {
22
22
  fs.mkdirSync(DIR, { recursive: true });
23
- fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2), "utf8");
23
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2), 'utf8');
24
24
  return true;
25
25
  } catch {
26
26
  return false;
27
27
  }
28
28
  }
29
29
 
30
- let cache = read();
30
+ const cache = read();
31
31
 
32
32
  export const config = {
33
33
  get gatewayUrl() {
34
34
  return process.env.NOOB_API_BASE || cache.gatewayUrl || DEFAULT_GATEWAY;
35
35
  },
36
36
  get apiKey() {
37
- return process.env.NOOB_API_KEY || cache.apiKey || "";
37
+ return process.env.NOOB_API_KEY || cache.apiKey || '';
38
38
  },
39
39
  get model() {
40
- return cache.model || "";
40
+ return cache.model || '';
41
41
  },
42
42
  setKey(key) {
43
43
  cache.apiKey = key;