@noobdemon/noob-cli 1.7.8 → 1.7.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -277,30 +277,24 @@ function memoryBlock() {
277
277
  // The proxy is stateless, so we serialize the whole transcript into one prompt.
278
278
  // extraToolsDoc: chuỗi mô tả thêm tool (vd spawn_agent khi agent mode bật) được
279
279
  // chèn ngay sau SYSTEM để model biết và dùng được.
280
- function buildPrompt(history, extraToolsDoc, goal) {
281
- const msgs = compact(history, MAX_PROMPT_CHARS);
282
- // Thứ tự CÓ CHỦ ĐÍCH: SYSTEM → memoryBlock (Rules dự án, vị trí mạnh thứ 2,
283
- // tránh lost-in-the-middle) → goalBlock (hard completion requirement, attention
284
- // cao — chống agentic laziness + goal drift) → extraToolsDoc → runtimeContext
285
- // → filesLedger → CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm
286
- // sát SYSTEM để model coi là luật.
280
+ function buildSystem(history, extraToolsDoc, goal) {
287
281
  const parts = [SYSTEM, "", memoryBlock()];
288
- if (goal && goal.trim()) {
289
- parts.push("", goalBlock(goal));
290
- }
282
+ if (goal && goal.trim()) parts.push("", goalBlock(goal));
291
283
  if (extraToolsDoc) parts.push("", extraToolsDoc);
292
- parts.push("", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", "");
284
+ parts.push("", runtimeContext());
285
+ return parts.join("\n");
286
+ }
287
+
288
+ function buildUserMessage(history) {
289
+ const msgs = compact(history, MAX_PROMPT_CHARS);
290
+ const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
293
291
  for (const m of msgs) {
294
292
  if (m.role === "user") parts.push(`## USER\n${m.content}`);
295
293
  else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
296
294
  else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
297
295
  parts.push("");
298
296
  }
299
- parts.push("=".repeat(60));
300
- // Recency bias: câu chốt cuối prompt nằm ở vị trí attention mạnh nhất. Nhắc
301
- // model đối chiếu FILES CHANGED trước khi claim đã sửa file — chống ảo giác
302
- // "đã tạo file" khi chưa gọi write_file/edit_file.
303
- parts.push("Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
297
+ parts.push("=".repeat(60), "Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
304
298
  return parts.join("\n");
305
299
  }
306
300
 
@@ -376,8 +370,9 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
376
370
  // Bộ nhớ dài hạn: thử tóm tắt nếu history đã phình. Im lặng nếu không cần.
377
371
  try { await maybeSummarize(history, { model, signal }); } catch {}
378
372
 
379
- const prompt = buildPrompt(history, extraToolsDoc, goal);
380
- tokenMeter?.addInput(countTokens(prompt));
373
+ const system = buildSystem(history, extraToolsDoc, goal);
374
+ const message = buildUserMessage(history);
375
+ tokenMeter?.addInput(countTokens(message));
381
376
  onStatus?.("thinking");
382
377
  onDelta?.({ type: "step-start" });
383
378
  // Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
@@ -385,7 +380,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
385
380
  // trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
386
381
  // throw ApiError retryable (network drop, 5xx, timeout).
387
382
  const { text, finishReason } = await streamWithRetry({
388
- model, message: prompt, signal, tokenMeter, onDelta, onStatus,
383
+ model, message, system, signal, tokenMeter, onDelta, onStatus,
389
384
  });
390
385
  tokenMeter?.endOutput();
391
386
  onDelta?.({ type: "step-end" });
@@ -426,7 +421,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
426
421
  * backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
427
422
  * - Throw lại nếu signal abort hoặc lỗi không retryable.
428
423
  */
429
- async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, onStatus }) {
424
+ async function streamWithRetry({ model, message, system, signal, tokenMeter, onDelta, onStatus }) {
430
425
  const MAX_RETRIES = 8;
431
426
  let lastErr = null;
432
427
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -435,6 +430,7 @@ async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, on
435
430
  mode: "chat",
436
431
  model,
437
432
  message,
433
+ system,
438
434
  signal,
439
435
  onDelta: (d) => {
440
436
  tokenMeter?.pushOutputDelta(d);
package/src/api.js CHANGED
@@ -3,6 +3,33 @@
3
3
  // upstream. The CLI only ever sees the gateway URL + the user's key.
4
4
  import { config } from "./config.js";
5
5
 
6
+ // ── memoryToken: per-session random token for upstream conversation state.
7
+ // Browser sends something like "uuid_uuid" (two v4 UUIDs joined by _).
8
+ // Upstream requires both `remember: true` and a valid `memoryToken`.
9
+ // ────────────────────────────────────────────────────────────────────────────
10
+ let _sessionMemoryToken = null;
11
+
12
+ function makeUUID() {
13
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
14
+ const r = (Math.random() * 16) | 0;
15
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
16
+ return v.toString(16);
17
+ });
18
+ }
19
+
20
+ function makeMemoryToken() {
21
+ return `${makeUUID()}_${makeUUID()}`;
22
+ }
23
+
24
+ function getMemoryToken() {
25
+ if (!_sessionMemoryToken) _sessionMemoryToken = makeMemoryToken();
26
+ return _sessionMemoryToken;
27
+ }
28
+
29
+ export function resetMemoryToken() {
30
+ _sessionMemoryToken = null;
31
+ }
32
+
6
33
  // Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
7
34
  // revocation proxy. Off by default. Prefer fixing the trust store.
8
35
  // LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
@@ -89,7 +116,7 @@ function hasUnclosedToolBlock(text) {
89
116
  *
90
117
  * @returns {Promise<{text:string, reasoning:string}>}
91
118
  */
92
- export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000, maxContinues = Infinity }) {
119
+ export async function stream({ mode = "chat", message, model, system, conversation, effort, signal, onDelta, onReasoning, onStatus, idleMs = 25000, maxContinues = Infinity }) {
93
120
  const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
94
121
 
95
122
  let fullText = "";
@@ -99,7 +126,7 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
99
126
  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)
100
127
 
101
128
  for (let attempt = 0; ; attempt++) {
102
- const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
129
+ const r = await streamOnce({ endpoint, mode, message: prompt, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning });
103
130
  fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
104
131
  if (r.reasoning) reasoning = r.reasoning;
105
132
 
@@ -148,8 +175,18 @@ function continuationPrompt(message, partial) {
148
175
  * One network attempt of the stream. Returns this attempt's accumulated text +
149
176
  * a `truncated` flag telling the caller whether the reply was cut short.
150
177
  */
151
- async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onStatus, onDelta, onReasoning }) {
152
- const body = mode === "search" ? { query: message } : mode === "merge" ? { message } : { message, model };
178
+ async function streamOnce({ endpoint, mode, message, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning }) {
179
+ // chat body: gửi system + conversation riêng để gateway forward đúng tới upstream.
180
+ // Worker gateway (handleChat) + upstream đều nhận shape này.
181
+ let body;
182
+ if (mode === "search") body = { query: message };
183
+ else if (mode === "merge") body = { message };
184
+ else {
185
+ body = { message, model, remember: true, memoryToken: getMemoryToken() };
186
+ if (system) body.customInstructions = system;
187
+ if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
188
+ if (effort) body.effort = effort;
189
+ }
153
190
 
154
191
  // Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
155
192
  // huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
@@ -158,14 +195,65 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
158
195
  let timedOut = false;
159
196
  const onUserAbort = () => ctrl.abort();
160
197
  signal?.addEventListener("abort", onUserAbort, { once: true });
161
- let idle;
162
- const arm = () => {
163
- clearTimeout(idle);
164
- idle = setTimeout(() => {
198
+ // Hai loại timer tách rời (xem comment dài bên dưới):
199
+ // - WIRE: kết nối TCP còn thở không? Reset mỗi khi reader.read() trả bytes
200
+ // (kể cả comment SSE `: keepalive` từ worker). Ngưỡng cao (idleMs*2) — chỉ
201
+ // để bắt trường hợp socket chết cứng không còn cả heartbeat.
202
+ // - CONTENT: upstream có thực sự đang nghĩ/nói không? Reset chỉ khi parser
203
+ // thấy delta/status/reasoning/done/truncated thật. WARN/PROBE/idleMs gắn
204
+ // vào timer này. Đây là fix cho bug: worker phát `: keepalive\n\n` mỗi 15s
205
+ // bất kể upstream Vercel còn sống hay đã treo → nếu reset idle theo wire,
206
+ // status đếm thời gian sẽ chạy mãi không bao giờ trigger probe/abort.
207
+ let wireIdle = null;
208
+ let contentIdle = null;
209
+ let warnTimer = null;
210
+ let probeTimer = null;
211
+ let probeInFlight = false;
212
+ const WARN_MS = Math.min(8000, idleMs / 3);
213
+ const PROBE_MS = Math.min(12000, (idleMs * 2) / 3);
214
+ const WIRE_MS = idleMs * 2; // socket dead-cứng (mất cả heartbeat) — ngưỡng rộng
215
+ const clearContentTimers = () => {
216
+ clearTimeout(warnTimer); warnTimer = null;
217
+ clearTimeout(probeTimer); probeTimer = null;
218
+ clearTimeout(contentIdle); contentIdle = null;
219
+ };
220
+ const armContent = () => {
221
+ clearContentTimers();
222
+ warnTimer = setTimeout(() => {
223
+ if (onStatus) onStatus("Đang chờ proxy phản hồi…");
224
+ }, WARN_MS);
225
+ probeTimer = setTimeout(async () => {
226
+ if (probeInFlight || ctrl.signal.aborted) return;
227
+ probeInFlight = true;
228
+ try {
229
+ const probeCtl = new AbortController();
230
+ const probeT = setTimeout(() => probeCtl.abort(), 3000);
231
+ const r = await fetch(config.gatewayUrl + "/api/usage", { headers: authHeaders(), signal: probeCtl.signal });
232
+ clearTimeout(probeT);
233
+ if (!r.ok && r.status >= 500) throw new Error("proxy 5xx");
234
+ } catch {
235
+ if (!ctrl.signal.aborted) {
236
+ timedOut = true;
237
+ if (onStatus) onStatus("Proxy không phản hồi — đang gọi lại model…");
238
+ ctrl.abort();
239
+ }
240
+ } finally {
241
+ probeInFlight = false;
242
+ }
243
+ }, PROBE_MS);
244
+ contentIdle = setTimeout(() => {
165
245
  timedOut = true;
166
246
  ctrl.abort();
167
247
  }, idleMs);
168
248
  };
249
+ const armWire = () => {
250
+ clearTimeout(wireIdle);
251
+ wireIdle = setTimeout(() => {
252
+ // Mất cả heartbeat → socket chết cứng. Abort, lớp trên sẽ retry.
253
+ timedOut = true;
254
+ ctrl.abort();
255
+ }, WIRE_MS);
256
+ };
169
257
 
170
258
  let text = "";
171
259
  let reasoning = "";
@@ -184,6 +272,12 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
184
272
  } catch {
185
273
  return;
186
274
  }
275
+ // Có ít nhất 1 field nội dung thực → upstream đang nghĩ/nói. Reset content
276
+ // idle/warn/probe. JSON rỗng `{}` (ping) hoặc comment `: keepalive` của
277
+ // worker KHÔNG khớp điều kiện này → không che mất idle detection.
278
+ if (p.status || p.delta || p.reasoning || p.done || p.truncated || p.error) {
279
+ armContent();
280
+ }
187
281
  if (p.status && onStatus) onStatus(p.status);
188
282
  if (p.delta) {
189
283
  text += p.delta;
@@ -200,7 +294,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
200
294
  };
201
295
 
202
296
  try {
203
- arm();
297
+ armWire();
298
+ armContent();
204
299
  const resp = await fetch(config.gatewayUrl + endpoint, {
205
300
  method: "POST",
206
301
  headers: authHeaders(),
@@ -214,7 +309,7 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
214
309
  let buf = "";
215
310
  while (true) {
216
311
  const { done, value } = await reader.read();
217
- arm(); // hoạt động → gia hạn idle
312
+ armWire(); // bất kỳ byte nào tới socket còn thở, gia hạn wire idle
218
313
  if (done) break;
219
314
  buf += decoder.decode(value, { stream: true });
220
315
  let nl;
@@ -239,7 +334,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
239
334
  if (mode === "chat" && text) return { text, reasoning, truncated: true };
240
335
  throw err;
241
336
  } finally {
242
- clearTimeout(idle);
337
+ clearTimeout(wireIdle);
338
+ clearContentTimers();
243
339
  signal?.removeEventListener("abort", onUserAbort);
244
340
  }
245
341
  }
package/src/repl.js CHANGED
@@ -6,7 +6,7 @@ import { createTui } from "./tui.js";
6
6
  import { runAgent, maybeSummarize } from "./agent.js";
7
7
  import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
8
8
  import { TokenMeter } from "./tokens.js";
9
- import { stream, usage, ApiError } from "./api.js";
9
+ import { stream, usage, ApiError, resetMemoryToken } from "./api.js";
10
10
  import { runTool, describe, DESTRUCTIVE, addRoot, listRoots } from "./tools.js";
11
11
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
12
12
  import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
@@ -360,7 +360,11 @@ export async function startRepl(opts = {}) {
360
360
  );
361
361
  console.log(c.dim("\n " + t.sessionResumeHint) + "\n");
362
362
  }
363
- const startFresh = () => (session = sessions.newSession({ cwd: process.cwd(), model: state.model.id }));
363
+ const startFresh = () => {
364
+ session = sessions.newSession({ cwd: process.cwd(), model: state.model.id });
365
+ // Reset per-session upstream memory token so the next chat starts fresh.
366
+ resetMemoryToken();
367
+ };
364
368
 
365
369
  // /frontend-design <yêu cầu> — vận dụng skill frontend-design (skills/frontend-design/SKILL.md)
366
370
  // để model tạo UI frontend chất lượng cao, tránh "AI slop" aesthetic.
@@ -535,6 +539,20 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
535
539
  // bắt nhầm khi model chỉ NHẮC tới token giữa văn xuôi. Không bao giờ chấp nhận
536
540
  // ở lượt lập kế hoạch (xem vòng lặp bên dưới).
537
541
  const ultraIsDone = (a) => a.trimEnd().endsWith(ULTRA_DONE);
542
+ // Detect "stuck": model bối rối, không nhận task, chỉ hỏi lại user hoặc spam
543
+ // list_dir/ls vô nghĩa. Xảy ra khi goal trống nghĩa / bị paste system prompt /
544
+ // model mất ngữ cảnh. 2 vòng stuck liên tiếp → auto-exit để không loop vô hạn.
545
+ const STUCK_PHRASES = [
546
+ "chưa giao task", "chưa nêu tác vụ", "chưa có yêu cầu", "chưa có task",
547
+ "không nhận task", "không thể nhận vai", "bạn muốn mình làm gì",
548
+ "chưa rõ yêu cầu", "cần mục tiêu rõ", "vui lòng cho biết",
549
+ "please provide", "what would you like", "no task", "clarify",
550
+ ];
551
+ const ultraLooksStuck = (a) => {
552
+ if (!a) return true;
553
+ const s = a.toLowerCase();
554
+ return STUCK_PHRASES.some((p) => s.includes(p));
555
+ };
538
556
  const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
539
557
  Mục tiêu tổng: ${goal}
540
558
 
@@ -568,10 +586,23 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
568
586
  let answer = await handle(ultraStart(goal));
569
587
  persist();
570
588
  let i = 0;
589
+ let stuckStreak = 0; // đếm vòng liên tiếp model bối rối / không nhận task
590
+ const STUCK_MAX = 2;
571
591
  // Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
572
592
  // "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
593
+ // Cũng dừng sớm nếu model 2 vòng liên tiếp tỏ ra mất ngữ cảnh (goal trống
594
+ // nghĩa, paste system prompt, model hỏi lại user) → tránh spam list_dir.
573
595
  while (state.ultra && i < MAX_QUESTS) {
574
596
  if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
597
+ if (ultraLooksStuck(answer)) {
598
+ stuckStreak++;
599
+ if (stuckStreak >= STUCK_MAX) {
600
+ console.log(c.err(" ⚠ ULTRA stuck: model không nhận task " + stuckStreak + " vòng liên tiếp. Thoát. Gõ /ultra <mục tiêu rõ> để thử lại."));
601
+ break;
602
+ }
603
+ } else {
604
+ stuckStreak = 0;
605
+ }
575
606
  i++;
576
607
  console.log(c.accent(" ↻ " + t.ultraQuest(i)));
577
608
  answer = await handle(ultraContinue(goal));