deel-local-cli 1.0.2 → 1.1.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.
@@ -5,20 +5,32 @@ import { dirname } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { globToRegex, walk, readText, readTextFull, SKIP_DIRS, 내부살림 } from './fsutil.js';
7
7
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
8
- import { checkCommand, checkPaths } from '../safety/guard.js';
8
+ import { checkCommand, checkPaths, isMutating } from '../safety/guard.js';
9
+ import { 띄우기, JOBS_TOOL } from './jobs.js';
9
10
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
10
11
  import { loadSkill } from '../skills/discover.js';
11
12
  import { WEB_FETCH_TOOL } from './webfetch.js';
12
13
  import { TODO_TOOL } from './todo.js';
14
+ import { TASK_TOOL } from './task.js';
15
+ import { OUTLINE_TOOL } from './outline.js';
16
+ import { VERIFY_TOOL } from './verify.js';
13
17
  import { allow as allowedIn } from '../agent/modes.js';
14
18
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
15
19
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
16
20
  import { diffLines } from '../ui/diff.js';
21
+ import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
17
22
 
18
- const MAX_READ_LINES = 2000;
23
+ /*
24
+ * 한 번에 돌려줄 양은 **모델에 맞춰** 정한다 (agent/budget.js).
25
+ *
26
+ * 전에는 못 박혀 있었다 — Read 2,000줄, Glob 200개, Grep 250줄. 그 값이 맞는
27
+ * 모델은 하나도 없다. 8k 짜리에는 한 번으로 창을 넘기는 양이고, 655k 짜리에는
28
+ * 있는 자리의 1%도 안 쓰는 양이다. 같은 숫자가 한쪽에선 너무 크고 다른 쪽에선
29
+ * 너무 작으면, 숫자를 잘못 고른 게 아니라 고정한 것 자체가 틀린 것이다.
30
+ *
31
+ * ctx.모델컨텍스트 가 없으면(검사·일회성 호출) budget.js 가 알아서 기본값을 쓴다.
32
+ */
19
33
  const MAX_OUT = 30000;
20
- // Glob 이 한 번에 돌려줄 최대 개수. 넘으면 잘랐다고 말해 준다.
21
- const GLOB_MAX = 200;
22
34
  // Grep 이 열어 볼 파일 크기 상한. 이보다 크면 글 파일이라도 안 본다 —
23
35
  // 한 파일에서 몇십 초를 쓰면 그동안 화면이 멈춘 것처럼 보인다.
24
36
  const GREP_MAX_FILE = 2 * 1024 * 1024;
@@ -94,6 +106,70 @@ export function 파일현황(abs) {
94
106
  } catch { return { path: abs, missing: true }; }
95
107
  }
96
108
 
109
+ // Bash 한 번에 이만큼까지만 떠 둔다. `rm` 에 파일 이름 백 개를 늘어놓는 일이
110
+ // 없지는 않은데, 그때 백 벌을 뜨면 되돌리기 이력이 그 한 번으로 밀려난다.
111
+ const 스냅샷상한 = 24;
112
+
113
+ /**
114
+ * 명령줄에서 **떠 둘 만한** 낱말을 고른다.
115
+ *
116
+ * guard.js 의 경로낱말() 을 안 쓴다. 그쪽은 슬래시가 든 것만 경로로 본다 —
117
+ * 울타리를 지키는 쪽에서는 그게 맞다. 안 걸린 것을 막아 버리면 멀쩡한 명령이
118
+ * 막히기 때문이다. 그런데 `del 지울것.txt` 처럼 **슬래시 없는 파일 이름**이
119
+ * 실제로 제일 흔하고, 그것들이 통째로 빠졌다.
120
+ *
121
+ * 여기는 막는 자리가 아니라 **읽는** 자리라 반대로 잡는다. 넓게 훑고,
122
+ * 실제로 그 자리에 파일이 있을 때만 뜬다. 헛다리를 짚어도 손해가 없다 —
123
+ * 없는 파일은 그냥 넘어간다.
124
+ */
125
+ function 뜰만한낱말(cmd) {
126
+ const out = [];
127
+ const re = /"([^"]*)"|'([^']*)'|[^\s|;&<>()]+/g;
128
+ let m;
129
+ while ((m = re.exec(String(cmd)))) {
130
+ let t = m[1] ?? m[2] ?? m[0];
131
+ if (!t) continue;
132
+ if (t.startsWith('-')) continue; // 옵션
133
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) continue; // 주소
134
+ t = t.replace(/^[<>]+/, '');
135
+ // 셸이 푸는 자리표·와일드카드는 여기서 못 편다. 억지로 풀면 엉뚱한
136
+ // 파일을 뜨게 되므로 그냥 넘긴다 — 대신 못 떴다는 사실이 결과에 남는다.
137
+ if (!t || /[$%*?]/.test(t)) continue;
138
+ out.push(t);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * 파일을 바꾸는 Bash 명령이면, 손대기 전 내용을 떠 둔다.
145
+ *
146
+ * 여기가 없으면 `mv`·`rm` 으로 사라진 것을 /undo 가 못 살린다. Write·Edit 만
147
+ * 지키는 안전망은 절반짜리다 — 모델은 파일을 옮길 때 당연히 Bash 를 쓴다.
148
+ *
149
+ * **못 뜨는 것이 있다는 사실을 숨기지 않는다.** 셸이 푸는 와일드카드(`rm *.tmp`),
150
+ * 스크립트 안에서 지우는 것, 폴더 통째는 여기서 안 보인다. 그래서 결과에
151
+ * '이건 되돌릴 수 있다' 는 말을 붙이지 않고, 뜬 개수만 사실대로 넘긴다.
152
+ *
153
+ * @returns {string[]} 떠 둔 파일들의 보인 이름
154
+ */
155
+ function 바꾸기전스냅샷(cmd, ctx) {
156
+ if (!isMutating(cmd)) return [];
157
+ const 뜬것 = [];
158
+ for (const t of 뜰만한낱말(cmd)) {
159
+ if (뜬것.length >= 스냅샷상한) break;
160
+ let abs;
161
+ // 범위 밖은 어차피 checkPaths 가 이미 막았다. 여기서 터지면 안 된다 —
162
+ // 뜨는 데 실패했다고 명령 자체를 막으면 안 되는 명령까지 막힌다.
163
+ try { abs = ctx.scope.resolve(t); } catch { continue; }
164
+ try {
165
+ if (!existsSync(abs) || statSync(abs).isDirectory()) continue;
166
+ ctx.history.snapshot(abs, 'Bash');
167
+ 뜬것.push(ctx.scope.show(abs));
168
+ } catch { /* 못 뜨면 그냥 넘어간다. 명령은 돌아야 한다 */ }
169
+ }
170
+ return 뜬것;
171
+ }
172
+
97
173
  /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
98
174
  function 줄수(abs, 인코딩) {
99
175
  try {
@@ -133,6 +209,226 @@ async function 엑셀읽기(abs, args, ctx) {
133
209
  };
134
210
  }
135
211
 
212
+
213
+ /**
214
+ * 파일 하나를 쓴다 — Write 의 알맹이.
215
+ *
216
+ * 되돌리기 스냅샷을 **여기서** 뜬다. 여러 개를 쓸 때도 파일마다 한 번씩 뜨는
217
+ * 것이 중요하다. 한 덩이로 뜨면 `/undo` 가 전부-아니면-전무가 되어, 넷 중
218
+ * 하나만 잘못 만들었을 때 나머지 셋까지 날려야 한다.
219
+ */
220
+ function 한파일쓰기(args, ctx) {
221
+ const abs = ctx.scope.resolve(args.file_path);
222
+ if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
223
+ // 읽기만 막고 쓰기를 열어 두면 남의 도구 살림을 덮어쓸 수 있다.
224
+ // 제 설정(.deel/config.json)을 덮어쓰면 연결이 통째로 날아간다.
225
+ const 못쓰는이유 = 내부살림(abs);
226
+ if (못쓰는이유) return { error: 못쓰는이유 };
227
+ // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
228
+ // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
229
+ if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
230
+ // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
231
+ // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
232
+ // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
233
+ const 바이너리막기 = 바이너리인가(abs);
234
+ if (바이너리막기) return { error: 바이너리막기 };
235
+ ctx.history.snapshot(abs, 'Write');
236
+ const existed = existsSync(abs);
237
+ // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
238
+ // 읽다 터지는 파일(바이너리 등)이면 그냥 없던 셈 친다 — 쓰는 것 자체는 막지 않는다.
239
+ let 이전 = null;
240
+ if (existed) { try { 이전 = readTextFull(abs).text; } catch { 이전 = null; } }
241
+ mkdirSync(dirname(abs), { recursive: true });
242
+
243
+ // 원래 있던 파일이면 그 파일이 쓰던 인코딩으로 되돌려 쓴다.
244
+ // 새 파일이면 UTF-8 이다 — 요즘 만드는 파일까지 옛 인코딩으로 둘 이유가 없다.
245
+ const 원래 = existed ? (ctx.enc?.get(abs) ?? 'utf-8') : 'utf-8';
246
+ const 만든것 = encode(args.content, 원래);
247
+ if (만든것.lost.length) {
248
+ return {
249
+ error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 그 인코딩에 없는 글자가 있습니다: `
250
+ + `${만든것.lost.slice(0, 8).join(' ')}\n`
251
+ + ` 그대로 쓰면 그 글자들이 뭉개집니다. 해당 글자를 빼거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
252
+ };
253
+ }
254
+ writeFileSync(abs, 만든것.buf);
255
+ ctx.seen.add(abs);
256
+ const n = args.content.split('\n').length;
257
+ const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
258
+ return {
259
+ content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄${표기})`,
260
+ summary: `${n}줄${표기}`,
261
+ changed: abs,
262
+ diff: 바뀐자리(이전, args.content),
263
+ };
264
+ }
265
+
266
+ /**
267
+ * 여러 파일을 한 번에.
268
+ *
269
+ * 하나가 실패해도 나머지는 간다. 첫 실패에서 통째로 멈추면 모델은 무엇이 되고
270
+ * 무엇이 안 됐는지 모른 채 여덟 개를 처음부터 다시 보낸다 — 왕복을 줄이려던
271
+ * 것이 오히려 늘어난다. 그래서 **한 줄씩 다 적어** 돌려준다.
272
+ */
273
+ function 여러파일쓰기(목록, ctx) {
274
+ const 결과 = [];
275
+ for (const x of 목록) {
276
+ if (typeof x.file_path !== 'string' || !x.file_path) {
277
+ 결과.push({ path: null, ok: false, error: 'file_path 가 없습니다' });
278
+ continue;
279
+ }
280
+ let r;
281
+ try { r = 한파일쓰기(x, ctx); }
282
+ catch (err) { r = { error: String(err?.message ?? err) }; }
283
+ 결과.push(r.error
284
+ ? { path: x.file_path, 보인이름: x.file_path, ok: false, error: r.error }
285
+ : {
286
+ path: r.changed,
287
+ 보인이름: ctx.scope.show(r.changed),
288
+ ok: true,
289
+ lines: String(x.content ?? '').split('\n').length,
290
+ diff: r.diff,
291
+ });
292
+ }
293
+
294
+ const 된것 = 결과.filter((r) => r.ok);
295
+ const 안된것 = 결과.filter((r) => !r.ok);
296
+ const 줄들 = 결과.map((r) => (r.ok
297
+ ? ` ✓ ${r.보인이름} (${r.lines}줄)`
298
+ : ` ✗ ${r.보인이름} — ${String(r.error).split('\n')[0]}`));
299
+
300
+ return {
301
+ content: `${된것.length}개 만들었습니다${안된것.length ? `, ${안된것.length}개 실패` : ''}.\n`
302
+ + 줄들.join('\n')
303
+ + (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
304
+ summary: `${된것.length}개 · ${된것.reduce((a, r) => a + (r.lines ?? 0), 0)}줄`
305
+ + (안된것.length ? ` · ${안된것.length}개 실패` : ''),
306
+ // 화면과 루프가 파일별로 처리하도록 그대로 넘긴다. changed 는 안 넣는다 —
307
+ // 넣으면 그 한 개만 세어지고 나머지가 조용히 빠진다.
308
+ 여럿: 결과,
309
+ error: 된것.length ? undefined : (안된것[0]?.error ?? '아무것도 못 만들었습니다'),
310
+ };
311
+ }
312
+
313
+ /**
314
+ * 한 군데를 고친다 — Edit 의 알맹이.
315
+ *
316
+ * 결과 모양을 바꾸면 안 된다. changed·diff·tier 를 보고 있는 자리가 셋이다:
317
+ * loop.js 의 잘린 인자 살려쓰기, repl.js 의 바뀐 자리 그리기, 되돌리기 스냅샷.
318
+ */
319
+ function 한군데고치기(args, ctx) {
320
+ const abs = ctx.scope.resolve(args.file_path);
321
+ if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
322
+ const 못고치는이유 = 내부살림(abs);
323
+ if (못고치는이유) return { error: 못고치는이유 };
324
+ // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
325
+ // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
326
+ if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
327
+ if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
328
+ if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
329
+
330
+ const 읽음 = readTextFull(abs);
331
+ const text = 읽음.text;
332
+ const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
333
+
334
+ if (!m.ok) {
335
+ if (m.reason === 'ambiguous') {
336
+ return { error: `${m.count}군데에서 발견됐습니다 (${TIER_LABELS[m.tier]}). 앞뒤로 더 넓게 잡아 하나만 가리키거나 replace_all 을 쓰세요.` };
337
+ }
338
+ const hint = m.near
339
+ ? `\n 파일의 ${m.near.line}번 줄이 가장 비슷합니다:\n ${m.near.text.trim().slice(0, 120)}\n 이 줄을 그대로 옮겨 담아 다시 시도하세요.`
340
+ : '\n Read 로 다시 읽어 실제 내용을 확인하세요.';
341
+ return { error: `찾지 못했습니다.${hint}` };
342
+ }
343
+
344
+ ctx.history.snapshot(abs, 'Edit');
345
+ const next = applySpans(text, m.spans, (matched) =>
346
+ m.tier === 'exact' ? args.new_string : reindent(args.new_string, matched, args.old_string));
347
+
348
+ // 읽은 그 인코딩으로 되돌려 쓴다.
349
+ const 만든것 = encode(next, 읽음.encoding);
350
+ if (만든것.lost.length) {
351
+ return {
352
+ error: `이 파일은 ${encLabel(읽음.encoding)} 로 되어 있는데, 그 인코딩에 없는 글자를 넣으려 합니다: `
353
+ + `${만든것.lost.slice(0, 8).join(' ')}\n`
354
+ + ` 그대로 쓰면 그 글자들이 뭉개집니다. 다른 표현을 쓰거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
355
+ };
356
+ }
357
+ writeFileSync(abs, 만든것.buf);
358
+
359
+ const n = m.spans.length;
360
+ const how = m.tier === 'exact' ? '' : ` · ${TIER_LABELS[m.tier]}`;
361
+ const 표기 = 읽음.encoding !== 'utf-8' ? ` · ${encLabel(읽음.encoding)}` : '';
362
+ return {
363
+ content: `고침: ${ctx.scope.show(abs)} (${n}군데${how}${표기})`,
364
+ summary: `${n}군데${how}${표기}`,
365
+ changed: abs,
366
+ tier: m.tier,
367
+ diff: 바뀐자리(text, next),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * 여러 군데를 한 번에.
373
+ *
374
+ * **차례로** 적용한다. 같은 파일을 두 번 고치는 경우가 흔한데, 한군데고치기()가
375
+ * 매번 디스크에서 다시 읽으므로 뒤엣것은 앞엣것이 반영된 글을 보고 찾는다.
376
+ * 한꺼번에 계산해서 붙이면 두 자리가 겹칠 때 조용히 어긋난다.
377
+ *
378
+ * 되돌리기는 그대로 한 턴이다. 스냅샷은 파일마다 그 턴의 첫 번만 뜨므로
379
+ * (undo.js snapshot 참고), 같은 파일을 여섯 군데 고쳐도 /undo 한 번이면
380
+ * 여섯 군데 다 손대기 전으로 돌아간다.
381
+ *
382
+ * 하나가 실패해도 나머지는 간다 — 여러파일쓰기() 와 같은 이유다.
383
+ */
384
+ function 여러군데고치기(목록, ctx) {
385
+ const 결과 = [];
386
+ for (const x of 목록) {
387
+ if (typeof x.file_path !== 'string' || !x.file_path) {
388
+ 결과.push({ path: null, 보인이름: '(경로 없음)', ok: false, error: 'file_path 가 없습니다' });
389
+ continue;
390
+ }
391
+ let r;
392
+ try { r = 한군데고치기(x, ctx); }
393
+ catch (err) { r = { error: String(err?.message ?? err) }; }
394
+ 결과.push(r.error
395
+ ? { path: x.file_path, 보인이름: x.file_path, ok: false, error: r.error }
396
+ : {
397
+ path: r.changed,
398
+ 보인이름: ctx.scope.show(r.changed),
399
+ ok: true,
400
+ 군데: Number(String(r.summary).match(/^(\d+)/)?.[1] ?? 1),
401
+ tier: r.tier,
402
+ diff: r.diff,
403
+ });
404
+ }
405
+
406
+ const 된것 = 결과.filter((r) => r.ok);
407
+ const 안된것 = 결과.filter((r) => !r.ok);
408
+ const 줄들 = 결과.map((r) => (r.ok
409
+ ? ` ✓ ${r.보인이름} (${r.군데}군데)`
410
+ : ` ✗ ${r.보인이름} — ${String(r.error).split('\n')[0]}`));
411
+
412
+ // 어느 파일이 몇 번 고쳐졌는지. 같은 파일을 여러 번 고치는 것이 보통이라
413
+ // '3개 파일' 이 아니라 '2개 파일 · 5군데' 라고 말해야 사실에 맞는다.
414
+ const 파일수 = new Set(된것.map((r) => r.path)).size;
415
+ const 군데수 = 된것.reduce((a, r) => a + (r.군데 ?? 0), 0);
416
+
417
+ return {
418
+ content: `${군데수}군데 고쳤습니다${안된것.length ? `, ${안된것.length}군데 실패` : ''}.\n`
419
+ + 줄들.join('\n')
420
+ + (안된것.length
421
+ ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.'
422
+ + ' 앞엣것이 이미 반영됐으니 **파일을 다시 Read 해서** 지금 내용을 보고 old_string 을 잡으세요.'
423
+ : ''),
424
+ summary: `${파일수}개 파일 · ${군데수}군데` + (안된것.length ? ` · ${안된것.length}개 실패` : ''),
425
+ // 화면과 루프가 자리별로 처리하도록 그대로 넘긴다. changed 는 안 넣는다 —
426
+ // 넣으면 그 한 개만 세어지고 나머지가 조용히 빠진다 (여러파일쓰기 와 같다).
427
+ 여럿: 결과,
428
+ error: 된것.length ? undefined : (안된것[0]?.error ?? '아무것도 못 고쳤습니다'),
429
+ };
430
+ }
431
+
136
432
  export const TOOLS = {
137
433
  Read: {
138
434
  schema: {
@@ -170,7 +466,8 @@ export const TOOLS = {
170
466
  const text = 읽음.text;
171
467
  const lines = text.split('\n');
172
468
  const start = Math.max(0, (args.offset ?? 1) - 1);
173
- const count = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES);
469
+ const 줄상한 = 읽을줄수(ctx.모델컨텍스트);
470
+ const count = Math.min(args.limit ?? 줄상한, 줄상한);
174
471
  const slice = lines.slice(start, start + count);
175
472
  const body = slice.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
176
473
  const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
@@ -186,60 +483,45 @@ export const TOOLS = {
186
483
  Write: {
187
484
  schema: {
188
485
  name: 'Write',
189
- description: '파일을 새로 쓰거나 통째로 덮어쓴다. 일부만 고칠 때는 Edit 을 쓴다.',
486
+ description: '파일을 새로 쓰거나 통째로 덮어쓴다. 일부만 고칠 때는 Edit 을 쓴다.'
487
+ + ' **여러 파일을 한 번에 만들 수 있다** — files 에 배열로 넣으면 된다.'
488
+ + ' 폴더 구조를 처음 잡을 때는 그렇게 해라. 한 개씩 부르면 파일 수만큼 모델을'
489
+ + ' 다시 불러야 해서, 여덟 개짜리 뼈대에 몇 분이 그냥 간다.',
190
490
  parameters: {
191
491
  type: 'object',
192
492
  properties: {
193
- file_path: { type: 'string', description: '쓸 파일 경로' },
194
- content: { type: 'string', description: '파일 전체 내용' },
493
+ file_path: { type: 'string', description: '쓸 파일 경로 (한 개일 때)' },
494
+ content: { type: 'string', description: '파일 전체 내용 (한 개일 때)' },
495
+ files: {
496
+ type: 'array',
497
+ description: '여러 개를 한 번에 만들 때. 이걸 쓰면 file_path·content 는 안 쓴다.',
498
+ items: {
499
+ type: 'object',
500
+ properties: {
501
+ file_path: { type: 'string', description: '쓸 파일 경로' },
502
+ content: { type: 'string', description: '파일 전체 내용' },
503
+ },
504
+ required: ['file_path', 'content'],
505
+ },
506
+ },
195
507
  },
196
- required: ['file_path', 'content'],
508
+ required: [],
197
509
  },
198
510
  },
511
+ /*
512
+ * 갈래만 정한다. 알맹이는 아래 한파일쓰기() 에 있다.
513
+ *
514
+ * 한 개일 때의 결과 모양은 **한 글자도 안 바꾼다.** 그 모양을 보고 있는
515
+ * 자리가 여럿이다 — loop.js 의 잘린 것 살려쓰기, repl.js 의 바뀐 자리 그리기,
516
+ * 되돌리기 스냅샷. 여러 개는 그것과 다른 모양(여럿)으로 따로 돌려준다.
517
+ */
199
518
  run(args, ctx) {
200
- const abs = ctx.scope.resolve(args.file_path);
201
- if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
202
- // 읽기만 막고 쓰기를 열어 두면 남의 도구 살림을 덮어쓸 수 있다.
203
- // 설정(.deel/config.json)을 덮어쓰면 연결이 통째로 날아간다.
204
- const 못쓰는이유 = 내부살림(abs);
205
- if (못쓰는이유) return { error: 못쓰는이유 };
206
- // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
207
- // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
208
- if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
209
- // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
210
- // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
211
- // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
212
- const 바이너리막기 = 바이너리인가(abs);
213
- if (바이너리막기) return { error: 바이너리막기 };
214
- ctx.history.snapshot(abs, 'Write');
215
- const existed = existsSync(abs);
216
- // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
217
- // 읽다 터지는 파일(바이너리 등)이면 그냥 없던 셈 친다 — 쓰는 것 자체는 막지 않는다.
218
- let 이전 = null;
219
- if (existed) { try { 이전 = readTextFull(abs).text; } catch { 이전 = null; } }
220
- mkdirSync(dirname(abs), { recursive: true });
221
-
222
- // 원래 있던 파일이면 그 파일이 쓰던 인코딩으로 되돌려 쓴다.
223
- // 새 파일이면 UTF-8 이다 — 요즘 만드는 파일까지 옛 인코딩으로 둘 이유가 없다.
224
- const 원래 = existed ? (ctx.enc?.get(abs) ?? 'utf-8') : 'utf-8';
225
- const 만든것 = encode(args.content, 원래);
226
- if (만든것.lost.length) {
227
- return {
228
- error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 그 인코딩에 없는 글자가 있습니다: `
229
- + `${만든것.lost.slice(0, 8).join(' ')}\n`
230
- + ` 그대로 쓰면 그 글자들이 뭉개집니다. 해당 글자를 빼거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
231
- };
519
+ const 목록 = Array.isArray(args.files) ? args.files.filter((x) => x && typeof x === 'object') : [];
520
+ if (목록.length) return 여러파일쓰기(목록, ctx);
521
+ if (typeof args.file_path !== 'string' || !args.file_path) {
522
+ return { error: 'file_path 없습니다. 한 개면 file_path·content 를, 여러 개면 files 배열을 주세요.' };
232
523
  }
233
- writeFileSync(abs, 만든것.buf);
234
- ctx.seen.add(abs);
235
- const n = args.content.split('\n').length;
236
- const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
237
- return {
238
- content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄${표기})`,
239
- summary: `${n}줄${표기}`,
240
- changed: abs,
241
- diff: 바뀐자리(이전, args.content),
242
- };
524
+ return 한파일쓰기(args, ctx);
243
525
  },
244
526
  },
245
527
 
@@ -313,68 +595,46 @@ export const TOOLS = {
313
595
  Edit: {
314
596
  schema: {
315
597
  name: 'Edit',
316
- description: '파일에서 정확히 일치하는 문자열 하나를 바꾼다. 먼저 Read 로 읽어야 한다.',
598
+ description: '파일에서 정확히 일치하는 문자열을 바꾼다. 먼저 Read 로 읽어야 한다.'
599
+ + ' **고칠 자리가 여럿이면 edits 배열로 한 번에 보내라** — 파일이 서로 달라도 된다.'
600
+ + ' 한 군데씩 부르면 자리 수만큼 모델을 다시 불러야 해서, 여섯 군데짜리 손질에 몇 분이 그냥 간다.',
317
601
  parameters: {
318
602
  type: 'object',
319
603
  properties: {
320
- file_path: { type: 'string', description: '고칠 파일 경로' },
604
+ file_path: { type: 'string', description: '고칠 파일 경로 (한 군데일 때)' },
321
605
  old_string: { type: 'string', description: '바꿀 대상. 파일에서 유일해야 한다' },
322
606
  new_string: { type: 'string', description: '바꿀 내용' },
323
607
  replace_all: { type: 'boolean', description: '모두 바꾸려면 true' },
608
+ edits: {
609
+ type: 'array',
610
+ description: '여러 군데를 한 번에. 적은 순서대로 차례로 적용된다. 이걸 쓰면 위 인자는 안 쓴다.',
611
+ items: {
612
+ type: 'object',
613
+ properties: {
614
+ file_path: { type: 'string', description: '고칠 파일 경로' },
615
+ old_string: { type: 'string', description: '바꿀 대상' },
616
+ new_string: { type: 'string', description: '바꿀 내용' },
617
+ replace_all: { type: 'boolean', description: '모두 바꾸려면 true' },
618
+ },
619
+ required: ['file_path', 'old_string', 'new_string'],
620
+ },
621
+ },
324
622
  },
325
- required: ['file_path', 'old_string', 'new_string'],
623
+ required: [],
326
624
  },
327
625
  },
626
+ /*
627
+ * 갈래만 정한다. Write 와 같은 모양이다 — 한 군데일 때의 결과는 한 글자도
628
+ * 안 바뀐다. 그 모양을 보고 있는 자리가 여럿이라서다(loop.js 의 살려쓰기,
629
+ * repl.js 의 바뀐 자리 그리기, 되돌리기 스냅샷).
630
+ */
328
631
  run(args, ctx) {
329
- const abs = ctx.scope.resolve(args.file_path);
330
- if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
331
- const 못고치는이유 = 내부살림(abs);
332
- if (못고치는이유) return { error: 못고치는이유 };
333
- // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
334
- // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
335
- if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
336
- if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
337
- if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
338
-
339
- const 읽음 = readTextFull(abs);
340
- const text = 읽음.text;
341
- const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
342
-
343
- if (!m.ok) {
344
- if (m.reason === 'ambiguous') {
345
- return { error: `${m.count}군데에서 발견됐습니다 (${TIER_LABELS[m.tier]}). 앞뒤로 더 넓게 잡아 하나만 가리키거나 replace_all 을 쓰세요.` };
346
- }
347
- const hint = m.near
348
- ? `\n 파일의 ${m.near.line}번 줄이 가장 비슷합니다:\n ${m.near.text.trim().slice(0, 120)}\n 이 줄을 그대로 옮겨 담아 다시 시도하세요.`
349
- : '\n Read 로 다시 읽어 실제 내용을 확인하세요.';
350
- return { error: `찾지 못했습니다.${hint}` };
632
+ const 목록 = Array.isArray(args.edits) ? args.edits.filter((x) => x && typeof x === 'object') : [];
633
+ if (목록.length) return 여러군데고치기(목록, ctx);
634
+ if (typeof args.file_path !== 'string' || !args.file_path) {
635
+ return { error: 'file_path 가 없습니다. 한 군데면 file_path·old_string·new_string 을, 여러 군데면 edits 배열을 주세요.' };
351
636
  }
352
-
353
- ctx.history.snapshot(abs, 'Edit');
354
- const next = applySpans(text, m.spans, (matched) =>
355
- m.tier === 'exact' ? args.new_string : reindent(args.new_string, matched, args.old_string));
356
-
357
- // 읽은 그 인코딩으로 되돌려 쓴다.
358
- const 만든것 = encode(next, 읽음.encoding);
359
- if (만든것.lost.length) {
360
- return {
361
- error: `이 파일은 ${encLabel(읽음.encoding)} 로 되어 있는데, 그 인코딩에 없는 글자를 넣으려 합니다: `
362
- + `${만든것.lost.slice(0, 8).join(' ')}\n`
363
- + ` 그대로 쓰면 그 글자들이 뭉개집니다. 다른 표현을 쓰거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
364
- };
365
- }
366
- writeFileSync(abs, 만든것.buf);
367
-
368
- const n = m.spans.length;
369
- const how = m.tier === 'exact' ? '' : ` · ${TIER_LABELS[m.tier]}`;
370
- const 표기 = 읽음.encoding !== 'utf-8' ? ` · ${encLabel(읽음.encoding)}` : '';
371
- return {
372
- content: `고침: ${ctx.scope.show(abs)} (${n}군데${how}${표기})`,
373
- summary: `${n}군데${how}${표기}`,
374
- changed: abs,
375
- tier: m.tier,
376
- diff: 바뀐자리(text, next),
377
- };
637
+ return 한군데고치기(args, ctx);
378
638
  },
379
639
  },
380
640
 
@@ -397,7 +657,7 @@ export const TOOLS = {
397
657
  const 맞는것 = walk(root)
398
658
  .filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()))
399
659
  .sort((a, b) => b.mtime - a.mtime);
400
- const files = 맞는것.slice(0, GLOB_MAX);
660
+ const files = 맞는것.slice(0, 찾을개수(ctx.모델컨텍스트));
401
661
  if (!files.length) return { content: `찾은 파일 없음: ${args.pattern}`, summary: '0개' };
402
662
  // 잘랐으면 잘랐다고 말한다. 전에는 '200개' 라고만 해서, 모델이 그게 전부인 줄
403
663
  // 알고 "전부 확인했습니다" 로 답을 맺었다. 실제로는 1,400개 중 200개였다.
@@ -445,7 +705,7 @@ export const TOOLS = {
445
705
  }
446
706
 
447
707
  const mode = args.output_mode ?? 'files_with_matches';
448
- const limit = args.head_limit ?? 250;
708
+ const limit = args.head_limit ?? 찾을줄수(ctx.모델컨텍스트);
449
709
  const hitFiles = [];
450
710
  const lines = [];
451
711
  let total = 0;
@@ -536,13 +796,16 @@ export const TOOLS = {
536
796
  Bash: {
537
797
  schema: {
538
798
  name: 'Bash',
539
- description: '명령을 실행한다. 되돌릴 수 없는 명령은 막힌다.',
799
+ description: '명령을 실행한다. 되돌릴 수 없는 명령은 막힌다.'
800
+ + ' **끝나지 않는 것(dev 서버·watch)은 background: true 로 띄워라** —'
801
+ + ' 그냥 부르면 시간 초과로 죽는다. 띄운 뒤에는 Jobs 로 출력을 읽는다.',
540
802
  parameters: {
541
803
  type: 'object',
542
804
  properties: {
543
805
  command: { type: 'string', description: '실행할 명령' },
544
806
  description: { type: 'string', description: '무엇을 하는 명령인지 한 줄' },
545
807
  timeout: { type: 'number', description: '제한 시간(ms). 기본 120000' },
808
+ background: { type: 'boolean', description: '끝나지 않는 명령이면 true. 바로 돌아오고 Jobs 로 읽는다' },
546
809
  },
547
810
  required: ['command'],
548
811
  },
@@ -557,6 +820,46 @@ export const TOOLS = {
557
820
  try { checkPaths(cmd, ctx.scope); }
558
821
  catch (err) { ctx.audit.blocked(err.message, cmd); return { error: `막힘 — ${err.message}` }; }
559
822
 
823
+ /*
824
+ * 파일을 바꾸는 명령이면 손대기 전 내용을 떠 둔다.
825
+ *
826
+ * 되돌리기는 Write·Edit 만 지키고 있었다. 그런데 `mv 옛것.js 새것.js` 나
827
+ * `rm 임시.txt` 는 Bash 로 간다 — 그 순간 파일이 사라지는데 /undo 는
828
+ * 아무것도 못 한다. 안전망에 난 구멍치고는 큰 편이다.
829
+ *
830
+ * 명령줄에 적힌 경로 중 **지금 있는 파일**만 뜬다. 완벽하지는 않다 —
831
+ * 셸이 풀어 주는 와일드카드(`rm *.tmp`)나 스크립트 안에서 지우는 것은
832
+ * 여기서 안 보인다. 그래서 '전부 되돌아간다' 고 말하지 않는다.
833
+ * 그래도 손으로 옮기고 지우는 흔한 자리는 이걸로 덮인다.
834
+ */
835
+ const 뜬것 = 바꾸기전스냅샷(cmd, ctx);
836
+
837
+ // 끝나지 않는 명령은 뒤에서 띄운다. 여기서 기다리면 그 턴이 통째로 멈춘다.
838
+ if (args.background === true) {
839
+ const r = await 띄우기(cmd, { cwd: ctx.scope.root, 설명: args.description ?? null });
840
+ if (r.error) return { error: r.error };
841
+ if (!r.떴나) {
842
+ // 지켜보는 사이에 죽었다. 포트가 물려 있거나 명령이 틀린 경우다.
843
+ // 이걸 '띄웠습니다' 로 넘기면 모델은 다음 단계로 가고, 사람은 안 뜬
844
+ // 서버를 찾아다닌다. 실패로 못 박고 나온 말을 그대로 준다.
845
+ return {
846
+ error: `띄우자마자 끝났습니다 (${r.시그널 ? `${r.시그널} 시그널` : `종료코드 ${r.종료코드}`}).`
847
+ + ' 뒤에서 돌 명령이 아니거나, 뜨자마자 탈이 난 것입니다.'
848
+ + (r.출력?.trim() ? `\n\n나온 말:\n${clip(r.출력, 4000)}` : ''),
849
+ failed: true,
850
+ };
851
+ }
852
+ return {
853
+ content: `${r.번호}번으로 띄웠습니다: ${cmd}\n`
854
+ + (r.출력?.trim() ? `\n${clip(r.출력, 4000)}\n` : '')
855
+ + `\n계속 돌고 있습니다. 새 출력은 Jobs({번호: ${r.번호}}) 로 읽고, 일이 끝나면 Jobs({번호: ${r.번호}, 끝내기: true}) 로 정리해라.`,
856
+ summary: `${r.번호}번으로 띄움`,
857
+ 일감번호: r.번호,
858
+ 뒤에서: true,
859
+ 되돌릴것: 뜬것,
860
+ };
861
+ }
862
+
560
863
  /*
561
864
  * 명령을 셸에 넘기는 방법. 윈도우에서 여기가 조용히 틀려 있었다.
562
865
  *
@@ -638,6 +941,8 @@ export const TOOLS = {
638
941
  failed: !잘됨,
639
942
  exitCode: code,
640
943
  signal: 시그널,
944
+ // 무엇을 떠 뒀는지 화면이 알아야 '되돌릴 수 있다' 를 사실대로 적는다.
945
+ 되돌릴것: 뜬것,
641
946
  });
642
947
  });
643
948
 
@@ -794,11 +1099,91 @@ export const TOOLS = {
794
1099
  },
795
1100
 
796
1101
  TodoWrite: TODO_TOOL,
1102
+
1103
+ // 만든 것이 진짜 되는지. 끝맺기 전에 오는 자리다 — verify.js 머리말 참고.
1104
+ Verify: VERIFY_TOOL,
1105
+
1106
+ // 프로젝트 뼈대만 싸게 보기. Read 앞에 오는 자리다 — outline.js 머리말 참고.
1107
+ Outline: OUTLINE_TOOL,
1108
+
1109
+ // 하위 작업. 실행은 loop.js 가 가로채서 한다 — task.js 머리말 참고.
1110
+ Task: TASK_TOOL,
1111
+
1112
+ // 뒤에서 도는 명령 보기·끝내기. Bash(background) 와 짝이다 — jobs.js 머리말 참고.
1113
+ Jobs: JOBS_TOOL,
797
1114
  };
798
1115
 
1116
+ /**
1117
+ * 도구 설명을 창 크기에 맞게 줄인다.
1118
+ *
1119
+ * 문장 단위로 자른다. 글자 수로 뚝 자르면 "…파일을 통째로 Read 하는 것보다"
1120
+ * 처럼 말이 끊긴 채로 모델에게 간다 — 그건 안 준 것만 못하다.
1121
+ * 첫 문장은 무슨 일이 있어도 남긴다. 그게 이 도구가 무엇인지다.
1122
+ *
1123
+ * 인자 설명도 같이 줄인다. 괄호로 붙인 보충(`(한 개일 때)`)이 먼저 떨어진다.
1124
+ */
1125
+ const 뻔한인자 = new Set(['file_path', 'content', 'pattern', 'path', 'command', 'text', 'name']);
1126
+
1127
+ export function 설명줄이기(schema, 한도) {
1128
+ if (!Number.isFinite(한도)) return schema;
1129
+
1130
+ const 자르기 = (글, 몫) => {
1131
+ const s = String(글 ?? '');
1132
+ if (s.length <= 몫) return s;
1133
+ // 한국어 문장은 '다.' 로 끝난다. 영문 마침표도 같이 본다.
1134
+ const 조각 = s.split(/(?<=다\.|[.!?])\s+/);
1135
+ let 모은것 = 조각[0] ?? s;
1136
+ for (const 다음 of 조각.slice(1)) {
1137
+ if ((모은것 + ' ' + 다음).length > 몫) break;
1138
+ 모은것 += ' ' + 다음;
1139
+ }
1140
+ return 모은것;
1141
+ };
1142
+
1143
+ const p = schema.parameters ?? {};
1144
+ const 인자몫 = Math.max(24, Math.round(한도 / 3));
1145
+ const 새속성 = {};
1146
+ for (const [이름, 값] of Object.entries(p.properties ?? {})) {
1147
+ // 이름만 봐도 아는 인자는 아주 좁은 창에서 설명을 통째로 뺀다.
1148
+ // `file_path` 가 무엇인지 설명하는 데 토큰을 쓰는 것은, 8k 모델에서는
1149
+ // 그 토큰만큼 대화를 잘라먹는 것과 같다. 헷갈릴 만한 것(offset·files·
1150
+ // replace_all·목적·할일)은 그대로 둔다 — 거기서 틀리면 일이 안 된다.
1151
+ if (한도 <= 100 && 뻔한인자.has(이름)) { 새속성[이름] = { type: 값.type }; continue; }
1152
+ /*
1153
+ * 배열 안쪽 설명은 좁은 창에서 통째로 뺀다.
1154
+ *
1155
+ * files·edits 의 items 는 바깥이 이미 한 말을 되풀이한다 — 바깥에서
1156
+ * "여러 파일을 한 번에" 라고 하고, 안쪽에서 다시 "쓸 파일 경로"·"파일 전체
1157
+ * 내용" 이라고 한다. 이름(file_path·content)만 봐도 아는 것이라, 8k 에서는
1158
+ * 그 되풀이가 곧 대화 자리를 먹는 것과 같다.
1159
+ *
1160
+ * **required 와 type 은 안 건드린다.** 그건 설명이 아니라 규격이라,
1161
+ * 빼면 모델이 무엇을 넣어야 하는지를 실제로 모르게 된다.
1162
+ */
1163
+ if (한도 <= 160 && 값?.type === 'array' && 값.items?.properties) {
1164
+ const 안쪽 = {};
1165
+ for (const [n, v] of Object.entries(값.items.properties)) 안쪽[n] = { type: v.type };
1166
+ 새속성[이름] = {
1167
+ ...값,
1168
+ description: 값.description ? 자르기(String(값.description), 인자몫) : undefined,
1169
+ items: { ...값.items, properties: 안쪽 },
1170
+ };
1171
+ continue;
1172
+ }
1173
+ 새속성[이름] = 값?.description
1174
+ ? { ...값, description: 자르기(String(값.description).replace(/\s*\([^)]*\)\s*$/, ''), 인자몫) }
1175
+ : 값;
1176
+ }
1177
+ return {
1178
+ ...schema,
1179
+ description: 자르기(schema.description, 한도),
1180
+ parameters: { ...p, properties: 새속성 },
1181
+ };
1182
+ }
1183
+
799
1184
  // 모델에게 넘길 도구 정의 목록.
800
1185
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
801
- export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null } = {}) {
1186
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null } = {}) {
802
1187
  let list = names ?? Object.keys(TOOLS).filter((n) => {
803
1188
  if (n === 'Skill') return hasSkills;
804
1189
  if (n === 'WebFetch') return web;
@@ -809,7 +1194,15 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
809
1194
  // 설계·계획·묻기 모드에서 파일을 바꾸면 안 된다고 프롬프트로 부탁할 수도 있다.
810
1195
  // 그런데 모델은 부탁을 잊는다. 목록에서 아예 빼면 잊을 것이 없다.
811
1196
  if (work) list = allowedIn(work, list);
812
- const 우리것 = list.map((n) => ({ type: 'function', function: TOOLS[n].schema }));
1197
+ /*
1198
+ * 창이 좁으면 설명을 줄여 싣는다 (budget.js 의 설명길이).
1199
+ *
1200
+ * 도구를 빼지는 않는다. 빼면 작은 모델만 할 수 있는 일이 달라져서
1201
+ * "환경마다 다르게 동작" 하게 되는데, 그건 이 프로그램이 피하려는 것이다.
1202
+ * 이름과 인자는 그대로 남으므로 할 수 있는 일은 똑같다.
1203
+ */
1204
+ const 한도 = 설명길이(ctx);
1205
+ const 우리것 = list.map((n) => ({ type: 'function', function: 설명줄이기(TOOLS[n].schema, 한도) }));
813
1206
 
814
1207
  /*
815
1208
  * 밖에서 붙인 도구(MCP)를 뒤에 붙인다.