@noobdemon/noob-cli 1.10.20 → 1.11.0

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/agent.js CHANGED
@@ -1,9 +1,9 @@
1
- import os from "node:os";
2
- import { stream } from "./api.js";
3
- import { loadMemory, memoryStats } from "./memory.js";
4
- import { listRoots } from "./tools.js";
5
- import { t } from "./i18n.js";
6
- import { countTokens } from "./tokens.js";
1
+ import os from 'node:os';
2
+ import { stream } from './api.js';
3
+ import { loadMemory, memoryStats } from './memory.js';
4
+ import { listRoots } from './tools.js';
5
+ import { t } from './i18n.js';
6
+ import { countTokens } from './tokens.js';
7
7
 
8
8
  const SYSTEM = `You are noob, an agentic coding assistant in the spirit of Claude Code. You help with software engineering tasks by reading and editing files and running commands in the user's current working directory.
9
9
 
@@ -130,7 +130,7 @@ const HIGH_PATTERNS = [
130
130
  ];
131
131
 
132
132
  export function classifyEffort(userMessage) {
133
- return "high";
133
+ return 'high';
134
134
  }
135
135
 
136
136
  // Số bước tool tối đa cho một lượt. Đặt rất cao theo yêu cầu người dùng: task
@@ -155,24 +155,24 @@ const SUMMARIZE_THRESHOLD_CHARS = 1000000; // ~250k tokens (83% window) — summ
155
155
  // nuốt mất. KHÔNG paraphrase goal — giữ nguyên text user gõ.
156
156
  function goalBlock(goal) {
157
157
  return [
158
- "# HARD GOAL (set via /goal — BINDING)",
159
- "Người dùng đã đặt MỤC TIÊU CỐT LÕI cho phiên này. Mọi lượt phản hồi/hành động PHẢI hướng tới việc hoàn thành goal này. KHÔNG được tuyên bố xong khi goal chưa thực sự đạt (chống agentic laziness). KHÔNG được trôi sang việc khác làm goal lu mờ (chống goal drift). Nếu user hỏi việc nhỏ trung gian, làm xong rồi quay lại goal.",
160
- "",
161
- "GOAL: " + goal.trim(),
162
- "",
158
+ '# HARD GOAL (set via /goal — BINDING)',
159
+ 'Người dùng đã đặt MỤC TIÊU CỐT LÕI cho phiên này. Mọi lượt phản hồi/hành động PHẢI hướng tới việc hoàn thành goal này. KHÔNG được tuyên bố xong khi goal chưa thực sự đạt (chống agentic laziness). KHÔNG được trôi sang việc khác làm goal lu mờ (chống goal drift). Nếu user hỏi việc nhỏ trung gian, làm xong rồi quay lại goal.',
160
+ '',
161
+ 'GOAL: ' + goal.trim(),
162
+ '',
163
163
  "Trước khi reply 'đã xong' / kết thúc phiên ULTRA / phát token hoàn thành, tự hỏi: goal trên đã ĐẠT chưa? Nếu chưa, làm tiếp.",
164
- ].join("\n");
164
+ ].join('\n');
165
165
  }
166
166
 
167
167
  // Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
168
168
  // khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
169
169
  // (PowerShell) báo lỗi.
170
170
  function runtimeContext() {
171
- const isWin = process.platform === "win32";
171
+ const isWin = process.platform === 'win32';
172
172
  const lines = [
173
- "# ENVIRONMENT",
173
+ '# ENVIRONMENT',
174
174
  `- OS: ${process.platform} (${os.release()})`,
175
- `- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
175
+ `- Shell for run_command: ${isWin ? 'Windows PowerShell (powershell.exe)' : 'bash'}`,
176
176
  `- Workspace (cwd): ${process.cwd()}`,
177
177
  ];
178
178
  // Phạm vi truy cập filesystem. Model mặc định CHỈ được chạm cwd + các folder
@@ -183,26 +183,30 @@ function runtimeContext() {
183
183
  try {
184
184
  const roots = listRoots();
185
185
  const extras = roots.slice(1); // [0] là cwd
186
- lines.push(`- Filesystem scope: workspace + ${extras.length} extra root(s)${extras.length ? ":" : " (chỉ workspace)."}`);
186
+ lines.push(
187
+ `- Filesystem scope: workspace + ${extras.length} extra root(s)${extras.length ? ':' : ' (chỉ workspace).'}`
188
+ );
187
189
  for (const r of extras) lines.push(` • ${r}`);
188
190
  lines.push(
189
- `- Nếu cần folder NGOÀI scope: dùng path tuyệt đối trong tool call — repl sẽ hỏi user, nếu duyệt folder tự được thêm + persist theo workspace.`,
191
+ `- Nếu cần folder NGOÀI scope: dùng path tuyệt đối trong tool call — repl sẽ hỏi user, nếu duyệt folder tự được thêm + persist theo workspace.`
190
192
  );
191
193
  } catch {}
192
194
  if (isWin) {
193
195
  lines.push(
194
- "- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.",
195
- " Use: Get-Content (not cat), Get-ChildItem (not ls), Select-String (not grep),",
196
- " (Get-Content f | Measure-Object -Line) (not wc -l). Paths use backslashes.",
197
- "- Trên Windows, script chạy bằng `script.bat` hoặc `script.cmd`, KHÔNG dùng `./script` (Unix).",
198
- " VD: gradlew → gradlew.bat, mvnw → mvnw.cmd.",
199
- "- Prefer the dedicated tools (read_file / list_dir / grep / glob) over shell commands;",
200
- " they are cross-platform. Use run_command mainly for builds/tests/installs.",
196
+ '- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.',
197
+ ' Use: Get-Content (not cat), Get-ChildItem (not ls), Select-String (not grep),',
198
+ ' (Get-Content f | Measure-Object -Line) (not wc -l). Paths use backslashes.',
199
+ '- Trên Windows, script chạy bằng `script.bat` hoặc `script.cmd`, KHÔNG dùng `./script` (Unix).',
200
+ ' VD: gradlew → gradlew.bat, mvnw → mvnw.cmd.',
201
+ '- Prefer the dedicated tools (read_file / list_dir / grep / glob) over shell commands;',
202
+ ' they are cross-platform. Use run_command mainly for builds/tests/installs.'
201
203
  );
202
204
  } else {
203
- lines.push("- Prefer the dedicated tools (read_file/list_dir/grep/glob) over shell when possible.");
205
+ lines.push(
206
+ '- Prefer the dedicated tools (read_file/list_dir/grep/glob) over shell when possible.'
207
+ );
204
208
  }
205
- return lines.join("\n");
209
+ return lines.join('\n');
206
210
  }
207
211
 
208
212
  // Lược ngữ cảnh để không vượt context khi phiên dài. KHÔNG đụng vào history thật
@@ -210,7 +214,7 @@ function runtimeContext() {
210
214
  // Nếu history đã có summary (do summarizeHistory ghi vào _summary), dùng làm head.
211
215
  // Exported cho test (scripts/test-compact.js) — không dùng ngoài file này.
212
216
  export function compact(history, budget) {
213
- const len = (m) => (m.content || "").length + 24;
217
+ const len = (m) => (m.content || '').length + 24;
214
218
  let total = history.reduce((s, m) => s + len(m), 0);
215
219
  if (total <= budget) return history;
216
220
  const out = history.map((m) => ({ ...m }));
@@ -218,12 +222,12 @@ export function compact(history, budget) {
218
222
  // nhất (đọc file lớn) và model đã xử lý xong rồi.
219
223
  // SKIP session_summary — đó là bộ nhớ dài hạn, không phải tool result cồng kềnh.
220
224
  const toolIdx = out
221
- .map((m, i) => (m.role === "tool" && m.name !== "session_summary" ? i : -1))
225
+ .map((m, i) => (m.role === 'tool' && m.name !== 'session_summary' ? i : -1))
222
226
  .filter((i) => i >= 0);
223
227
  for (const i of toolIdx.slice(0, Math.max(0, toolIdx.length - 5))) {
224
228
  if (total <= budget) break;
225
229
  const before = len(out[i]);
226
- out[i].content = "[kết quả công cụ cũ đã được lược bớt để tiết kiệm ngữ cảnh]";
230
+ out[i].content = '[kết quả công cụ cũ đã được lược bớt để tiết kiệm ngữ cảnh]';
227
231
  total -= before - len(out[i]);
228
232
  }
229
233
  if (total <= budget) return out;
@@ -231,14 +235,18 @@ export function compact(history, budget) {
231
235
  // do maybeSummarize() chèn) + USER đầu tiên (nhiệm vụ gốc) + 12 message gần
232
236
  // nhất. KHÔNG BỎ session_summary — đó là bộ nhớ phiên, mất nó là mất hết.
233
237
  const summaryIdx = out
234
- .map((m, i) => (m.role === "tool" && m.name === "session_summary" ? i : -1))
238
+ .map((m, i) => (m.role === 'tool' && m.name === 'session_summary' ? i : -1))
235
239
  .filter((i) => i >= 0);
236
- const firstUser = out.findIndex((m) => m.role === "user");
240
+ const firstUser = out.findIndex((m) => m.role === 'user');
237
241
  const headIndices = new Set([...summaryIdx, ...(firstUser >= 0 ? [firstUser] : [])]);
238
242
  const head = [...headIndices].sort((a, b) => a - b).map((i) => out[i]);
239
243
  const maxHeadIdx = head.length ? Math.max(...headIndices) : -1;
240
244
  const tailStart = Math.max(maxHeadIdx + 1, out.length - 12);
241
- const elided = { role: "tool", name: "context", content: "[… các lượt trước đã được lược bớt …]" };
245
+ const elided = {
246
+ role: 'tool',
247
+ name: 'context',
248
+ content: '[… các lượt trước đã được lược bớt …]',
249
+ };
242
250
  return [...head, elided, ...out.slice(tailStart)];
243
251
  }
244
252
 
@@ -257,10 +265,12 @@ export async function maybeSummarize(history, { model, signal, force = false } =
257
265
  // Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
258
266
  const head = history.slice(0, history.length - keepTail);
259
267
  const tail = history.slice(history.length - keepTail);
260
- const transcript = head.map((m) => {
261
- const role = m.role === "tool" ? `TOOL(${m.name || "?"})` : m.role.toUpperCase();
262
- return `## ${role}\n${(m.content || "").slice(0, 2000)}`;
263
- }).join("\n\n");
268
+ const transcript = head
269
+ .map((m) => {
270
+ const role = m.role === 'tool' ? `TOOL(${m.name || '?'})` : m.role.toUpperCase();
271
+ return `## ${role}\n${(m.content || '').slice(0, 2000)}`;
272
+ })
273
+ .join('\n\n');
264
274
  const ask = `Tóm tắt phần hội thoại sau thành BẢN GHI NGẮN GỌN (~25-40 dòng, tiếng Việt) để bạn — chính bạn — đọc lại sau và tiếp tục công việc mà không quên ngữ cảnh quan trọng.
265
275
 
266
276
  BẮT BUỘC giữ lại:
@@ -289,13 +299,13 @@ LOẠI BỎ: chi tiết nội dung file đọc được, output dài của tool,
289
299
  --- HỘI THOẠI CẦN TÓM TẮT ---
290
300
  ${transcript}`;
291
301
  try {
292
- const { text } = await stream({ mode: "chat", model, message: ask, signal });
293
- const summary = (text || "").trim();
302
+ const { text } = await stream({ mode: 'chat', model, message: ask, signal });
303
+ const summary = (text || '').trim();
294
304
  if (!summary || summary.length < 50) return false;
295
305
  // Thay head bằng MỘT message tool tên "session_summary".
296
306
  const summaryMsg = {
297
- role: "tool",
298
- name: "session_summary",
307
+ role: 'tool',
308
+ name: 'session_summary',
299
309
  content: `[BỘ NHỚ DÀI HẠN — tóm tắt ${head.length} lượt đầu để không quên ngữ cảnh]\n\n${summary}`,
300
310
  };
301
311
  history.splice(0, history.length, summaryMsg, ...tail);
@@ -313,16 +323,19 @@ ${transcript}`;
313
323
  export function filesLedger(history) {
314
324
  const touched = new Map(); // path -> "đã ghi" | "đã sửa"
315
325
  for (const m of history) {
316
- if (m.role !== "tool" || typeof m.content !== "string" || m.content.startsWith("ERROR")) continue;
326
+ if (m.role !== 'tool' || typeof m.content !== 'string' || m.content.startsWith('ERROR'))
327
+ continue;
317
328
  let mm;
318
- if (m.name === "write_file" && (mm = m.content.match(/ to (.+)$/m))) touched.set(mm[1].trim(), "đã ghi");
319
- else if (m.name === "edit_file" && (mm = m.content.match(/^Edited (.+?) \(/m))) touched.set(mm[1].trim(), "đã sửa");
329
+ if (m.name === 'write_file' && (mm = m.content.match(/ to (.+)$/m)))
330
+ touched.set(mm[1].trim(), 'đã ghi');
331
+ else if (m.name === 'edit_file' && (mm = m.content.match(/^Edited (.+?) \(/m)))
332
+ touched.set(mm[1].trim(), 'đã sửa');
320
333
  }
321
334
  if (!touched.size)
322
- return "# FILES CHANGED THIS SESSION: none.\nYou have NOT created or modified any file yet. Do not claim otherwise — to change a file you MUST emit write_file/edit_file. Describing a change in prose does nothing.";
335
+ return '# FILES CHANGED THIS SESSION: none.\nYou have NOT created or modified any file yet. Do not claim otherwise — to change a file you MUST emit write_file/edit_file. Describing a change in prose does nothing.';
323
336
  return (
324
- "# FILES ACTUALLY CHANGED THIS SESSION (runtime ground truth — trust THIS over your memory)\n" +
325
- [...touched].map(([p, a]) => `- ${p} (${a})`).join("\n") +
337
+ '# FILES ACTUALLY CHANGED THIS SESSION (runtime ground truth — trust THIS over your memory)\n' +
338
+ [...touched].map(([p, a]) => `- ${p} (${a})`).join('\n') +
326
339
  "\nIf you think you changed a file that is NOT in this list, you did NOT — emit the tool call now. Never say a file 'was reverted'/'should exist' from memory; verify with read_file or list_dir first."
327
340
  );
328
341
  }
@@ -338,14 +351,17 @@ function memoryBlock() {
338
351
  const stats = memoryStats();
339
352
  const statLine = stats
340
353
  ? `\n\n📊 noob.md hiện tại: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}. Nếu cũ/quá ít, cân nhắc /learn để chưng cất bài học mới.`
341
- : "";
354
+ : '';
342
355
  if (!mem)
343
- return "# PROJECT RULES & MEMORY (noob.md)\n(Chưa có noob.md trong thư mục này. Khi rút ra điều đáng nhớ lâu dài — lệnh build/test/run, quy ước, kiến trúc, sở thích người dùng, tiến độ — hãy TẠO noob.md bằng write_file và ghi vào đó.)" + statLine;
356
+ return (
357
+ '# PROJECT RULES & MEMORY (noob.md)\n(Chưa có noob.md trong thư mục này. Khi rút ra điều đáng nhớ lâu dài — lệnh build/test/run, quy ước, kiến trúc, sở thích người dùng, tiến độ — hãy TẠO noob.md bằng write_file và ghi vào đó.)' +
358
+ statLine
359
+ );
344
360
  return (
345
- "# PROJECT RULES & MEMORY (noob.md) — BINDING\n" +
346
- "Phần `## Rules` dưới đây là LUẬT DỰ ÁN bạn PHẢI tuân theo trong mọi hành động ở lượt này — coi như mở rộng của SYSTEM, không phải gợi ý. Phần `## Notes` là quan sát tham khảo, có thể xác minh lại với filesystem nếu nghi ngờ.\n\n" +
361
+ '# PROJECT RULES & MEMORY (noob.md) — BINDING\n' +
362
+ 'Phần `## Rules` dưới đây là LUẬT DỰ ÁN bạn PHẢI tuân theo trong mọi hành động ở lượt này — coi như mở rộng của SYSTEM, không phải gợi ý. Phần `## Notes` là quan sát tham khảo, có thể xác minh lại với filesystem nếu nghi ngờ.\n\n' +
347
363
  mem +
348
- "\n\nTrước khi emit hành động, đối chiếu với `## Rules` ở trên. Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file (Notes mới, promote lên Rules khi đã chứng minh)." +
364
+ '\n\nTrước khi emit hành động, đối chiếu với `## Rules` ở trên. Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file (Notes mới, promote lên Rules khi đã chứng minh).' +
349
365
  statLine
350
366
  );
351
367
  }
@@ -355,25 +371,25 @@ function memoryBlock() {
355
371
  // ý /resume nếu user muốn tiếp tục. Bỏ qua phiên hiện tại (repl.js lọc).
356
372
  // recentSessions: [{ id, title, turns, updatedAt }] — đã sort mới → cũ.
357
373
  function recentSessionsBlock(recentSessions) {
358
- if (!recentSessions || !recentSessions.length) return "";
374
+ if (!recentSessions || !recentSessions.length) return '';
359
375
  const lines = [
360
- "# RECENT SESSIONS IN THIS WORKSPACE (newest first)",
376
+ '# RECENT SESSIONS IN THIS WORKSPACE (newest first)',
361
377
  "Đây là các phiên TRƯỚC của cùng dự án. User có thể `/resume <id>` để tiếp tục 1 phiên cụ thể (xem lịch sử đầy đủ). Nếu user nói chung chung ('tiếp tục hôm qua', 'làm lại cái kia') mà KHÔNG chỉ rõ — hỏi lại hoặc dùng breadcrumbs dưới để đoán (xem title + số lượt + thời gian).",
362
- "",
378
+ '',
363
379
  ];
364
380
  for (const s of recentSessions) {
365
381
  const ago = relTime(s.updatedAt);
366
- const title = s.title || "(chưa đặt tiêu đề)";
382
+ const title = s.title || '(chưa đặt tiêu đề)';
367
383
  lines.push(`- \`${s.id}\` — "${title}" · ${s.turns} lượt · ${ago}`);
368
384
  }
369
- return lines.join("\n");
385
+ return lines.join('\n');
370
386
  }
371
387
 
372
388
  // "X ago" ngắn gọn, tiếng Việt. Dùng cho noob.md mtime + recent sessions.
373
389
  function relTime(ts) {
374
- if (!ts) return "";
390
+ if (!ts) return '';
375
391
  const ms = Date.now() - ts;
376
- if (ms < 5000) return "vừa xong"; // < 5s hoặc tương lai → "vừa xong" (tránh "0s trước" xấu)
392
+ if (ms < 5000) return 'vừa xong'; // < 5s hoặc tương lai → "vừa xong" (tránh "0s trước" xấu)
377
393
  const s = Math.floor(ms / 1000);
378
394
  if (s < 60) return `${s}s trước`;
379
395
  const m = Math.floor(s / 60);
@@ -393,27 +409,30 @@ function relTime(ts) {
393
409
  // recentSessions: breadcrumbs các phiên trước cùng workspace (repl.js cung cấp)
394
410
  // → chèn ngay sau memoryBlock() để model "thấy" lịch sử dù chưa /resume.
395
411
  export function buildSystem(history, extraToolsDoc, goal, recentSessions) {
396
- const parts = [SYSTEM, "", memoryBlock()];
412
+ const parts = [SYSTEM, '', memoryBlock()];
397
413
  if (recentSessions && recentSessions.length) {
398
- parts.push("", recentSessionsBlock(recentSessions));
414
+ parts.push('', recentSessionsBlock(recentSessions));
399
415
  }
400
- if (goal && goal.trim()) parts.push("", goalBlock(goal));
401
- if (extraToolsDoc) parts.push("", extraToolsDoc);
402
- parts.push("", runtimeContext());
403
- return parts.join("\n");
416
+ if (goal && goal.trim()) parts.push('', goalBlock(goal));
417
+ if (extraToolsDoc) parts.push('', extraToolsDoc);
418
+ parts.push('', runtimeContext());
419
+ return parts.join('\n');
404
420
  }
405
421
 
406
422
  export function buildUserMessage(history) {
407
423
  const msgs = compact(history, MAX_PROMPT_CHARS);
408
- const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
424
+ const parts = [filesLedger(history), '', '='.repeat(60), '# CONVERSATION', ''];
409
425
  for (const m of msgs) {
410
- if (m.role === "user") parts.push(`## USER\n${m.content}`);
411
- else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
412
- else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
413
- parts.push("");
426
+ if (m.role === 'user') parts.push(`## USER\n${m.content}`);
427
+ else if (m.role === 'assistant') parts.push(`## ASSISTANT\n${m.content}`);
428
+ else if (m.role === 'tool') parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
429
+ parts.push('');
414
430
  }
415
- 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.");
416
- return parts.join("\n");
431
+ parts.push(
432
+ '='.repeat(60),
433
+ '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.'
434
+ );
435
+ return parts.join('\n');
417
436
  }
418
437
 
419
438
  // Detect câu trả lời bị cắt giữa chừng — KHÔNG phải câu hoàn chỉnh.
@@ -429,10 +448,15 @@ function isIncompleteResponse(text) {
429
448
  const lastChar = t.slice(-1);
430
449
  if (lastChar && !/[.!?:;)\]"'`#>\n]/.test(lastChar) && t.length > 50) {
431
450
  // Kiểm thêm: dòng cuối chứa từ khóa bị cắt (ví dụ, vd, hay, hoặc, và, nhưng, mà)
432
- const lastLine = t.split("\n").pop().trim();
451
+ const lastLine = t.split('\n').pop().trim();
433
452
  if (/\s(ví|vd|hay|hoặc|và|nhưng|mà|hoac|or|and|but|e\.g|i\.e)\s*$/i.test(lastLine)) return true;
434
453
  // Dòng cuối là 1 câu bắt đầu nhưng chưa xong (có chủ ngữ nhưng không có vị ngữ hoàn chỉnh)
435
- if (/\s(Bạn|Tôi|Mình|Anh|Em|Chị|Ông|Bà|Họ)\s+(muốn|có|thấy|nên|cần|đã|đang|sẽ|chọn|định|sắp|vừa)\s*$/i.test(lastLine)) return true;
454
+ if (
455
+ /\s(Bạn|Tôi|Mình|Anh|Em|Chị|Ông|Bà|Họ)\s+(muốn|có|thấy|nên|cần|đã|đang|sẽ|chọn|định|sắp|vừa)\s*$/i.test(
456
+ lastLine
457
+ )
458
+ )
459
+ return true;
436
460
  }
437
461
  return false;
438
462
  }
@@ -443,11 +467,11 @@ function isIncompleteResponse(text) {
443
467
  // would close the block early and break the JSON. Instead, find the ```tool (or
444
468
  // ```json) opener and brace-match the first balanced JSON object after it.
445
469
  function parseToolCall(text) {
446
- for (const fence of ["tool", "json"]) {
447
- const open = text.match(new RegExp("```" + fence + "[ \\t]*\\n"));
470
+ for (const fence of ['tool', 'json']) {
471
+ const open = text.match(new RegExp('```' + fence + '[ \\t]*\\n'));
448
472
  if (!open) continue;
449
473
  const obj = extractJsonObject(text, open.index + open[0].length);
450
- if (obj && typeof obj.name === "string") return { name: obj.name, input: obj.input || {} };
474
+ if (obj && typeof obj.name === 'string') return { name: obj.name, input: obj.input || {} };
451
475
  }
452
476
  return null;
453
477
  }
@@ -456,20 +480,22 @@ function parseToolCall(text) {
456
480
  // escapes so braces or backticks INSIDE string values don't throw off the depth
457
481
  // count. Returns the parsed object, or null if malformed/truncated (unbalanced).
458
482
  function extractJsonObject(s, from) {
459
- const start = s.indexOf("{", from);
483
+ const start = s.indexOf('{', from);
460
484
  if (start === -1) return null;
461
- let depth = 0, inStr = false, esc = false;
485
+ let depth = 0,
486
+ inStr = false,
487
+ esc = false;
462
488
  for (let i = start; i < s.length; i++) {
463
489
  const ch = s[i];
464
490
  if (inStr) {
465
491
  if (esc) esc = false;
466
- else if (ch === "\\") esc = true;
492
+ else if (ch === '\\') esc = true;
467
493
  else if (ch === '"') inStr = false;
468
494
  continue;
469
495
  }
470
496
  if (ch === '"') inStr = true;
471
- else if (ch === "{") depth++;
472
- else if (ch === "}" && --depth === 0) {
497
+ else if (ch === '{') depth++;
498
+ else if (ch === '}' && --depth === 0) {
473
499
  try {
474
500
  return JSON.parse(s.slice(start, i + 1));
475
501
  } catch {
@@ -493,7 +519,20 @@ function extractJsonObject(s, from) {
493
519
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
494
520
  * @returns {Promise<string>} the final assistant answer (no tool block)
495
521
  */
496
- export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal, recentSessions, pendingTasks }) {
522
+ export async function runAgent({
523
+ history,
524
+ model,
525
+ signal,
526
+ onTool,
527
+ onStatus,
528
+ onDelta,
529
+ onSteer,
530
+ tokenMeter,
531
+ extraToolsDoc,
532
+ goal,
533
+ recentSessions,
534
+ pendingTasks,
535
+ }) {
497
536
  // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
498
537
  // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
499
538
  // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
@@ -502,11 +541,11 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
502
541
  const MAX_LOOP_DETECTIONS = 3; // sau 3 lần loop detection liên tiếp → force stop
503
542
  // Effort classifier: phân loại task từ user message gốc → set effort level.
504
543
  // Chỉ classify 1 lần ở bước đầu, giữ nguyên suốt task (thay đổi giữa chừng gây bất ổn).
505
- const effort = classifyEffort(history.find((m) => m.role === "user")?.content || "");
544
+ const effort = classifyEffort(history.find((m) => m.role === 'user')?.content || '');
506
545
  for (let step = 0; step < MAX_STEPS; step++) {
507
546
  // Check abort signal ngay đầu mỗi iteration để Ctrl+C dừng ngay lập tức
508
547
  if (signal?.aborted) {
509
- throw new Error("aborted");
548
+ throw new Error('aborted');
510
549
  }
511
550
  // Mỗi 100 bước log một mốc để người dùng biết noob vẫn đang chạy (task dài).
512
551
  if (step > 0 && step % 100 === 0) onStatus?.(`đã chạy ${step} bước…`);
@@ -514,40 +553,50 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
514
553
  // Steering: tin nhắn người dùng gõ GIỮA CHỪNG được chèn vào hội thoại TRƯỚC
515
554
  // lần gọi model kế tiếp → model thấy và điều chỉnh ngay trong cùng task.
516
555
  const steer = onSteer?.() || [];
517
- for (const msg of steer) history.push({ role: "user", content: msg });
556
+ for (const msg of steer) history.push({ role: 'user', content: msg });
518
557
 
519
558
  // 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.
520
- try { await maybeSummarize(history, { model, signal }); } catch {}
559
+ try {
560
+ await maybeSummarize(history, { model, signal });
561
+ } catch {}
521
562
 
522
563
  const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
523
564
  const message = buildUserMessage(history);
524
565
  tokenMeter?.addInput(countTokens(system) + countTokens(message));
525
566
  tokenMeter?.setContext(tokenMeter.total);
526
- onStatus?.("thinking");
527
- onDelta?.({ type: "step-start" });
567
+ onStatus?.('thinking');
568
+ onDelta?.({ type: 'step-start' });
528
569
  // Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
529
570
  // api.js đã tự nối tiếp khi truncated (maxContinues=Infinity); agent.js xử lý các
530
571
  // trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
531
572
  // throw ApiError retryable (network drop, 5xx, timeout).
532
573
  const { text, finishReason } = await streamWithRetry({
533
- model, message, system, signal, tokenMeter, onDelta, onStatus, effort,
574
+ model,
575
+ message,
576
+ system,
577
+ signal,
578
+ tokenMeter,
579
+ onDelta,
580
+ onStatus,
581
+ effort,
534
582
  });
535
583
  tokenMeter?.endOutput();
536
- onDelta?.({ type: "step-end" });
537
- history.push({ role: "assistant", content: text });
584
+ onDelta?.({ type: 'step-end' });
585
+ history.push({ role: 'assistant', content: text });
538
586
 
539
587
  const call = parseToolCall(text);
540
588
  if (!call) {
541
589
  // Không có tool call. Nếu finishReason bất thường (empty/tool_unclosed) →
542
590
  // model bị cắt ngay trước khi kịp gọi tool → nudge tiếp 1 lượt nữa thay vì
543
591
  // return text rỗng/dở dang.
544
- if (finishReason === "empty" || finishReason === "tool_unclosed") {
592
+ if (finishReason === 'empty' || finishReason === 'tool_unclosed') {
545
593
  history.push({
546
- role: "tool",
547
- name: "stream_recovery",
548
- content: finishReason === "tool_unclosed"
549
- ? "[STREAM CUT] Bạn vừa emit tool block mở mà chưa đóng. Lặp lại tool call đó NGUYÊN VẸN, đóng đúng cú pháp ```tool ... ``` rồi STOP."
550
- : "[STREAM EMPTY] Lượt vừa rồi không trả về text. Hãy tiếp tục công việc nếu cần tool thì emit tool block, nếu xong thì tổng kết.",
594
+ role: 'tool',
595
+ name: 'stream_recovery',
596
+ content:
597
+ finishReason === 'tool_unclosed'
598
+ ? '[STREAM CUT] Bạn vừa emit tool block mở mà chưa đóng. Lặp lại tool call đó NGUYÊN VẸN, đóng đúng pháp ```tool ... ``` rồi STOP.'
599
+ : '[STREAM EMPTY] Lượt vừa rồi không trả về text. Hãy tiếp tục công việc — nếu cần tool thì emit tool block, nếu xong thì tổng kết.',
551
600
  });
552
601
  continue;
553
602
  }
@@ -555,9 +604,10 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
555
604
  // đột ngột không có dấu câu/đóng danh sách/tool block → nudge model viết tiếp.
556
605
  if (isIncompleteResponse(text)) {
557
606
  history.push({
558
- role: "tool",
559
- name: "stream_recovery",
560
- content: "[STREAM CUT] Lượt vừa bị cắt giữa chừng. Hãy viết TIẾP liền mạch — nếu đang hỏi user thì hoàn tất câu hỏi, nếu đang list thì đóng danh sách, nếu đang viết tool block thì đóng đúng JSON.",
607
+ role: 'tool',
608
+ name: 'stream_recovery',
609
+ content:
610
+ '[STREAM CUT] Lượt vừa bị cắt giữa chừng. Hãy viết TIẾP liền mạch — nếu đang hỏi user thì hoàn tất câu hỏi, nếu đang list thì đóng danh sách, nếu đang viết tool block thì đóng đúng JSON.',
561
611
  });
562
612
  continue;
563
613
  }
@@ -567,7 +617,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
567
617
 
568
618
  const { allow, result } = await onTool(call.name, call.input);
569
619
  history.push({
570
- role: "tool",
620
+ role: 'tool',
571
621
  name: call.name,
572
622
  content: allow ? result : t.toolDenied,
573
623
  });
@@ -580,8 +630,8 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
580
630
  if (tasks.length > 0) {
581
631
  const next = tasks[0];
582
632
  history.push({
583
- role: "user",
584
- content: `[SYSTEM] Việc "${call.name}" đã hoàn thành. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(", ")}. Việc tiếp theo BẮT BUỘC phải làm ngay: "${next}". Gọi tool (write_file/edit_file/run_command) để làm việc này. KHÔNG dừng, KHÔNG tóm tắt.`,
633
+ role: 'user',
634
+ content: `[SYSTEM] Việc "${call.name}" đã hoàn thành. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(', ')}. Việc tiếp theo BẮT BUỘC phải làm ngay: "${next}". Gọi tool (write_file/edit_file/run_command) để làm việc này. KHÔNG dừng, KHÔNG tóm tắt.`,
585
635
  });
586
636
  }
587
637
  }
@@ -596,43 +646,62 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
596
646
  recentCalls.push({ name: call.name, inputStr });
597
647
  if (recentCalls.length > LOOP_DETECT_WINDOW) recentCalls.shift();
598
648
  let loopType = null; // 'consecutive' | 'pattern' | null
649
+ let lastN = [];
599
650
  if (recentCalls.length >= LOOP_DETECT_THRESHOLD + 1) {
600
651
  // (A) Same consecutive check
601
- const lastN = recentCalls.slice(-LOOP_DETECT_THRESHOLD);
602
- const allSame = lastN.every((c) => c.name === lastN[0].name && c.inputStr === lastN[0].inputStr);
652
+ lastN = recentCalls.slice(-LOOP_DETECT_THRESHOLD);
653
+ const allSame = lastN.every(
654
+ (c) => c.name === lastN[0].name && c.inputStr === lastN[0].inputStr
655
+ );
603
656
  if (allSame) loopType = 'consecutive';
604
657
  }
605
658
  // (B) Pattern cycle check: tìm chu kỳ lặp trong window (độ dài 2-3)
606
659
  if (!loopType && recentCalls.length >= 4) {
607
- for (let cycleLen = 2; cycleLen <= Math.min(3, Math.floor(recentCalls.length / 2)); cycleLen++) {
660
+ for (
661
+ let cycleLen = 2;
662
+ cycleLen <= Math.min(3, Math.floor(recentCalls.length / 2));
663
+ cycleLen++
664
+ ) {
608
665
  const half = Math.floor(recentCalls.length / cycleLen) * cycleLen;
609
666
  const first = recentCalls.slice(-half, -half + cycleLen);
610
667
  const rest = recentCalls.slice(-half + cycleLen);
611
668
  let matched = true;
612
669
  for (let i = 0; i < rest.length; i++) {
613
- if (rest[i].name !== first[i % cycleLen].name || rest[i].inputStr !== first[i % cycleLen].inputStr) {
670
+ if (
671
+ rest[i].name !== first[i % cycleLen].name ||
672
+ rest[i].inputStr !== first[i % cycleLen].inputStr
673
+ ) {
614
674
  matched = false;
615
675
  break;
616
676
  }
617
677
  }
618
- if (matched) { loopType = 'pattern'; break; }
678
+ if (matched) {
679
+ loopType = 'pattern';
680
+ break;
681
+ }
619
682
  }
620
683
  }
621
684
  if (loopType) {
622
- const label = loopType === 'consecutive' ? lastN[0].name : `pattern [${recentCalls.slice(-4).map((c) => c.name).join(", ")}]`;
685
+ const label =
686
+ loopType === 'consecutive'
687
+ ? lastN[0].name
688
+ : `pattern [${recentCalls
689
+ .slice(-4)
690
+ .map((c) => c.name)
691
+ .join(', ')}]`;
623
692
  loopDetectedCount++;
624
693
  if (loopDetectedCount >= MAX_LOOP_DETECTIONS) {
625
694
  history.push({
626
- role: "tool",
627
- name: "loop_detection",
695
+ role: 'tool',
696
+ name: 'loop_detection',
628
697
  content: `[LOOP DETECTED × ${loopDetectedCount}] Bạn lặp lại ${label} nhiều lần — KHÔNG THỂ TIẾP TỤC. Dừng ngay.`,
629
698
  });
630
699
  return `[LOOP STOPPED] Đã dừng vì model bị kẹt trong vòng lặp.`;
631
700
  }
632
701
  recentCalls.length = 0;
633
702
  history.push({
634
- role: "tool",
635
- name: "loop_detection",
703
+ role: 'tool',
704
+ name: 'loop_detection',
636
705
  content: `[LOOP DETECTED × ${loopDetectedCount}] Bạn lặp lại ${label} — có vẻ đang kẹt. HÃY CHUYỂN BƯỚC: gọi tool khác hoặc trả lời Markdown nếu xong. KHÔNG lặp lại. Nếu tiếp tục, task sẽ dừng.`,
637
706
  });
638
707
  } else {
@@ -649,13 +718,22 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
649
718
  * backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
650
719
  * - Throw lại nếu signal abort hoặc lỗi không retryable.
651
720
  */
652
- async function streamWithRetry({ model, message, system, signal, tokenMeter, onDelta, onStatus, effort }) {
721
+ async function streamWithRetry({
722
+ model,
723
+ message,
724
+ system,
725
+ signal,
726
+ tokenMeter,
727
+ onDelta,
728
+ onStatus,
729
+ effort,
730
+ }) {
653
731
  const MAX_RETRIES = 8;
654
732
  let lastErr = null;
655
733
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
656
734
  try {
657
735
  const result = await stream({
658
- mode: "chat",
736
+ mode: 'chat',
659
737
  model,
660
738
  message,
661
739
  system,
@@ -663,28 +741,39 @@ async function streamWithRetry({ model, message, system, signal, tokenMeter, onD
663
741
  effort,
664
742
  onDelta: (d) => {
665
743
  tokenMeter?.pushOutputDelta(d);
666
- onDelta?.({ type: "delta", text: d });
744
+ onDelta?.({ type: 'delta', text: d });
667
745
  },
668
746
  });
669
747
  return result; // { text, reasoning, finishReason }
670
748
  } catch (err) {
671
749
  if (signal?.aborted) throw err; // user Ctrl+C — không retry
672
- if (err?.name !== "ApiError" || !err.retryable) throw err; // lỗi cứng — bail
750
+ if (err?.name !== 'ApiError' || !err.retryable) throw err; // lỗi cứng — bail
673
751
  lastErr = err;
674
752
  if (attempt >= MAX_RETRIES) break;
675
753
  const backoff = Math.min(30000, 1000 * Math.pow(2, attempt));
676
- onStatus?.(`Lỗi kết nối — thử lại sau ${(backoff/1000)|0}s [${attempt+1}/${MAX_RETRIES}]…`);
754
+ onStatus?.(
755
+ `Lỗi kết nối — thử lại sau ${(backoff / 1000) | 0}s [${attempt + 1}/${MAX_RETRIES}]…`
756
+ );
677
757
  await sleep(backoff, signal);
678
758
  }
679
759
  }
680
- throw lastErr || new Error("streamWithRetry: exhausted retries");
760
+ throw lastErr || new Error('streamWithRetry: exhausted retries');
681
761
  }
682
762
 
683
763
  function sleep(ms, signal) {
684
764
  return new Promise((resolve, reject) => {
685
- const id = setTimeout(() => { cleanup(); resolve(); }, ms);
686
- const onAbort = () => { cleanup(); reject(new Error("aborted")); };
687
- const cleanup = () => { clearTimeout(id); signal?.removeEventListener("abort", onAbort); };
688
- signal?.addEventListener("abort", onAbort, { once: true });
765
+ const id = setTimeout(() => {
766
+ cleanup();
767
+ resolve();
768
+ }, ms);
769
+ const onAbort = () => {
770
+ cleanup();
771
+ reject(new Error('aborted'));
772
+ };
773
+ const cleanup = () => {
774
+ clearTimeout(id);
775
+ signal?.removeEventListener('abort', onAbort);
776
+ };
777
+ signal?.addEventListener('abort', onAbort, { once: true });
689
778
  });
690
779
  }