@noobdemon/noob-cli 1.12.1 → 1.12.3

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();
@@ -148,13 +159,43 @@ export async function startRepl(opts = {}) {
148
159
  // ĐỪNG DI CHUYỂN khối này lên trên: dependencies cần sẵn — `tui` (~line 112),
149
160
  // `pending` (~line 107), `ask` (~line 136), `c`/`t`/`truncate` (import top).
150
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ệ.
151
- const askPermission = (name) =>
152
- _askPermission(name, { tui, ask, pending, c, t, truncate });
162
+ const askPermission = (name, targetPath) =>
163
+ _askPermission(name, { tui, ask, pending, c, t, truncate, targetPath });
153
164
  const askAddRoot = (root, targetPath) =>
154
165
  _askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate });
155
166
  const askWorkflowAgentMode = () =>
156
167
  _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
157
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
+
158
199
  // NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
159
200
  // "event loop cạn"). 'beforeExit' nổ = loop cạn (stdin chết). 'exit' = thoát.
160
201
  if (process.env.NOOB_DEBUG === '1') {
@@ -420,6 +461,8 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
420
461
  // `builtInName` (optional): nếu có thì SKIP loadSkill dynamic-workflows (prompt
421
462
  // built-in đã hardcode pattern + step cụ thể rồi, không cần model design lại).
422
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;
423
466
  let prompt;
424
467
  if (builtInName) {
425
468
  // Built-in prompt đã đầy đủ PLAN + 4 bước thực thi — KHÔNG wrap thêm skill.
@@ -452,7 +495,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
452
495
  2. Spawn sub-agent theo plan — mỗi prompt sub-agent có 5 mục GOAL/INPUTS/METHOD/OUTPUT SHAPE/STOP CONDITION.
453
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.`;
454
497
  }
455
- if (!state.agent) {
498
+ if (!state.agentMode) {
456
499
  // Đừng tự bật — workflow cần spawn_agent, đây là quyền nặng (sub-agent chạy
457
500
  // tool độc lập). Hỏi 1 lần, user chọn y thì bật & chạy, n thì huỷ sạch +
458
501
  // gợi ý /agent. Tránh buộc user gõ lại /workflow sau khi /agent.
@@ -466,7 +509,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
466
509
  )
467
510
  );
468
511
  }
469
- state.agent = true;
512
+ state.agentMode = true;
470
513
  console.log(
471
514
  c.tool(' ✓ ' + (t.workflowAgentEnabled || 'đã bật agent mode cho workflow này.'))
472
515
  );
@@ -794,6 +837,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
794
837
  async function runUltra(goal) {
795
838
  if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
796
839
  if (!goal) return console.log(c.err(' ' + t.ultraNeedGoal));
840
+ if (!(await checkQuotaBeforeHeavy('/ultra'))) return;
797
841
  state.mode = 'chat'; // tự hành chỉ chạy ở chế độ agent
798
842
  state.ultra = true;
799
843
  console.log(c.accent(' 🚀 ' + t.ultraOn));
@@ -966,6 +1010,7 @@ Sau khi xong tick này, TỰ ĐÁNH GIÁ:
966
1010
  if (!task) return console.log(c.err(' ' + t.loopNeedArgs));
967
1011
  const intervalMs = parseInterval(intervalStr);
968
1012
  if (!intervalMs) return console.log(c.err(' ' + t.loopBadInterval(intervalStr)));
1013
+ if (!(await checkQuotaBeforeHeavy('/loop'))) return;
969
1014
  const normInterval = fmtMs(intervalMs);
970
1015
  state.loop = {
971
1016
  intervalMs,
@@ -1102,6 +1147,50 @@ NGUYÊN TẮC:
1102
1147
  console.log(c.dim(' ' + memoryPath() + t.memoryStat(mem.split('\n').length)));
1103
1148
  }
1104
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
+
1105
1194
  // /auto-yolo — lưu/bỏ yolo làm MẶC ĐỊNH (mỗi lần mở noob tự bật). Vì yolo tự
1106
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.
1107
1196
  async function toggleAutoYolo() {
@@ -1324,126 +1413,13 @@ NGUYÊN TẮC:
1324
1413
  startSpin(t.thinking);
1325
1414
  let printer = null;
1326
1415
 
1327
- const dispatchTool = async (name, input, depth = 0) => {
1328
- // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
1329
- // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
1330
- if (name === 'spawn_agent' || name === 'spawn_agents') {
1331
- if (!state.agentMode)
1332
- return {
1333
- allow: true,
1334
- result: 'ERROR: agent mode đang TẮT — gõ /agent on để bật trước khi spawn.',
1335
- };
1336
- if (depth >= MAX_SUBAGENT_DEPTH)
1337
- return {
1338
- allow: true,
1339
- result: `ERROR: đã đạt depth tối đa (${MAX_SUBAGENT_DEPTH}) — không spawn thêm.`,
1340
- };
1341
- const tasks =
1342
- name === 'spawn_agent' ? [input] : Array.isArray(input?.agents) ? input.agents : [];
1343
- if (!tasks.length) return { allow: true, result: 'ERROR: thiếu task cho sub-agent.' };
1344
- stopSpin();
1345
- console.log(
1346
- chalk.hex('#8b5cf6')(
1347
- ` ⊕ spawn ${tasks.length} sub-agent (depth ${depth + 1}/${MAX_SUBAGENT_DEPTH})`
1348
- )
1349
- );
1350
- startSpin(t.thinking);
1351
- try {
1352
- const runData = state.workflowRun?.data || null;
1353
- const results = await Promise.all(
1354
- tasks.map((task, i) => {
1355
- // Per-sub-agent model routing: task.model có thể là id model hoặc tên thân thiện.
1356
- // findModel() resolve cả hai; nếu không match thì fallback model của cha.
1357
- let subModel = state.model.id;
1358
- let modelTag = '';
1359
- if (task?.model) {
1360
- const m = findModel(task.model);
1361
- if (m) {
1362
- subModel = m.id;
1363
- modelTag = ` [${m.name}]`;
1364
- } else
1365
- modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
1366
- }
1367
- const taskBody = task?.task || task?.prompt || '';
1368
- const taskCtx = task?.context || '';
1369
- // Workflow journal: nếu đang trong run + task đã done lần trước → return
1370
- // cached result, tiết kiệm token. Hash = crc32(task+ctx+model).
1371
- if (runData) {
1372
- const hash = hashWorkflowTask({ task: taskBody, context: taskCtx, model: subModel });
1373
- const cached = lookupWorkflowTaskResult(runData, hash);
1374
- if (cached !== null) {
1375
- stopSpin();
1376
- console.log(
1377
- chalk.hex('#8b5cf6')(
1378
- ` ⊘ sub-agent #${i + 1}${modelTag} skip — đã done trong run trước (cached)`
1379
- )
1380
- );
1381
- startSpin(t.thinking);
1382
- return Promise.resolve(
1383
- `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
1384
- );
1385
- }
1386
- recordWorkflowTaskStart(runData, {
1387
- hash,
1388
- task: taskBody,
1389
- context: taskCtx,
1390
- model: subModel,
1391
- });
1392
- // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
1393
- return runSubAgent({
1394
- task: taskBody,
1395
- context: taskCtx,
1396
- depth: depth + 1,
1397
- model: subModel,
1398
- signal: abort.signal,
1399
- tokenMeter,
1400
- dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
1401
- onLog: (msg) => {
1402
- stopSpin();
1403
- console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
1404
- startSpin(t.thinking);
1405
- },
1406
- })
1407
- .then((r) => {
1408
- recordWorkflowTaskDone(runData, hash, r);
1409
- return `── sub-agent #${i + 1}${modelTag} ──\n${r}`;
1410
- })
1411
- .catch((e) => {
1412
- recordWorkflowTaskFailed(runData, hash, e);
1413
- return `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`;
1414
- });
1415
- }
1416
- // Không có active workflow run → behavior cũ.
1417
- return runSubAgent({
1418
- task: taskBody,
1419
- context: taskCtx,
1420
- depth: depth + 1,
1421
- model: subModel,
1422
- signal: abort.signal,
1423
- tokenMeter,
1424
- dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
1425
- onLog: (msg) => {
1426
- stopSpin();
1427
- console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
1428
- startSpin(t.thinking);
1429
- },
1430
- })
1431
- .then((r) => `── sub-agent #${i + 1}${modelTag} ──\n${r}`)
1432
- .catch(
1433
- (e) => `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`
1434
- );
1435
- })
1436
- );
1437
- return { allow: true, result: results.join('\n\n') };
1438
- } catch (err) {
1439
- return { allow: true, result: 'ERROR sub-agent: ' + (err?.message || String(err)) };
1440
- }
1441
- }
1442
- stopSpin();
1443
- const res = await execTool(name, input);
1444
- startSpin(t.thinking);
1445
- return res;
1446
- };
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
+ });
1447
1423
 
1448
1424
  const answer = await runAgent({
1449
1425
  history: state.history,
@@ -1495,6 +1471,9 @@ NGUYÊN TẮC:
1495
1471
  } finally {
1496
1472
  abort = null;
1497
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();
1498
1477
  // Auto-compact dựa trên context tokens thay vì chars.
1499
1478
  // Với CONTEXT_WINDOW = 2M tokens (xem src/tokens.js):
1500
1479
  // 75% (1.5M tokens) → auto compact
@@ -1604,12 +1583,34 @@ NGUYÊN TẮC:
1604
1583
  }
1605
1584
  }
1606
1585
 
1607
- if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
1608
- const a = await askPermission(name);
1609
- if (a === 'a') state.autoApprove.add(name);
1610
- else if (a === 'n') {
1611
- console.log(c.err(' ' + t.denied));
1612
- 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
+ }
1613
1614
  }
1614
1615
  }
1615
1616
 
@@ -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;
package/src/skills.js CHANGED
@@ -70,3 +70,62 @@ export function listSkills() {
70
70
  const merged = new Set([...listIn(userSkillsDir()), ...listIn(PACKAGE_SKILLS_DIR)]);
71
71
  return [...merged].sort();
72
72
  }
73
+
74
+ // Parse YAML frontmatter tối giản ở đầu SKILL.md. Chỉ đọc các key đơn giản
75
+ // dạng `key: value` (không nested, không list). Trả về { meta, body }.
76
+ // VD đầu file:
77
+ // ---
78
+ // auto: true
79
+ // ---
80
+ // → meta = { auto: 'true' }, body = phần còn lại.
81
+ export function parseSkillFrontmatter(text) {
82
+ if (typeof text !== 'string') return { meta: {}, body: text || '' };
83
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
84
+ if (!m) return { meta: {}, body: text };
85
+ const meta = {};
86
+ for (const line of m[1].split(/\r?\n/)) {
87
+ const kv = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/);
88
+ if (!kv) continue;
89
+ let v = kv[2];
90
+ // strip quotes nếu có
91
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
92
+ v = v.slice(1, -1);
93
+ }
94
+ meta[kv[1]] = v;
95
+ }
96
+ return { meta, body: text.slice(m[0].length) };
97
+ }
98
+
99
+ function isTruthy(v) {
100
+ if (v === true) return true;
101
+ if (typeof v !== 'string') return false;
102
+ return /^(true|yes|1|on)$/i.test(v.trim());
103
+ }
104
+
105
+ // Liệt kê các skill có `auto: true` ở frontmatter — sẽ được auto-inject vào
106
+ // system prompt mỗi turn (xem `agent.js::buildSystem`). Trả về [{name, body}].
107
+ // User skill (`<cwd>/skills/<name>`) override built-in cùng tên.
108
+ export function listAutoSkills() {
109
+ const out = [];
110
+ for (const name of listSkills()) {
111
+ const text = loadSkill(name);
112
+ if (!text) continue;
113
+ const { meta, body } = parseSkillFrontmatter(text);
114
+ if (isTruthy(meta.auto)) out.push({ name, body });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // Block để chèn vào system prompt. Trả về '' nếu không có skill auto nào.
120
+ export function autoSkillsBlock() {
121
+ const skills = listAutoSkills();
122
+ if (!skills.length) return '';
123
+ const parts = [
124
+ '# AUTO-ACTIVE SKILLS',
125
+ 'Các skill dưới đây luôn ON — áp dụng trong MỌI lượt khi tình huống khớp (xem mục "Khi nào kích hoạt" của từng skill). KHÔNG cần slash command.',
126
+ ];
127
+ for (const { name, body } of skills) {
128
+ parts.push('', `=== SKILL: ${name} ===`, body.trim(), `=== END SKILL: ${name} ===`);
129
+ }
130
+ return parts.join('\n');
131
+ }