@noobdemon/noob-cli 1.10.19 → 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
 
@@ -86,17 +86,21 @@ Follow this pattern exactly. Your very first response to a task that needs the f
86
86
  // LOW/MEDIUM = model skip thinking cho vấn đề đơn → nhanh hơn nhiều.
87
87
  const LOW_PATTERNS = [
88
88
  /^(list|ls|dir)\s/i,
89
- /^(xem|hiện|đọc|read)\s+(file|thư mục|folder)/i,
90
- /^(tìm|find|grep|search)\s+.{0,30}$/i,
91
- /^(có|is|are|was|were)\s+.+\?$/i,
89
+ /^@/, // @file reference — typically a quick read
90
+ /^(xem|hiện|show|display|liệt kê)\s/i,
91
+ /^(đọc|read)\s+\S+/i,
92
+ /^(tìm|find|grep|search)\s/i,
93
+ /^(có|is|are|was|were|what|where|how|why|who|which)\s+.+\?$/i,
94
+ /^(giải thích|explain)\s/i,
92
95
  /^(version|phiên bản)\s*\??$/i,
93
- /^(help|trợ giúp|help)\s*$/i,
96
+ /^(help|trợ giúp)\s*$/i,
94
97
  /^(cwd|thư mục hiện tại)\s*$/i,
95
98
  /^(status|trạng thái)\s*$/i,
96
99
  /^(tokens?|token)\s*$/i,
97
100
  /^(memory|noob\.md)\s*$/i,
98
101
  /^(logout|đăng xuất)\s*$/i,
99
- /^@/, // @file reference — typically a quick read
102
+ /^(hello|hi|chào|xin chào)\s*$/i,
103
+ /^(cảm ơn|cám ơn|thanks)\s*$/i,
100
104
  ];
101
105
  const MEDIUM_PATTERNS = [
102
106
  /^(edit|sửa|fix|thay đổi)\s/i,
@@ -122,20 +126,11 @@ const HIGH_PATTERNS = [
122
126
  /(test|kiểm chứng)\s+(toàn bộ|all|comprehensive|end.to.end)/i,
123
127
  /(tạo|create|write)\s+(noob\.md|SKILL|skill|workflow)/i,
124
128
  /(ghi|write)\s+.+\s+(vào|into|to)\s+.+/i, // write X into Y — multi-step
125
- /\b(ultra|goal|workflow)\b/i,
129
+ /\b(ultra|workflow)\b/i,
126
130
  ];
127
131
 
128
132
  export function classifyEffort(userMessage) {
129
- const msg = (userMessage || "").trim();
130
- if (!msg) return "medium";
131
- // Kiểm high TRƯỚC (nhiều pattern hơn, ưu tiên)
132
- for (const rx of HIGH_PATTERNS) if (rx.test(msg)) return "high";
133
- // Kiểm low TRƯỚC medium — các thao tác đọc/list đơn nên ưu tiên low
134
- for (const rx of LOW_PATTERNS) if (rx.test(msg)) return "low";
135
- // Kiểm medium
136
- for (const rx of MEDIUM_PATTERNS) if (rx.test(msg)) return "medium";
137
- // Mặc định: message dài (>200 chars) → medium, ngắn → low
138
- return msg.length > 200 ? "medium" : "low";
133
+ return 'high';
139
134
  }
140
135
 
141
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
@@ -160,24 +155,24 @@ const SUMMARIZE_THRESHOLD_CHARS = 1000000; // ~250k tokens (83% window) — summ
160
155
  // nuốt mất. KHÔNG paraphrase goal — giữ nguyên text user gõ.
161
156
  function goalBlock(goal) {
162
157
  return [
163
- "# HARD GOAL (set via /goal — BINDING)",
164
- "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.",
165
- "",
166
- "GOAL: " + goal.trim(),
167
- "",
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
+ '',
168
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.",
169
- ].join("\n");
164
+ ].join('\n');
170
165
  }
171
166
 
172
167
  // Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
173
168
  // khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
174
169
  // (PowerShell) báo lỗi.
175
170
  function runtimeContext() {
176
- const isWin = process.platform === "win32";
171
+ const isWin = process.platform === 'win32';
177
172
  const lines = [
178
- "# ENVIRONMENT",
173
+ '# ENVIRONMENT',
179
174
  `- OS: ${process.platform} (${os.release()})`,
180
- `- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
175
+ `- Shell for run_command: ${isWin ? 'Windows PowerShell (powershell.exe)' : 'bash'}`,
181
176
  `- Workspace (cwd): ${process.cwd()}`,
182
177
  ];
183
178
  // Phạm vi truy cập filesystem. Model mặc định CHỈ được chạm cwd + các folder
@@ -188,26 +183,30 @@ function runtimeContext() {
188
183
  try {
189
184
  const roots = listRoots();
190
185
  const extras = roots.slice(1); // [0] là cwd
191
- 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
+ );
192
189
  for (const r of extras) lines.push(` • ${r}`);
193
190
  lines.push(
194
- `- 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.`
195
192
  );
196
193
  } catch {}
197
194
  if (isWin) {
198
195
  lines.push(
199
- "- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.",
200
- " Use: Get-Content (not cat), Get-ChildItem (not ls), Select-String (not grep),",
201
- " (Get-Content f | Measure-Object -Line) (not wc -l). Paths use backslashes.",
202
- "- Trên Windows, script chạy bằng `script.bat` hoặc `script.cmd`, KHÔNG dùng `./script` (Unix).",
203
- " VD: gradlew → gradlew.bat, mvnw → mvnw.cmd.",
204
- "- Prefer the dedicated tools (read_file / list_dir / grep / glob) over shell commands;",
205
- " 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.'
206
203
  );
207
204
  } else {
208
- 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
+ );
209
208
  }
210
- return lines.join("\n");
209
+ return lines.join('\n');
211
210
  }
212
211
 
213
212
  // Lược ngữ cảnh để không vượt context khi phiên dài. KHÔNG đụng vào history thật
@@ -215,7 +214,7 @@ function runtimeContext() {
215
214
  // Nếu history đã có summary (do summarizeHistory ghi vào _summary), dùng làm head.
216
215
  // Exported cho test (scripts/test-compact.js) — không dùng ngoài file này.
217
216
  export function compact(history, budget) {
218
- const len = (m) => (m.content || "").length + 24;
217
+ const len = (m) => (m.content || '').length + 24;
219
218
  let total = history.reduce((s, m) => s + len(m), 0);
220
219
  if (total <= budget) return history;
221
220
  const out = history.map((m) => ({ ...m }));
@@ -223,12 +222,12 @@ export function compact(history, budget) {
223
222
  // nhất (đọc file lớn) và model đã xử lý xong rồi.
224
223
  // SKIP session_summary — đó là bộ nhớ dài hạn, không phải tool result cồng kềnh.
225
224
  const toolIdx = out
226
- .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))
227
226
  .filter((i) => i >= 0);
228
227
  for (const i of toolIdx.slice(0, Math.max(0, toolIdx.length - 5))) {
229
228
  if (total <= budget) break;
230
229
  const before = len(out[i]);
231
- 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]';
232
231
  total -= before - len(out[i]);
233
232
  }
234
233
  if (total <= budget) return out;
@@ -236,14 +235,18 @@ export function compact(history, budget) {
236
235
  // do maybeSummarize() chèn) + USER đầu tiên (nhiệm vụ gốc) + 12 message gần
237
236
  // nhất. KHÔNG BỎ session_summary — đó là bộ nhớ phiên, mất nó là mất hết.
238
237
  const summaryIdx = out
239
- .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))
240
239
  .filter((i) => i >= 0);
241
- const firstUser = out.findIndex((m) => m.role === "user");
240
+ const firstUser = out.findIndex((m) => m.role === 'user');
242
241
  const headIndices = new Set([...summaryIdx, ...(firstUser >= 0 ? [firstUser] : [])]);
243
242
  const head = [...headIndices].sort((a, b) => a - b).map((i) => out[i]);
244
243
  const maxHeadIdx = head.length ? Math.max(...headIndices) : -1;
245
244
  const tailStart = Math.max(maxHeadIdx + 1, out.length - 12);
246
- 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
+ };
247
250
  return [...head, elided, ...out.slice(tailStart)];
248
251
  }
249
252
 
@@ -262,10 +265,12 @@ export async function maybeSummarize(history, { model, signal, force = false } =
262
265
  // Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
263
266
  const head = history.slice(0, history.length - keepTail);
264
267
  const tail = history.slice(history.length - keepTail);
265
- const transcript = head.map((m) => {
266
- const role = m.role === "tool" ? `TOOL(${m.name || "?"})` : m.role.toUpperCase();
267
- return `## ${role}\n${(m.content || "").slice(0, 2000)}`;
268
- }).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');
269
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.
270
275
 
271
276
  BẮT BUỘC giữ lại:
@@ -294,13 +299,13 @@ LOẠI BỎ: chi tiết nội dung file đọc được, output dài của tool,
294
299
  --- HỘI THOẠI CẦN TÓM TẮT ---
295
300
  ${transcript}`;
296
301
  try {
297
- const { text } = await stream({ mode: "chat", model, message: ask, signal });
298
- const summary = (text || "").trim();
302
+ const { text } = await stream({ mode: 'chat', model, message: ask, signal });
303
+ const summary = (text || '').trim();
299
304
  if (!summary || summary.length < 50) return false;
300
305
  // Thay head bằng MỘT message tool tên "session_summary".
301
306
  const summaryMsg = {
302
- role: "tool",
303
- name: "session_summary",
307
+ role: 'tool',
308
+ name: 'session_summary',
304
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}`,
305
310
  };
306
311
  history.splice(0, history.length, summaryMsg, ...tail);
@@ -318,16 +323,19 @@ ${transcript}`;
318
323
  export function filesLedger(history) {
319
324
  const touched = new Map(); // path -> "đã ghi" | "đã sửa"
320
325
  for (const m of history) {
321
- 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;
322
328
  let mm;
323
- if (m.name === "write_file" && (mm = m.content.match(/ to (.+)$/m))) touched.set(mm[1].trim(), "đã ghi");
324
- 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');
325
333
  }
326
334
  if (!touched.size)
327
- 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.';
328
336
  return (
329
- "# FILES ACTUALLY CHANGED THIS SESSION (runtime ground truth — trust THIS over your memory)\n" +
330
- [...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') +
331
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."
332
340
  );
333
341
  }
@@ -343,14 +351,17 @@ function memoryBlock() {
343
351
  const stats = memoryStats();
344
352
  const statLine = stats
345
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.`
346
- : "";
354
+ : '';
347
355
  if (!mem)
348
- 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
+ );
349
360
  return (
350
- "# PROJECT RULES & MEMORY (noob.md) — BINDING\n" +
351
- "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' +
352
363
  mem +
353
- "\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).' +
354
365
  statLine
355
366
  );
356
367
  }
@@ -360,25 +371,25 @@ function memoryBlock() {
360
371
  // ý /resume nếu user muốn tiếp tục. Bỏ qua phiên hiện tại (repl.js lọc).
361
372
  // recentSessions: [{ id, title, turns, updatedAt }] — đã sort mới → cũ.
362
373
  function recentSessionsBlock(recentSessions) {
363
- if (!recentSessions || !recentSessions.length) return "";
374
+ if (!recentSessions || !recentSessions.length) return '';
364
375
  const lines = [
365
- "# RECENT SESSIONS IN THIS WORKSPACE (newest first)",
376
+ '# RECENT SESSIONS IN THIS WORKSPACE (newest first)',
366
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).",
367
- "",
378
+ '',
368
379
  ];
369
380
  for (const s of recentSessions) {
370
381
  const ago = relTime(s.updatedAt);
371
- const title = s.title || "(chưa đặt tiêu đề)";
382
+ const title = s.title || '(chưa đặt tiêu đề)';
372
383
  lines.push(`- \`${s.id}\` — "${title}" · ${s.turns} lượt · ${ago}`);
373
384
  }
374
- return lines.join("\n");
385
+ return lines.join('\n');
375
386
  }
376
387
 
377
388
  // "X ago" ngắn gọn, tiếng Việt. Dùng cho noob.md mtime + recent sessions.
378
389
  function relTime(ts) {
379
- if (!ts) return "";
390
+ if (!ts) return '';
380
391
  const ms = Date.now() - ts;
381
- 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)
382
393
  const s = Math.floor(ms / 1000);
383
394
  if (s < 60) return `${s}s trước`;
384
395
  const m = Math.floor(s / 60);
@@ -398,27 +409,30 @@ function relTime(ts) {
398
409
  // recentSessions: breadcrumbs các phiên trước cùng workspace (repl.js cung cấp)
399
410
  // → chèn ngay sau memoryBlock() để model "thấy" lịch sử dù chưa /resume.
400
411
  export function buildSystem(history, extraToolsDoc, goal, recentSessions) {
401
- const parts = [SYSTEM, "", memoryBlock()];
412
+ const parts = [SYSTEM, '', memoryBlock()];
402
413
  if (recentSessions && recentSessions.length) {
403
- parts.push("", recentSessionsBlock(recentSessions));
414
+ parts.push('', recentSessionsBlock(recentSessions));
404
415
  }
405
- if (goal && goal.trim()) parts.push("", goalBlock(goal));
406
- if (extraToolsDoc) parts.push("", extraToolsDoc);
407
- parts.push("", runtimeContext());
408
- 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');
409
420
  }
410
421
 
411
422
  export function buildUserMessage(history) {
412
423
  const msgs = compact(history, MAX_PROMPT_CHARS);
413
- const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
424
+ const parts = [filesLedger(history), '', '='.repeat(60), '# CONVERSATION', ''];
414
425
  for (const m of msgs) {
415
- if (m.role === "user") parts.push(`## USER\n${m.content}`);
416
- else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
417
- else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
418
- 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('');
419
430
  }
420
- 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.");
421
- 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');
422
436
  }
423
437
 
424
438
  // Detect câu trả lời bị cắt giữa chừng — KHÔNG phải câu hoàn chỉnh.
@@ -434,10 +448,15 @@ function isIncompleteResponse(text) {
434
448
  const lastChar = t.slice(-1);
435
449
  if (lastChar && !/[.!?:;)\]"'`#>\n]/.test(lastChar) && t.length > 50) {
436
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à)
437
- const lastLine = t.split("\n").pop().trim();
451
+ const lastLine = t.split('\n').pop().trim();
438
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;
439
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)
440
- 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;
441
460
  }
442
461
  return false;
443
462
  }
@@ -448,11 +467,11 @@ function isIncompleteResponse(text) {
448
467
  // would close the block early and break the JSON. Instead, find the ```tool (or
449
468
  // ```json) opener and brace-match the first balanced JSON object after it.
450
469
  function parseToolCall(text) {
451
- for (const fence of ["tool", "json"]) {
452
- 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'));
453
472
  if (!open) continue;
454
473
  const obj = extractJsonObject(text, open.index + open[0].length);
455
- 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 || {} };
456
475
  }
457
476
  return null;
458
477
  }
@@ -461,20 +480,22 @@ function parseToolCall(text) {
461
480
  // escapes so braces or backticks INSIDE string values don't throw off the depth
462
481
  // count. Returns the parsed object, or null if malformed/truncated (unbalanced).
463
482
  function extractJsonObject(s, from) {
464
- const start = s.indexOf("{", from);
483
+ const start = s.indexOf('{', from);
465
484
  if (start === -1) return null;
466
- let depth = 0, inStr = false, esc = false;
485
+ let depth = 0,
486
+ inStr = false,
487
+ esc = false;
467
488
  for (let i = start; i < s.length; i++) {
468
489
  const ch = s[i];
469
490
  if (inStr) {
470
491
  if (esc) esc = false;
471
- else if (ch === "\\") esc = true;
492
+ else if (ch === '\\') esc = true;
472
493
  else if (ch === '"') inStr = false;
473
494
  continue;
474
495
  }
475
496
  if (ch === '"') inStr = true;
476
- else if (ch === "{") depth++;
477
- else if (ch === "}" && --depth === 0) {
497
+ else if (ch === '{') depth++;
498
+ else if (ch === '}' && --depth === 0) {
478
499
  try {
479
500
  return JSON.parse(s.slice(start, i + 1));
480
501
  } catch {
@@ -498,7 +519,20 @@ function extractJsonObject(s, from) {
498
519
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
499
520
  * @returns {Promise<string>} the final assistant answer (no tool block)
500
521
  */
501
- 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
+ }) {
502
536
  // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
503
537
  // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
504
538
  // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
@@ -507,11 +541,11 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
507
541
  const MAX_LOOP_DETECTIONS = 3; // sau 3 lần loop detection liên tiếp → force stop
508
542
  // Effort classifier: phân loại task từ user message gốc → set effort level.
509
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).
510
- const effort = classifyEffort(history.find((m) => m.role === "user")?.content || "");
544
+ const effort = classifyEffort(history.find((m) => m.role === 'user')?.content || '');
511
545
  for (let step = 0; step < MAX_STEPS; step++) {
512
546
  // Check abort signal ngay đầu mỗi iteration để Ctrl+C dừng ngay lập tức
513
547
  if (signal?.aborted) {
514
- throw new Error("aborted");
548
+ throw new Error('aborted');
515
549
  }
516
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).
517
551
  if (step > 0 && step % 100 === 0) onStatus?.(`đã chạy ${step} bước…`);
@@ -519,40 +553,50 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
519
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
520
554
  // lần gọi model kế tiếp → model thấy và điều chỉnh ngay trong cùng task.
521
555
  const steer = onSteer?.() || [];
522
- for (const msg of steer) history.push({ role: "user", content: msg });
556
+ for (const msg of steer) history.push({ role: 'user', content: msg });
523
557
 
524
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.
525
- try { await maybeSummarize(history, { model, signal }); } catch {}
559
+ try {
560
+ await maybeSummarize(history, { model, signal });
561
+ } catch {}
526
562
 
527
563
  const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
528
564
  const message = buildUserMessage(history);
529
565
  tokenMeter?.addInput(countTokens(system) + countTokens(message));
530
566
  tokenMeter?.setContext(tokenMeter.total);
531
- onStatus?.("thinking");
532
- onDelta?.({ type: "step-start" });
567
+ onStatus?.('thinking');
568
+ onDelta?.({ type: 'step-start' });
533
569
  // Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
534
570
  // api.js đã tự nối tiếp khi truncated (maxContinues=Infinity); agent.js xử lý các
535
571
  // trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
536
572
  // throw ApiError retryable (network drop, 5xx, timeout).
537
573
  const { text, finishReason } = await streamWithRetry({
538
- model, message, system, signal, tokenMeter, onDelta, onStatus, effort,
574
+ model,
575
+ message,
576
+ system,
577
+ signal,
578
+ tokenMeter,
579
+ onDelta,
580
+ onStatus,
581
+ effort,
539
582
  });
540
583
  tokenMeter?.endOutput();
541
- onDelta?.({ type: "step-end" });
542
- history.push({ role: "assistant", content: text });
584
+ onDelta?.({ type: 'step-end' });
585
+ history.push({ role: 'assistant', content: text });
543
586
 
544
587
  const call = parseToolCall(text);
545
588
  if (!call) {
546
589
  // Không có tool call. Nếu finishReason bất thường (empty/tool_unclosed) →
547
590
  // model bị cắt ngay trước khi kịp gọi tool → nudge tiếp 1 lượt nữa thay vì
548
591
  // return text rỗng/dở dang.
549
- if (finishReason === "empty" || finishReason === "tool_unclosed") {
592
+ if (finishReason === 'empty' || finishReason === 'tool_unclosed') {
550
593
  history.push({
551
- role: "tool",
552
- name: "stream_recovery",
553
- content: finishReason === "tool_unclosed"
554
- ? "[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."
555
- : "[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.',
556
600
  });
557
601
  continue;
558
602
  }
@@ -560,9 +604,10 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
560
604
  // đột ngột không có dấu câu/đóng danh sách/tool block → nudge model viết tiếp.
561
605
  if (isIncompleteResponse(text)) {
562
606
  history.push({
563
- role: "tool",
564
- name: "stream_recovery",
565
- 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.',
566
611
  });
567
612
  continue;
568
613
  }
@@ -572,7 +617,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
572
617
 
573
618
  const { allow, result } = await onTool(call.name, call.input);
574
619
  history.push({
575
- role: "tool",
620
+ role: 'tool',
576
621
  name: call.name,
577
622
  content: allow ? result : t.toolDenied,
578
623
  });
@@ -585,8 +630,8 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
585
630
  if (tasks.length > 0) {
586
631
  const next = tasks[0];
587
632
  history.push({
588
- role: "user",
589
- 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.`,
590
635
  });
591
636
  }
592
637
  }
@@ -601,43 +646,62 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
601
646
  recentCalls.push({ name: call.name, inputStr });
602
647
  if (recentCalls.length > LOOP_DETECT_WINDOW) recentCalls.shift();
603
648
  let loopType = null; // 'consecutive' | 'pattern' | null
649
+ let lastN = [];
604
650
  if (recentCalls.length >= LOOP_DETECT_THRESHOLD + 1) {
605
651
  // (A) Same consecutive check
606
- const lastN = recentCalls.slice(-LOOP_DETECT_THRESHOLD);
607
- 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
+ );
608
656
  if (allSame) loopType = 'consecutive';
609
657
  }
610
658
  // (B) Pattern cycle check: tìm chu kỳ lặp trong window (độ dài 2-3)
611
659
  if (!loopType && recentCalls.length >= 4) {
612
- 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
+ ) {
613
665
  const half = Math.floor(recentCalls.length / cycleLen) * cycleLen;
614
666
  const first = recentCalls.slice(-half, -half + cycleLen);
615
667
  const rest = recentCalls.slice(-half + cycleLen);
616
668
  let matched = true;
617
669
  for (let i = 0; i < rest.length; i++) {
618
- 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
+ ) {
619
674
  matched = false;
620
675
  break;
621
676
  }
622
677
  }
623
- if (matched) { loopType = 'pattern'; break; }
678
+ if (matched) {
679
+ loopType = 'pattern';
680
+ break;
681
+ }
624
682
  }
625
683
  }
626
684
  if (loopType) {
627
- 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(', ')}]`;
628
692
  loopDetectedCount++;
629
693
  if (loopDetectedCount >= MAX_LOOP_DETECTIONS) {
630
694
  history.push({
631
- role: "tool",
632
- name: "loop_detection",
695
+ role: 'tool',
696
+ name: 'loop_detection',
633
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.`,
634
698
  });
635
699
  return `[LOOP STOPPED] Đã dừng vì model bị kẹt trong vòng lặp.`;
636
700
  }
637
701
  recentCalls.length = 0;
638
702
  history.push({
639
- role: "tool",
640
- name: "loop_detection",
703
+ role: 'tool',
704
+ name: 'loop_detection',
641
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.`,
642
706
  });
643
707
  } else {
@@ -654,13 +718,22 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
654
718
  * backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
655
719
  * - Throw lại nếu signal abort hoặc lỗi không retryable.
656
720
  */
657
- 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
+ }) {
658
731
  const MAX_RETRIES = 8;
659
732
  let lastErr = null;
660
733
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
661
734
  try {
662
735
  const result = await stream({
663
- mode: "chat",
736
+ mode: 'chat',
664
737
  model,
665
738
  message,
666
739
  system,
@@ -668,28 +741,39 @@ async function streamWithRetry({ model, message, system, signal, tokenMeter, onD
668
741
  effort,
669
742
  onDelta: (d) => {
670
743
  tokenMeter?.pushOutputDelta(d);
671
- onDelta?.({ type: "delta", text: d });
744
+ onDelta?.({ type: 'delta', text: d });
672
745
  },
673
746
  });
674
747
  return result; // { text, reasoning, finishReason }
675
748
  } catch (err) {
676
749
  if (signal?.aborted) throw err; // user Ctrl+C — không retry
677
- 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
678
751
  lastErr = err;
679
752
  if (attempt >= MAX_RETRIES) break;
680
753
  const backoff = Math.min(30000, 1000 * Math.pow(2, attempt));
681
- 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
+ );
682
757
  await sleep(backoff, signal);
683
758
  }
684
759
  }
685
- throw lastErr || new Error("streamWithRetry: exhausted retries");
760
+ throw lastErr || new Error('streamWithRetry: exhausted retries');
686
761
  }
687
762
 
688
763
  function sleep(ms, signal) {
689
764
  return new Promise((resolve, reject) => {
690
- const id = setTimeout(() => { cleanup(); resolve(); }, ms);
691
- const onAbort = () => { cleanup(); reject(new Error("aborted")); };
692
- const cleanup = () => { clearTimeout(id); signal?.removeEventListener("abort", onAbort); };
693
- 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 });
694
778
  });
695
779
  }