@noobdemon/noob-cli 1.12.0 → 1.12.2

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/repl.js CHANGED
@@ -4,9 +4,9 @@ import path from 'node:path';
4
4
  import chalk from 'chalk';
5
5
  import { createTui } from './tui.js';
6
6
  import { runAgent, maybeSummarize, buildSystem, buildUserMessage } from './agent.js';
7
- import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from './subagent.js';
7
+ import { spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from './subagent.js';
8
8
  import { TokenMeter, countMessages, CONTEXT_WINDOW, countTokens } from './tokens.js';
9
- import { stream, usage, cachedUsage, resetUsageCache, ApiError, resetMemoryToken } from './api.js';
9
+ import { stream, usage, cachedUsage, resetUsageCache, evaluateQuotaWarning, ApiError, resetMemoryToken } from './api.js';
10
10
  import {
11
11
  runTool,
12
12
  describe,
@@ -35,17 +35,27 @@ import { t } from './i18n.js';
35
35
  import { checkLatest, runUpdate, CURRENT } from './update.js';
36
36
  import * as sessions from './sessions.js';
37
37
  import { loadSkill, listSkills } from './skills.js';
38
+ import {
39
+ loadGraph as kgLoad,
40
+ createEntities as kgCreateEntities,
41
+ createRelations as kgCreateRelations,
42
+ addObservations as kgAddObservations,
43
+ deleteEntities as kgDeleteEntities,
44
+ deleteObservations as kgDeleteObservations,
45
+ deleteRelations as kgDeleteRelations,
46
+ searchNodes as kgSearchNodes,
47
+ openNodes as kgOpenNodes,
48
+ formatGraphPretty as kgFormat,
49
+ kgFilePath,
50
+ KGEntityNotFound,
51
+ KGMarkerError,
52
+ } from './kg.js';
38
53
  import { saveWorkflow, loadWorkflow } from './workflows.js';
39
54
  import {
40
55
  createRun as createWorkflowRun,
41
56
  loadRun as loadWorkflowRun,
42
57
  listRuns as listWorkflowRuns,
43
58
  closeRun as closeWorkflowRun,
44
- hashTask as hashWorkflowTask,
45
- lookupTaskResult as lookupWorkflowTaskResult,
46
- recordTaskStart as recordWorkflowTaskStart,
47
- recordTaskDone as recordWorkflowTaskDone,
48
- recordTaskFailed as recordWorkflowTaskFailed,
49
59
  } from './workflow-runs.js';
50
60
  import { getBuiltinWorkflow, loadBuiltinPrompt } from './workflows-builtin.js';
51
61
  import { SLASH, completeInput, mentionedFiles } from './repl/complete.js';
@@ -70,6 +80,7 @@ import { createState } from './repl/state.js';
70
80
  import {
71
81
  shortCwd, shortPath, relTime, firstLine, truncate, fmtTime, fmtK, preview,
72
82
  } from './repl/utils.js';
83
+ import { createAgentDispatcher } from './repl/agent-dispatch.js';
73
84
  export async function startRepl(opts = {}) {
74
85
  const state = createState(opts, config);
75
86
  const tokenMeter = new TokenMeter();
@@ -138,6 +149,53 @@ export async function startRepl(opts = {}) {
138
149
  return tui.read(prompt);
139
150
  }
140
151
 
152
+ // ── permission prompts (y/n/a) ────────────────────────────────────────
153
+ // PHẢI khai báo Ở ĐÂY (ngay sau `ask`), TRƯỚC bất kỳ function nào có thể
154
+ // gọi tới — trước đây 3 const này nằm rải rác (line ~405, ~1648, ~1655)
155
+ // gây TDZ ReferenceError: "Cannot access 'askAddRoot' before initialization"
156
+ // khi execToolCore / workflowExecute chạy lần đầu. Logic vẫn ở permission.js,
157
+ // chỉ wire prompt UI (tui/ask/pending/...) từ scope startRepl.
158
+ //
159
+ // ĐỪNG DI CHUYỂN khối này lên trên: dependencies cần sẵn — `tui` (~line 112),
160
+ // `pending` (~line 107), `ask` (~line 136), `c`/`t`/`truncate` (import top).
161
+ // Nếu dời lên trước `ask` sẽ lại TDZ. Vị trí hiện tại là điểm sớm nhất hợp lệ.
162
+ const askPermission = (name, targetPath) =>
163
+ _askPermission(name, { tui, ask, pending, c, t, truncate, targetPath });
164
+ const askAddRoot = (root, targetPath) =>
165
+ _askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate });
166
+ const askWorkflowAgentMode = () =>
167
+ _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
168
+
169
+ // ── quota soft-cap guard ───────────────────────────────────────────
170
+ // Trước khi start ULTRA / loop / workflow (3 lệnh ăn nhiều request nhất),
171
+ // check quota cached. Nếu sắp cạn → cảnh báo + yêu cầu confirm y. Mục đích:
172
+ // chống user tự đốt key vô ý, đặc biệt key trial 200 req hết là dead.
173
+ //
174
+ // Trả về true (cho phép tiếp) / false (huỷ). Trong --yolo vẫn IN cảnh báo
175
+ // nhưng skip confirm (giữ visibility).
176
+ async function checkQuotaBeforeHeavy(label) {
177
+ let u = cachedUsage();
178
+ if (!u) {
179
+ // Chưa có cache → fetch 1 lần (cache 90s ở api.js, không gọi lại nhiều).
180
+ try {
181
+ u = await usage();
182
+ } catch {
183
+ return true; // không lấy được usage → đừng chặn user.
184
+ }
185
+ }
186
+ const warn = evaluateQuotaWarning(u, label, t);
187
+ if (!warn) return true;
188
+ console.log(c.err(' ⚠ ' + t.quotaWarnTitle + ': ' + warn.message));
189
+ if (state.yolo) {
190
+ console.log(c.dim(' ' + t.quotaYoloBypass));
191
+ return true;
192
+ }
193
+ const ans = ((await ask(c.tool(' ' + t.quotaConfirm + ' '))) ?? '').trim().toLowerCase();
194
+ if (ans === 'y' || ans === 'yes' || ans === 'có') return true;
195
+ console.log(c.dim(' ' + t.quotaCancelled(label)));
196
+ return false;
197
+ }
198
+
141
199
  // NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
142
200
  // "event loop cạn"). 'beforeExit' nổ = loop cạn (stdin chết). 'exit' = thoát.
143
201
  if (process.env.NOOB_DEBUG === '1') {
@@ -399,15 +457,12 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
399
457
  await workflowExecute(trimmed);
400
458
  }
401
459
 
402
- // Hỏi quyền bật agent mode để chạy workflow. CHỈ chấp nhận y/n (Enter = yes).
403
- // Nếu nhận dòng lạ & dài (paste nhầm tin nhắn) → xếp hàng + hỏi lại (y hệt
404
- // askPermission / askAddRoot) để user khỏi phải gõ lại /workflow.
405
- const askWorkflowAgentMode = () => _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
406
-
407
460
  // Chạy thật workflow prompt — chia sẻ giữa ad-hoc và `run <name>`.
408
461
  // `builtInName` (optional): nếu có thì SKIP loadSkill dynamic-workflows (prompt
409
462
  // built-in đã hardcode pattern + step cụ thể rồi, không cần model design lại).
410
463
  async function workflowExecute(userRequest, { builtInName = null, resumeRun = null } = {}) {
464
+ // Resume KHÔNG cần check (đang tiếp tục run dở, user đã chấp nhận trước đó).
465
+ if (!resumeRun && !(await checkQuotaBeforeHeavy('/workflow'))) return;
411
466
  let prompt;
412
467
  if (builtInName) {
413
468
  // Built-in prompt đã đầy đủ PLAN + 4 bước thực thi — KHÔNG wrap thêm skill.
@@ -440,7 +495,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
440
495
  2. Spawn sub-agent theo plan — mỗi prompt sub-agent có 5 mục GOAL/INPUTS/METHOD/OUTPUT SHAPE/STOP CONDITION.
441
496
  3. Gom kết quả, dedupe, reconcile mâu thuẫn, viết báo cáo cuối tiếng Việt. Sub-agent KHÔNG nói trực tiếp với user.`;
442
497
  }
443
- if (!state.agent) {
498
+ if (!state.agentMode) {
444
499
  // Đừng tự bật — workflow cần spawn_agent, đây là quyền nặng (sub-agent chạy
445
500
  // tool độc lập). Hỏi 1 lần, user chọn y thì bật & chạy, n thì huỷ sạch +
446
501
  // gợi ý /agent. Tránh buộc user gõ lại /workflow sau khi /agent.
@@ -454,7 +509,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
454
509
  )
455
510
  );
456
511
  }
457
- state.agent = true;
512
+ state.agentMode = true;
458
513
  console.log(
459
514
  c.tool(' ✓ ' + (t.workflowAgentEnabled || 'đã bật agent mode cho workflow này.'))
460
515
  );
@@ -782,6 +837,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
782
837
  async function runUltra(goal) {
783
838
  if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
784
839
  if (!goal) return console.log(c.err(' ' + t.ultraNeedGoal));
840
+ if (!(await checkQuotaBeforeHeavy('/ultra'))) return;
785
841
  state.mode = 'chat'; // tự hành chỉ chạy ở chế độ agent
786
842
  state.ultra = true;
787
843
  console.log(c.accent(' 🚀 ' + t.ultraOn));
@@ -954,6 +1010,7 @@ Sau khi xong tick này, TỰ ĐÁNH GIÁ:
954
1010
  if (!task) return console.log(c.err(' ' + t.loopNeedArgs));
955
1011
  const intervalMs = parseInterval(intervalStr);
956
1012
  if (!intervalMs) return console.log(c.err(' ' + t.loopBadInterval(intervalStr)));
1013
+ if (!(await checkQuotaBeforeHeavy('/loop'))) return;
957
1014
  const normInterval = fmtMs(intervalMs);
958
1015
  state.loop = {
959
1016
  intervalMs,
@@ -1090,6 +1147,50 @@ NGUYÊN TẮC:
1090
1147
  console.log(c.dim(' ' + memoryPath() + t.memoryStat(mem.split('\n').length)));
1091
1148
  }
1092
1149
 
1150
+ // /memory stats — thống kê size noob.md + noob-archive.md, cảnh báo bloat.
1151
+ // Mỗi turn agent inject toàn bộ noob.md vào prompt → file dài = token bloat.
1152
+ // Ngưỡng khuyến nghị: ≤ 200 dòng / ≤ 20KB (~5k token cl100k_base).
1153
+ function runMemoryStats() {
1154
+ const fmtBytes = (n) => (n >= 1024 ? (n / 1024).toFixed(1) + 'KB' : n + 'B');
1155
+ const mp = memoryPath();
1156
+ const ap = path.resolve(process.cwd(), 'noob-archive.md');
1157
+ console.log(c.tool(' ' + t.memoryStatsHeader));
1158
+ let mainLines = 0;
1159
+ let mainBytes = 0;
1160
+ let mainKB = 0;
1161
+ try {
1162
+ const st = fs.statSync(mp);
1163
+ mainBytes = st.size;
1164
+ mainKB = mainBytes / 1024;
1165
+ const txt = fs.readFileSync(mp, 'utf8');
1166
+ mainLines = txt.split('\n').length;
1167
+ const stats = memoryStats() || { rules: 0, notes: 0, mtime: st.mtimeMs };
1168
+ console.log(
1169
+ c.dim(
1170
+ t.memoryStatsMain(mainLines, fmtBytes(mainBytes), stats.rules, stats.notes, relTime(stats.mtime))
1171
+ )
1172
+ );
1173
+ } catch {
1174
+ console.log(c.dim(' noob.md : (chưa có — gõ /init để tạo)'));
1175
+ }
1176
+ try {
1177
+ const st = fs.statSync(ap);
1178
+ const txt = fs.readFileSync(ap, 'utf8');
1179
+ const lines = txt.split('\n').length;
1180
+ console.log(c.dim(t.memoryStatsArchive(lines, fmtBytes(st.size), relTime(st.mtimeMs))));
1181
+ } catch {
1182
+ console.log(c.dim(t.memoryStatsArchiveMissing));
1183
+ }
1184
+ const warnLines = mainLines > 200;
1185
+ const warnBytes = mainKB > 20;
1186
+ if (!warnLines && !warnBytes) {
1187
+ if (mainLines > 0) console.log(c.ok(' ' + t.memoryStatsOk));
1188
+ } else {
1189
+ if (warnLines) console.log(c.err(' ' + t.memoryStatsWarnLines(mainLines)));
1190
+ if (warnBytes) console.log(c.err(' ' + t.memoryStatsWarnBytes(mainKB.toFixed(1))));
1191
+ }
1192
+ }
1193
+
1093
1194
  // /auto-yolo — lưu/bỏ yolo làm MẶC ĐỊNH (mỗi lần mở noob tự bật). Vì yolo tự
1094
1195
  // duyệt mọi thao tác, BẬT phải xác nhận lần 2 bằng cách gõ 'y'. TẮT thì không.
1095
1196
  async function toggleAutoYolo() {
@@ -1312,126 +1413,13 @@ NGUYÊN TẮC:
1312
1413
  startSpin(t.thinking);
1313
1414
  let printer = null;
1314
1415
 
1315
- const dispatchTool = async (name, input, depth = 0) => {
1316
- // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
1317
- // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
1318
- if (name === 'spawn_agent' || name === 'spawn_agents') {
1319
- if (!state.agentMode)
1320
- return {
1321
- allow: true,
1322
- result: 'ERROR: agent mode đang TẮT — gõ /agent on để bật trước khi spawn.',
1323
- };
1324
- if (depth >= MAX_SUBAGENT_DEPTH)
1325
- return {
1326
- allow: true,
1327
- result: `ERROR: đã đạt depth tối đa (${MAX_SUBAGENT_DEPTH}) — không spawn thêm.`,
1328
- };
1329
- const tasks =
1330
- name === 'spawn_agent' ? [input] : Array.isArray(input?.agents) ? input.agents : [];
1331
- if (!tasks.length) return { allow: true, result: 'ERROR: thiếu task cho sub-agent.' };
1332
- stopSpin();
1333
- console.log(
1334
- chalk.hex('#8b5cf6')(
1335
- ` ⊕ spawn ${tasks.length} sub-agent (depth ${depth + 1}/${MAX_SUBAGENT_DEPTH})`
1336
- )
1337
- );
1338
- startSpin(t.thinking);
1339
- try {
1340
- const runData = state.workflowRun?.data || null;
1341
- const results = await Promise.all(
1342
- tasks.map((task, i) => {
1343
- // Per-sub-agent model routing: task.model có thể là id model hoặc tên thân thiện.
1344
- // findModel() resolve cả hai; nếu không match thì fallback model của cha.
1345
- let subModel = state.model.id;
1346
- let modelTag = '';
1347
- if (task?.model) {
1348
- const m = findModel(task.model);
1349
- if (m) {
1350
- subModel = m.id;
1351
- modelTag = ` [${m.name}]`;
1352
- } else
1353
- modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
1354
- }
1355
- const taskBody = task?.task || task?.prompt || '';
1356
- const taskCtx = task?.context || '';
1357
- // Workflow journal: nếu đang trong run + task đã done lần trước → return
1358
- // cached result, tiết kiệm token. Hash = crc32(task+ctx+model).
1359
- if (runData) {
1360
- const hash = hashWorkflowTask({ task: taskBody, context: taskCtx, model: subModel });
1361
- const cached = lookupWorkflowTaskResult(runData, hash);
1362
- if (cached !== null) {
1363
- stopSpin();
1364
- console.log(
1365
- chalk.hex('#8b5cf6')(
1366
- ` ⊘ sub-agent #${i + 1}${modelTag} skip — đã done trong run trước (cached)`
1367
- )
1368
- );
1369
- startSpin(t.thinking);
1370
- return Promise.resolve(
1371
- `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
1372
- );
1373
- }
1374
- recordWorkflowTaskStart(runData, {
1375
- hash,
1376
- task: taskBody,
1377
- context: taskCtx,
1378
- model: subModel,
1379
- });
1380
- // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
1381
- return runSubAgent({
1382
- task: taskBody,
1383
- context: taskCtx,
1384
- depth: depth + 1,
1385
- model: subModel,
1386
- signal: abort.signal,
1387
- tokenMeter,
1388
- dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
1389
- onLog: (msg) => {
1390
- stopSpin();
1391
- console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
1392
- startSpin(t.thinking);
1393
- },
1394
- })
1395
- .then((r) => {
1396
- recordWorkflowTaskDone(runData, hash, r);
1397
- return `── sub-agent #${i + 1}${modelTag} ──\n${r}`;
1398
- })
1399
- .catch((e) => {
1400
- recordWorkflowTaskFailed(runData, hash, e);
1401
- return `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`;
1402
- });
1403
- }
1404
- // Không có active workflow run → behavior cũ.
1405
- return runSubAgent({
1406
- task: taskBody,
1407
- context: taskCtx,
1408
- depth: depth + 1,
1409
- model: subModel,
1410
- signal: abort.signal,
1411
- tokenMeter,
1412
- dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
1413
- onLog: (msg) => {
1414
- stopSpin();
1415
- console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
1416
- startSpin(t.thinking);
1417
- },
1418
- })
1419
- .then((r) => `── sub-agent #${i + 1}${modelTag} ──\n${r}`)
1420
- .catch(
1421
- (e) => `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`
1422
- );
1423
- })
1424
- );
1425
- return { allow: true, result: results.join('\n\n') };
1426
- } catch (err) {
1427
- return { allow: true, result: 'ERROR sub-agent: ' + (err?.message || String(err)) };
1428
- }
1429
- }
1430
- stopSpin();
1431
- const res = await execTool(name, input);
1432
- startSpin(t.thinking);
1433
- return res;
1434
- };
1416
+ // dispatchTool: xử spawn_agent/spawn_agents (sub-agent + workflow journal)
1417
+ // hoặc forward sang execTool cho các tool thường. Logic tách sang
1418
+ // src/repl/agent-dispatch.js (v1.12.x). Factory được gọi MỖI turn vì abort
1419
+ // được rebind trong handle() không cache.
1420
+ const dispatchTool = createAgentDispatcher({
1421
+ state, abort, tokenMeter, stopSpin, startSpin, execTool,
1422
+ });
1435
1423
 
1436
1424
  const answer = await runAgent({
1437
1425
  history: state.history,
@@ -1483,6 +1471,9 @@ NGUYÊN TẮC:
1483
1471
  } finally {
1484
1472
  abort = null;
1485
1473
  tui.setBusy(false);
1474
+ // Reset turn-scoped auto-approve — chỉ áp dụng trong runAgent vừa rồi.
1475
+ // (autoApprove + autoApproveFile vẫn giữ nguyên cho phiên.)
1476
+ state.autoApproveTurn.clear();
1486
1477
  // Auto-compact dựa trên context tokens thay vì chars.
1487
1478
  // Với CONTEXT_WINDOW = 2M tokens (xem src/tokens.js):
1488
1479
  // 75% (1.5M tokens) → auto compact
@@ -1592,12 +1583,34 @@ NGUYÊN TẮC:
1592
1583
  }
1593
1584
  }
1594
1585
 
1595
- if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
1596
- const a = await askPermission(name);
1597
- if (a === 'a') state.autoApprove.add(name);
1598
- else if (a === 'n') {
1599
- console.log(c.err(' ' + t.denied));
1600
- return { allow: false };
1586
+ if (DESTRUCTIVE.has(name) && !state.yolo) {
1587
+ // Resolve absolute path nếu tool có .path (edit_file/write_file).
1588
+ // run_command không path → bỏ qua scope `f`.
1589
+ const rawPath = typeof input?.path === 'string' && input.path.length > 0 ? input.path : null;
1590
+ let absPath = null;
1591
+ if (rawPath) {
1592
+ try { absPath = path.resolve(process.cwd(), rawPath); } catch { absPath = null; }
1593
+ }
1594
+ const fileKey = absPath ? name + ':' + absPath : null;
1595
+
1596
+ const preApproved =
1597
+ state.autoApprove.has(name) ||
1598
+ state.autoApproveTurn.has(name) ||
1599
+ (fileKey && state.autoApproveFile.has(fileKey));
1600
+
1601
+ if (!preApproved) {
1602
+ const a = await askPermission(name, absPath);
1603
+ if (a === 'a') state.autoApprove.add(name);
1604
+ else if (a === 't') {
1605
+ state.autoApproveTurn.add(name);
1606
+ console.log(c.dim(' ' + t.permGrantedTurn(name)));
1607
+ } else if (a === 'f' && fileKey) {
1608
+ state.autoApproveFile.add(fileKey);
1609
+ console.log(c.dim(' ' + t.permGrantedFile(name, rawPath)));
1610
+ } else if (a === 'n') {
1611
+ console.log(c.err(' ' + t.denied));
1612
+ return { allow: false };
1613
+ }
1601
1614
  }
1602
1615
  }
1603
1616
 
@@ -1642,18 +1655,6 @@ NGUYÊN TẮC:
1642
1655
  }
1643
1656
  }
1644
1657
 
1645
- // Hỏi user có muốn cấp quyền folder ngoài workspace cho tool call hiện tại
1646
- // hay không. Trả về "y" | "n" | "a" (luôn). y = chỉ lần này; a = auto-approve
1647
- // mọi add-root trong phiên. Cùng style retry với askPermission (lạc → queue).
1648
- const askAddRoot = (root, targetPath) => _askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate });
1649
-
1650
- // Đọc câu trả lời cho phép một cách CHẮC CHẮN. Chỉ chấp nhận y/n/a (hoặc
1651
- // Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
1652
- // bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
1653
- // "tự tắt" trước đây) — thì KHÔNG coi là từ chối: xếp nó vào hàng đợi tin
1654
- // nhắn rồi HỎI LẠI. Nhờ vậy không thao tác nào bị quyết định bởi rác.
1655
- const askPermission = (name) => _askPermission(name, { tui, ask, pending, c, t, truncate });
1656
-
1657
1658
  // ── slash commands ─────────────────────────────────────────────────────
1658
1659
  async function command(input) {
1659
1660
  const [cmd, ...rest] = input.slice(1).split(/\s+/);
@@ -1701,6 +1702,120 @@ NGUYÊN TẮC:
1701
1702
  );
1702
1703
  break;
1703
1704
  }
1705
+ case 'kg': {
1706
+ // Knowledge graph CRUD — port từ mcp-knowledge-graph, lưu .noob/kg.jsonl.
1707
+ // Sub-cmd: list, path, add, obs, link, unlink, unobs, get, search, rm.
1708
+ const argText = String(arg || '').trim();
1709
+ const m = argText.match(/^(\S+)(?:\s+([\s\S]*))?$/);
1710
+ const sub = m ? m[1].toLowerCase() : '';
1711
+ const rest = m && m[2] ? m[2].trim() : '';
1712
+ try {
1713
+ if (!sub || sub === 'list' || sub === 'ls') {
1714
+ const g = await kgLoad();
1715
+ if (!g.entities.length && !g.relations.length) {
1716
+ console.log(c.dim(` KG rỗng (${kgFilePath()})`));
1717
+ } else {
1718
+ console.log(kgFormat(g));
1719
+ }
1720
+ } else if (sub === 'path') {
1721
+ console.log(c.dim(' ') + kgFilePath());
1722
+ } else if (sub === 'add') {
1723
+ const m2 = rest.match(/^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/);
1724
+ if (!m2) {
1725
+ console.log(c.err(' Cú pháp: /kg add <Name> <type> [<obs1>; <obs2>; ...]'));
1726
+ } else {
1727
+ const [, name, etype, obsRaw] = m2;
1728
+ const observations = obsRaw
1729
+ ? obsRaw.split(';').map((s) => s.trim()).filter(Boolean)
1730
+ : [];
1731
+ const created = await kgCreateEntities([{ name, entityType: etype, observations }]);
1732
+ if (!created.length) console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
1733
+ else console.log(c.ok(' ✓ ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`);
1734
+ }
1735
+ } else if (sub === 'obs') {
1736
+ const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
1737
+ if (!m2) {
1738
+ console.log(c.err(' Cú pháp: /kg obs <Name> <observation text>'));
1739
+ } else {
1740
+ const [, name, obs] = m2;
1741
+ const out = await kgAddObservations([{ entityName: name, contents: [obs] }]);
1742
+ const added = out[0]?.addedObservations || [];
1743
+ if (!added.length) console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
1744
+ else console.log(c.ok(' ✓ ') + `đã thêm obs cho ${name}: ${added[0]}`);
1745
+ }
1746
+ } else if (sub === 'link') {
1747
+ const parts = rest.split(/\s+/).filter(Boolean);
1748
+ if (parts.length < 3) {
1749
+ console.log(c.err(' Cú pháp: /kg link <From> <verb> <To>'));
1750
+ } else {
1751
+ const [from, verb, ...toParts] = parts;
1752
+ const to = toParts.join(' ');
1753
+ const created = await kgCreateRelations([{ from, to, relationType: verb }]);
1754
+ if (!created.length) console.log(c.dim(' Relation đã tồn tại — không tạo lại'));
1755
+ else console.log(c.ok(' ✓ ') + `${from} --${verb}--> ${to}`);
1756
+ }
1757
+ } else if (sub === 'unlink') {
1758
+ const parts = rest.split(/\s+/).filter(Boolean);
1759
+ if (parts.length < 3) {
1760
+ console.log(c.err(' Cú pháp: /kg unlink <From> <verb> <To>'));
1761
+ } else {
1762
+ const [from, verb, ...toParts] = parts;
1763
+ const to = toParts.join(' ');
1764
+ const out = await kgDeleteRelations([{ from, to, relationType: verb }]);
1765
+ if (!out.deleted) console.log(c.dim(' Không có relation nào khớp'));
1766
+ else console.log(c.ok(' ✓ ') + `xóa relation ${from} --${verb}--> ${to}`);
1767
+ }
1768
+ } else if (sub === 'unobs') {
1769
+ const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
1770
+ if (!m2) {
1771
+ console.log(c.err(' Cú pháp: /kg unobs <Name> <observation text>'));
1772
+ } else {
1773
+ const [, name, obs] = m2;
1774
+ await kgDeleteObservations([{ entityName: name, observations: [obs] }]);
1775
+ console.log(c.ok(' ✓ ') + `xóa obs '${obs}' khỏi ${name} (silent nếu không có)`);
1776
+ }
1777
+ } else if (sub === 'get') {
1778
+ if (!rest) {
1779
+ console.log(c.err(' Cú pháp: /kg get <Name1> [<Name2> ...]'));
1780
+ } else {
1781
+ const names = rest.split(/\s+/).filter(Boolean);
1782
+ const g = await kgOpenNodes(names);
1783
+ if (!g.entities.length) console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
1784
+ else console.log(kgFormat(g));
1785
+ }
1786
+ } else if (sub === 'search') {
1787
+ if (!rest) {
1788
+ console.log(c.err(' Cú pháp: /kg search <query>'));
1789
+ } else {
1790
+ const g = await kgSearchNodes(rest);
1791
+ if (!g.entities.length) console.log(c.dim(` Không có kết quả cho "${rest}"`));
1792
+ else console.log(kgFormat(g));
1793
+ }
1794
+ } else if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
1795
+ if (!rest) {
1796
+ console.log(c.err(' Cú pháp: /kg rm <Name1> [<Name2> ...]'));
1797
+ } else {
1798
+ const names = rest.split(/\s+/).filter(Boolean);
1799
+ const out = await kgDeleteEntities(names);
1800
+ console.log(c.ok(' ✓ ') + `đã xóa ${out.deleted} entity (cascade relation)`);
1801
+ }
1802
+ } else {
1803
+ console.log(
1804
+ c.err(' Sub-command không nhận diện. ') +
1805
+ c.dim('Dùng: list | path | add | obs | link | unlink | unobs | get | search | rm')
1806
+ );
1807
+ }
1808
+ } catch (e) {
1809
+ if (e instanceof KGEntityNotFound) {
1810
+ console.log(c.err(' ✗ ') + e.message + c.dim(' (gõ /kg add trước khi /kg obs)'));
1811
+ } else if (e instanceof KGMarkerError) {
1812
+ console.log(c.err(' ✗ ') + e.message);
1813
+ } else {
1814
+ console.log(c.err(' ✗ ') + (e?.message || String(e)));
1815
+ }
1816
+ }
1817
+ break;
1818
+ }
1704
1819
  case 'goal': {
1705
1820
  // HARD GOAL = completion requirement (xem tweet_dump.txt mục "Combine
1706
1821
  // with /goal and /loop"). Set xong sẽ inject vào MỌI prompt tới khi clear.
@@ -1808,6 +1923,10 @@ NGUYÊN TẮC:
1808
1923
  persist(); // giữ lại phiên cũ trên đĩa
1809
1924
  state.history = [];
1810
1925
  state._longSessionWarned = false; // reset cờ cảnh báo phiên dài
1926
+ // Reset toàn bộ permission scopes — task mới hoàn toàn, không kế thừa.
1927
+ state.autoApprove.clear();
1928
+ state.autoApproveTurn.clear();
1929
+ state.autoApproveFile.clear();
1811
1930
  startFresh(); // phiên mới (phiên cũ vẫn resume được)
1812
1931
  if (!tui.tty) console.clear();
1813
1932
  banner();
package/src/sessions.js CHANGED
@@ -76,6 +76,7 @@ const normDir = (p) => {
76
76
  */
77
77
  export function list(limit = 30, cwd = null) {
78
78
  ensure();
79
+ maybeAutoPrune();
79
80
  const want = cwd != null ? normDir(cwd) : null;
80
81
  let files;
81
82
  try {
@@ -120,4 +121,92 @@ export function remove(id) {
120
121
  }
121
122
  }
122
123
 
124
+ // Cleanup policy: giữ N phiên gần nhất + xoá phiên già hơn maxAgeDays. Mặc định
125
+ // giữ 50, xoá > 30 ngày — đủ "--continue" + "/sessions" hoạt động trơn cho
126
+ // workflow vài tuần, không phình ổ cứng.
127
+ //
128
+ // opts:
129
+ // keep : số phiên gần nhất tối thiểu phải giữ (default 50)
130
+ // maxAgeDays : phiên cũ hơn ngần này thì xoá NGAY CẢ trong top N (default 30)
131
+ // dryRun : true → không xoá, chỉ trả danh sách dự kiến
132
+ //
133
+ // Trả về {scanned, kept, deleted, errors, deletedIds}.
134
+ export function prune(opts = {}) {
135
+ const keep = Math.max(1, Number(opts.keep) || 50);
136
+ const maxAgeDays = Math.max(1, Number(opts.maxAgeDays) || 30);
137
+ const dryRun = !!opts.dryRun;
138
+ ensure();
139
+ let files = [];
140
+ try {
141
+ files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
142
+ } catch {
143
+ return { scanned: 0, kept: 0, deleted: 0, errors: 0, deletedIds: [] };
144
+ }
145
+ const now = Date.now();
146
+ const ageCutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
147
+ const items = [];
148
+ let parseErrors = 0;
149
+ for (const f of files) {
150
+ const full = path.join(DIR, f);
151
+ try {
152
+ const txt = fs.readFileSync(full, 'utf8');
153
+ const s = JSON.parse(txt);
154
+ const updatedAt = s.updatedAt || s.createdAt || 0;
155
+ items.push({ id: s.id || f.replace(/\.json$/, ''), file: full, updatedAt, broken: false });
156
+ } catch {
157
+ let mtime = 0;
158
+ try {
159
+ mtime = fs.statSync(full).mtimeMs;
160
+ } catch {}
161
+ items.push({ id: f.replace(/\.json$/, ''), file: full, updatedAt: mtime, broken: true });
162
+ parseErrors++;
163
+ }
164
+ }
165
+ items.sort((a, b) => b.updatedAt - a.updatedAt);
166
+ const toDelete = [];
167
+ for (let i = 0; i < items.length; i++) {
168
+ const it = items[i];
169
+ const tooOld = it.updatedAt > 0 && it.updatedAt < ageCutoff;
170
+ const overCap = i >= keep;
171
+ if (it.broken || tooOld || overCap) toDelete.push(it);
172
+ }
173
+ let unlinkErrors = 0;
174
+ let deleted = 0;
175
+ const deletedIds = [];
176
+ if (!dryRun) {
177
+ for (const it of toDelete) {
178
+ try {
179
+ fs.unlinkSync(it.file);
180
+ deleted++;
181
+ deletedIds.push(it.id);
182
+ } catch {
183
+ unlinkErrors++;
184
+ }
185
+ }
186
+ }
187
+ const reportedDeleted = dryRun ? toDelete.length : deleted;
188
+ return {
189
+ scanned: items.length,
190
+ kept: items.length - reportedDeleted,
191
+ deleted: reportedDeleted,
192
+ errors: parseErrors + unlinkErrors,
193
+ deletedIds,
194
+ };
195
+ }
196
+
197
+ // Auto-prune im lặng khi thư mục phình to (>AUTO_PRUNE_AT file). Gọi từ list()
198
+ // mỗi lần liệt kê — chi phí 1 lần readdir, không đáng kể.
199
+ const AUTO_PRUNE_AT = 100;
200
+ let _autoPruneRan = false;
201
+ function maybeAutoPrune() {
202
+ if (_autoPruneRan) return; // chỉ thử 1 lần / phiên CLI để tránh I/O lặp
203
+ _autoPruneRan = true;
204
+ try {
205
+ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
206
+ if (files.length > AUTO_PRUNE_AT) prune({ keep: 50, maxAgeDays: 30 });
207
+ } catch {
208
+ /* best-effort */
209
+ }
210
+ }
211
+
123
212
  export const sessionsDir = DIR;