deel-local-cli 1.6.0 → 1.6.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.
@@ -1,7 +1,7 @@
1
1
  // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
3
  import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync } from 'node:fs';
4
- import { dirname, join, relative, sep } from 'node:path';
4
+ import { dirname, extname, 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
7
  import { 건너뜀말 } from './ignore.js';
@@ -23,7 +23,9 @@ import { allow as allowedIn } from '../agent/modes.js';
23
23
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
24
24
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
25
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';
26
+ import { 바꿔볼까, 직접못읽나, 변환기찾기, 글로바꾸기, 못바꿈말 } from './convert.js';
27
+ import { 물음검사 } from '../agent/askcheck.js';
28
+ import { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침, 한쪽도못읽음말 } from './pdf.js';
27
29
  import { diffLines } from '../ui/diff.js';
28
30
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
29
31
  import { 도구설명EN } from './desc.en.js';
@@ -123,6 +125,56 @@ export function 붙박이그림줄이기(줄들) {
123
125
  return { 줄들: 나온것, 알림, 줄인바이트, 몇개 };
124
126
  }
125
127
 
128
+ /*
129
+ * ── 안 보여 준 것을 지우게 두면 안 된다 ─────────────────────────────────
130
+ *
131
+ * 위 붙박이그림줄이기 는 **읽을 때만** 그림을 뺀다. 파일은 그대로다. 그런데
132
+ * 그 다음이 문제였다 — 모델은 그렇게 받은 글을 손봐서 Write 로 통째로 덮어쓴다.
133
+ * 그러면 제가 못 본 그림은 새 내용에 없다. **사용자 파일에서 그림이 사라진다.**
134
+ *
135
+ * 실제로 이렇게 났다 (사내 보고서 HTML):
136
+ *
137
+ * ◧ Read(보고서.html) 1425줄 · 그림 8.0MB 생략
138
+ * ◈ Write(보고서.html) 1420줄 ← 8MB 짜리 그림 일곱 개가 여기서 없어진다
139
+ *
140
+ * 오류는 없다. 화면도 멀쩡하다. 파일을 열어 봐야 안다 — 그림이 있던 자리에
141
+ * 깨진 아이콘만 남는다. 몇 주 뒤에 알아채면 되돌릴 방법도 없다.
142
+ *
143
+ * 안 보여 준 것에 대한 책임은 우리에게 있다. 모델은 최선을 다한 것이다 —
144
+ * 있는 줄 몰랐던 것을 지킬 수는 없다. 그러니 여기서 막는다.
145
+ *
146
+ * 세는 방식이 중요하다. **생략 표시가 있나** 로만 보면 모자란다. 모델이 그
147
+ * 줄을 통째로 지워 버리는 경우가 더 흔하고, 그때는 표시도 같이 사라진다.
148
+ * 그래서 **개수를 견준다** — 있던 것보다 줄었으면 잃은 것이다.
149
+ */
150
+ export function 박힌그림수(글) {
151
+ return (String(글 ?? '').match(그림자리) ?? []).length;
152
+ }
153
+
154
+ /**
155
+ * 이 덮어쓰기가 박힌 그림을 잃게 하나.
156
+ *
157
+ * @returns {string|null} 막을 이유. 잃는 것이 없으면 null.
158
+ */
159
+ function 그림잃나(abs, 새내용) {
160
+ if (!existsSync(abs)) return null;
161
+ let 옛것;
162
+ try { 옛것 = readTextFull(abs).text; } catch { return null; }
163
+ const 있던것 = 박힌그림수(옛것);
164
+ if (!있던것) return null;
165
+ const 남는것 = 박힌그림수(새내용);
166
+ if (남는것 >= 있던것) return null;
167
+
168
+ const 잃는것 = 있던것 - 남는것;
169
+ const 생략표시 = /…\([\d.]+\s*[KMG]?B 생략\)…/.test(새내용);
170
+ return `이 파일에 박혀 있는 그림 ${있던것}개 중 ${잃는것}개가 새 내용에 없습니다 — 덮어쓰면 사라집니다.\n`
171
+ + (생략표시
172
+ ? ' Read 가 보여준 「…(생략)…」 표시를 그대로 되쓰셨습니다. 그건 그림이 아니라 자리 표시입니다.\n'
173
+ : ' Read 는 그림 자리를 빼고 보여줍니다. 못 본 것이라 새로 쓸 때 빠진 것입니다.\n')
174
+ + ' 통째로 덮어쓰지 말고 **Edit 으로 고칠 자리만** 바꾸세요. 그러면 그림은 파일에 그대로 남습니다.\n'
175
+ + ' 정말 그림을 빼는 것이 목적이면 사용자에게 먼저 확인하세요.';
176
+ }
177
+
126
178
  // 엑셀 파일에 쓰려 할 때 하는 말. 왜 안 되는지와, 그럼 어떻게 하는지를 같이 준다.
127
179
  /**
128
180
  * 이 자리에 글을 써 넣으면 안 되는 파일인가.
@@ -279,7 +331,18 @@ function 엑셀은못고침(보인이름) {
279
331
  */
280
332
  async function 엑셀읽기(abs, args, ctx) {
281
333
  const r = await readExcel(abs, { askPassword: ctx.askPassword ?? null });
282
- if (!r.ok) return { error: r.error };
334
+ if (!r.ok) {
335
+ /*
336
+ * 옛 `.xls` 는 엑셀 COM 말고는 길이 없었다. 그래서 엑셀이 없는 PC —
337
+ * 맥과 리눅스가 전부 여기다 — 에서는 그냥 실패로 끝났다. 이 PC 에
338
+ * LibreOffice 가 있으면 그것이 곧 길이다. 표는 아니고 글이지만,
339
+ * 아무것도 못 읽는 것보다 낫다.
340
+ */
341
+ const 빌린것 = await 빌려읽기(abs, ctx, r.error);
342
+ if (빌린것) return 빌린것;
343
+ if (직접못읽나(abs)) return { error: 못바꿈말(ctx.scope.show(abs), extname(abs)), 끝났다: true };
344
+ return { error: r.error };
345
+ }
283
346
 
284
347
  const { text, 잘림 } = excelText(r.sheets);
285
348
  const 말 = [...(r.notes ?? []), ...잘림];
@@ -299,9 +362,57 @@ async function 엑셀읽기(abs, args, ctx) {
299
362
  * 엑셀읽기와 같은 규칙 — ctx.seen 에 안 넣는다. 넣으면 Edit 이 '이 파일 고칠
300
363
  * 수 있다' 고 오해한다. 문서는 이 도구로 고치는 물건이 아니다.
301
364
  */
302
- function 문서읽기(abs, ctx) {
365
+ /**
366
+ * 우리가 못 읽는 문서를 **이미 깔린 변환기**로 한 번 더 해 본다 (tools/convert.js).
367
+ *
368
+ * `rg` 를 빌려 쓰는 것과 같은 원칙이다 — 아무것도 설치하지 않고, 있으면 쓰고,
369
+ * 없으면 없다고 말한다. 여기가 붙기 전에는 이렇게 끝났다:
370
+ *
371
+ * ◧ Read(보고서.pptx)
372
+ * └ pptx 모양이 아닙니다 — 깨졌거나 다른 형식입니다.
373
+ *
374
+ * 정작 그 PC 에는 LibreOffice 가 깔려 있었다. 사람이 그 파일을 열어 보는 바로
375
+ * 그 프로그램이다. 그런데 모델은 그걸 부를 수도 없었고(울타리에 막혔다),
376
+ * 있는지 볼 수도 없었다.
377
+ *
378
+ * @returns {object|null} 읽어냈으면 도구 결과. 못 하면 null (부르는 쪽이 원래 오류를 낸다)
379
+ */
380
+ async function 빌려읽기(abs, ctx, 원래오류) {
381
+ if (!바꿔볼까(abs)) return null;
382
+ const root = ctx.scope?.root;
383
+ if (!root) return null;
384
+
385
+ // ctx.변환기 는 검사에서 가짜 변환기를 끼우는 자리다. 진짜 LibreOffice 를
386
+ // 깔아야만 확인되는 검사는 아무도 안 돌린다.
387
+ const 있는것 = ctx.변환기 ?? 변환기찾기();
388
+ if (!있는것.soffice && !있는것.textutil) return null;
389
+
390
+ // signal 을 같이 넘긴다. 멈추라고 하면 soffice 를 죽여야 한다 — 안 그러면
391
+ // ESC 를 듣고도 남은 90초를 그대로 기다린다.
392
+ const r = await 글로바꾸기(abs, root, { 찾은것: 있는것, signal: ctx.signal ?? null });
393
+ if (!r.ok || !r.text.trim()) return null;
394
+
395
+ const 줄수 = r.text.split('\n').length;
396
+ return {
397
+ content: clip(
398
+ `${r.text}\n\n(deel 이 직접 못 읽는 형식이라 이 PC 의 ${r.쓴것} 로 글만 뽑아 보여준 것입니다.`
399
+ + ' 원본은 한 글자도 안 바뀌었습니다. 이 파일은 Edit/Write 로 고칠 수 없습니다 —'
400
+ + ` 고쳐야 하면 새 파일에 쓰세요.)\n(원래 못 읽은 까닭: ${String(원래오류).split('\n')[0]})`,
401
+ ),
402
+ summary: `${r.쓴것} 로 바꿔 읽음 · ${줄수}줄`,
403
+ };
404
+ }
405
+
406
+ async function 문서읽기(abs, ctx) {
303
407
  const r = readDoc(abs);
304
- if (!r.ok) return { error: r.error };
408
+ if (!r.ok) {
409
+ // 우리가 못 읽는다고 끝이 아니다. 이 PC 에 변환기가 있으면 빌려 본다.
410
+ const 빌린것 = await 빌려읽기(abs, ctx, r.error);
411
+ if (빌린것) return 빌린것;
412
+ // 끝났다 를 그대로 넘긴다. 여기서 떨구면 docs.js 가 「다시 열어도 같다」고
413
+ // 판정해 놓은 것이 루프까지 못 가서, 되풀이 억제가 안 걸린다.
414
+ return { error: r.error, ...(r.끝났다 ? { 끝났다: true } : {}) };
415
+ }
305
416
  const { text, 잘림 } = docText(r.덩이들);
306
417
  return {
307
418
  content: clip(
@@ -328,6 +439,19 @@ function pdf읽기(abs, ctx) {
328
439
  if (!r.ok) return { error: r.error };
329
440
  const { text, 잘림 } = pdfText(r);
330
441
  const 못 = 못읽은말(r);
442
+ /*
443
+ * 한 쪽도 못 읽었으면 **실패다.** 성공으로 돌려주면 안 된다.
444
+ *
445
+ * 여태 「글이 하나도 없는 PDF 입니다」를 본문으로 돌려줬다. 그러면 도구는
446
+ * 성공한 것이 되어 되풀이 감지도, 배움도, 재시도 억제도 하나도 안 걸린다.
447
+ * 그래서 모델은 같은 파일을 몇 번씩 다시 열었고, 그때마다 같은 답을 받았다.
448
+ */
449
+ // text 로 재면 안 된다 — 못 읽은 쪽에도 `--- 3쪽 ---` 와 「못 읽었습니다」가
450
+ // 들어가서 글이 있는 것처럼 보인다. 실제 문단이 하나라도 나왔는지로 가른다.
451
+ const 글나온쪽 = (r.덩이들 ?? []).some((d) => (d.문단들 ?? []).some((p) => String(p).trim()));
452
+ if (!글나온쪽) {
453
+ return { error: 한쪽도못읽음말(r, ctx.scope.show(abs)), 끝났다: true };
454
+ }
331
455
  return {
332
456
  content: clip(
333
457
  `${text || '(글이 하나도 없는 PDF 입니다.)'}
@@ -503,6 +627,9 @@ function 한파일쓰기(args, ctx) {
503
627
  // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
504
628
  const 바이너리막기 = 바이너리인가(abs);
505
629
  if (바이너리막기) return { error: 바이너리막기 };
630
+ // 우리가 안 보여 준 그림을 모델이 지우게 두지 않는다 (그림잃나 머리말).
631
+ const 그림막기 = 그림잃나(abs, args.content);
632
+ if (그림막기) return { error: 그림막기 };
506
633
  ctx.history.snapshot(abs, 'Write');
507
634
  const existed = existsSync(abs);
508
635
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
@@ -771,7 +898,7 @@ export const TOOLS = {
771
898
  required: ['file_path'],
772
899
  },
773
900
  },
774
- run(args, ctx) {
901
+ async run(args, ctx) {
775
902
  const abs = ctx.scope.resolve(args.file_path);
776
903
  if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
777
904
  if (statSync(abs).isDirectory()) return { error: `폴더입니다. Glob 을 쓰세요: ${args.file_path}` };
@@ -796,10 +923,34 @@ export const TOOLS = {
796
923
  if (abs.toLowerCase().endsWith('.hwp')) {
797
924
  try {
798
925
  const 머리 = readFileSync(abs);
799
- if (looksOldHwp(abs, 머리)) return { error: 옛hwp안내(ctx.scope.show(abs)) };
926
+ if (looksOldHwp(abs, 머리)) {
927
+ // 여기도 먼저 빌려 본다. 이 PC 에 변환기가 있으면 안내문보다 글이 낫다.
928
+ const 안내 = 옛hwp안내(ctx.scope.show(abs));
929
+ // 괄호가 있어야 한다. await 를 빼면 `??` 가 **약속(Promise)** 을
930
+ // 보고 "값이 있다" 고 판단해서, 안내문이 영영 안 나간다.
931
+ return (await 빌려읽기(abs, ctx, 안내)) ?? { error: 안내 };
932
+ }
800
933
  } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
801
934
  }
802
935
 
936
+ /*
937
+ * 옛 Office(.ppt·.doc·.xls·.rtf·.odt…)가 여기서 걸린다.
938
+ *
939
+ * 위의 갈래들과 달리 우리는 이걸 아예 못 읽는다. 그냥 두면 아래 일반
940
+ * 읽기가 `바이너리 파일입니다` 로 끝냈다 — 까닭도 길도 없는 거절이라
941
+ * 모델이 같은 문을 계속 두드린다. 사내 자료에 제일 흔한 갈래가 하필
942
+ * 이것들이다.
943
+ *
944
+ * 그래서 여기서 두 갈래로 끝낸다. 이 PC 에 변환기가 있으면 빌려 읽고,
945
+ * 없으면 **없다고 못 박고 끝낸다**(끝났다: true → 되풀이 억제가 걸린다).
946
+ */
947
+ if (직접못읽나(abs)) {
948
+ const 보인이름 = ctx.scope.show(abs);
949
+ const 빌린것 = await 빌려읽기(abs, ctx, `${보인이름} 을 deel 이 직접 못 읽습니다.`);
950
+ if (빌린것) return 빌린것;
951
+ return { error: 못바꿈말(보인이름, extname(abs)), 끝났다: true };
952
+ }
953
+
803
954
  const 읽음 = readTextFull(abs);
804
955
  // 무엇으로 읽었는지 기억해 둔다. 나중에 고칠 때 같은 것으로 되돌려 써야 한다.
805
956
  // 안 그러면 사내 CP949 문서가 한 번 고치는 것만으로 UTF-8 이 되어 버린다.
@@ -816,9 +967,23 @@ export const TOOLS = {
816
967
  const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
817
968
  ctx.seen.add(abs);
818
969
  const 별난인코딩 = 읽음.encoding !== 'utf-8';
970
+ /*
971
+ * 요약에는 **실제로 건넨 줄 수**를 적는다.
972
+ *
973
+ * 여태 파일의 전체 줄 수를 적었다. 그래서 32k 모델에서 1,425줄짜리
974
+ * 파일을 열면 모델은 384줄만 받는데 화면에는 `1425줄` 이 찍혔다 —
975
+ * 사람은 다 읽은 줄 알고 "왜 저걸 못 고치지" 를 되풀이한다. 잘렸다는
976
+ * 말은 모델에게 가는 본문에만 있었고, 보는 사람에게는 없었다.
977
+ *
978
+ * budget.js 의 읽을줄수 머리말에 이 함정이 그대로 적혀 있는데
979
+ * (「화면에는 2,000줄이라고만 떠서 사람은 잘 읽은 줄 안다」) 정작
980
+ * 이 줄은 안 고쳐져 있었다.
981
+ */
982
+ const 준줄수 = slice.length;
983
+ const 다못줌 = start + count < lines.length || start > 0;
819
984
  return {
820
985
  content: clip(body + more + 줄인것.알림),
821
- summary: `${lines.length}줄`
986
+ summary: (다못줌 ? `${준줄수}/${lines.length}줄 (일부만)` : `${lines.length}줄`)
822
987
  + (줄인것.줄인바이트 ? ` · 그림 ${몇KB(줄인것.줄인바이트)} 생략` : '')
823
988
  + (별난인코딩 ? ` · ${encLabel(읽음.encoding)}` : ''),
824
989
  };
@@ -1055,7 +1220,9 @@ export const TOOLS = {
1055
1220
  run(args, ctx) {
1056
1221
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
1057
1222
  const re = globToRegex(args.pattern);
1058
- const 전부 = walk(root);
1223
+ const 전부 = walk(root, { signal: ctx.signal });
1224
+ // 훑다 말고 나왔으면 그렇다고 말한다. 조용히 적게 주면 「그런 파일이 없다」가 된다.
1225
+ if (전부.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1059
1226
  // .gitignore 로 건너뛴 것은 수를 말한다 — 조용히 빼면 '그 파일이 없다' 로 읽힌다 (tools/ignore.js).
1060
1227
  const 건너뜀 = 건너뜀말(전부.건너뜀, 전부.잘림, 전부.상한);
1061
1228
  const 맞는것 = 전부
@@ -1099,7 +1266,9 @@ export const TOOLS = {
1099
1266
  required: ['pattern'],
1100
1267
  },
1101
1268
  },
1102
- run(args, ctx) {
1269
+ // async 인 까닭: 아래 빠르게찾기() 가 rg·git grep 을 **비동기로** 부른다.
1270
+ // 큰 저장소에서 20초를 도는 동안에도 ESC 가 들려야 하기 때문이다.
1271
+ async run(args, ctx) {
1103
1272
  let re;
1104
1273
  try { re = new RegExp(args.pattern, args['-i'] ? 'i' : ''); }
1105
1274
  catch (err) { return { error: `정규식이 잘못됐습니다: ${err.message}` }; }
@@ -1122,13 +1291,15 @@ export const TOOLS = {
1122
1291
  * 다르게 나왔을 때 무엇으로 찾은 것인지 모르면 사람은 코드를 의심한다.
1123
1292
  */
1124
1293
  const 무시파일 = join(ctx.scope.root, '.deelignore');
1125
- const 빠른것 = isFile ? null : 빠르게찾기({
1294
+ const 빠른것 = isFile ? null : await 빠르게찾기({
1126
1295
  무늬: args.pattern,
1127
1296
  자리: root,
1128
1297
  glob: args.glob ?? null,
1129
1298
  대소문자무시: !!args['-i'],
1130
1299
  무시파일: existsSync(무시파일) ? 무시파일 : null,
1131
1300
  최대: Math.max(limit * 4, 2000),
1301
+ // 멈추라고 하면 rg 를 죽인다. 이게 없으면 ESC 를 듣고도 20초를 더 기다린다.
1302
+ signal: ctx.signal ?? null,
1132
1303
  });
1133
1304
  if (빠른것) {
1134
1305
  const 파일별 = new Map();
@@ -1179,7 +1350,8 @@ export const TOOLS = {
1179
1350
  */
1180
1351
  let files = isFile
1181
1352
  ? [{ path: root, rel: ctx.scope.show(root) }]
1182
- : walk(root);
1353
+ : walk(root, { signal: ctx.signal });
1354
+ if (files.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1183
1355
  const 안본것 = isFile ? '' : 건너뜀말(files.건너뜀, files.잘림, files.상한).trim(); // .gitignore 로 건너뛴 수 — 꼬리에 적는다
1184
1356
  // 훑기 상한에 걸렸으면 "일치 없음" 이라고 잘라 말하면 안 된다. 안 본 것이다.
1185
1357
  const 다못봄 = !isFile && !!files.잘림;
@@ -1502,13 +1674,21 @@ export const TOOLS = {
1502
1674
  * 첫 문장에 제일 중요한 것을 둔다 — 좁은 창에서는 설명줄이기가
1503
1675
  * **뒤에서부터 문장째로** 잘라 낸다. 8k 에서 남는 것은 앞 90자다.
1504
1676
  */
1505
- description: '갈림길에서 사람에게 하나 묻는다. 글로 "알려주세요" 하고 끝내지 마라 '
1506
- + ' 턴이 끝나서 여태 것이 버려진다. options 2~4개를 주면 숫자로 답한다.'
1677
+ description: '갈림길에서 사람에게 하나 묻는다. **먼저 `이해` 이번 요청을 무엇으로'
1678
+ + ' 알아들었는지 줄로 적고**, 그러고도 정말 정할 것이 남았을 때만 묻는다.'
1679
+ + ' 글로 "알려주세요" 하고 끝내지 마라 — 턴이 끝나서 여태 본 것이 버려진다.'
1680
+ + ' options 에 2~4개를 주면 숫자로 답한다.'
1507
1681
  + ' **이미 사용자가 말한 것을 다시 묻지 마라.** "파일 정리해 줘" 라고 했으면'
1508
1682
  + ' 그게 답이다 — 그대로 하면 된다. 어떻게 할지 정하는 것은 네 일이다.',
1509
1683
  parameters: {
1510
1684
  type: 'object',
1511
1685
  properties: {
1686
+ 이해: {
1687
+ type: 'string',
1688
+ description: '이번 요청을 무엇으로 알아들었는지 한 줄. 사람은 이 줄을 보고 네가'
1689
+ + ' 제대로 읽었는지 판단한다. "요청을 이해했습니다" 같은 인사말은 안 된다 —'
1690
+ + ' 그 일의 내용이 들어가야 한다',
1691
+ },
1512
1692
  question: { type: 'string', description: '한 문장짜리 질문. 무엇을 정해야 하는지 분명하게' },
1513
1693
  options: {
1514
1694
  type: 'array',
@@ -1516,7 +1696,7 @@ export const TOOLS = {
1516
1696
  description: '고를 것 2~4개. 각각 한 줄로, 무엇이 달라지는지 알 수 있게',
1517
1697
  },
1518
1698
  },
1519
- required: ['question'],
1699
+ required: ['이해', 'question'],
1520
1700
  },
1521
1701
  },
1522
1702
  async run(args, ctx) {
@@ -1525,11 +1705,16 @@ export const TOOLS = {
1525
1705
 
1526
1706
  const 고를것 = (Array.isArray(args.options) ? args.options : [])
1527
1707
  .map((x) => String(x ?? '').trim()).filter(Boolean).slice(0, 4);
1708
+ const 이해 = String(args.이해 ?? '').trim();
1528
1709
 
1529
1710
  /*
1530
1711
  * 물어볼 자리가 없는 데서도 안 죽어야 한다 — `deel -p` 한 방 실행, 파이프,
1531
1712
  * 하위 작업. 거기서는 **막히지 말고** 스스로 판단하라고 돌려준다.
1532
1713
  * 답을 기다리며 서 있으면 그 실행은 영영 안 끝난다.
1714
+ *
1715
+ * 아래 관문보다 **먼저** 본다. 어차피 아무도 못 듣는 자리에서 "이해를
1716
+ * 채워서 다시 물어라" 라고 돌려주면, 고쳐서 다시 불러도 결과가 같다 —
1717
+ * 왕복만 한 번 늘고 그 실행은 그만큼 늦어진다.
1533
1718
  */
1534
1719
  /*
1535
1720
  * 결과는 반드시 `content` 로 돌려준다.
@@ -1546,7 +1731,28 @@ export const TOOLS = {
1546
1731
  };
1547
1732
  }
1548
1733
 
1549
- const 답 = await ctx.ask물음(물음, 고를것);
1734
+ /*
1735
+ * 사람에게 내보내기 전에 한 번 거른다 (agent/askcheck.js).
1736
+ *
1737
+ * 막힌 물음은 **오류로** 돌려준다. content 로 돌려주면 모델은 그것을
1738
+ * 사람의 답으로 읽고 그대로 이어 간다 — 묻지도 않은 답을 받은 셈이 된다.
1739
+ * 오류여야 되풀이 감지에도 걸리고, 같은 물음을 또 던지지 않는다.
1740
+ */
1741
+ const 관문 = 물음검사({
1742
+ 물음, 고를것, 이해, 요청: ctx.요청 ?? '', 이미물은것: ctx.물은것 ?? [],
1743
+ });
1744
+ if (!관문.ok) return { error: 관문.할말, 끝났다: true, 물음막힘: 관문.왜 };
1745
+
1746
+ const 답 = await ctx.ask물음(물음, 고를것, 이해);
1747
+
1748
+ /*
1749
+ * 물어본 것을 적어 둔다.
1750
+ *
1751
+ * 관문이 "이미 물었나" 를 보려면 기억할 자리가 있어야 한다. 답까지 같이
1752
+ * 적는 것은, 또 물으려 할 때 **앞의 답을 그대로 돌려주기** 위해서다 —
1753
+ * "이미 물었습니다" 만 말하면 모델은 그 답이 무엇이었는지 못 찾는다.
1754
+ */
1755
+ (ctx.물은것 ??= []).push({ 물음, 고를것, 답: 답 == null ? '' : String(답) });
1550
1756
  if (답 === null || 답 === undefined || String(답).trim() === '') {
1551
1757
  return { content: '사람이 답하지 않았습니다. 되묻지 말고 스스로 판단해 이어가세요.' };
1552
1758
  }
@@ -1585,7 +1791,24 @@ export const TOOLS = {
1585
1791
  const 왜 = r.예산초과
1586
1792
  ? `지난 대화 ${r.전체파일}개 중 ${r.본파일}개까지만 뒤졌습니다(양이 많아 멈춤). 못 찾았습니다`
1587
1793
  : `지난 대화 ${r.본파일}개를 다 뒤졌지만 없습니다`;
1588
- return { summary: `${왜}: ${q}`, hits: [], searched: r.본파일, total: r.전체파일, partial: r.예산초과 };
1794
+ /*
1795
+ * content 를 반드시 채운다.
1796
+ *
1797
+ * 대화에 실리는 것은 content 다. 여태 여기는 summary 만 돌려줬고,
1798
+ * 그래서 **모델에게는 빈 글이 갔다.** 사람 화면에는 「없습니다」가
1799
+ * 멀쩡히 찍히니 아무도 못 알아챘다. 모델은 못 찾은 줄도 모르고
1800
+ * 찾아본 줄도 몰라서, 그 자리에서 엉뚱한 선택지를 들이밀었다.
1801
+ *
1802
+ * 「없다」는 것도 알아낸 것이다. 알아낸 것은 반드시 전한다.
1803
+ */
1804
+ const 없다는말 = `${왜}: ${q}\n`
1805
+ + '이 대화 밖에는 단서가 없습니다. 없는 것을 지어내지 말고,'
1806
+ + ' 지금 대화에 있는 것으로 판단하거나 무엇이 없는지 밝히세요.';
1807
+ return {
1808
+ content: 없다는말,
1809
+ summary: `${왜}: ${q}`,
1810
+ hits: [], searched: r.본파일, total: r.전체파일, partial: r.예산초과,
1811
+ };
1589
1812
  }
1590
1813
 
1591
1814
  const 줄들 = r.맞은것.map((h) => {
@@ -1909,9 +2132,39 @@ export async function runTool(name, args, ctx) {
1909
2132
 
1910
2133
  const t = TOOLS[name];
1911
2134
  if (!t) return { error: `모르는 도구: ${name}` };
2135
+
2136
+ /*
2137
+ * ── 멈추라고 했으면 시작도 안 한다 ──────────────────────────────────
2138
+ *
2139
+ * 여럿을 함께 돌릴 때(loop.js 의 Promise.all) 앞엣것이 도는 사이 사람이
2140
+ * ESC 를 누르면, 뒤엣것들은 **아직 아무 일도 안 했는데** 그대로 돌았다.
2141
+ * 여기서 한 번 보면 그 자리가 막힌다.
2142
+ *
2143
+ * 중단은 **실패가 아니다.** 그래서 중단됨 을 따로 단다 — 이걸 실패로
2144
+ * 세면 되풀이 감지가 엉뚱하게 걸려서, 다음에 같은 도구를 부르는 것까지
2145
+ * "또 그러네" 로 막아 버린다. 사람이 멈춘 것은 도구 잘못이 아니다.
2146
+ */
2147
+ if (ctx.signal?.aborted) {
2148
+ return { error: '중단했습니다. 실행하지 않았습니다.', 끝났다: true, 중단됨: true };
2149
+ }
2150
+
1912
2151
  try {
1913
2152
  const r = await t.run(args ?? {}, ctx);
1914
2153
  ctx.audit.tool(name, args, r);
2154
+ /*
2155
+ * 도는 중에 멈췄다면 결과를 안 쓴다.
2156
+ *
2157
+ * 다만 **이미 바꿔 놓은 것은 사실대로 말한다.** 파일을 고친 뒤에
2158
+ * "중단했습니다" 만 돌려주면 모델은 안 고쳐진 줄 알고 또 고친다 —
2159
+ * 그러면 같은 편집이 두 번 들어가거나, 되돌리기 기록과 어긋난다.
2160
+ * 읽기만 한 것은 버려도 잃을 것이 없으니 중단으로 끝낸다.
2161
+ */
2162
+ if (ctx.signal?.aborted) {
2163
+ if (!isMutating(name) || r.error) {
2164
+ return { error: '중단했습니다.', 끝났다: true, 중단됨: true };
2165
+ }
2166
+ return { ...r, 중단됨: true, 중단전에끝남: true };
2167
+ }
1915
2168
  return await 고친뒤진단(name, r, ctx);
1916
2169
  } catch (err) {
1917
2170
  const r = { error: err.message };
@@ -232,7 +232,8 @@ export const OUTLINE_TOOL = {
232
232
  const 하나인가 = statSync(시작).isFile();
233
233
  let 파일들 = 하나인가
234
234
  ? [{ path: 시작, rel: ctx.scope.show(시작), mtime: statSync(시작).mtimeMs }]
235
- : walk(시작, { skipDirs: SKIP_DIRS });
235
+ : walk(시작, { skipDirs: SKIP_DIRS, signal: ctx.signal });
236
+ if (파일들.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
236
237
  const 건너뜀 = 하나인가 ? '' : 건너뜀말(파일들.건너뜀, 파일들.잘림, 파일들.상한); // .gitignore 로 건너뛴 수 (tools/ignore.js)
237
238
 
238
239
  // 좁히는 방식은 Glob 도구와 **같은 것**을 쓴다. 두 도구가 같은 패턴에
package/src/tools/pdf.js CHANGED
@@ -1290,8 +1290,50 @@ export function toText(r, { maxChars = 최대글자 } = {}) {
1290
1290
  export function summarize(r) {
1291
1291
  if (!r?.ok) return '';
1292
1292
  const 통째로못읽음 = (r.못읽은쪽 ?? []).filter((x) => !x.일부).length;
1293
- const = 통째로못읽음 ? ` · ${통째로못읽음}쪽 못 읽음` : '';
1294
- return `pdf · ${r.쪽수}쪽${뒤}`;
1293
+ if (!통째로못읽음) return `pdf · ${r.쪽수}쪽`;
1294
+ /*
1295
+ * **왜** 못 읽었는지를 화면 요약에 올린다.
1296
+ *
1297
+ * 여태 `11쪽 못 읽음` 까지만 찍혔다. 까닭은 모델에게 가는 본문에만 있었다.
1298
+ * 그런데 그 둘은 사람이 할 일이 완전히 다르다 — 스캔본이면 OCR 을 찾아야
1299
+ * 하고, 글꼴 표가 없는 것이면 원본에서 다시 뽑으면 된다. 화면만 보는 사람은
1300
+ * 그 갈림길을 못 본 채 "왜 안 읽히지" 만 되풀이한다.
1301
+ */
1302
+ const 까닭들 = new Set((r.못읽은쪽 ?? []).filter((x) => !x.일부).map((x) => 짧은까닭(x.왜)));
1303
+ const 왜 = 까닭들.size === 1 ? ` (${[...까닭들][0]})` : '';
1304
+ return `pdf · ${r.쪽수}쪽 · ${통째로못읽음}쪽 못 읽음${왜}`;
1305
+ }
1306
+
1307
+ /** 화면 한 줄에 들어갈 만큼 줄인 까닭. */
1308
+ function 짧은까닭(왜) {
1309
+ const s = String(왜 ?? '');
1310
+ if (/ToUnicode/.test(s)) return '글꼴에 /ToUnicode 표 없음';
1311
+ if (/스캔|OCR/.test(s)) return '글이 없는 쪽 — 스캔본일 수 있음';
1312
+ if (/빈 쪽/.test(s)) return '빈 쪽';
1313
+ if (/압축|못 풀/.test(s)) return '내용을 못 풀었음';
1314
+ return s.length > 28 ? `${s.slice(0, 28)}…` : s;
1315
+ }
1316
+
1317
+ /**
1318
+ * 한 쪽도 글을 못 꺼냈을 때 하는 말. **길을 같이 준다.**
1319
+ *
1320
+ * 이건 성공이 아니라 실패다. 여태는 「글이 하나도 없는 PDF 입니다」를 본문으로
1321
+ * 돌려줬는데, 그러면 도구는 성공한 것이 되어 되풀이 감지도 배움도 안 걸린다.
1322
+ * 모델은 같은 파일을 계속 다시 열었다.
1323
+ */
1324
+ export function 한쪽도못읽음말(r, 보인이름) {
1325
+ const 까닭들 = [...new Set((r?.못읽은쪽 ?? []).map((x) => String(x.왜)))];
1326
+ const 줄 = [`${r?.쪽수 ?? 0}쪽을 전부 글로 못 읽었습니다: ${보인이름}`];
1327
+ for (const 왜 of 까닭들.slice(0, 3)) 줄.push(` · ${왜}`);
1328
+ if (까닭들.some((x) => /ToUnicode/.test(x))) {
1329
+ 줄.push(' 글꼴이 글자표를 안 싣고 있어 되돌릴 수 없습니다. 이 PDF 를 만든 원본');
1330
+ 줄.push(' (pptx·docx 등)이 있으면 그쪽을 읽으세요. 훨씬 정확합니다.');
1331
+ } else {
1332
+ 줄.push(' 글이 그림으로만 들어 있습니다. 이 도구로는 더 해 볼 것이 없습니다 —');
1333
+ 줄.push(' OCR 이 필요하거나, 이 PDF 를 만든 원본 파일을 읽어야 합니다.');
1334
+ }
1335
+ 줄.push(' **같은 파일을 다시 Read 하지 마세요. 결과는 같습니다.**');
1336
+ return 줄.join('\n');
1295
1337
  }
1296
1338
 
1297
1339
  /**
@@ -0,0 +1,99 @@
1
+ // 바깥 프로그램을 부른다 — **기다리는 동안 귀를 열어 둔 채로.**
2
+ //
3
+ // ── 왜 이 파일이 따로 있나 ─────────────────────────────────────────────
4
+ //
5
+ // 전에는 찾기(fastgrep.js)도 변환(convert.js)도 각자 spawnSync 를 썼다.
6
+ // 쓰기는 편하다. 그런데 그 호출이 도는 동안 Node 는 이벤트 루프를 통째로
7
+ // 멈춘다. 그 사이에는 키 입력이 배달되지 않는다 — 사람이 ESC 를 몇 번을
8
+ // 눌러도, 그 키는 버퍼에만 쌓여 있다가 **일이 다 끝난 뒤에야** 들어온다.
9
+ //
10
+ // rg 로 큰 저장소 찾기 최대 20초
11
+ // soffice 로 문서 바꾸기 최대 90초
12
+ //
13
+ // 그동안 프로그램은 귀머거리다. "ESC 를 눌러도 안 멈춘다" 는 제보의 뿌리가
14
+ // 여기였다. 화면에는 여전히 돌아가는 그림이 보이니 사람은 더 답답하다.
15
+ //
16
+ // 두 군데에 따로 적어 두면 언젠가 한쪽만 고쳐지고, 그때부터 "검색은 멈추는데
17
+ // 문서 변환은 안 멈추는" 프로그램이 된다. 그건 고장보다 설명하기 어렵다.
18
+ // 그래서 한 벌만 둔다.
19
+ //
20
+ // ── 돌려주는 모양 ──────────────────────────────────────────────────────
21
+ //
22
+ // 일부러 spawnSync 와 **똑같이** 맞췄다 — {error, status, stdout, stderr}.
23
+ // 부르는 쪽의 판단 코드(`status === 2 면 실패` 같은 것)를 한 줄도 안 건드리고
24
+ // 바꾸려는 것이다. 바꾼 자리가 적을수록 조용히 달라지는 것도 적다.
25
+ import { spawn } from 'node:child_process';
26
+
27
+ /**
28
+ * 자식 프로그램을 부르고, 끝날 때까지 기다린다 — 루프는 계속 돈다.
29
+ *
30
+ * @param {string} 이름 실행할 것
31
+ * @param {string[]} 인자 인자들. 셸을 안 거치므로 여기 든 따옴표·세미콜론은 글자다.
32
+ * @param {object} [옵션]
33
+ * @param {number} [옵션.timeout] 이 시간을 넘기면 죽이고 실패로 돌려준다
34
+ * @param {number} [옵션.maxBuffer] 나온 글이 이보다 커지면 죽인다
35
+ * @param {AbortSignal|null} [옵션.signal] 눌리면 자식을 죽인다
36
+ * @param {object} [옵션.덤] spawn 에 그대로 넘길 것 (windowsVerbatimArguments 같은)
37
+ * @returns {Promise<{error:Error|null, status:number|null, stdout:string, stderr:string}>}
38
+ */
39
+ export function 돌려보기(이름, 인자, { timeout = 20000, maxBuffer = 32 * 1024 * 1024, signal = null, 덤 = {} } = {}) {
40
+ return new Promise((resolve) => {
41
+ // 이미 눌렸으면 띄우지도 않는다. 여럿을 함께 돌릴 때 뒤엣것이 여기로 온다.
42
+ if (signal?.aborted) { resolve({ error: new Error('중단했습니다'), status: null, stdout: '', stderr: '' }); return; }
43
+
44
+ let 아이;
45
+ try {
46
+ 아이 = spawn(이름, 인자, { windowsHide: true, ...덤 });
47
+ } catch (탈난것) {
48
+ resolve({ error: 탈난것, status: null, stdout: '', stderr: '' });
49
+ return;
50
+ }
51
+
52
+ let 밖 = ''; let 탈 = ''; let 끝났나 = false;
53
+ const 끝내기 = (것) => {
54
+ if (끝났나) return;
55
+ 끝났나 = true;
56
+ clearTimeout(시계);
57
+ signal?.removeEventListener?.('abort', 중단하기);
58
+ resolve(것);
59
+ };
60
+
61
+ /*
62
+ * 멈추라고 하면 **자식을 죽인다.**
63
+ *
64
+ * 여기가 없으면 반쪽짜리다. 비동기로 바꾼 덕에 ESC 는 들리지만, 정작
65
+ * soffice 는 남은 시간을 마저 돈다 — 사람 눈에는 여전히 "눌렀는데 안
66
+ * 멈춘다". 듣는 것과 멈추는 것은 다른 일이라, 둘 다 해야 한 가지가 끝난다.
67
+ */
68
+ const 중단하기 = () => {
69
+ try { 아이.kill(); } catch { /* 이미 죽었다 */ }
70
+ 끝내기({ error: new Error('중단했습니다'), status: null, stdout: '', stderr: '' });
71
+ };
72
+ signal?.addEventListener?.('abort', 중단하기, { once: true });
73
+
74
+ const 시계 = setTimeout(() => {
75
+ try { 아이.kill(); } catch { /* 이미 죽었다 */ }
76
+ 끝내기({ error: new Error(`${이름} 가 ${timeout}ms 안에 안 끝났습니다`), status: null, stdout: 밖, stderr: 탈 });
77
+ }, timeout);
78
+ // 이 타이머가 프로그램을 붙들면 안 된다. 자식의 파이프가 이미 루프를 붙들고 있다.
79
+ 시계.unref?.();
80
+
81
+ 아이.stdout?.setEncoding('utf8');
82
+ 아이.stderr?.setEncoding('utf8');
83
+ 아이.stdout?.on('data', (조각) => {
84
+ 밖 += 조각;
85
+ /*
86
+ * spawnSync 는 이 자리에서 ENOBUFS 를 냈다. 조용히 잘라 버리면 사람
87
+ * 눈에는 '결과가 적다' 로만 보인다 — 오류도 안 나고 아무도 눈치 못 챈다.
88
+ * 같은 자리에서 같은 실패를 내야 부르는 쪽이 예전 길로 내려간다.
89
+ */
90
+ if (밖.length > maxBuffer) {
91
+ try { 아이.kill(); } catch { /* 이미 죽었다 */ }
92
+ 끝내기({ error: new Error('결과가 너무 많습니다'), status: null, stdout: '', stderr: 탈 });
93
+ }
94
+ });
95
+ 아이.stderr?.on('data', (조각) => { if (탈.length < 65536) 탈 += 조각; });
96
+ 아이.on('error', (탈난것) => 끝내기({ error: 탈난것, status: null, stdout: 밖, stderr: 탈 }));
97
+ 아이.on('close', (코드) => 끝내기({ error: null, status: 코드, stdout: 밖, stderr: 탈 }));
98
+ });
99
+ }
@@ -444,7 +444,15 @@ export class InputBox {
444
444
  // 답이 흘러나오는 동안 상자가 답 줄에 얹히는 것을 여기서 막는다.
445
445
  if (!this.일감 || this.줄중간) return;
446
446
  const 지난 = Date.now() - this.일감.시작;
447
- const 갈래 = 지난 >= 느긋해질때 ? '느긋' : this.일감.갈래;
447
+ /*
448
+ * '멈춤' 은 '느긋' 을 이긴다.
449
+ *
450
+ * 45초가 넘으면 갈래를 느긋으로 갈아 주는데, 사람이 ESC 를 누르는 때가
451
+ * 하필 딱 그때다 — 오래 걸려서 누르는 것이다. 순서를 안 정해 두면
452
+ * 「멈추는 중」이 화면에 영영 안 뜨고, 사람은 안 먹었다고 여겨 또 누른다.
453
+ */
454
+ const 멈추나 = this.일감.갈래 === '멈춤';
455
+ const 갈래 = (!멈추나 && 지난 >= 느긋해질때) ? '느긋' : this.일감.갈래;
448
456
  const 회차 = Math.floor((this.틱 * 돌림주기) / 문구주기);
449
457
  this.그리기(this.session, '', 0, {
450
458
  // 갈래에 맞는 작은 그림. 무엇을 하는 중인지 글자와 그림이 같이 말한다.
@@ -459,7 +467,8 @@ export class InputBox {
459
467
  this.일감.예약수 ? `${this.일감.예약수}건 예약됨` : '',
460
468
  // 도는 동안 보이는 자리라 여기서는 ESC 만 말한다. Ctrl+C 도 되지만
461
469
  // 두 번 누르면 끝나 버려서, 급히 멈추려는 사람에게 권할 것이 못 된다.
462
- 'ESC 중단',
470
+ // 이미 멈추는 중이면 안 권한다 — 또 누르라는 말로 읽힌다.
471
+ 멈추나 ? '' : 'ESC 중단',
463
472
  ].filter(Boolean).join(' · '),
464
473
  대기: this.일감.대기,
465
474
  });