@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/CHANGELOG.md +465 -0
- package/README.md +113 -27
- package/bin/noob.js +40 -27
- package/package.json +30 -2
- package/src/agent.js +223 -139
- package/src/api.js +105 -48
- package/src/config.js +11 -11
- package/src/i18n.js +171 -148
- package/src/memory.js +24 -13
- package/src/models.js +96 -46
- package/src/prompts/system.md +85 -0
- package/src/repl/complete.js +120 -0
- package/src/repl/todos.js +38 -0
- package/src/repl/ultra.js +62 -0
- package/src/repl/workflow-commands.js +238 -0
- package/src/repl.js +794 -769
- package/src/sessions.js +20 -20
- package/src/skills.js +13 -9
- package/src/subagent.js +3 -3
- package/src/tokens.js +37 -12
- package/src/tools.js +202 -121
- package/src/tui.js +240 -124
- package/src/ui.js +44 -44
- package/src/update.js +21 -21
- package/src/workflows-builtin.js +16 -14
- package/src/workflows.js +29 -27
package/src/agent.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import os from
|
|
2
|
-
import { stream } from
|
|
3
|
-
import { loadMemory, memoryStats } from
|
|
4
|
-
import { listRoots } from
|
|
5
|
-
import { t } from
|
|
6
|
-
import { countTokens } from
|
|
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
|
-
|
|
90
|
-
/^(
|
|
91
|
-
/^(
|
|
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
|
|
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
|
-
|
|
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|
|
|
129
|
+
/\b(ultra|workflow)\b/i,
|
|
126
130
|
];
|
|
127
131
|
|
|
128
132
|
export function classifyEffort(userMessage) {
|
|
129
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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 ===
|
|
171
|
+
const isWin = process.platform === 'win32';
|
|
177
172
|
const lines = [
|
|
178
|
-
|
|
173
|
+
'# ENVIRONMENT',
|
|
179
174
|
`- OS: ${process.platform} (${os.release()})`,
|
|
180
|
-
`- Shell for run_command: ${isWin ?
|
|
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(
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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(
|
|
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(
|
|
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 ||
|
|
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 ===
|
|
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 =
|
|
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 ===
|
|
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 ===
|
|
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 = {
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
298
|
-
const summary = (text ||
|
|
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:
|
|
303
|
-
name:
|
|
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 !==
|
|
326
|
+
if (m.role !== 'tool' || typeof m.content !== 'string' || m.content.startsWith('ERROR'))
|
|
327
|
+
continue;
|
|
322
328
|
let mm;
|
|
323
|
-
if (m.name ===
|
|
324
|
-
|
|
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
|
|
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
|
-
|
|
330
|
-
[...touched].map(([p, a]) => `- ${p} (${a})`).join(
|
|
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
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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(
|
|
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
|
|
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,
|
|
412
|
+
const parts = [SYSTEM, '', memoryBlock()];
|
|
402
413
|
if (recentSessions && recentSessions.length) {
|
|
403
|
-
parts.push(
|
|
414
|
+
parts.push('', recentSessionsBlock(recentSessions));
|
|
404
415
|
}
|
|
405
|
-
if (goal && goal.trim()) parts.push(
|
|
406
|
-
if (extraToolsDoc) parts.push(
|
|
407
|
-
parts.push(
|
|
408
|
-
return parts.join(
|
|
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),
|
|
424
|
+
const parts = [filesLedger(history), '', '='.repeat(60), '# CONVERSATION', ''];
|
|
414
425
|
for (const m of msgs) {
|
|
415
|
-
if (m.role ===
|
|
416
|
-
else if (m.role ===
|
|
417
|
-
else if (m.role ===
|
|
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(
|
|
421
|
-
|
|
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(
|
|
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 (
|
|
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 [
|
|
452
|
-
const open = text.match(new RegExp(
|
|
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 ===
|
|
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(
|
|
483
|
+
const start = s.indexOf('{', from);
|
|
465
484
|
if (start === -1) return null;
|
|
466
|
-
let depth = 0,
|
|
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 ===
|
|
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 ===
|
|
477
|
-
else if (ch ===
|
|
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({
|
|
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 ===
|
|
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(
|
|
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:
|
|
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 {
|
|
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?.(
|
|
532
|
-
onDelta?.({ type:
|
|
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,
|
|
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:
|
|
542
|
-
history.push({ role:
|
|
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 ===
|
|
592
|
+
if (finishReason === 'empty' || finishReason === 'tool_unclosed') {
|
|
550
593
|
history.push({
|
|
551
|
-
role:
|
|
552
|
-
name:
|
|
553
|
-
content:
|
|
554
|
-
|
|
555
|
-
|
|
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 cú 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:
|
|
564
|
-
name:
|
|
565
|
-
content:
|
|
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:
|
|
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:
|
|
589
|
-
content: `[SYSTEM] Việc "${call.name}" đã hoàn thành. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(
|
|
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
|
-
|
|
607
|
-
const allSame = lastN.every(
|
|
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 (
|
|
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 (
|
|
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) {
|
|
678
|
+
if (matched) {
|
|
679
|
+
loopType = 'pattern';
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
624
682
|
}
|
|
625
683
|
}
|
|
626
684
|
if (loopType) {
|
|
627
|
-
const label =
|
|
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:
|
|
632
|
-
name:
|
|
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:
|
|
640
|
-
name:
|
|
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({
|
|
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:
|
|
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:
|
|
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 !==
|
|
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?.(
|
|
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(
|
|
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(() => {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
}
|