deel-local-cli 1.5.8 → 1.6.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.
Files changed (57) hide show
  1. package/README.en.md +23 -13
  2. package/README.md +22 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +78 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +13 -2
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +226 -36
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -0,0 +1,192 @@
1
+ // .gitignore 를 읽어, git 이 건너뛰는 것을 도구도 건너뛴다 — Glob · Grep · Outline · Verify · @폴더.
2
+ //
3
+ // 왜: walk() 는 정해진 폴더 몇 개(node_modules · dist …)만 건너뛰었다. 실제 저장소는 out/ ·
4
+ // .gradle/ · coverage/ · 만들어진 코드 · 자료 덤프를 .gitignore 에 적어 두는데, 그걸 다 훑으면
5
+ // 32k 창에서 Grep 한 번이 빌드 산출물로 예산을 다 쓴다. 사람은 git 이 안 보는 것을 도구도
6
+ // 안 볼 거라고 생각한다 — 그 기대를 맞춘다.
7
+ //
8
+ // 지원하는 것 — git 규칙의 부분집합:
9
+ // 빈 줄 · # 주석 · !부정 · 끝의 / (폴더만) · 앞의 / (그 자리에 고정) · * · ** · ? · [abc] · \ 이스케이프
10
+ // 슬래시가 없는 패턴은 어느 깊이든 이름으로 맞고, 슬래시가 있으면 그 파일이 있는 폴더 기준이다.
11
+ // 아래 폴더의 .gitignore 는 그 아래에만 적용되고, 뒤에 오는 규칙이 이긴다 (git 과 같다).
12
+ // 건너뛴 폴더 안의 것은 !로도 되살릴 수 없다 (git 도 그렇다 — 폴더째 안 들어간다).
13
+ // 뿌리의 .deelignore 도 같은 문법으로 .gitignore 뒤에 읽는다 — git 에는 안 적고 deel 만 건너뛸 것.
14
+ // 안 하는 것: 작업 폴더 **위**의 .gitignore, .git/info/exclude, core.excludesFile.
15
+ // 윈도우에서는 대소문자를 안 가린다 (globToRegex 와 같다).
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ /**
20
+ * 규칙 파일 하나를 규칙 목록으로.
21
+ * @param {string} text 파일 내용
22
+ * @param {string} 기준 그 파일이 있는 폴더 (작업 폴더 기준 상대, / 구분, 뿌리면 '')
23
+ */
24
+ export function 규칙읽기(text, 기준 = '') {
25
+ const out = [];
26
+ for (const 줄 of String(text ?? '').replace(/\r/g, '').split('\n')) {
27
+ const r = 패턴규칙(줄, 기준);
28
+ if (r) out.push(r);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** 줄 하나 → 규칙. 빈 줄·주석이면 null. */
34
+ export function 패턴규칙(줄, 기준 = '') {
35
+ let p = String(줄 ?? '');
36
+ // 끝의 빈칸은 뗀다 — 역슬래시로 살린 것만 남긴다.
37
+ p = p.replace(/(?<!\\)\s+$/, '');
38
+ if (!p || p.startsWith('#')) return null;
39
+ let 부정 = false;
40
+ if (p.startsWith('!')) { 부정 = true; p = p.slice(1); }
41
+ else if (p.startsWith('\\!') || p.startsWith('\\#')) p = p.slice(1);
42
+ if (!p) return null;
43
+ let 폴더만 = false;
44
+ if (p.endsWith('/') && !p.endsWith('\\/')) { 폴더만 = true; p = p.replace(/\/+$/, ''); }
45
+ if (!p) return null;
46
+ let 고정 = false;
47
+ if (p.startsWith('/')) { 고정 = true; p = p.replace(/^\/+/, ''); }
48
+ // 슬래시가 가운데 있어도 그 자리에 고정된다. 없으면 어느 깊이든 이름으로.
49
+ if (p.includes('/')) 고정 = true;
50
+ const 몸 = 글롭정규식(p);
51
+ const src = 고정 ? `^${몸}$` : `(?:^|/)${몸}$`;
52
+ return { re: new RegExp(src, process.platform === 'win32' ? 'i' : ''), 부정, 폴더만, 기준: String(기준 ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''), 원문: 줄 };
53
+ }
54
+
55
+ // gitignore 글롭 → 정규식 몸통 (앞뒤 고정은 부르는 쪽이 붙인다).
56
+ function 글롭정규식(p) {
57
+ let re = '';
58
+ let i = 0;
59
+ while (i < p.length) {
60
+ const ch = p[i];
61
+ if (ch === '\\' && i + 1 < p.length) { re += 글자(p[i + 1]); i += 2; continue; }
62
+ if (ch === '*') {
63
+ if (p[i + 1] === '*') {
64
+ /*
65
+ * `**` 는 **제 자리에 있을 때만** 슬래시를 넘는다.
66
+ *
67
+ * 맨 앞의 별둘 + 슬래시 → 어느 깊이든
68
+ * 맨 뒤의 슬래시 + 별둘 → 그 안의 전부
69
+ * 슬래시 사이의 별둘 → 폴더 0개 이상
70
+ *
71
+ * 그 밖의 자리(`a**b`)는 git 이 그냥 별 하나로 읽는다 — 슬래시를 안
72
+ * 넘는다. 여기서 `.*` 로 읽으면 `a**b` 가 `a/dir/b` 를 걸어 버려서,
73
+ * git 은 그대로 두는 파일을 deel 만 감춘다. 규칙을 적은 사람은
74
+ * 없어진 파일을 찾을 길이 없다.
75
+ */
76
+ const 앞이경계 = i === 0 || p[i - 1] === '/';
77
+ if (앞이경계 && p[i + 2] === '/') { re += '(?:.*/)?'; i += 3; continue; }
78
+ if (앞이경계 && i + 2 === p.length) { re += '.*'; i += 2; continue; }
79
+ // 붙어 있는 별은 몇 개든 하나로 친다.
80
+ while (p[i] === '*') i += 1;
81
+ re += '[^/]*';
82
+ continue;
83
+ }
84
+ re += '[^/]*'; i += 1; continue;
85
+ }
86
+ if (ch === '?') { re += '[^/]'; i += 1; continue; }
87
+ if (ch === '[') {
88
+ const end = p.indexOf(']', i + 1);
89
+ if (end > i + 1) {
90
+ let 안 = p.slice(i + 1, end);
91
+ if (안.startsWith('!')) 안 = '^' + 안.slice(1);
92
+ re += `[${안.replace(/\\/g, '\\\\')}]`;
93
+ i = end + 1;
94
+ continue;
95
+ }
96
+ re += '\\['; i += 1; continue;
97
+ }
98
+ re += 글자(ch); i += 1;
99
+ }
100
+ return re;
101
+ }
102
+
103
+ function 글자(ch) { return ch.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); }
104
+
105
+ // 규칙의 기준 폴더 안에 있는 경로면 그 기준부터의 경로를, 아니면 null.
106
+ function 기준안(rel, 기준) {
107
+ if (!기준) return rel;
108
+ if (rel === 기준) return null; // 기준 폴더 자신은 그 안의 규칙 대상이 아니다
109
+ if (!rel.startsWith(기준 + '/')) return null;
110
+ return rel.slice(기준.length + 1);
111
+ }
112
+
113
+ /** 경로 하나가 규칙에 걸리는가 — 부모 폴더는 이미 통과했다고 보고 이 경로만 본다. walk 가 쓴다. */
114
+ export function 걸리나(rel, 폴더인가, 규칙들) {
115
+ let 답 = false;
116
+ for (const r of 규칙들) {
117
+ if (r.폴더만 && !폴더인가) continue;
118
+ const sub = 기준안(rel, r.기준);
119
+ if (sub === null) continue;
120
+ if (r.re.test(sub)) 답 = !r.부정;
121
+ }
122
+ return 답;
123
+ }
124
+
125
+ /**
126
+ * 경로 하나가 무시되는가 — 부모 폴더까지 본다. 건너뛴 폴더 안의 것은 !로도 못 살린다.
127
+ * @param {string} rel 작업 폴더 기준 상대 경로 (/ 구분)
128
+ */
129
+ export function 무시하나(rel, 폴더인가, 규칙들) {
130
+ if (!규칙들?.length) return false;
131
+ const 칸 = String(rel).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').split('/');
132
+ for (let i = 1; i < 칸.length; i++) {
133
+ if (걸리나(칸.slice(0, i).join('/'), true, 규칙들)) return true;
134
+ }
135
+ return 걸리나(칸.join('/'), 폴더인가, 규칙들);
136
+ }
137
+
138
+ /** 파일을 읽어 규칙으로. 없거나 못 읽으면 빈 목록. */
139
+ export function 파일규칙읽기(path, 기준 = '') {
140
+ try { return 규칙읽기(readFileSync(path, 'utf8'), 기준); } catch { return []; }
141
+ }
142
+
143
+ /** 뿌리의 규칙 — .gitignore 다음에 .deelignore. */
144
+ export function 뿌리규칙읽기(root) {
145
+ return [...파일규칙읽기(join(root, '.gitignore'), ''), ...파일규칙읽기(join(root, '.deelignore'), '')];
146
+ }
147
+
148
+ /**
149
+ * 뿌리부터 이 폴더까지 오면서 만나는 규칙을 다 모은다.
150
+ *
151
+ * walk() 는 내려가면서 폴더마다 더하면 되지만, @폴더 처럼 중간을 바로 들여다보는
152
+ * 쪽은 자기 위쪽을 스스로 되짚어야 한다. 안 그러면 sub/.gitignore 가 있는데도
153
+ * @sub 로 지목했을 때만 규칙이 없는 것처럼 보인다 — 같은 폴더가 도구마다 다르게
154
+ * 보이는 것이 제일 나쁘다.
155
+ */
156
+ export function 계보규칙읽기(root, rel = '') {
157
+ const 규칙 = 뿌리규칙읽기(root);
158
+ const 조각 = String(rel ?? '').replace(/\\/g, '/').split('/').filter((x) => x && x !== '.');
159
+ let 여기 = '';
160
+ for (const 한칸 of 조각) {
161
+ 여기 = 여기 ? `${여기}/${한칸}` : 한칸;
162
+ 규칙.push(...파일규칙읽기(join(root, ...여기.split('/'), '.gitignore'), 여기));
163
+ }
164
+ return 규칙;
165
+ }
166
+
167
+ /** 화면 한 줄. 건너뛴 것이 없으면 빈 문자열 — 없는데 줄을 만들면 그게 소음이다. */
168
+ /**
169
+ * @param 건너뜀 `{ 폴더, 파일 }` — .gitignore 로 뺀 수
170
+ * @param 잘림 상한에서 멈췄나 (walk 가 붙여 준다)
171
+ * @param 상한 몇 개까지 봤나. 0 이면 수 없이 말한다.
172
+ * (숫자를 인자로 받는다 — fsutil 이 ignore 를 부르므로 거꾸로 가져오면 고리가 된다)
173
+ */
174
+ export function 건너뜀말(건너뜀, 잘림 = false, 상한 = 0) {
175
+ const 폴더 = 건너뜀?.폴더 ?? 0;
176
+ const 파일 = 건너뜀?.파일 ?? 0;
177
+ const 줄 = [];
178
+ if (폴더 || 파일) {
179
+ const 몫 = [폴더 ? `폴더 ${폴더}개` : null, 파일 ? `파일 ${파일}개` : null].filter(Boolean).join(' · ');
180
+ 줄.push(`(.gitignore 로 ${몫} 건너뜀 — 경로를 직접 주면 Read 된다)`);
181
+ }
182
+ /*
183
+ * 상한에서 멈췄으면 반드시 말한다.
184
+ *
185
+ * 이 한 줄이 없으면 "일치 없음" 이 "없다" 로 읽힌다. 실제로는 안 본 것이다.
186
+ */
187
+ if (잘림) {
188
+ const 몇 = 상한 ? `${상한.toLocaleString('en-US')}개까지만` : '앞부분만';
189
+ 줄.push(`(파일이 너무 많아 ${몇} 봤습니다 — 못 본 자리에 있는 것은 여기 안 나옵니다. 폴더를 좁혀서 다시 시켜 보세요)`);
190
+ }
191
+ return 줄.length ? `\n\n${줄.join('\n')}` : '';
192
+ }
@@ -4,9 +4,11 @@ import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, sta
4
4
  import { dirname, join, relative, sep } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
7
+ import { 건너뜀말 } from './ignore.js';
7
8
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
8
9
  import { checkCommand, checkPaths, isMutating } from '../safety/guard.js';
9
10
  import { 띄우기, JOBS_TOOL } from './jobs.js';
11
+ import { 셸명령 } from './shell.js';
10
12
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
11
13
  import { loadSkill } from '../skills/discover.js';
12
14
  import { WEB_FETCH_TOOL } from './webfetch.js';
@@ -21,9 +23,12 @@ import { allow as allowedIn } from '../agent/modes.js';
21
23
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
22
24
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
23
25
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
26
+ import { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침 } from './pdf.js';
24
27
  import { diffLines } from '../ui/diff.js';
25
28
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
26
29
  import { 도구설명EN } from './desc.en.js';
30
+ import { 그림인가, 그림읽기, 크기말, 기본한도 } from '../backend/vision.js';
31
+ import { 빠르게찾기, 엔진말, 안볼정규식 } from './fastgrep.js';
27
32
  import { 지시말 } from '../i18n/index.js';
28
33
 
29
34
  /*
@@ -41,7 +46,9 @@ const MAX_OUT = 30000;
41
46
  // 한 파일에서 몇십 초를 쓰면 그동안 화면이 멈춘 것처럼 보인다.
42
47
  const GREP_MAX_FILE = 2 * 1024 * 1024;
43
48
  // 정규식으로 찾을 것이 없는 파일들. 열어 봐야 시간만 든다.
44
- const 안읽을확장자 = /\.(png|jpe?g|gif|bmp|ico|webp|svgz|pdf|zip|gz|tgz|7z|rar|exe|dll|so|dylib|bin|dat|db|sqlite3?|woff2?|ttf|otf|eot|mp[34]|wav|avi|mov|mkv|class|jar|pyc|pyo|o|a|lib|pack|idx|map|min\.js|min\.css|lock)$/i;
49
+ // 목록은 tools/fastgrep.js 에 한 벌만 둔다 — rg 도 같은 목록으로 걸러야
50
+ // 엔진이 달라도 같은 파일을 본다.
51
+ const 안읽을확장자 = 안볼정규식;
45
52
  // 이보다 큰 파일은 바뀐 자리를 안 재고 넘어간다. 화면에 못 담을 양이기도 하고,
46
53
  // 재는 값보다 기다리는 값이 커진다.
47
54
  const MAX_DIFF_CHARS = 4_000_000;
@@ -308,6 +315,31 @@ function 문서읽기(abs, ctx) {
308
315
  };
309
316
  }
310
317
 
318
+ /**
319
+ * PDF 를 쪽마다 글로 읽어 돌려준다.
320
+ *
321
+ * 문서읽기와 같은 규칙 — ctx.seen 에 안 넣는다(고칠 수 있는 물건이 아니다).
322
+ * 다른 점은 **못 읽은 쪽을 반드시 말한다**는 것이다. PDF 는 글이 아예 안 든
323
+ * 쪽(스캔본)이 흔해서, 빈 글을 그냥 돌려주면 「그런 내용이 없는 문서」로
324
+ * 읽힌다. 모델은 그걸 근거로 답하고, 사람은 그 답을 믿는다.
325
+ */
326
+ function pdf읽기(abs, ctx) {
327
+ const r = readPdf(abs);
328
+ if (!r.ok) return { error: r.error };
329
+ const { text, 잘림 } = pdfText(r);
330
+ const 못 = 못읽은말(r);
331
+ return {
332
+ content: clip(
333
+ `${text || '(글이 하나도 없는 PDF 입니다.)'}
334
+
335
+ (PDF 를 쪽마다 글로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
336
+ + (못 ? `\n${못}` : '')
337
+ + (잘림.length ? `\n(${잘림.join(' · ')})` : ''),
338
+ ),
339
+ summary: pdfSummary(r) + (잘림.length ? ' · 일부만' : ''),
340
+ };
341
+ }
342
+
311
343
  /**
312
344
  * 파일 하나를 쓴다 — Write 의 알맹이.
313
345
  *
@@ -371,9 +403,19 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
371
403
  * 그대로 되지만 되돌리기에는 안 잡힌다. 그런 폴더를 되돌리자고 수만 개를
372
404
  * 뜨는 쪽이 훨씬 나쁘다.
373
405
  */
406
+ // 옮길 때는 .gitignore 를 안 본다 — 옮겨지는 것은 전부이고, 되돌리기도 전부를 떠야 한다.
407
+ const 훑은것 = 폴더인가 ? walk(앞, { ignore: false }) : null;
374
408
  const 짝들 = 폴더인가
375
- ? walk(앞).map((f) => [f.path, join(뒤, relative(앞, f.path))])
409
+ ? 훑은것.map((f) => [f.path, join(뒤, relative(앞, f.path))])
376
410
  : [[앞, 뒤]];
411
+ /*
412
+ * 훑기 상한에 걸리면 **되돌리기가 반쪽이 된다.**
413
+ *
414
+ * 옮기는 것 자체는 renameSync 가 폴더째 하므로 전부 옮겨진다. 그런데 이력에
415
+ * 뜨는 것은 여기서 훑은 것뿐이라, 2만 개가 넘는 폴더를 옮기면 `/undo` 가
416
+ * 앞의 2만 개만 되돌린다. 그걸 말 안 하면 사람은 되돌렸다고 믿고 넘어간다.
417
+ */
418
+ const 되돌리기반쪽 = !!훑은것?.잘림;
377
419
  for (const [a, b] of 짝들) {
378
420
  ctx.history.snapshot(a, 'Move');
379
421
  ctx.history.snapshot(b, 'Move');
@@ -399,10 +441,22 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
399
441
 
400
442
  for (const [, b] of 짝들) ctx.seen.add(b);
401
443
  const 무엇 = 폴더인가 ? `폴더 ${짝들.length}개 파일` : '';
444
+ const 경고 = 되돌리기반쪽
445
+ ? `\n(파일이 ${훑은것.상한.toLocaleString('en-US')}개를 넘어 되돌리기에는 앞부분만 떴습니다 — 옮기기는 전부 됐지만 /undo 는 다 못 되돌립니다)`
446
+ : '';
402
447
  return {
403
- content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}`,
448
+ content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}${경고}`,
404
449
  summary: `${ctx.scope.show(뒤)}`,
405
450
  changed: 뒤,
451
+ /*
452
+ * 폴더를 옮겼으면 **옮겨진 파일 하나하나**를 적어 준다.
453
+ *
454
+ * 닿은 자리가 이미 있던 폴더면(`Move('새것', 'src')`) 그 폴더에는 남이
455
+ * 고치던 파일도 산다. 「이 폴더가 바뀌었다」 로만 적어 두면 나중에
456
+ * /commit 이 그 폴더를 통째로 담고, 남의 변경이 이 커밋에 실린다.
457
+ * 무엇이 실제로 움직였는지는 지금 이 자리만 안다.
458
+ */
459
+ 바뀐것들: 폴더인가 ? 짝들.map(([, b]) => b) : null,
406
460
  };
407
461
  }
408
462
 
@@ -443,6 +497,7 @@ function 한파일쓰기(args, ctx) {
443
497
  // 문서(hwpx·docx·pptx)도 같은 이유로 또렷하게 거절한다. 일반 '바이너리'
444
498
  // 오류로 넘기면 왜 안 되는지가 안 실려서, 모델이 우회로를 찾는다.
445
499
  if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
500
+ if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
446
501
  // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
447
502
  // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
448
503
  // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
@@ -541,6 +596,7 @@ function 한군데고치기(args, ctx) {
541
596
  // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
542
597
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
543
598
  if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
599
+ if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
544
600
  if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
545
601
  if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
546
602
 
@@ -654,6 +710,49 @@ function 여러군데고치기(목록, ctx) {
654
710
  };
655
711
  }
656
712
 
713
+ /*
714
+ * 그림 파일을 Read 로 열었을 때.
715
+ *
716
+ * 두 갈래다.
717
+ *
718
+ * 눈이 있는 모델 — 도구 결과에는 "열었다" 는 말만 남기고, 그림 자체는
719
+ * 루프가 바로 뒤에 사람 말로 붙인다 (vision.js 의 그림메시지 머리말 참고).
720
+ * 여기서 base64 를 content 에 넣으면 안 된다. 도구 결과는 글 한 덩어리라
721
+ * 그림으로 안 읽히고, 그냥 자리만 5MB 먹는다.
722
+ *
723
+ * 눈이 없는 모델 — **바이트를 아예 안 싣는다.** 대신 한 줄로 말한다.
724
+ * 안 보이는 모델에게 보내 봐야 400 이 오거나, 더 나쁘게는 서버가 조용히
725
+ * 무시하고 답을 지어낸다. 그러면 사람은 모델이 화면을 봤다고 믿는다.
726
+ */
727
+ function 그림보기(abs, ctx) {
728
+ const show = ctx.scope.show(abs);
729
+ const 눈있나 = !!ctx.눈있나;
730
+ const 것 = 그림읽기(abs, { 한도: 기본한도 });
731
+
732
+ if (!것.ok) {
733
+ return { error: `${show} — ${것.왜}` };
734
+ }
735
+ ctx.seen.add(abs);
736
+ const 잰것 = `그림 · ${크기말(것.bytes)} · ${것.mime}`;
737
+
738
+ if (!눈있나) {
739
+ return {
740
+ content: `${show} 는 그림입니다 (${잰것}).\n`
741
+ + '지금 붙어 있는 모델은 그림을 못 봅니다 — 그래서 보내지 않았습니다.\n'
742
+ + '이 파일을 코드처럼 읽으려 하지 마세요. 무엇이 찍혀 있는지 알아야 한다면'
743
+ + ' 사용자에게 말로 설명해 달라고 하세요.',
744
+ summary: `${잰것} · 이 모델은 못 봄`,
745
+ 그림없음: true,
746
+ };
747
+ }
748
+ return {
749
+ content: `${show} 를 열었습니다 (${잰것}). 그림은 이 다음 메시지에 붙어 있습니다.`,
750
+ summary: 잰것,
751
+ // 루프가 이걸 보고 사람 말 자리에 그림을 붙인다. 모델에게 가는 글이 아니다.
752
+ 그림: { b64: 것.b64, mime: 것.mime, bytes: 것.bytes, show },
753
+ };
754
+ }
755
+
657
756
  export const TOOLS = {
658
757
  Read: {
659
758
  schema: {
@@ -680,11 +779,15 @@ export const TOOLS = {
680
779
  const 막을이유 = 내부살림(abs);
681
780
  if (막을이유) return { error: 막을이유 };
682
781
 
782
+ // 그림은 글로 읽지 않는다. 읽으면 깨진 글자 수천 자가 대화에 실린다.
783
+ if (그림인가(abs)) return 그림보기(abs, ctx);
683
784
  // 엑셀 파일은 글이 아니라 압축 꾸러미다. 그냥 읽으면 '바이너리' 로 끝난다.
684
785
  // 여기서 표로 바꿔 돌려준다 — 사람이 손으로 CSV 로 내보낼 일이 없게.
685
786
  if (isExcelPath(abs)) return 엑셀읽기(abs, args, ctx);
686
787
  // hwpx·docx·pptx 도 같다 — 속이 ZIP+XML 이라 글로 바꿔 돌려준다 (docs.js).
687
788
  if (isDocPath(abs)) return 문서읽기(abs, ctx);
789
+ // PDF 도 같다. 속은 사전+흐름이라 zlib 만으로 쪽마다 글을 꺼낸다 (pdf.js).
790
+ if (isPdfPath(abs)) return pdf읽기(abs, ctx);
688
791
  /*
689
792
  * 구형 hwp 는 '바이너리' 로 끝내지 않는다. 그 오류에는 길이 없어서
690
793
  * 모델이 우회로(새로 쓰기)를 찾는다 — 실제로 그렇게 원본이 죽은 적이
@@ -952,18 +1055,27 @@ export const TOOLS = {
952
1055
  run(args, ctx) {
953
1056
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
954
1057
  const re = globToRegex(args.pattern);
955
- const 맞는것 = walk(root)
1058
+ const 전부 = walk(root);
1059
+ // .gitignore 로 건너뛴 것은 수를 말한다 — 조용히 빼면 '그 파일이 없다' 로 읽힌다 (tools/ignore.js).
1060
+ const 건너뜀 = 건너뜀말(전부.건너뜀, 전부.잘림, 전부.상한);
1061
+ const 맞는것 = 전부
956
1062
  .filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()))
957
1063
  .sort((a, b) => b.mtime - a.mtime);
958
1064
  const files = 맞는것.slice(0, 찾을개수(ctx.모델컨텍스트));
959
- if (!files.length) return { content: `찾은 파일 없음: ${args.pattern}`, summary: '0개' };
1065
+ // 훑기 상한에서 멈췄으면 '없다' 아니라 '본 데까지는 없다' 다.
1066
+ if (!files.length) {
1067
+ return {
1068
+ content: `${전부.잘림 ? '본 데까지는 찾은 파일 없음' : '찾은 파일 없음'}: ${args.pattern}${건너뜀}`,
1069
+ summary: 전부.잘림 ? '0개 (다 못 봄)' : '0개',
1070
+ };
1071
+ }
960
1072
  // 잘랐으면 잘랐다고 말한다. 전에는 '200개' 라고만 해서, 모델이 그게 전부인 줄
961
1073
  // 알고 "전부 확인했습니다" 로 답을 맺었다. 실제로는 1,400개 중 200개였다.
962
1074
  const 잘림 = 맞는것.length > files.length
963
1075
  ? `\n\n… 모두 ${맞는것.length}개인데 최근 것 ${files.length}개만 보여 줍니다. 범위를 좁혀 다시 찾으세요.`
964
1076
  : '';
965
1077
  return {
966
- content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림,
1078
+ content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림 + 건너뜀,
967
1079
  summary: 맞는것.length > files.length ? `${files.length}/${맞는것.length}개` : `${files.length}개`,
968
1080
  };
969
1081
  },
@@ -994,20 +1106,88 @@ export const TOOLS = {
994
1106
 
995
1107
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
996
1108
  const isFile = existsSync(root) && statSync(root).isFile();
1109
+
1110
+ const mode = args.output_mode ?? 'files_with_matches';
1111
+ const limit = args.head_limit ?? 찾을줄수(ctx.모델컨텍스트);
1112
+ const hitFiles = [];
1113
+ const lines = [];
1114
+ let total = 0;
1115
+
1116
+ /*
1117
+ * 이 PC 에 rg 나 git grep 이 이미 있으면 빌려 쓴다 (tools/fastgrep.js).
1118
+ *
1119
+ * 깔지는 않는다 — 없으면 아래 자바스크립트 길로 그대로 간다. 5만 개짜리
1120
+ * 저장소에서 수십 초가 1초 안쪽이 된다. 대신 **무엇으로 찾았는지 반드시
1121
+ * 적는다.** rg 의 정규식 문법은 자바스크립트와 조금 달라서, 결과가
1122
+ * 다르게 나왔을 때 무엇으로 찾은 것인지 모르면 사람은 코드를 의심한다.
1123
+ */
1124
+ const 무시파일 = join(ctx.scope.root, '.deelignore');
1125
+ const 빠른것 = isFile ? null : 빠르게찾기({
1126
+ 무늬: args.pattern,
1127
+ 자리: root,
1128
+ glob: args.glob ?? null,
1129
+ 대소문자무시: !!args['-i'],
1130
+ 무시파일: existsSync(무시파일) ? 무시파일 : null,
1131
+ 최대: Math.max(limit * 4, 2000),
1132
+ });
1133
+ if (빠른것) {
1134
+ const 파일별 = new Map();
1135
+ for (const x of 빠른것.줄들) {
1136
+ // 우리 규칙(글 아닌 것·큰 파일)은 rg 쪽 옵션으로 이미 걸었다. 여기서는 세기만.
1137
+ const rel = ctx.scope.show(x.파일);
1138
+ 파일별.set(rel, (파일별.get(rel) ?? 0) + 1);
1139
+ total += 1;
1140
+ if (mode === 'content' && lines.length < limit) {
1141
+ const num = args['-n'] === false ? '' : `:${x.줄}`;
1142
+ lines.push(`${rel}${num}: ${x.내용.trim().slice(0, 200)}`);
1143
+ }
1144
+ }
1145
+ for (const [rel, n] of 파일별) hitFiles.push({ rel, n });
1146
+ /*
1147
+ * 꼬리에 무엇을 적나.
1148
+ *
1149
+ * 자바스크립트 길은 '몇 개를 건너뛰었는지' 를 세어서 적는다. rg 는 그
1150
+ * 숫자를 안 알려준다. 그러면 **모르는 것을 안 적는다** — 지어낸 숫자를
1151
+ * 적느니 안 셌다고 말하는 편이 낫다.
1152
+ */
1153
+ const 꼬리2 = [
1154
+ 빠른것.잘림 ? '(결과가 많아 앞부분만 봤습니다 — 더 있을 수 있습니다)' : '',
1155
+ 엔진말(빠른것.엔진),
1156
+ '(.gitignore·.deelignore 는 지켰습니다. 건너뛴 수는 안 셌습니다)',
1157
+ ].filter(Boolean).join(' ');
1158
+ const 붙이기2 = (t) => (꼬리2 ? [t, '', 꼬리2].join('\n') : t);
1159
+ if (!total) return { content: 붙이기2(`일치 없음: ${args.pattern}`), summary: `0건 · ${빠른것.엔진}` };
1160
+ if (mode === 'content') return { content: 붙이기2(clip(lines.join('\n'))), summary: `${total}건 · ${빠른것.엔진}` };
1161
+ if (mode === 'count') {
1162
+ return {
1163
+ content: 붙이기2(hitFiles.slice(0, limit).map((f) => `${f.n}\t${f.rel}`).join('\n')),
1164
+ summary: `${hitFiles.length}개 파일 · ${빠른것.엔진}`,
1165
+ };
1166
+ }
1167
+ return {
1168
+ content: 붙이기2(hitFiles.slice(0, limit).map((f) => f.rel).join('\n')),
1169
+ summary: `${hitFiles.length}개 파일 · ${total}건 · ${빠른것.엔진}`,
1170
+ };
1171
+ }
1172
+
1173
+ /*
1174
+ * ── 여기서부터 예전 길 (자바스크립트로 하나씩 연다) ──
1175
+ *
1176
+ * 폴더를 훑는 일(`walk`)을 여기까지 미뤄 두었다. 빠른 엔진이 답을 준
1177
+ * 경우에는 훑을 까닭이 없는데, 전에는 위에서 먼저 훑고 있었다 —
1178
+ * 그러면 rg 를 빌려 쓰고도 제일 오래 걸리는 일을 그대로 한 셈이 된다.
1179
+ */
997
1180
  let files = isFile
998
1181
  ? [{ path: root, rel: ctx.scope.show(root) }]
999
1182
  : walk(root);
1183
+ const 안본것 = isFile ? '' : 건너뜀말(files.건너뜀, files.잘림, files.상한).trim(); // .gitignore 로 건너뛴 수 — 꼬리에 적는다
1184
+ // 훑기 상한에 걸렸으면 "일치 없음" 이라고 잘라 말하면 안 된다. 안 본 것이다.
1185
+ const 다못봄 = !isFile && !!files.잘림;
1000
1186
  if (args.glob) {
1001
1187
  const g = globToRegex(args.glob);
1002
1188
  files = files.filter((f) => g.test(f.rel) || g.test(f.rel.split('/').pop()));
1003
1189
  }
1004
1190
 
1005
- const mode = args.output_mode ?? 'files_with_matches';
1006
- const limit = args.head_limit ?? 찾을줄수(ctx.모델컨텍스트);
1007
- const hitFiles = [];
1008
- const lines = [];
1009
- let total = 0;
1010
-
1011
1191
  /*
1012
1192
  * 큰 파일과 글이 아닌 파일은 건너뛴다.
1013
1193
  *
@@ -1046,10 +1226,16 @@ export const TOOLS = {
1046
1226
  멈춤 === '중단' ? '(중단하셔서 여기까지만 찾았습니다)' : '',
1047
1227
  멈춤 === '상한' ? `(${limit}개에서 멈췄습니다 — 더 있을 수 있습니다)` : '',
1048
1228
  건너뛴것 ? `(글이 아니거나 너무 큰 파일 ${건너뛴것}개는 건너뛰었습니다)` : '',
1229
+ 안본것,
1049
1230
  ].filter(Boolean).join(' ');
1050
1231
  const 붙이기 = (s) => (꼬리 ? `${s}\n\n${꼬리}` : s);
1051
1232
 
1052
- if (!total) return { content: 붙이기(`일치 없음: ${args.pattern}`), summary: '0건' };
1233
+ if (!total) {
1234
+ return {
1235
+ content: 붙이기(다못봄 ? `본 데까지는 일치 없음: ${args.pattern}` : `일치 없음: ${args.pattern}`),
1236
+ summary: 다못봄 ? '0건 (다 못 봄)' : '0건',
1237
+ };
1238
+ }
1053
1239
  if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'))), summary: `${total}건` };
1054
1240
  if (mode === 'count') {
1055
1241
  return { content: 붙이기(hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n')), summary: `${hitFiles.length}개 파일` };
@@ -1158,27 +1344,9 @@ export const TOOLS = {
1158
1344
  };
1159
1345
  }
1160
1346
 
1161
- /*
1162
- * 명령을 셸에 넘기는 방법. 윈도우에서 여기가 조용히 틀려 있었다.
1163
- *
1164
- * 무슨 일이 있었나:
1165
- * Node 는 인자를 넘길 때 따옴표를 \" 로 바꿔 준다. 그런데 cmd.exe 는
1166
- * \" 를 모른다. 그래서 따옴표가 든 명령이 통째로 뭉개졌다 —
1167
- * node -e "console.log(1)" → 아무것도 안 하고 **종료코드 0**
1168
- * 출력도 없고 오류도 없이 '성공' 이다. 모델은 잘된 줄 알고 넘어간다.
1169
- * `node -e`, `python -c`, `git commit -m "..."` 이 전부 이 자리였다.
1170
- *
1171
- * Node 의 exec() 가 안에서 하는 것과 똑같이 맞춘다 — 명령을 통째로
1172
- * 따옴표로 감싸고, 인자를 손대지 말라고(verbatim) 일러 준다.
1173
- * /s 는 그 감싼 따옴표 한 쌍을 벗기라는 뜻이라 짝이 맞는다.
1174
- */
1175
- const shell = process.platform === 'win32'
1176
- ? {
1177
- file: process.env.COMSPEC ?? 'cmd.exe',
1178
- args: ['/d', '/s', '/c', `"${cmd}"`],
1179
- verbatim: true,
1180
- }
1181
- : { file: '/bin/sh', args: ['-c', cmd] };
1347
+ // 어느 셸로 넘기나 — tools/shell.js 가 정한다. Jobs 와 같은 답이어야 하므로 한 군데다.
1348
+ // (윈도우 cmd 따옴표 문제와 해법도 거기 적혀 있다.)
1349
+ const shell = 셸명령(cmd);
1182
1350
 
1183
1351
  const 제한 = args.timeout ?? 120000;
1184
1352
  return new Promise((끝) => {
@@ -1582,6 +1750,26 @@ export function 설명줄이기(schema, 한도) {
1582
1750
  * 빈 설명을 내보내지 않는다. 설명 없는 도구는 모델이 언제 쓰는지 모른 채로
1583
1751
  * 목록에만 서 있게 되는데, 그건 없는 것보다 나쁘다.
1584
1752
  */
1753
+ /*
1754
+ * 그림 이야기는 **볼 수 있는 모델에게만** 한다.
1755
+ *
1756
+ * 못 보는 모델에게 "그림도 읽을 수 있다" 고 적어 두면 모델은 화면 사진을
1757
+ * 열려 들고, 열어 봐야 "이 모델은 못 봅니다" 를 받는다. 그 한 걸음이 매번
1758
+ * 헛간다 — 작은 로컬 모델일수록 그 한 걸음이 아깝다.
1759
+ *
1760
+ * 반대로 볼 수 있는데 안 적어 두면 그림 파일을 아예 안 연다. 사람이 화면
1761
+ * 사진을 폴더에 넣어 두고 "이거 봐" 라고 해도 이름만 보고 지나간다.
1762
+ */
1763
+ function 눈붙이기(fn, 이름, vision) {
1764
+ if (이름 !== 'Read' || !vision) return fn;
1765
+ // 화면 말을 그대로 본다. 설명 글자를 보고 짐작하면 안 된다 — 줄이기가 문장
1766
+ // 한복판을 자르므로 마지막 글자가 무엇일지 정해져 있지 않다.
1767
+ const 덧말 = 지시말() === 'en'
1768
+ ? ' Screenshots and images (.png/.jpg/.gif/.webp) can be opened too — this model can see them.'
1769
+ : ' 화면 사진·그림(.png/.jpg/.gif/.webp)도 그대로 열 수 있다 — 지금 붙어 있는 모델은 그림을 본다.';
1770
+ return { ...fn, description: String(fn.description ?? '') + 덧말 };
1771
+ }
1772
+
1585
1773
  function 영어설명(schema, 이름) {
1586
1774
  if (지시말() !== 'en') return schema;
1587
1775
  const 것 = 도구설명EN[이름];
@@ -1602,7 +1790,7 @@ function 영어설명(schema, 이름) {
1602
1790
 
1603
1791
  // 모델에게 넘길 도구 정의 목록.
1604
1792
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
1605
- export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false } = {}) {
1793
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false, vision = false } = {}) {
1606
1794
  let list = names ?? Object.keys(TOOLS).filter((n) => {
1607
1795
  if (n === 'Skill') return hasSkills;
1608
1796
  if (n === 'WebFetch') return web;
@@ -1643,7 +1831,9 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
1643
1831
  */
1644
1832
  const 우리것 = list.map((n) => ({
1645
1833
  type: 'function',
1646
- function: 설명줄이기(영어설명(TOOLS[n].schema, n), 한도),
1834
+ // 이야기는 줄인 **뒤에** 붙인다. 먼저 붙이면 한도에 걸려 그 한 문장이
1835
+ // 그대로 잘려 나간다 — 좁은 창일수록 정작 알려야 할 때 안 실린다.
1836
+ function: 눈붙이기(설명줄이기(영어설명(TOOLS[n].schema, n), 한도), n, vision),
1647
1837
  }));
1648
1838
 
1649
1839
  /*
package/src/tools/jobs.js CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { spawn, execFileSync } from 'node:child_process';
27
27
  import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
+ import { 셸명령 } from './shell.js';
28
29
 
29
30
  // 한 일감이 **들고 있을** 출력 상한. 넘으면 앞을 버리고 뒤를 남긴다 —
30
31
  // 오래 도는 것에서 필요한 건 언제나 **방금** 나온 쪽이다.
@@ -61,18 +62,9 @@ const 끝난것상한 = 8;
61
62
  */
62
63
  const 죽는말기다림 = 700;
63
64
 
64
- /**
65
- * 명령을 셸에 넘기는 방법.
66
- *
67
- * 윈도우에서 여기가 조용히 틀리면 따옴표가 든 명령이 통째로 뭉개진다 —
68
- * 출력도 오류도 없이 **종료코드 0** 이다. Bash 쪽과 같은 값을 쓴다.
69
- * 한 군데서만 정의하는 것이 중요하다. 두 벌이 되면 한쪽만 고쳐진다.
70
- */
71
- export function 셸명령(cmd) {
72
- return process.platform === 'win32'
73
- ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}"`], verbatim: true }
74
- : { file: '/bin/sh', args: ['-c', cmd], verbatim: false };
75
- }
65
+ // 명령을 셸에 넘기는 방법은 tools/shell.js 한 군데서 정한다. Bash 도구와 같은 값이어야
66
+ // 한다 벌이 되면 한쪽만 고쳐지고, 윈도우에서 따옴표가 든 명령이 조용히 뭉개진다.
67
+ export { 셸명령 };
76
68
 
77
69
  /**
78
70
  * 어떻게 띄우나 — spawn 에 넘길 것들.
@@ -32,6 +32,7 @@
32
32
  import { readFileSync, existsSync, statSync } from 'node:fs';
33
33
  import { extname } from 'node:path';
34
34
  import { walk, SKIP_DIRS, globToRegex, 내부살림 } from './fsutil.js';
35
+ import { 건너뜀말 } from './ignore.js';
35
36
  import { decode, looksBinary } from './encoding.js';
36
37
  import { 찾을개수, 뼈대줄수 } from '../agent/budget.js';
37
38
 
@@ -232,6 +233,7 @@ export const OUTLINE_TOOL = {
232
233
  let 파일들 = 하나인가
233
234
  ? [{ path: 시작, rel: ctx.scope.show(시작), mtime: statSync(시작).mtimeMs }]
234
235
  : walk(시작, { skipDirs: SKIP_DIRS });
236
+ const 건너뜀 = 하나인가 ? '' : 건너뜀말(파일들.건너뜀, 파일들.잘림, 파일들.상한); // .gitignore 로 건너뛴 수 (tools/ignore.js)
235
237
 
236
238
  // 좁히는 방식은 Glob 도구와 **같은 것**을 쓴다. 두 도구가 같은 패턴에
237
239
  // 다르게 답하면 모델이 둘 중 어느 것을 믿어야 할지 알 수 없다.
@@ -323,7 +325,7 @@ export const OUTLINE_TOOL = {
323
325
  }
324
326
 
325
327
  return {
326
- content: 줄들.join('\n').trimEnd(),
328
+ content: 줄들.join('\n').trimEnd() + 건너뜀,
327
329
  summary: `${뼈대있는파일}개 파일 · ${항목수}곳`
328
330
  + (못읽은것.size ? ` · 못 읽음 ${[...못읽은것.values()].reduce((a, x) => a + x.length, 0)}개` : ''),
329
331
  };