@noobdemon/noob-cli 1.7.8 → 1.8.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/package.json +1 -1
- package/src/agent.js +68 -23
- package/src/api.js +107 -11
- package/src/i18n.js +3 -0
- package/src/memory.js +29 -0
- package/src/repl.js +65 -3
- package/src/tui.js +161 -35
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { stream } from "./api.js";
|
|
3
|
-
import { loadMemory } from "./memory.js";
|
|
3
|
+
import { loadMemory, memoryStats } from "./memory.js";
|
|
4
4
|
import { listRoots } from "./tools.js";
|
|
5
5
|
import { t } from "./i18n.js";
|
|
6
6
|
import { countTokens } from "./tokens.js";
|
|
@@ -262,45 +262,88 @@ export function filesLedger(history) {
|
|
|
262
262
|
// lại mỗi lượt. Không có file → nhắc model tự tạo khi rút ra điều đáng nhớ.
|
|
263
263
|
// Framing MẠNH: phần `## Rules` là binding (luật dự án), `## Notes` mới là
|
|
264
264
|
// tham khảo. Đặt ngay sau SYSTEM trong buildPrompt() để không bị lost-in-the-middle.
|
|
265
|
+
// Footer "📊 noob.md hiện tại: …" cho model thấy memory "tươi" hay "stale" — nếu
|
|
266
|
+
// đã cũ, model có thể tự quyết định /learn hoặc xác minh lại với filesystem.
|
|
265
267
|
function memoryBlock() {
|
|
266
268
|
const mem = loadMemory();
|
|
269
|
+
const stats = memoryStats();
|
|
270
|
+
const statLine = stats
|
|
271
|
+
? `\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.`
|
|
272
|
+
: "";
|
|
267
273
|
if (!mem)
|
|
268
|
-
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 đó.)";
|
|
274
|
+
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;
|
|
269
275
|
return (
|
|
270
276
|
"# PROJECT RULES & MEMORY (noob.md) — BINDING\n" +
|
|
271
277
|
"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" +
|
|
272
278
|
mem +
|
|
273
|
-
"\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)."
|
|
279
|
+
"\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)." +
|
|
280
|
+
statLine
|
|
274
281
|
);
|
|
275
282
|
}
|
|
276
283
|
|
|
284
|
+
// Recent sessions block: danh sách phiên gần đây CÙNG workspace. Model thấy
|
|
285
|
+
// breadcrumbs để (a) trả lời "phiên trước làm gì" mà KHÔNG cần /resume, (b) gợi
|
|
286
|
+
// ý /resume nếu user muốn tiếp tục. Bỏ qua phiên hiện tại (repl.js lọc).
|
|
287
|
+
// recentSessions: [{ id, title, turns, updatedAt }] — đã sort mới → cũ.
|
|
288
|
+
export function recentSessionsBlock(recentSessions) {
|
|
289
|
+
if (!recentSessions || !recentSessions.length) return "";
|
|
290
|
+
const lines = [
|
|
291
|
+
"# RECENT SESSIONS IN THIS WORKSPACE (newest first)",
|
|
292
|
+
"Đâ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).",
|
|
293
|
+
"",
|
|
294
|
+
];
|
|
295
|
+
for (const s of recentSessions) {
|
|
296
|
+
const ago = relTime(s.updatedAt);
|
|
297
|
+
const title = s.title || "(chưa đặt tiêu đề)";
|
|
298
|
+
lines.push(`- \`${s.id}\` — "${title}" · ${s.turns} lượt · ${ago}`);
|
|
299
|
+
}
|
|
300
|
+
return lines.join("\n");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// "X ago" ngắn gọn, tiếng Việt. Dùng cho noob.md mtime + recent sessions.
|
|
304
|
+
export function relTime(ts) {
|
|
305
|
+
if (!ts) return "—";
|
|
306
|
+
const ms = Date.now() - ts;
|
|
307
|
+
if (ms < 5000) return "vừa xong"; // < 5s hoặc tương lai → "vừa xong" (tránh "0s trước" xấu)
|
|
308
|
+
const s = Math.floor(ms / 1000);
|
|
309
|
+
if (s < 60) return `${s}s trước`;
|
|
310
|
+
const m = Math.floor(s / 60);
|
|
311
|
+
if (m < 60) return `${m} phút trước`;
|
|
312
|
+
const h = Math.floor(m / 60);
|
|
313
|
+
if (h < 24) return `${h} giờ trước`;
|
|
314
|
+
const d = Math.floor(h / 24);
|
|
315
|
+
if (d < 30) return `${d} ngày trước`;
|
|
316
|
+
const mo = Math.floor(d / 30);
|
|
317
|
+
if (mo < 12) return `${mo} tháng trước`;
|
|
318
|
+
return `${Math.floor(d / 365)} năm trước`;
|
|
319
|
+
}
|
|
320
|
+
|
|
277
321
|
// The proxy is stateless, so we serialize the whole transcript into one prompt.
|
|
278
322
|
// extraToolsDoc: chuỗi mô tả thêm tool (vd spawn_agent khi agent mode bật) được
|
|
279
323
|
// chèn ngay sau SYSTEM để model biết và dùng được.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
// tránh lost-in-the-middle) → goalBlock (hard completion requirement, attention
|
|
284
|
-
// cao — chống agentic laziness + goal drift) → extraToolsDoc → runtimeContext
|
|
285
|
-
// → filesLedger → CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm
|
|
286
|
-
// sát SYSTEM để model coi là luật.
|
|
324
|
+
// recentSessions: breadcrumbs các phiên trước cùng workspace (repl.js cung cấp)
|
|
325
|
+
// → chèn ngay sau memoryBlock() để model "thấy" lịch sử dù chưa /resume.
|
|
326
|
+
function buildSystem(history, extraToolsDoc, goal, recentSessions) {
|
|
287
327
|
const parts = [SYSTEM, "", memoryBlock()];
|
|
288
|
-
if (
|
|
289
|
-
parts.push("",
|
|
328
|
+
if (recentSessions && recentSessions.length) {
|
|
329
|
+
parts.push("", recentSessionsBlock(recentSessions));
|
|
290
330
|
}
|
|
331
|
+
if (goal && goal.trim()) parts.push("", goalBlock(goal));
|
|
291
332
|
if (extraToolsDoc) parts.push("", extraToolsDoc);
|
|
292
|
-
parts.push("", runtimeContext()
|
|
333
|
+
parts.push("", runtimeContext());
|
|
334
|
+
return parts.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildUserMessage(history) {
|
|
338
|
+
const msgs = compact(history, MAX_PROMPT_CHARS);
|
|
339
|
+
const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
|
|
293
340
|
for (const m of msgs) {
|
|
294
341
|
if (m.role === "user") parts.push(`## USER\n${m.content}`);
|
|
295
342
|
else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
|
|
296
343
|
else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
|
|
297
344
|
parts.push("");
|
|
298
345
|
}
|
|
299
|
-
parts.push("=".repeat(60));
|
|
300
|
-
// Recency bias: câu chốt cuối prompt nằm ở vị trí attention mạnh nhất. Nhắc
|
|
301
|
-
// model đối chiếu FILES CHANGED trước khi claim đã sửa file — chống ảo giác
|
|
302
|
-
// "đã tạo file" khi chưa gọi write_file/edit_file.
|
|
303
|
-
parts.push("Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
|
|
346
|
+
parts.push("=".repeat(60), "Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
|
|
304
347
|
return parts.join("\n");
|
|
305
348
|
}
|
|
306
349
|
|
|
@@ -360,7 +403,7 @@ function extractJsonObject(s, from) {
|
|
|
360
403
|
* @param {(msg:string)=>void} opts.onStatus thinking/streaming status
|
|
361
404
|
* @returns {Promise<string>} the final assistant answer (no tool block)
|
|
362
405
|
*/
|
|
363
|
-
export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal }) {
|
|
406
|
+
export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal, recentSessions }) {
|
|
364
407
|
// [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
|
|
365
408
|
// chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
|
|
366
409
|
// model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
|
|
@@ -376,8 +419,9 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
376
419
|
// Bộ nhớ dài hạn: thử tóm tắt nếu history đã phình. Im lặng nếu không cần.
|
|
377
420
|
try { await maybeSummarize(history, { model, signal }); } catch {}
|
|
378
421
|
|
|
379
|
-
const
|
|
380
|
-
|
|
422
|
+
const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
|
|
423
|
+
const message = buildUserMessage(history);
|
|
424
|
+
tokenMeter?.addInput(countTokens(message));
|
|
381
425
|
onStatus?.("thinking");
|
|
382
426
|
onDelta?.({ type: "step-start" });
|
|
383
427
|
// Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
|
|
@@ -385,7 +429,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
385
429
|
// trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
|
|
386
430
|
// throw ApiError retryable (network drop, 5xx, timeout).
|
|
387
431
|
const { text, finishReason } = await streamWithRetry({
|
|
388
|
-
model, message
|
|
432
|
+
model, message, system, signal, tokenMeter, onDelta, onStatus,
|
|
389
433
|
});
|
|
390
434
|
tokenMeter?.endOutput();
|
|
391
435
|
onDelta?.({ type: "step-end" });
|
|
@@ -426,7 +470,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
426
470
|
* backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
|
|
427
471
|
* - Throw lại nếu signal abort hoặc lỗi không retryable.
|
|
428
472
|
*/
|
|
429
|
-
async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, onStatus }) {
|
|
473
|
+
async function streamWithRetry({ model, message, system, signal, tokenMeter, onDelta, onStatus }) {
|
|
430
474
|
const MAX_RETRIES = 8;
|
|
431
475
|
let lastErr = null;
|
|
432
476
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
@@ -435,6 +479,7 @@ async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, on
|
|
|
435
479
|
mode: "chat",
|
|
436
480
|
model,
|
|
437
481
|
message,
|
|
482
|
+
system,
|
|
438
483
|
signal,
|
|
439
484
|
onDelta: (d) => {
|
|
440
485
|
tokenMeter?.pushOutputDelta(d);
|
package/src/api.js
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
// upstream. The CLI only ever sees the gateway URL + the user's key.
|
|
4
4
|
import { config } from "./config.js";
|
|
5
5
|
|
|
6
|
+
// ── memoryToken: per-session random token for upstream conversation state.
|
|
7
|
+
// Browser sends something like "uuid_uuid" (two v4 UUIDs joined by _).
|
|
8
|
+
// Upstream requires both `remember: true` and a valid `memoryToken`.
|
|
9
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
let _sessionMemoryToken = null;
|
|
11
|
+
|
|
12
|
+
function makeUUID() {
|
|
13
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
14
|
+
const r = (Math.random() * 16) | 0;
|
|
15
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
16
|
+
return v.toString(16);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeMemoryToken() {
|
|
21
|
+
return `${makeUUID()}_${makeUUID()}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getMemoryToken() {
|
|
25
|
+
if (!_sessionMemoryToken) _sessionMemoryToken = makeMemoryToken();
|
|
26
|
+
return _sessionMemoryToken;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resetMemoryToken() {
|
|
30
|
+
_sessionMemoryToken = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
6
33
|
// Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
|
|
7
34
|
// revocation proxy. Off by default. Prefer fixing the trust store.
|
|
8
35
|
// LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
|
|
@@ -89,7 +116,7 @@ function hasUnclosedToolBlock(text) {
|
|
|
89
116
|
*
|
|
90
117
|
* @returns {Promise<{text:string, reasoning:string}>}
|
|
91
118
|
*/
|
|
92
|
-
export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs =
|
|
119
|
+
export async function stream({ mode = "chat", message, model, system, conversation, effort, signal, onDelta, onReasoning, onStatus, idleMs = 25000, maxContinues = Infinity }) {
|
|
93
120
|
const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
|
|
94
121
|
|
|
95
122
|
let fullText = "";
|
|
@@ -99,7 +126,7 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
|
|
|
99
126
|
let emptyStreak = 0; // số lần liên tiếp stream rỗng (chống loop vô tận khi upstream chết hẳn)
|
|
100
127
|
|
|
101
128
|
for (let attempt = 0; ; attempt++) {
|
|
102
|
-
const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
|
|
129
|
+
const r = await streamOnce({ endpoint, mode, message: prompt, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning });
|
|
103
130
|
fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
|
|
104
131
|
if (r.reasoning) reasoning = r.reasoning;
|
|
105
132
|
|
|
@@ -148,8 +175,18 @@ function continuationPrompt(message, partial) {
|
|
|
148
175
|
* One network attempt of the stream. Returns this attempt's accumulated text +
|
|
149
176
|
* a `truncated` flag telling the caller whether the reply was cut short.
|
|
150
177
|
*/
|
|
151
|
-
async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onStatus, onDelta, onReasoning }) {
|
|
152
|
-
|
|
178
|
+
async function streamOnce({ endpoint, mode, message, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning }) {
|
|
179
|
+
// chat body: gửi system + conversation riêng để gateway forward đúng tới upstream.
|
|
180
|
+
// Worker gateway (handleChat) + upstream đều nhận shape này.
|
|
181
|
+
let body;
|
|
182
|
+
if (mode === "search") body = { query: message };
|
|
183
|
+
else if (mode === "merge") body = { message };
|
|
184
|
+
else {
|
|
185
|
+
body = { message, model, remember: true, memoryToken: getMemoryToken() };
|
|
186
|
+
if (system) body.customInstructions = system;
|
|
187
|
+
if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
|
|
188
|
+
if (effort) body.effort = effort;
|
|
189
|
+
}
|
|
153
190
|
|
|
154
191
|
// Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
|
|
155
192
|
// huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
|
|
@@ -158,14 +195,65 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
158
195
|
let timedOut = false;
|
|
159
196
|
const onUserAbort = () => ctrl.abort();
|
|
160
197
|
signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
198
|
+
// Hai loại timer tách rời (xem comment dài bên dưới):
|
|
199
|
+
// - WIRE: kết nối TCP còn thở không? Reset mỗi khi reader.read() trả bytes
|
|
200
|
+
// (kể cả comment SSE `: keepalive` từ worker). Ngưỡng cao (idleMs*2) — chỉ
|
|
201
|
+
// để bắt trường hợp socket chết cứng không còn cả heartbeat.
|
|
202
|
+
// - CONTENT: upstream có thực sự đang nghĩ/nói không? Reset chỉ khi parser
|
|
203
|
+
// thấy delta/status/reasoning/done/truncated thật. WARN/PROBE/idleMs gắn
|
|
204
|
+
// vào timer này. Đây là fix cho bug: worker phát `: keepalive\n\n` mỗi 15s
|
|
205
|
+
// bất kể upstream Vercel còn sống hay đã treo → nếu reset idle theo wire,
|
|
206
|
+
// status đếm thời gian sẽ chạy mãi không bao giờ trigger probe/abort.
|
|
207
|
+
let wireIdle = null;
|
|
208
|
+
let contentIdle = null;
|
|
209
|
+
let warnTimer = null;
|
|
210
|
+
let probeTimer = null;
|
|
211
|
+
let probeInFlight = false;
|
|
212
|
+
const WARN_MS = Math.min(8000, idleMs / 3);
|
|
213
|
+
const PROBE_MS = Math.min(12000, (idleMs * 2) / 3);
|
|
214
|
+
const WIRE_MS = idleMs * 2; // socket dead-cứng (mất cả heartbeat) — ngưỡng rộng
|
|
215
|
+
const clearContentTimers = () => {
|
|
216
|
+
clearTimeout(warnTimer); warnTimer = null;
|
|
217
|
+
clearTimeout(probeTimer); probeTimer = null;
|
|
218
|
+
clearTimeout(contentIdle); contentIdle = null;
|
|
219
|
+
};
|
|
220
|
+
const armContent = () => {
|
|
221
|
+
clearContentTimers();
|
|
222
|
+
warnTimer = setTimeout(() => {
|
|
223
|
+
if (onStatus) onStatus("Đang chờ proxy phản hồi…");
|
|
224
|
+
}, WARN_MS);
|
|
225
|
+
probeTimer = setTimeout(async () => {
|
|
226
|
+
if (probeInFlight || ctrl.signal.aborted) return;
|
|
227
|
+
probeInFlight = true;
|
|
228
|
+
try {
|
|
229
|
+
const probeCtl = new AbortController();
|
|
230
|
+
const probeT = setTimeout(() => probeCtl.abort(), 3000);
|
|
231
|
+
const r = await fetch(config.gatewayUrl + "/api/usage", { headers: authHeaders(), signal: probeCtl.signal });
|
|
232
|
+
clearTimeout(probeT);
|
|
233
|
+
if (!r.ok && r.status >= 500) throw new Error("proxy 5xx");
|
|
234
|
+
} catch {
|
|
235
|
+
if (!ctrl.signal.aborted) {
|
|
236
|
+
timedOut = true;
|
|
237
|
+
if (onStatus) onStatus("Proxy không phản hồi — đang gọi lại model…");
|
|
238
|
+
ctrl.abort();
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
probeInFlight = false;
|
|
242
|
+
}
|
|
243
|
+
}, PROBE_MS);
|
|
244
|
+
contentIdle = setTimeout(() => {
|
|
165
245
|
timedOut = true;
|
|
166
246
|
ctrl.abort();
|
|
167
247
|
}, idleMs);
|
|
168
248
|
};
|
|
249
|
+
const armWire = () => {
|
|
250
|
+
clearTimeout(wireIdle);
|
|
251
|
+
wireIdle = setTimeout(() => {
|
|
252
|
+
// Mất cả heartbeat → socket chết cứng. Abort, lớp trên sẽ retry.
|
|
253
|
+
timedOut = true;
|
|
254
|
+
ctrl.abort();
|
|
255
|
+
}, WIRE_MS);
|
|
256
|
+
};
|
|
169
257
|
|
|
170
258
|
let text = "";
|
|
171
259
|
let reasoning = "";
|
|
@@ -184,6 +272,12 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
184
272
|
} catch {
|
|
185
273
|
return;
|
|
186
274
|
}
|
|
275
|
+
// Có ít nhất 1 field nội dung thực → upstream đang nghĩ/nói. Reset content
|
|
276
|
+
// idle/warn/probe. JSON rỗng `{}` (ping) hoặc comment `: keepalive` của
|
|
277
|
+
// worker KHÔNG khớp điều kiện này → không che mất idle detection.
|
|
278
|
+
if (p.status || p.delta || p.reasoning || p.done || p.truncated || p.error) {
|
|
279
|
+
armContent();
|
|
280
|
+
}
|
|
187
281
|
if (p.status && onStatus) onStatus(p.status);
|
|
188
282
|
if (p.delta) {
|
|
189
283
|
text += p.delta;
|
|
@@ -200,7 +294,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
200
294
|
};
|
|
201
295
|
|
|
202
296
|
try {
|
|
203
|
-
|
|
297
|
+
armWire();
|
|
298
|
+
armContent();
|
|
204
299
|
const resp = await fetch(config.gatewayUrl + endpoint, {
|
|
205
300
|
method: "POST",
|
|
206
301
|
headers: authHeaders(),
|
|
@@ -214,7 +309,7 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
214
309
|
let buf = "";
|
|
215
310
|
while (true) {
|
|
216
311
|
const { done, value } = await reader.read();
|
|
217
|
-
|
|
312
|
+
armWire(); // bất kỳ byte nào tới → socket còn thở, gia hạn wire idle
|
|
218
313
|
if (done) break;
|
|
219
314
|
buf += decoder.decode(value, { stream: true });
|
|
220
315
|
let nl;
|
|
@@ -239,7 +334,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
|
|
|
239
334
|
if (mode === "chat" && text) return { text, reasoning, truncated: true };
|
|
240
335
|
throw err;
|
|
241
336
|
} finally {
|
|
242
|
-
clearTimeout(
|
|
337
|
+
clearTimeout(wireIdle);
|
|
338
|
+
clearContentTimers();
|
|
243
339
|
signal?.removeEventListener("abort", onUserAbort);
|
|
244
340
|
}
|
|
245
341
|
}
|
package/src/i18n.js
CHANGED
|
@@ -130,6 +130,9 @@ export const t = {
|
|
|
130
130
|
loopAutoStop: (n) => `Loop tự dừng sau tick #${n} — model phát <<LOOP_DONE>> (task hoàn tất).`,
|
|
131
131
|
loopAlreadyRunning: "Đã có loop đang chạy. /loop stop trước khi đặt loop mới.",
|
|
132
132
|
learning: "đang chưng cất bài học vào noob.md…",
|
|
133
|
+
learnSuggest: (n) => `💡 Phiên này có ${n} lượt. Gõ /learn trước để chưng cất bài học vào noob.md (sau khi /new thì history sẽ mất).`,
|
|
134
|
+
memoryStatus: (lines, rules, notes, ago) => `📝 noob.md: ${lines} dòng (${rules} rules, ${notes} notes) · cập nhật ${ago}`,
|
|
135
|
+
memoryMissing: "📝 noob.md: chưa có — gõ /init để tạo từ dự án.",
|
|
133
136
|
compactRunning: "đang tóm tắt phiên để gọn ngữ cảnh…",
|
|
134
137
|
compactEmpty: "Phiên còn trống — không có gì để tóm tắt.",
|
|
135
138
|
compactSkipped: "Phiên còn ngắn hoặc tóm tắt thất bại — bỏ qua.",
|
package/src/memory.js
CHANGED
|
@@ -19,3 +19,32 @@ export function loadMemory() {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// Thống kê nhanh về noob.md: dùng cho (a) footer trong system prompt để model
|
|
24
|
+
// biết memory "tươi" hay "stale" và (b) banner khi mở noob để user thấy model
|
|
25
|
+
// có memory gì. Trả về null nếu chưa có file.
|
|
26
|
+
export function memoryStats() {
|
|
27
|
+
let txt;
|
|
28
|
+
try {
|
|
29
|
+
txt = fs.readFileSync(memoryPath(), "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!txt.trim()) return null;
|
|
34
|
+
const lines = txt.split("\n");
|
|
35
|
+
let rules = 0;
|
|
36
|
+
let notes = 0;
|
|
37
|
+
let inSection = "";
|
|
38
|
+
for (const l of lines) {
|
|
39
|
+
if (/^##\s+Rules\b/i.test(l)) { inSection = "rules"; continue; }
|
|
40
|
+
if (/^##\s+Notes\b/i.test(l)) { inSection = "notes"; continue; }
|
|
41
|
+
if (/^##\s/.test(l)) { inSection = ""; continue; } // mục khác → reset
|
|
42
|
+
if (/^\s*[-*]\s+/.test(l)) {
|
|
43
|
+
if (inSection === "rules") rules++;
|
|
44
|
+
else if (inSection === "notes") notes++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let mtime = 0;
|
|
48
|
+
try { mtime = fs.statSync(memoryPath()).mtimeMs; } catch {}
|
|
49
|
+
return { lines: lines.length, rules, notes, mtime, path: memoryPath() };
|
|
50
|
+
}
|
package/src/repl.js
CHANGED
|
@@ -6,12 +6,12 @@ import { createTui } from "./tui.js";
|
|
|
6
6
|
import { runAgent, maybeSummarize } from "./agent.js";
|
|
7
7
|
import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
|
|
8
8
|
import { TokenMeter } from "./tokens.js";
|
|
9
|
-
import { stream, usage, ApiError } from "./api.js";
|
|
9
|
+
import { stream, usage, ApiError, resetMemoryToken } from "./api.js";
|
|
10
10
|
import { runTool, describe, DESTRUCTIVE, addRoot, listRoots } from "./tools.js";
|
|
11
11
|
import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
|
|
12
12
|
import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
|
|
13
13
|
import { config } from "./config.js";
|
|
14
|
-
import { loadMemory, memoryPath } from "./memory.js";
|
|
14
|
+
import { loadMemory, memoryPath, memoryStats } from "./memory.js";
|
|
15
15
|
import { t } from "./i18n.js";
|
|
16
16
|
import { checkLatest, runUpdate, CURRENT } from "./update.js";
|
|
17
17
|
import * as sessions from "./sessions.js";
|
|
@@ -360,7 +360,11 @@ export async function startRepl(opts = {}) {
|
|
|
360
360
|
);
|
|
361
361
|
console.log(c.dim("\n " + t.sessionResumeHint) + "\n");
|
|
362
362
|
}
|
|
363
|
-
const startFresh = () =>
|
|
363
|
+
const startFresh = () => {
|
|
364
|
+
session = sessions.newSession({ cwd: process.cwd(), model: state.model.id });
|
|
365
|
+
// Reset per-session upstream memory token so the next chat starts fresh.
|
|
366
|
+
resetMemoryToken();
|
|
367
|
+
};
|
|
364
368
|
|
|
365
369
|
// /frontend-design <yêu cầu> — vận dụng skill frontend-design (skills/frontend-design/SKILL.md)
|
|
366
370
|
// để model tạo UI frontend chất lượng cao, tránh "AI slop" aesthetic.
|
|
@@ -535,6 +539,20 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
535
539
|
// bắt nhầm khi model chỉ NHẮC tới token giữa văn xuôi. Không bao giờ chấp nhận
|
|
536
540
|
// ở lượt lập kế hoạch (xem vòng lặp bên dưới).
|
|
537
541
|
const ultraIsDone = (a) => a.trimEnd().endsWith(ULTRA_DONE);
|
|
542
|
+
// Detect "stuck": model bối rối, không nhận task, chỉ hỏi lại user hoặc spam
|
|
543
|
+
// list_dir/ls vô nghĩa. Xảy ra khi goal trống nghĩa / bị paste system prompt /
|
|
544
|
+
// model mất ngữ cảnh. 2 vòng stuck liên tiếp → auto-exit để không loop vô hạn.
|
|
545
|
+
const STUCK_PHRASES = [
|
|
546
|
+
"chưa giao task", "chưa nêu tác vụ", "chưa có yêu cầu", "chưa có task",
|
|
547
|
+
"không nhận task", "không thể nhận vai", "bạn muốn mình làm gì",
|
|
548
|
+
"chưa rõ yêu cầu", "cần mục tiêu rõ", "vui lòng cho biết",
|
|
549
|
+
"please provide", "what would you like", "no task", "clarify",
|
|
550
|
+
];
|
|
551
|
+
const ultraLooksStuck = (a) => {
|
|
552
|
+
if (!a) return true;
|
|
553
|
+
const s = a.toLowerCase();
|
|
554
|
+
return STUCK_PHRASES.some((p) => s.includes(p));
|
|
555
|
+
};
|
|
538
556
|
const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
|
|
539
557
|
Mục tiêu tổng: ${goal}
|
|
540
558
|
|
|
@@ -568,10 +586,23 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
|
|
|
568
586
|
let answer = await handle(ultraStart(goal));
|
|
569
587
|
persist();
|
|
570
588
|
let i = 0;
|
|
589
|
+
let stuckStreak = 0; // đếm vòng liên tiếp model bối rối / không nhận task
|
|
590
|
+
const STUCK_MAX = 2;
|
|
571
591
|
// Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
|
|
572
592
|
// "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
|
|
593
|
+
// Cũng dừng sớm nếu model 2 vòng liên tiếp tỏ ra mất ngữ cảnh (goal trống
|
|
594
|
+
// nghĩa, paste system prompt, model hỏi lại user) → tránh spam list_dir.
|
|
573
595
|
while (state.ultra && i < MAX_QUESTS) {
|
|
574
596
|
if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
|
|
597
|
+
if (ultraLooksStuck(answer)) {
|
|
598
|
+
stuckStreak++;
|
|
599
|
+
if (stuckStreak >= STUCK_MAX) {
|
|
600
|
+
console.log(c.err(" ⚠ ULTRA stuck: model không nhận task " + stuckStreak + " vòng liên tiếp. Thoát. Gõ /ultra <mục tiêu rõ> để thử lại."));
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
stuckStreak = 0;
|
|
605
|
+
}
|
|
575
606
|
i++;
|
|
576
607
|
console.log(c.accent(" ↻ " + t.ultraQuest(i)));
|
|
577
608
|
answer = await handle(ultraContinue(goal));
|
|
@@ -841,6 +872,17 @@ NGUYÊN TẮC:
|
|
|
841
872
|
tui.start();
|
|
842
873
|
banner();
|
|
843
874
|
printStatus(state);
|
|
875
|
+
// noob.md status line — cho user thấy model có memory gì (số dòng, rules/notes,
|
|
876
|
+
// bao lâu cập nhật). Nếu chưa có → gợi ý /init. Giúp user tin tưởng model có
|
|
877
|
+
// "trí nhớ" thay vì cảm giác mù context khi bắt đầu phiên mới.
|
|
878
|
+
{
|
|
879
|
+
const stats = memoryStats();
|
|
880
|
+
if (stats) {
|
|
881
|
+
console.log(c.dim(` 📝 noob.md: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}`));
|
|
882
|
+
} else {
|
|
883
|
+
console.log(c.dim(` 📝 noob.md: chưa có — gõ /init để tạo từ dự án.`));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
844
886
|
if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
|
|
845
887
|
else console.log(c.dim(" " + t.ready + "\n"));
|
|
846
888
|
|
|
@@ -1045,6 +1087,10 @@ NGUYÊN TẮC:
|
|
|
1045
1087
|
signal: abort.signal,
|
|
1046
1088
|
tokenMeter,
|
|
1047
1089
|
goal: state.goal,
|
|
1090
|
+
// Breadcrumbs: 5 phiên gần nhất CÙNG workspace, trừ phiên hiện tại.
|
|
1091
|
+
// Model thấy "đã làm gì" trước đó dù chưa /resume — sửa cảm giác
|
|
1092
|
+
// "model không nhớ session" khi user mở phiên mới trong cùng dự án.
|
|
1093
|
+
recentSessions: sessions.list(5, process.cwd()).filter((s) => s.id !== session?.id),
|
|
1048
1094
|
extraToolsDoc: state.agentMode ? spawnAgentToolsDoc(0) : "",
|
|
1049
1095
|
onStatus: () => tick(t.thinking),
|
|
1050
1096
|
onSteer: () => {
|
|
@@ -1313,6 +1359,15 @@ NGUYÊN TẮC:
|
|
|
1313
1359
|
break;
|
|
1314
1360
|
case "clear":
|
|
1315
1361
|
case "new":
|
|
1362
|
+
// Nếu phiên hiện tại có nhiều lượt (≥ 5 user turns) → nhắc /learn TRƯỚC
|
|
1363
|
+
// khi xoá, vì sau khi clear thì history mất và /learn sẽ chạy trên
|
|
1364
|
+
// history rỗng. In hint để user tự quyết định; không block (UX).
|
|
1365
|
+
{
|
|
1366
|
+
const userTurns = state.history.filter((m) => m.role === "user").length;
|
|
1367
|
+
if (userTurns >= 5) {
|
|
1368
|
+
console.log(c.dim(` 💡 Phiên này có ${userTurns} lượt. Gõ /learn trước để chưng cất bài học vào noob.md (sau khi /new thì history sẽ mất).`));
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1316
1371
|
persist(); // giữ lại phiên cũ trên đĩa
|
|
1317
1372
|
state.history = [];
|
|
1318
1373
|
state._longSessionWarned = false; // reset cờ cảnh báo phiên dài
|
|
@@ -1320,6 +1375,13 @@ NGUYÊN TẮC:
|
|
|
1320
1375
|
if (!tui.tty) console.clear();
|
|
1321
1376
|
banner();
|
|
1322
1377
|
printStatus(state);
|
|
1378
|
+
// noob.md status cũng hiện ở banner phiên mới (gọi lại sau clear)
|
|
1379
|
+
{
|
|
1380
|
+
const stats = memoryStats();
|
|
1381
|
+
if (stats) {
|
|
1382
|
+
console.log(c.dim(` 📝 noob.md: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}`));
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1323
1385
|
console.log(c.dim(" " + t.ctxCleared + "\n"));
|
|
1324
1386
|
break;
|
|
1325
1387
|
case "resume":
|
package/src/tui.js
CHANGED
|
@@ -12,6 +12,58 @@ import { c } from "./ui.js";
|
|
|
12
12
|
const ESC = "\x1b";
|
|
13
13
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
14
|
const visLen = (s) => s.replace(ANSI_RE, "").length;
|
|
15
|
+
// Trả index trong `text` mà tại đó vị trí VISUAL đạt `targetVis`. Bỏ qua toàn
|
|
16
|
+
// bộ ANSI escape sequence khi đếm. Dùng cho soft-wrap khi text có màu.
|
|
17
|
+
function findVisPos(text, targetVis) {
|
|
18
|
+
let vis = 0;
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < text.length && vis < targetVis) {
|
|
21
|
+
if (text[i] === "\x1b") {
|
|
22
|
+
const m = text.slice(i).match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/);
|
|
23
|
+
if (m) { i += m[0].length; continue; }
|
|
24
|
+
}
|
|
25
|
+
vis++;
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
return i;
|
|
29
|
+
}
|
|
30
|
+
// Soft-wrap `text` thành tối đa `maxLines` dòng, sao cho mỗi dòng có độ rộng
|
|
31
|
+
// VISUAL ≤ `width`. Ưu tiên cắt tại khoảng trắng gần cuối (word boundary); nếu
|
|
32
|
+
// không có space hợp lý → hard-slice theo visual position. Nếu text gốc có
|
|
33
|
+
// ANSI escape thì MỌI dòng output (kể cả dòng cuối "vừa khít") đều kết thúc
|
|
34
|
+
// bằng `\x1b[0m` reset — chống "chảy máu" màu khi status bar có dim/accent.
|
|
35
|
+
// Nếu vẫn còn dư → dòng cuối thêm "…".
|
|
36
|
+
function wrapText(text, width, maxLines) {
|
|
37
|
+
if (!text) return [""];
|
|
38
|
+
const hasAnsi = /\x1b/.test(text);
|
|
39
|
+
const RESET = "\x1b[0m";
|
|
40
|
+
const close = (line) => (hasAnsi ? line + RESET : line);
|
|
41
|
+
if (visLen(text) <= width) return [close(text)];
|
|
42
|
+
const lines = [];
|
|
43
|
+
let remaining = text;
|
|
44
|
+
while (remaining && lines.length < maxLines) {
|
|
45
|
+
if (visLen(remaining) <= width) {
|
|
46
|
+
lines.push(close(remaining));
|
|
47
|
+
remaining = "";
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
// Cắt tại vị trí visual = width. Sau đó thử lùi về space gần nhất (trong
|
|
51
|
+
// khoảng 30–100% width) để tránh cắt giữa từ.
|
|
52
|
+
let cutPos = findVisPos(remaining, width);
|
|
53
|
+
const slice = remaining.slice(0, cutPos);
|
|
54
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
55
|
+
if (lastSpace > width * 0.3) cutPos = lastSpace;
|
|
56
|
+
lines.push(close(remaining.slice(0, cutPos).trimEnd()));
|
|
57
|
+
remaining = remaining.slice(cutPos).trimStart();
|
|
58
|
+
}
|
|
59
|
+
if (remaining && lines.length) {
|
|
60
|
+
const last = lines.length - 1;
|
|
61
|
+
const lastLine = lines[last];
|
|
62
|
+
const body = lastLine.endsWith(RESET) ? lastLine.slice(0, -RESET.length) : lastLine;
|
|
63
|
+
lines[last] = (body.length ? body.slice(0, -1) : "") + "…" + (hasAnsi ? RESET : "");
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
15
67
|
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
68
|
|
|
17
69
|
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
|
|
@@ -126,39 +178,105 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
126
178
|
menu = [];
|
|
127
179
|
}
|
|
128
180
|
|
|
129
|
-
// ----- ô nhập → chuỗi đầy đủ + bản tô màu (paste = chip) -----
|
|
181
|
+
// ----- ô nhập → chuỗi đầy đủ + bản tô màu (paste = chip có preview) -----
|
|
182
|
+
// Chip paste: hiện `[pasted N lines: "dòng đầu tiên…"]` để user xem được
|
|
183
|
+
// nội dung trước khi Enter. N dòng thì lấy dòng 1; nếu dòng 1 dài >24 ký tự
|
|
184
|
+
// thì cắt + "…". Tổng chiều dài chip ~38 ký tự — đủ gọn để 1–2 chip vẫn
|
|
185
|
+
// wrap được trong thanh input tiêu chuẩn.
|
|
186
|
+
const PASTE_PREVIEW_MAX = 24;
|
|
187
|
+
const pastePreview = (content) => {
|
|
188
|
+
const firstLine = (content.split("\n")[0] || "").trim();
|
|
189
|
+
if (!firstLine) return "";
|
|
190
|
+
return firstLine.length > PASTE_PREVIEW_MAX
|
|
191
|
+
? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) + "…"
|
|
192
|
+
: firstLine;
|
|
193
|
+
};
|
|
130
194
|
const cellStr = (x) => (x.paste !== undefined ? x.paste : x.c);
|
|
131
|
-
const cellPlain = (x) =>
|
|
195
|
+
const cellPlain = (x) => {
|
|
196
|
+
if (x.paste === undefined) return x.c;
|
|
197
|
+
const preview = pastePreview(x.paste);
|
|
198
|
+
return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
199
|
+
};
|
|
132
200
|
const fullText = () => cells.map(cellStr).join("");
|
|
133
201
|
const coloredInput = () =>
|
|
134
|
-
cells.map((x) =>
|
|
202
|
+
cells.map((x) => {
|
|
203
|
+
if (x.paste === undefined) return x.c;
|
|
204
|
+
const preview = pastePreview(x.paste);
|
|
205
|
+
const label = preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
206
|
+
return c.dim(label);
|
|
207
|
+
}).join("");
|
|
135
208
|
|
|
136
|
-
// Dựng thanh nhập + tính
|
|
137
|
-
//
|
|
138
|
-
//
|
|
209
|
+
// Dựng thanh nhập + tính vị trí con trỏ trên màn (`cursorScreenCol` +
|
|
210
|
+
// `cursorScreenRow`). Vừa khung → tô màu đầy đủ, 1 dòng. Tràn khung → soft-
|
|
211
|
+
// wrap dọc (≤ MAX_BAR_LINES), indent các dòng nối tiếp dưới prompt để con trỏ
|
|
212
|
+
// vẫn map đúng (col = promptW + colInLine). Dòng cuối nếu vẫn bị truncate →
|
|
213
|
+
// thêm "…".
|
|
139
214
|
let cursorScreenCol = 0;
|
|
215
|
+
let cursorScreenRow = 0;
|
|
140
216
|
function renderBar() {
|
|
141
217
|
const promptW = visLen(promptLabel);
|
|
142
218
|
const budget = Math.max(4, cols() - promptW - 1);
|
|
219
|
+
const indent = " ".repeat(promptW);
|
|
143
220
|
const plains = cells.map(cellPlain);
|
|
144
|
-
|
|
145
|
-
for (let k = 0; k < cur; k++) curCol += plains[k].length;
|
|
221
|
+
const plain = plains.join("");
|
|
146
222
|
let total = 0;
|
|
147
223
|
for (const p of plains) total += p.length;
|
|
224
|
+
let curCharPos = 0;
|
|
225
|
+
for (let k = 0; k < cur; k++) curCharPos += plains[k].length;
|
|
226
|
+
|
|
227
|
+
// Vừa khung → 1 dòng như cũ, con trỏ map thẳng.
|
|
148
228
|
if (total <= budget) {
|
|
149
|
-
|
|
229
|
+
cursorScreenRow = 0;
|
|
230
|
+
cursorScreenCol = promptW + curCharPos;
|
|
150
231
|
return promptLabel + coloredInput();
|
|
151
232
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
233
|
+
|
|
234
|
+
// Tràn khung → soft-wrap dọc tối đa MAX_BAR_LINES, ưu tiên cắt tại space.
|
|
235
|
+
// Dòng 0 có promptLabel; các dòng sau thụt vào `indent` để con trỏ thẳng
|
|
236
|
+
// cột với text sau prompt. Paste chip đếm như 1 "từ" dài `[pasted N lines]`
|
|
237
|
+
// → nếu không vừa, đẩy nguyên chip sang dòng mới (vẫn đọc được).
|
|
238
|
+
const MAX_BAR_LINES = 5;
|
|
239
|
+
const lines = [];
|
|
240
|
+
let pos = 0;
|
|
241
|
+
while (pos < total && lines.length < MAX_BAR_LINES) {
|
|
242
|
+
const remaining = total - pos;
|
|
243
|
+
if (remaining <= budget) {
|
|
244
|
+
lines.push(plain.slice(pos));
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
// Tìm space gần nhất trong slice [pos, pos+budget]; nếu > 30% budget → cắt
|
|
248
|
+
// tại đó. Không có space hợp lý → hard-slice theo budget.
|
|
249
|
+
const slice = plain.slice(pos, pos + budget);
|
|
250
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
251
|
+
let cutLen = budget;
|
|
252
|
+
if (lastSpace > budget * 0.3) cutLen = lastSpace;
|
|
253
|
+
lines.push(slice.slice(0, cutLen).trimEnd());
|
|
254
|
+
pos += cutLen;
|
|
255
|
+
while (pos < total && plain[pos] === " ") pos++; // bỏ space đầu dòng sau
|
|
256
|
+
}
|
|
257
|
+
// Nếu vẫn còn dư → đánh dấu dòng cuối bị truncate.
|
|
258
|
+
if (pos < total && lines.length) {
|
|
259
|
+
const last = lines.length - 1;
|
|
260
|
+
const lastLine = lines[last];
|
|
261
|
+
lines[last] = (lastLine.length >= budget ? lastLine.slice(0, -1) : lastLine) + "…";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Map curCharPos → (line, colInLine).
|
|
265
|
+
let curLine = 0;
|
|
266
|
+
let acc = 0;
|
|
267
|
+
for (let i = 0; i < lines.length; i++) {
|
|
268
|
+
const lineLen = lines[i].length;
|
|
269
|
+
if (curCharPos <= acc + lineLen || i === lines.length - 1) {
|
|
270
|
+
curLine = i;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
acc += lineLen;
|
|
274
|
+
}
|
|
275
|
+
const colInLine = Math.min(curCharPos - acc, lines[curLine].length);
|
|
276
|
+
cursorScreenRow = curLine;
|
|
277
|
+
cursorScreenCol = promptW + colInLine;
|
|
278
|
+
|
|
279
|
+
return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join("\n");
|
|
162
280
|
}
|
|
163
281
|
function topRow() {
|
|
164
282
|
if (liveOut) {
|
|
@@ -166,17 +284,17 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
166
284
|
// CHỈ KHI liveOut đủ ngắn để cả dòng (liveOut + meta) chắc chắn fit trong
|
|
167
285
|
// 1 dòng terminal. Nếu liveOut quá dài → ưu tiên prose, bỏ meta lượt này
|
|
168
286
|
// (tránh terminal wrap làm dòng tạm kẹt lại trong prose vĩnh viễn — xem
|
|
169
|
-
// Note "token meter chèn vào prose" trong noob.md).
|
|
287
|
+
// Note "token meter chèn vào prose" trong noob.md). Trường hợp wrap nhiều
|
|
288
|
+
// dòng: chỉ dòng đầu ghép meta (nếu vừa), các dòng sau prose thuần.
|
|
170
289
|
if (busy && busyMeta) {
|
|
171
290
|
const meta = c.dim(" · " + busyMeta);
|
|
172
291
|
const metaLen = visLen(meta);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return liveOut + meta;
|
|
292
|
+
if (visLen(liveOut) + metaLen <= cols()) {
|
|
293
|
+
return [liveOut + meta];
|
|
176
294
|
}
|
|
177
|
-
// liveOut đã dài:
|
|
295
|
+
// liveOut đã dài: meta sẽ bị bỏ (xem note ở trên).
|
|
178
296
|
}
|
|
179
|
-
return liveOut
|
|
297
|
+
return wrapText(liveOut, cols(), 2);
|
|
180
298
|
}
|
|
181
299
|
const spin = FRAMES[frame % FRAMES.length];
|
|
182
300
|
// Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
|
|
@@ -185,12 +303,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
185
303
|
const meta = busy && busyMeta ? c.dim(" · " + busyMeta) : "";
|
|
186
304
|
const tail = busy ? c.dim(" · Ctrl+C để dừng") : "";
|
|
187
305
|
const line = c.dim(spin + " ") + statusText + meta + tail;
|
|
188
|
-
return line
|
|
306
|
+
return wrapText(line, cols(), 2);
|
|
189
307
|
}
|
|
190
308
|
if (busy) {
|
|
191
309
|
const meta = busyMeta ? " · " + busyMeta : "";
|
|
192
310
|
const line = c.dim(spin + " " + (busyLabel || "đang chạy") + meta + " · Ctrl+C để dừng");
|
|
193
|
-
return line
|
|
311
|
+
return wrapText(line, cols(), 2);
|
|
194
312
|
}
|
|
195
313
|
return null;
|
|
196
314
|
}
|
|
@@ -210,21 +328,29 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
210
328
|
function rows() {
|
|
211
329
|
const r = [];
|
|
212
330
|
const top = topRow();
|
|
213
|
-
if (top !== null) r.push(top);
|
|
331
|
+
if (top !== null) r.push(...top);
|
|
214
332
|
for (const mr of menuRows()) r.push(mr);
|
|
215
|
-
|
|
333
|
+
const bar = renderBar();
|
|
334
|
+
if (bar) r.push(...bar.split("\n"));
|
|
216
335
|
return r;
|
|
217
336
|
}
|
|
218
337
|
function eraseSeq() {
|
|
219
338
|
if (!drawn) return "\r";
|
|
220
339
|
return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
|
|
221
340
|
}
|
|
222
|
-
// Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối).
|
|
223
|
-
// đúng
|
|
224
|
-
|
|
341
|
+
// Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối, cột cuối).
|
|
342
|
+
// Đưa về đúng (row, col) của con trỏ logic: \r về cột 0 → đi lên `upBy` hàng
|
|
343
|
+
// (nếu thanh wrap nhiều dòng) → dịch phải `cursorScreenCol` cột.
|
|
344
|
+
const placeCursor = (totalRows) => {
|
|
345
|
+
const upBy = totalRows - 1 - cursorScreenRow;
|
|
346
|
+
let s = "\r";
|
|
347
|
+
if (upBy > 0) s += `${ESC}[${upBy}A`;
|
|
348
|
+
if (cursorScreenCol > 0) s += `${ESC}[${cursorScreenCol}C`;
|
|
349
|
+
return s;
|
|
350
|
+
};
|
|
225
351
|
function draw() {
|
|
226
|
-
const rs = rows(); // rows() → renderBar() cập nhật
|
|
227
|
-
w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + placeCursor() + `${ESC}[?25h`);
|
|
352
|
+
const rs = rows(); // rows() → renderBar() cập nhật cursorScreenRow/Col
|
|
353
|
+
w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + placeCursor(rs.length) + `${ESC}[?25h`);
|
|
228
354
|
prevRows = rs.length;
|
|
229
355
|
drawn = true;
|
|
230
356
|
}
|
|
@@ -234,7 +360,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
234
360
|
let s = `${ESC}[?25l` + eraseSeq();
|
|
235
361
|
s += block;
|
|
236
362
|
if (!block.endsWith("\n")) s += "\n";
|
|
237
|
-
s += rs.join("\n") + placeCursor() + `${ESC}[?25h`;
|
|
363
|
+
s += rs.join("\n") + placeCursor(rs.length) + `${ESC}[?25h`;
|
|
238
364
|
w(s);
|
|
239
365
|
prevRows = rs.length;
|
|
240
366
|
drawn = true;
|