@noobdemon/noob-cli 1.13.0 → 1.13.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/diff.js CHANGED
@@ -8,6 +8,42 @@
8
8
  // File lớn hơn (write_file overwrite 1k+ dòng) → caller truncate trước khi gọi.
9
9
  import chalk from 'chalk';
10
10
 
11
+ // Bề rộng khả dụng cho NỘI DUNG diff (trừ tiền tố ' │ ' = 4 cột + '+ '/'- ' = 2).
12
+ // Cap tối thiểu 20 để không wrap loạn ở terminal hẹp; mặc định 80 khi không phải TTY.
13
+ function contentWidth() {
14
+ const cols = process.stdout.columns || 80;
15
+ return Math.max(20, cols - 6);
16
+ }
17
+
18
+ // Soft-wrap 1 đoạn text THUẦN (không màu) thành mảng dòng có vis-width ≤ width.
19
+ // Ưu tiên word boundary; không có space hợp lý → hard-slice. Diff text đã bị
20
+ // slice(0,200) ở caller nên không cần ANSI-aware ở đây.
21
+ function wrapPlain(text, width) {
22
+ if (text.length <= width) return [text];
23
+ const lines = [];
24
+ let remaining = text;
25
+ while (remaining.length > width) {
26
+ let cut = width;
27
+ const lastSpace = remaining.slice(0, width).lastIndexOf(' ');
28
+ if (lastSpace > width * 0.3) cut = lastSpace;
29
+ lines.push(remaining.slice(0, cut).trimEnd());
30
+ remaining = remaining.slice(cut).trimStart();
31
+ }
32
+ if (remaining) lines.push(remaining);
33
+ return lines.length ? lines : [''];
34
+ }
35
+
36
+ // Emit 1 dòng diff đã wrap: dòng đầu có dấu (+/-/space), dòng wrap kế thụt 2 cột
37
+ // cho thẳng cột nội dung. Mỗi dòng con tự bọc tiền tố '│' + tô màu riêng (chalk
38
+ // reset cuối từng đoạn) → wrap KHÔNG mất màu / mất khung.
39
+ function emitDiffLine(out, text, color, sign) {
40
+ const segs = wrapPlain(text, contentWidth());
41
+ for (let i = 0; i < segs.length; i++) {
42
+ const prefix = i === 0 ? sign : ' ';
43
+ out.push(chalk.dim(' │ ') + color(prefix + segs[i]));
44
+ }
45
+ }
46
+
11
47
  // LCS table giữa 2 mảng lines. Trả về matrix (M+1) x (N+1).
12
48
  function lcsTable(a, b) {
13
49
  const m = a.length;
@@ -129,11 +165,9 @@ export function renderUnifiedDiff(
129
165
  continue;
130
166
  }
131
167
  const text = String(op.text).slice(0, 200);
132
- let line;
133
- if (op.type === 'add') line = chalk.green('+ ') + chalk.green(text);
134
- else if (op.type === 'del') line = chalk.red('- ') + chalk.red(text);
135
- else line = chalk.dim(' ') + chalk.dim(text);
136
- out.push(chalk.dim(' │ ') + line);
168
+ if (op.type === 'add') emitDiffLine(out, text, chalk.green, '+ ');
169
+ else if (op.type === 'del') emitDiffLine(out, text, chalk.red, '- ');
170
+ else emitDiffLine(out, text, chalk.dim, ' ');
137
171
  printed++;
138
172
  }
139
173
  }
@@ -153,7 +187,7 @@ export function renderNewFilePreview(content, { label = '', maxLines = 20 } = {}
153
187
  const out = [];
154
188
  out.push(chalk.dim(' ┌─ ' + label + ' ') + chalk.green(`(file MỚI · ${lines.length} dòng)`));
155
189
  for (const l of head) {
156
- out.push(chalk.dim(' │ ') + chalk.green('+ ') + chalk.green(l.slice(0, 200)));
190
+ emitDiffLine(out, l.slice(0, 200), chalk.green, '+ ');
157
191
  }
158
192
  if (more > 0) out.push(chalk.dim(' │ ') + chalk.dim(`… +${more} dòng nữa`));
159
193
  out.push(chalk.dim(' └─'));
package/src/i18n.js CHANGED
@@ -26,7 +26,6 @@ export const t = {
26
26
  // auth
27
27
  notLoggedIn:
28
28
  'Bạn chưa đăng nhập. Chạy: noob login <api-key>\nChưa có key? Liên hệ admin để lấy key (Pro / Pro+ / Trial).',
29
- loginOk: (plan) => `Đăng nhập thành công. Gói: ${plan}.`,
30
29
  loginSaved: (p) => `Đã lưu API key vào ${p}`,
31
30
  loggedOut: 'Đã đăng xuất, xoá API key khỏi máy.',
32
31
  needKeyArg: 'Thiếu key. Dùng: noob login <api-key>',
@@ -173,11 +172,6 @@ export const t = {
173
172
  loopAutoStop: (n) => `Loop tự dừng sau tick #${n} — model phát <<LOOP_DONE>> (task hoàn tất).`,
174
173
  loopAlreadyRunning: 'Đã có loop đang chạy. /loop stop trước khi đặt loop mới.',
175
174
  learning: 'đang chưng cất bài học vào noob.md…',
176
- learnSuggest: (n) =>
177
- `💡 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).`,
178
- memoryStatus: (lines, rules, notes, ago) =>
179
- `📝 noob.md: ${lines} dòng (${rules} rules, ${notes} notes) · cập nhật ${ago}`,
180
- memoryMissing: '📝 noob.md: chưa có — gõ /init để tạo từ dự án.',
181
175
  compactRunning: 'đang tóm tắt phiên để gọn ngữ cảnh…',
182
176
  compactEmpty: 'Phiên còn trống — không có gì để tóm tắt.',
183
177
  compactSkipped: 'Phiên còn ngắn hoặc tóm tắt thất bại — bỏ qua.',
@@ -199,12 +193,8 @@ export const t = {
199
193
  'cần mô tả yêu cầu. Ví dụ: /frontend-design landing page cho app nghe nhạc lo-fi',
200
194
  workflowRunning: 'đang chạy dynamic workflow đa sub-agent…',
201
195
  workflowNoSkill: 'không tìm thấy skills/dynamic-workflows/SKILL.md — skill chưa được cài.',
202
- workflowNeedArg: 'cần mô tả task. Ví dụ: /workflow audit toàn bộ src/ tìm lỗ hổng SQL injection',
203
- workflowAgentAutoOn: 'agent mode tự bật cho /workflow (cần spawn_agent)',
204
196
  workflowAgentAskHint:
205
197
  '🎼 /workflow cần spawn sub-agent (spawn_agent) — agent mode hiện đang TẮT.',
206
- workflowAgentAskPrompt:
207
- ' bật agent mode và chạy workflow? [y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ',
208
198
  workflowAgentEnabled: 'đã bật agent mode cho workflow này.',
209
199
  bgWorkflowDone: (label) => `workflow nền ${label} đã xong`,
210
200
  bgWorkflowFailed: (label, err) => `workflow nền ${label} lỗi: ${err}`,
@@ -212,8 +202,6 @@ export const t = {
212
202
  workflowAgentDenied:
213
203
  'đã huỷ /workflow — agent mode vẫn TẮT. Gõ /agent rồi chạy lại lệnh nếu muốn.',
214
204
  // saved workflows (CRUD)
215
- workflowListEmpty: (dir) =>
216
- `Chưa có workflow đã lưu. Tạo bằng /workflow save <name> <yêu cầu>. Thư mục: ${dir}`,
217
205
  workflowListHeader: (dir) => `Workflow đã lưu (${dir}):`,
218
206
  workflowSaveNeedArgs: 'Cách dùng: /workflow save <name> <yêu cầu workflow>',
219
207
  workflowSaveEmptyPrompt:
@@ -228,7 +216,6 @@ export const t = {
228
216
  workflowSaveDescOk: (n, d) => `Đã thêm mô tả cho '${n}': ${d}`,
229
217
  workflowRunNeedName: 'Cách dùng: /workflow run <name> [thêm ngữ cảnh]',
230
218
  workflowRunError: (n, e) => `Không nạp được workflow '${n}': ${e}`,
231
- workflowRunOk: (n) => `Chạy workflow đã lưu '${n}'…`,
232
219
  workflowRunPreviewBuiltin: (n, title) => `Built-in workflow '${n}' (${title})`,
233
220
  workflowRunPreviewSaved: (n) => `Workflow đã lưu '${n}'`,
234
221
  workflowLoadNeedName: 'Cách dùng: /workflow load <name>',
@@ -267,7 +254,6 @@ export const t = {
267
254
  updateBgDone: 'Đang cập nhật nền. Mở lại noob để dùng bản mới.',
268
255
  updateChecking: 'Đang kiểm tra cập nhật…',
269
256
  updateLatest: (cur) => `Đã ở bản mới nhất (${cur}).`,
270
- updating: 'Đang cập nhật…',
271
257
  updateOk: '✓ Cập nhật xong. Mở lại noob để dùng bản mới.',
272
258
  updateFail: '✗ Cập nhật thất bại. Thử thủ công: npm i -g @noobdemon/noob-cli@latest',
273
259
  };
@@ -0,0 +1,37 @@
1
+ // Slash command /goal — HARD GOAL của phiên (completion requirement).
2
+ // Tách khỏi repl.js để giảm phình god module. Dependency (state, tui, persist,
3
+ // helper màu c) inject qua tham số — không truy cập closure startRepl.
4
+ //
5
+ // Set xong sẽ inject vào MỌI prompt tới khi /goal clear. Cú pháp:
6
+ // /goal → xem goal hiện tại
7
+ // /goal <mục tiêu> → đặt goal
8
+ // /goal clear|off|xoá|xoa → xoá goal
9
+
10
+ /**
11
+ * @param {object} args
12
+ * @param {string} args.arg - text sau "/goal "
13
+ * @param {object} args.state - session state (đọc/ghi state.goal)
14
+ * @param {object} args.tui - TUI (tui.setGoal)
15
+ * @param {Function} args.persist - lưu phiên xuống đĩa
16
+ * @param {object} args.c - helper màu (c.accent/c.dim)
17
+ */
18
+ export function runGoalCommand({ arg, state, tui, persist, c }) {
19
+ const v = String(arg || '').trim();
20
+ if (!v) {
21
+ if (state.goal) console.log(c.accent(' 🎯 goal: ') + state.goal);
22
+ else console.log(c.dim(' chưa đặt goal. Cú pháp: /goal <mục tiêu> · /goal clear để xoá'));
23
+ return;
24
+ }
25
+ const lower = v.toLowerCase();
26
+ if (lower === 'clear' || lower === 'off' || lower === 'xoá' || lower === 'xoa') {
27
+ state.goal = null;
28
+ tui.setGoal('');
29
+ console.log(c.dim(' đã xoá goal'));
30
+ persist();
31
+ return;
32
+ }
33
+ state.goal = v;
34
+ tui.setGoal(v);
35
+ console.log(c.accent(' 🎯 đã đặt goal: ') + v);
36
+ persist();
37
+ }
@@ -0,0 +1,147 @@
1
+ // Slash command /kg — knowledge graph CRUD (port từ mcp-knowledge-graph).
2
+ // Tách khỏi repl.js để giảm độ phình của god module. Mọi dependency (hàm kg*,
3
+ // helper màu c) được inject qua tham số — không truy cập closure startRepl,
4
+ // nên test được độc lập.
5
+ //
6
+ // Sub-cmd: list/ls, path, add, obs, link, unlink, unobs, get, search, rm.
7
+
8
+ /**
9
+ * @param {object} args
10
+ * @param {string} args.arg - phần text sau "/kg " (vd "add Foo type obs")
11
+ * @param {object} args.c - bảng helper màu (c.ok/c.err/c.dim)
12
+ * @param {object} args.kg - các hàm knowledge graph + error class
13
+ */
14
+ export async function runKgCommand({ arg, c, kg }) {
15
+ const {
16
+ kgLoad,
17
+ kgCreateEntities,
18
+ kgCreateRelations,
19
+ kgAddObservations,
20
+ kgDeleteEntities,
21
+ kgDeleteObservations,
22
+ kgDeleteRelations,
23
+ kgSearchNodes,
24
+ kgOpenNodes,
25
+ kgFormat,
26
+ kgFilePath,
27
+ KGEntityNotFound,
28
+ KGMarkerError,
29
+ } = kg;
30
+
31
+ const argText = String(arg || '').trim();
32
+ const m = argText.match(/^(\S+)(?:\s+([\s\S]*))?$/);
33
+ const sub = m ? m[1].toLowerCase() : '';
34
+ const rest = m && m[2] ? m[2].trim() : '';
35
+ try {
36
+ if (!sub || sub === 'list' || sub === 'ls') {
37
+ const g = await kgLoad();
38
+ if (!g.entities.length && !g.relations.length) {
39
+ console.log(c.dim(` KG rỗng (${kgFilePath()})`));
40
+ } else {
41
+ console.log(kgFormat(g));
42
+ }
43
+ } else if (sub === 'path') {
44
+ console.log(c.dim(' ') + kgFilePath());
45
+ } else if (sub === 'add') {
46
+ const m2 = rest.match(/^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/);
47
+ if (!m2) {
48
+ console.log(c.err(' Cú pháp: /kg add <Name> <type> [<obs1>; <obs2>; ...]'));
49
+ } else {
50
+ const [, name, etype, obsRaw] = m2;
51
+ const observations = obsRaw
52
+ ? obsRaw
53
+ .split(';')
54
+ .map((s) => s.trim())
55
+ .filter(Boolean)
56
+ : [];
57
+ const created = await kgCreateEntities([{ name, entityType: etype, observations }]);
58
+ if (!created.length)
59
+ console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
60
+ else
61
+ console.log(c.ok(' ✓ ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`);
62
+ }
63
+ } else if (sub === 'obs') {
64
+ const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
65
+ if (!m2) {
66
+ console.log(c.err(' Cú pháp: /kg obs <Name> <observation text>'));
67
+ } else {
68
+ const [, name, obs] = m2;
69
+ const out = await kgAddObservations([{ entityName: name, contents: [obs] }]);
70
+ const added = out[0]?.addedObservations || [];
71
+ if (!added.length)
72
+ console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
73
+ else console.log(c.ok(' ✓ ') + `đã thêm obs cho ${name}: ${added[0]}`);
74
+ }
75
+ } else if (sub === 'link') {
76
+ const parts = rest.split(/\s+/).filter(Boolean);
77
+ if (parts.length < 3) {
78
+ console.log(c.err(' Cú pháp: /kg link <From> <verb> <To>'));
79
+ } else {
80
+ const [from, verb, ...toParts] = parts;
81
+ const to = toParts.join(' ');
82
+ const created = await kgCreateRelations([{ from, to, relationType: verb }]);
83
+ if (!created.length) console.log(c.dim(' Relation đã tồn tại — không tạo lại'));
84
+ else console.log(c.ok(' ✓ ') + `${from} --${verb}--> ${to}`);
85
+ }
86
+ } else if (sub === 'unlink') {
87
+ const parts = rest.split(/\s+/).filter(Boolean);
88
+ if (parts.length < 3) {
89
+ console.log(c.err(' Cú pháp: /kg unlink <From> <verb> <To>'));
90
+ } else {
91
+ const [from, verb, ...toParts] = parts;
92
+ const to = toParts.join(' ');
93
+ const out = await kgDeleteRelations([{ from, to, relationType: verb }]);
94
+ if (!out.deleted) console.log(c.dim(' Không có relation nào khớp'));
95
+ else console.log(c.ok(' ✓ ') + `xóa relation ${from} --${verb}--> ${to}`);
96
+ }
97
+ } else if (sub === 'unobs') {
98
+ const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
99
+ if (!m2) {
100
+ console.log(c.err(' Cú pháp: /kg unobs <Name> <observation text>'));
101
+ } else {
102
+ const [, name, obs] = m2;
103
+ await kgDeleteObservations([{ entityName: name, observations: [obs] }]);
104
+ console.log(c.ok(' ✓ ') + `xóa obs '${obs}' khỏi ${name} (silent nếu không có)`);
105
+ }
106
+ } else if (sub === 'get') {
107
+ if (!rest) {
108
+ console.log(c.err(' Cú pháp: /kg get <Name1> [<Name2> ...]'));
109
+ } else {
110
+ const names = rest.split(/\s+/).filter(Boolean);
111
+ const g = await kgOpenNodes(names);
112
+ if (!g.entities.length)
113
+ console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
114
+ else console.log(kgFormat(g));
115
+ }
116
+ } else if (sub === 'search') {
117
+ if (!rest) {
118
+ console.log(c.err(' Cú pháp: /kg search <query>'));
119
+ } else {
120
+ const g = await kgSearchNodes(rest);
121
+ if (!g.entities.length) console.log(c.dim(` Không có kết quả cho "${rest}"`));
122
+ else console.log(kgFormat(g));
123
+ }
124
+ } else if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
125
+ if (!rest) {
126
+ console.log(c.err(' Cú pháp: /kg rm <Name1> [<Name2> ...]'));
127
+ } else {
128
+ const names = rest.split(/\s+/).filter(Boolean);
129
+ const out = await kgDeleteEntities(names);
130
+ console.log(c.ok(' ✓ ') + `đã xóa ${out.deleted} entity (cascade relation)`);
131
+ }
132
+ } else {
133
+ console.log(
134
+ c.err(' Sub-command không nhận diện. ') +
135
+ c.dim('Dùng: list | path | add | obs | link | unlink | unobs | get | search | rm')
136
+ );
137
+ }
138
+ } catch (e) {
139
+ if (e instanceof KGEntityNotFound) {
140
+ console.log(c.err(' ✗ ') + e.message + c.dim(' (gõ /kg add trước khi /kg obs)'));
141
+ } else if (e instanceof KGMarkerError) {
142
+ console.log(c.err(' ✗ ') + e.message);
143
+ } else {
144
+ console.log(c.err(' ✗ ') + (e?.message || String(e)));
145
+ }
146
+ }
147
+ }
package/src/repl.js CHANGED
@@ -72,6 +72,8 @@ import {
72
72
  import { getBuiltinWorkflow, loadBuiltinPrompt } from './workflows-builtin.js';
73
73
  import { SLASH, completeInput, mentionedFiles } from './repl/complete.js';
74
74
  import { parseTodosFromHistory } from './repl/todos.js';
75
+ import { runKgCommand } from './repl/commands/kg.js';
76
+ import { runGoalCommand } from './repl/commands/goal.js';
75
77
  import {
76
78
  ULTRA_DONE,
77
79
  MAX_QUESTS,
@@ -1566,12 +1568,18 @@ NGUYÊN TẮC:
1566
1568
  }
1567
1569
 
1568
1570
  const files = mentionedFiles(text);
1569
- const image =
1570
- opts.images?.[0] ||
1571
- imageDataUrl(files.find((f) => IMAGE_MIME[path.extname(f).toLowerCase()]));
1571
+ // Multi-image: gom mọi ảnh paste (opts.images) + ảnh @file được nhắc tới.
1572
+ // Giữ thứ tự: ảnh paste trước, rồi ảnh @file. Dedupe theo data URL.
1573
+ const pasted = Array.isArray(opts.images) ? opts.images.filter(Boolean) : [];
1574
+ const mentionedImages = files
1575
+ .filter((f) => IMAGE_MIME[path.extname(f).toLowerCase()])
1576
+ .map((f) => imageDataUrl(f))
1577
+ .filter(Boolean);
1578
+ const images = [...new Set([...pasted, ...mentionedImages])];
1579
+ const image = images[0] || null;
1572
1580
  const content = files.length
1573
1581
  ? text +
1574
- `\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.${image ? ' Ảnh đầu tiên đã được đính kèm cho model vision.' : ''}]`
1582
+ `\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.${images.length ? ` ${images.length} ảnh đã được đính kèm cho model vision.` : ''}]`
1575
1583
  : text;
1576
1584
  state.history.push({ role: 'user', content });
1577
1585
  // Update terminal title với session name (trích từ message đầu).
@@ -1616,6 +1624,7 @@ NGUYÊN TẮC:
1616
1624
  history: state.history,
1617
1625
  model: state.model.id,
1618
1626
  image,
1627
+ images,
1619
1628
  signal: abort.signal,
1620
1629
  tokenMeter,
1621
1630
  goal: state.goal,
@@ -1926,152 +1935,32 @@ NGUYÊN TẮC:
1926
1935
  break;
1927
1936
  }
1928
1937
  case 'kg': {
1929
- // Knowledge graph CRUD — port từ mcp-knowledge-graph, lưu .noob/kg.jsonl.
1930
- // Sub-cmd: list, path, add, obs, link, unlink, unobs, get, search, rm.
1931
- const argText = String(arg || '').trim();
1932
- const m = argText.match(/^(\S+)(?:\s+([\s\S]*))?$/);
1933
- const sub = m ? m[1].toLowerCase() : '';
1934
- const rest = m && m[2] ? m[2].trim() : '';
1935
- try {
1936
- if (!sub || sub === 'list' || sub === 'ls') {
1937
- const g = await kgLoad();
1938
- if (!g.entities.length && !g.relations.length) {
1939
- console.log(c.dim(` KG rỗng (${kgFilePath()})`));
1940
- } else {
1941
- console.log(kgFormat(g));
1942
- }
1943
- } else if (sub === 'path') {
1944
- console.log(c.dim(' ') + kgFilePath());
1945
- } else if (sub === 'add') {
1946
- const m2 = rest.match(/^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/);
1947
- if (!m2) {
1948
- console.log(c.err(' Cú pháp: /kg add <Name> <type> [<obs1>; <obs2>; ...]'));
1949
- } else {
1950
- const [, name, etype, obsRaw] = m2;
1951
- const observations = obsRaw
1952
- ? obsRaw
1953
- .split(';')
1954
- .map((s) => s.trim())
1955
- .filter(Boolean)
1956
- : [];
1957
- const created = await kgCreateEntities([{ name, entityType: etype, observations }]);
1958
- if (!created.length)
1959
- console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
1960
- else
1961
- console.log(
1962
- c.ok(' ✓ ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`
1963
- );
1964
- }
1965
- } else if (sub === 'obs') {
1966
- const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
1967
- if (!m2) {
1968
- console.log(c.err(' Cú pháp: /kg obs <Name> <observation text>'));
1969
- } else {
1970
- const [, name, obs] = m2;
1971
- const out = await kgAddObservations([{ entityName: name, contents: [obs] }]);
1972
- const added = out[0]?.addedObservations || [];
1973
- if (!added.length)
1974
- console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
1975
- else console.log(c.ok(' ✓ ') + `đã thêm obs cho ${name}: ${added[0]}`);
1976
- }
1977
- } else if (sub === 'link') {
1978
- const parts = rest.split(/\s+/).filter(Boolean);
1979
- if (parts.length < 3) {
1980
- console.log(c.err(' Cú pháp: /kg link <From> <verb> <To>'));
1981
- } else {
1982
- const [from, verb, ...toParts] = parts;
1983
- const to = toParts.join(' ');
1984
- const created = await kgCreateRelations([{ from, to, relationType: verb }]);
1985
- if (!created.length) console.log(c.dim(' Relation đã tồn tại — không tạo lại'));
1986
- else console.log(c.ok(' ✓ ') + `${from} --${verb}--> ${to}`);
1987
- }
1988
- } else if (sub === 'unlink') {
1989
- const parts = rest.split(/\s+/).filter(Boolean);
1990
- if (parts.length < 3) {
1991
- console.log(c.err(' Cú pháp: /kg unlink <From> <verb> <To>'));
1992
- } else {
1993
- const [from, verb, ...toParts] = parts;
1994
- const to = toParts.join(' ');
1995
- const out = await kgDeleteRelations([{ from, to, relationType: verb }]);
1996
- if (!out.deleted) console.log(c.dim(' Không có relation nào khớp'));
1997
- else console.log(c.ok(' ✓ ') + `xóa relation ${from} --${verb}--> ${to}`);
1998
- }
1999
- } else if (sub === 'unobs') {
2000
- const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
2001
- if (!m2) {
2002
- console.log(c.err(' Cú pháp: /kg unobs <Name> <observation text>'));
2003
- } else {
2004
- const [, name, obs] = m2;
2005
- await kgDeleteObservations([{ entityName: name, observations: [obs] }]);
2006
- console.log(c.ok(' ✓ ') + `xóa obs '${obs}' khỏi ${name} (silent nếu không có)`);
2007
- }
2008
- } else if (sub === 'get') {
2009
- if (!rest) {
2010
- console.log(c.err(' Cú pháp: /kg get <Name1> [<Name2> ...]'));
2011
- } else {
2012
- const names = rest.split(/\s+/).filter(Boolean);
2013
- const g = await kgOpenNodes(names);
2014
- if (!g.entities.length)
2015
- console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
2016
- else console.log(kgFormat(g));
2017
- }
2018
- } else if (sub === 'search') {
2019
- if (!rest) {
2020
- console.log(c.err(' Cú pháp: /kg search <query>'));
2021
- } else {
2022
- const g = await kgSearchNodes(rest);
2023
- if (!g.entities.length) console.log(c.dim(` Không có kết quả cho "${rest}"`));
2024
- else console.log(kgFormat(g));
2025
- }
2026
- } else if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
2027
- if (!rest) {
2028
- console.log(c.err(' Cú pháp: /kg rm <Name1> [<Name2> ...]'));
2029
- } else {
2030
- const names = rest.split(/\s+/).filter(Boolean);
2031
- const out = await kgDeleteEntities(names);
2032
- console.log(c.ok(' ✓ ') + `đã xóa ${out.deleted} entity (cascade relation)`);
2033
- }
2034
- } else {
2035
- console.log(
2036
- c.err(' Sub-command không nhận diện. ') +
2037
- c.dim('Dùng: list | path | add | obs | link | unlink | unobs | get | search | rm')
2038
- );
2039
- }
2040
- } catch (e) {
2041
- if (e instanceof KGEntityNotFound) {
2042
- console.log(c.err(' ✗ ') + e.message + c.dim(' (gõ /kg add trước khi /kg obs)'));
2043
- } else if (e instanceof KGMarkerError) {
2044
- console.log(c.err(' ✗ ') + e.message);
2045
- } else {
2046
- console.log(c.err(' ✗ ') + (e?.message || String(e)));
2047
- }
2048
- }
1938
+ // Knowledge graph CRUD — logic tách sang src/repl/commands/kg.js.
1939
+ // Inject các hàm kg* đã import + helper màu c (không truy cập closure).
1940
+ await runKgCommand({
1941
+ arg,
1942
+ c,
1943
+ kg: {
1944
+ kgLoad,
1945
+ kgCreateEntities,
1946
+ kgCreateRelations,
1947
+ kgAddObservations,
1948
+ kgDeleteEntities,
1949
+ kgDeleteObservations,
1950
+ kgDeleteRelations,
1951
+ kgSearchNodes,
1952
+ kgOpenNodes,
1953
+ kgFormat,
1954
+ kgFilePath,
1955
+ KGEntityNotFound,
1956
+ KGMarkerError,
1957
+ },
1958
+ });
2049
1959
  break;
2050
1960
  }
2051
1961
  case 'goal': {
2052
- // HARD GOAL = completion requirement (xem tweet_dump.txt mục "Combine
2053
- // with /goal and /loop"). Set xong sẽ inject vào MỌI prompt tới khi clear.
2054
- const v = arg.trim();
2055
- if (!v) {
2056
- if (state.goal) console.log(c.accent(' 🎯 goal: ') + state.goal);
2057
- else
2058
- console.log(c.dim(' chưa đặt goal. Cú pháp: /goal <mục tiêu> · /goal clear để xoá'));
2059
- } else if (
2060
- v.toLowerCase() === 'clear' ||
2061
- v.toLowerCase() === 'off' ||
2062
- v.toLowerCase() === 'xoá' ||
2063
- v.toLowerCase() === 'xoa'
2064
- ) {
2065
- state.goal = null;
2066
- tui.setGoal('');
2067
- console.log(c.dim(' đã xoá goal'));
2068
- persist();
2069
- } else {
2070
- state.goal = v;
2071
- tui.setGoal(v);
2072
- console.log(c.accent(' 🎯 đã đặt goal: ') + v);
2073
- persist();
2074
- }
1962
+ // HARD GOAL logic tách sang src/repl/commands/goal.js. Inject deps.
1963
+ runGoalCommand({ arg, state, tui, persist, c });
2075
1964
  break;
2076
1965
  }
2077
1966
  case 'tokens': {
package/src/tools.js CHANGED
@@ -348,7 +348,17 @@ export const TOOLS = {
348
348
  // raw:true hoặc không phải HTML → trả nguyên văn (đã clip). Ngược lại strip HTML.
349
349
  const isHtml = /html/i.test(ct) || /^\s*<(!doctype|html)/i.test(body);
350
350
  const text = raw || !isHtml ? body : htmlToText(body);
351
- return clip(head + text.trim());
351
+ const trimmed = text.trim();
352
+ // SPA hint: HTML lớn nhưng strip ra gần rỗng → nhiều khả năng client-render (JS).
353
+ // htmlToText là regex, không chạy JS → trả signal rõ thay vì để model tưởng trang trống.
354
+ if (!raw && isHtml && trimmed.length < 200 && body.length > 2000) {
355
+ return clip(
356
+ head +
357
+ `[web_fetch: trang trả ${body.length} byte HTML nhưng chỉ strip được ${trimmed.length} ký tự text — nhiều khả năng render bằng JS (SPA). Thử lại với raw:true để xem HTML thô, hoặc tìm API/endpoint dữ liệu của trang.]\n\n` +
358
+ trimmed
359
+ );
360
+ }
361
+ return clip(head + trimmed);
352
362
  },
353
363
 
354
364
  async write_file({ path: p, content }, { signal } = {}) {
@@ -408,7 +418,7 @@ export const TOOLS = {
408
418
  // Không thấy → lỗi GIÀU THÔNG TIN: cho model thấy đúng byte trong file để sửa.
409
419
  throw new Error(
410
420
  `old_string not found in ${rel(file)}. Copy the target text EXACTLY (indentation/whitespace included, NO line-number prefix).` +
411
- nearbyContext(data, old_string)
421
+ nearbyContext(data, old_string, rel(file))
412
422
  );
413
423
  },
414
424
 
@@ -730,7 +740,8 @@ function matchByLines(data, oldStr) {
730
740
 
731
741
  // Khi không khớp: in vùng file gần dòng giống nhất, dạng JSON-escaped để model
732
742
  // thấy rõ tab/space → sửa old_string cho khớp ngay lần sau.
733
- function nearbyContext(data, oldStr) {
743
+ function nearbyContext(data, oldStr, p) {
744
+ const oldLineCount = oldStr.replace(/\r\n/g, '\n').split('\n').length;
734
745
  const want = (
735
746
  oldStr
736
747
  .replace(/\r\n/g, '\n')
@@ -756,14 +767,19 @@ function nearbyContext(data, oldStr) {
756
767
  }
757
768
  }
758
769
  if (hit < 0 || best < 6)
759
- return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
770
+ return ` (no similar line found; the file has ${lines.length} lines — re-read it.)${readFileRetryHint(p, 1, Math.min(lines.length, 80))}`;
760
771
  const a = Math.max(0, hit - 2);
761
- const b = Math.min(lines.length, hit + 3);
772
+ const b = Math.min(lines.length, hit + Math.max(8, oldLineCount + 5));
762
773
  const snippet = lines
763
774
  .slice(a, b)
764
775
  .map((l, k) => ` ${a + k + 1}: ${JSON.stringify(l)}`)
765
776
  .join('\n');
766
- return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}`;
777
+ return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}${readFileRetryHint(p, a + 1, b - a)}`;
778
+ }
779
+
780
+ function readFileRetryHint(pathHint, offset, limit) {
781
+ const pathPart = `"path":${JSON.stringify(pathHint || '<same path>')},`;
782
+ return `\nNEXT REQUIRED TOOL: read_file {${pathPart}"offset":${offset},"limit":${limit}}. Then retry edit_file using text copied from that fresh read_file result.`;
767
783
  }
768
784
 
769
785
  function globToRegExp(glob) {