deel-local-cli 1.6.0 → 1.6.1

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/README.en.md CHANGED
@@ -100,7 +100,7 @@ This page is the **summary**. Each section links to the detail behind it.
100
100
  | [Speed and spend](docs/en/tuning.md) | Per-stage effort · the prefix cache · context length |
101
101
  | [Safety and corporate review](docs/en/safety.md) | Undo · working scope · audit log · the review package |
102
102
  | [Configuration](docs/en/config.md) · [Development](docs/en/develop.md) | Env vars · run flags · running the tests · folder layout |
103
- | [Release notes](docs/en/releases.md) | [1.6.0](docs/en/releases.md#160) · [1.5.8](docs/en/releases.md#158) · [1.5.7](docs/en/releases.md#157) · [1.5.6](docs/en/releases.md#156) · [1.5.5](docs/en/releases.md#155) · [1.5.4](docs/en/releases.md#154) · [1.5.3](docs/en/releases.md#153) · [1.5.2](docs/en/releases.md#152) · [1.5.1](docs/en/releases.md#151) · [1.5.0](docs/en/releases.md#150) · [1.4.3](docs/en/releases.md#143) · [1.4.2](docs/en/releases.md#142) · [1.4.1](docs/en/releases.md#141) · [1.4.0](docs/en/releases.md#140) · [1.3.0](docs/en/releases.md#130) · [1.2.0](docs/en/releases.md#120) |
103
+ | [Release notes](docs/en/releases.md) | [1.6.1](docs/en/releases.md#161) · [1.6.0](docs/en/releases.md#160) · [1.5.8](docs/en/releases.md#158) · [1.5.7](docs/en/releases.md#157) · [1.5.6](docs/en/releases.md#156) · [1.5.5](docs/en/releases.md#155) · [1.5.4](docs/en/releases.md#154) · [1.5.3](docs/en/releases.md#153) · [1.5.2](docs/en/releases.md#152) · [1.5.1](docs/en/releases.md#151) · [1.5.0](docs/en/releases.md#150) · [1.4.3](docs/en/releases.md#143) · [1.4.2](docs/en/releases.md#142) · [1.4.1](docs/en/releases.md#141) · [1.4.0](docs/en/releases.md#140) · [1.3.0](docs/en/releases.md#130) · [1.2.0](docs/en/releases.md#120) |
104
104
 
105
105
  ---
106
106
 
@@ -536,7 +536,12 @@ their cost; here is why.
536
536
  **A file saved as CP949 is written back as CP949.** The encoding is never changed.
537
537
  Excel (`.xlsx`) is read as CSV — read-only.
538
538
 
539
- > **More** Encoding · Excel
539
+ Old formats (`.ppt`, `.doc`, `.xls`, `.rtf`) are read by **borrowing the LibreOffice already
540
+ on this machine** — the same terms on which deel borrows `rg`, and nothing is ever installed.
541
+ With no converter it **says so definitively and stops**: what is missing, what you can do about
542
+ it, and not to open the file again. Turn it off with `DEEL_CONVERT=off`.
543
+
544
+ > **More** — Encoding · Excel · borrowing a converter
540
545
  >
541
546
  > **[Korean documents and Excel read →](docs/en/documents.md#korean-text-and-excel)**
542
547
 
@@ -993,6 +998,7 @@ so one run tells you everything.
993
998
 
994
999
  | Version | What changed |
995
1000
  |---|---|
1001
+ | **[1.6.1](docs/en/releases.md#161)** | The places that made a turn spin in circles — the fence blocking `/dev/null` · old documents it could not read · images vanishing from files |
996
1002
  | **[1.6.0](docs/en/releases.md#160)** | The things that did not work on a corporate network — proxy · 429 · Windows shell · 50k-file repos · PDF · clipboard paste · editor resume |
997
1003
  | **[1.5.8](docs/en/releases.md#158)** | A 5MB document showed 8 lines out of 919 · a regex read as a path blocked the command |
998
1004
  | **[1.5.7](docs/en/releases.md#157)** | Korean folders on macOS made your own files "out of scope" · paste · ESC |
package/README.md CHANGED
@@ -101,7 +101,7 @@
101
101
  | [속도와 씀씀이](docs/ko/tuning.md) | 단계별 추론 강도 · 프리픽스 캐시 · 컨텍스트 길이 |
102
102
  | [안전망과 사내 반입](docs/ko/safety.md) | 되돌리기 · 작업 범위 · 감사기록 · 심사 서류 |
103
103
  | [설정](docs/ko/config.md) · [개발](docs/ko/develop.md) | 환경변수 · 실행 옵션 · 검사 돌리기 · 폴더 구조 |
104
- | [릴리스 노트](docs/ko/releases.md) | [1.6.0](docs/ko/releases.md#160) · [1.5.8](docs/ko/releases.md#158) · [1.5.7](docs/ko/releases.md#157) · [1.5.6](docs/ko/releases.md#156) · [1.5.5](docs/ko/releases.md#155) · [1.5.4](docs/ko/releases.md#154) · [1.5.3](docs/ko/releases.md#153) · [1.5.2](docs/ko/releases.md#152) · [1.5.1](docs/ko/releases.md#151) · [1.5.0](docs/ko/releases.md#150) · [1.4.3](docs/ko/releases.md#143) · [1.4.2](docs/ko/releases.md#142) · [1.4.1](docs/ko/releases.md#141) · [1.4.0](docs/ko/releases.md#140) · [1.3.0](docs/ko/releases.md#130) · [1.2.0](docs/ko/releases.md#120) |
104
+ | [릴리스 노트](docs/ko/releases.md) | [1.6.1](docs/ko/releases.md#161) · [1.6.0](docs/ko/releases.md#160) · [1.5.8](docs/ko/releases.md#158) · [1.5.7](docs/ko/releases.md#157) · [1.5.6](docs/ko/releases.md#156) · [1.5.5](docs/ko/releases.md#155) · [1.5.4](docs/ko/releases.md#154) · [1.5.3](docs/ko/releases.md#153) · [1.5.2](docs/ko/releases.md#152) · [1.5.1](docs/ko/releases.md#151) · [1.5.0](docs/ko/releases.md#150) · [1.4.3](docs/ko/releases.md#143) · [1.4.2](docs/ko/releases.md#142) · [1.4.1](docs/ko/releases.md#141) · [1.4.0](docs/ko/releases.md#140) · [1.3.0](docs/ko/releases.md#130) · [1.2.0](docs/ko/releases.md#120) |
105
105
 
106
106
  ---
107
107
 
@@ -513,7 +513,12 @@ PDF 는 **못 읽은 쪽을 못 읽었다고 말합니다.** 스캔본·암호·
513
513
  빈 글로 넘기지 않고 그 자리에 까닭을 적습니다 — 빈 글은 「그런 내용이 없다」로
514
514
  읽히기 때문입니다.
515
515
 
516
- > **자세히** 인코딩 · 엑셀 · hwpx/docx/pptx · PDF
516
+ 형식(`.ppt`·`.doc`·`.xls`·`.rtf`)은 **이 PC 깔린 LibreOffice 빌려서**
517
+ 읽습니다 — `rg` 를 빌려 쓰는 것과 같은 원칙으로, 아무것도 설치하지 않습니다.
518
+ 없으면 **없다고 못 박고 끝냅니다**(무엇이 없어서 못 하는지, 무엇을 하면 되는지,
519
+ 그리고 「다시 열지 말라」까지). 끄려면 `DEEL_CONVERT=off`.
520
+
521
+ > **자세히** — 인코딩 · 엑셀 · hwpx/docx/pptx · PDF · 변환기 빌려 쓰기
517
522
  >
518
523
  > **[한글 문서와 엑셀 읽기 →](docs/ko/documents.md#한글-문서와-엑셀)**
519
524
 
@@ -984,6 +989,7 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
984
989
 
985
990
  | 판 | 무엇이 바뀌었나 |
986
991
  |---|---|
992
+ | **[1.6.1](docs/ko/releases.md#161)** | 헛도는 턴을 만들던 자리들 — 울타리가 `/dev/null` 까지 막던 것 · 옷 문서를 못 읽던 것 · 파일에서 그림이 사라지던 것 |
987
993
  | **[1.6.0](docs/ko/releases.md#160)** | 사내망에서 안 되던 것들 — 프록시 · 429 · 윈도우 셸 · 5만 개 저장소 · PDF · 캡처 붙여넣기 · 에디터 이어하기 |
988
994
  | **[1.5.8](docs/ko/releases.md#158)** | 5MB 문서를 919줄 중 8줄만 보던 것 · 정규식을 경로로 읽어 명령을 막던 것 |
989
995
  | **[1.5.7](docs/ko/releases.md#157)** | 맥 한글 폴더에서 제 파일이 「범위 밖」이던 것 · 붙여넣기 · ESC |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  "verify": "node test/no-bundle.test.js && node test/network.test.js && node test/web.test.js",
52
52
  "bench": "node test/edit-bench.js",
53
53
  "demo": "node test/demo.js",
54
- "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/models.js && node --check src/agent/grade.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/tools/docs.js && node --check src/tools/pdf.js && node --check src/tools/clipboard.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/retry.js && node --check src/backend/proxy.js && node --check src/backend/azure.js && node --check src/backend/vision.js && node --check src/backend/quota.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/desc.en.js && node --check src/tools/lsp.js && node --check src/lsp/rpc.js && node --check src/lsp/servers.js && node --check src/lsp/client.js && node --check src/lsp/diag.js && node --check src/tools/fsutil.js && node --check src/tools/ignore.js && node --check src/tools/fastgrep.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/tools/shell.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/keystore.js && node --check src/safety/policy.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/completion.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/ui/office.js && node --check src/ui/notify.js && node --check src/ui/intro.js && node --check src/ui/banner.js && node --check src/ui/export.js && node --check src/i18n/index.js && node --check src/i18n/ko.js && node --check src/i18n/en.js && node --check src/i18n/ja.js && node --check src/i18n/zh.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && node --check src/ui/wrap.js && node --check src/ui/md.js && node --check src/agent/threads.js && node --check src/agent/evolve.js && node --check src/agent/pins.js && node --check src/agent/card.js && node --check src/agent/preset.js && node --check src/agent/evidence.js && node --check src/agent/commit.js && node --check src/agent/review.js && node --check src/acp/jsonrpc.js && node --check src/acp/map.js && node --check src/acp/serve.js && node --check src/pack/sbom.js && node --check src/safety/secrets.js && echo OK",
54
+ "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/models.js && node --check src/agent/grade.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/tools/docs.js && node --check src/tools/convert.js && node --check src/tools/pdf.js && node --check src/tools/clipboard.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/retry.js && node --check src/backend/proxy.js && node --check src/backend/azure.js && node --check src/backend/vision.js && node --check src/backend/quota.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/desc.en.js && node --check src/tools/lsp.js && node --check src/lsp/rpc.js && node --check src/lsp/servers.js && node --check src/lsp/client.js && node --check src/lsp/diag.js && node --check src/tools/fsutil.js && node --check src/tools/ignore.js && node --check src/tools/fastgrep.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/tools/shell.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/keystore.js && node --check src/safety/policy.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/completion.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/ui/office.js && node --check src/ui/notify.js && node --check src/ui/intro.js && node --check src/ui/banner.js && node --check src/ui/export.js && node --check src/i18n/index.js && node --check src/i18n/ko.js && node --check src/i18n/en.js && node --check src/i18n/ja.js && node --check src/i18n/zh.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && node --check src/ui/wrap.js && node --check src/ui/md.js && node --check src/agent/threads.js && node --check src/agent/evolve.js && node --check src/agent/pins.js && node --check src/agent/card.js && node --check src/agent/preset.js && node --check src/agent/evidence.js && node --check src/agent/commit.js && node --check src/agent/review.js && node --check src/acp/jsonrpc.js && node --check src/acp/map.js && node --check src/acp/serve.js && node --check src/pack/sbom.js && node --check src/safety/secrets.js && echo OK",
55
55
  "coverage": "node test/coverage.mjs",
56
56
  "prepublishOnly": "npm run check && npm test"
57
57
  },
package/src/acp/serve.js CHANGED
@@ -402,6 +402,12 @@ export async function acp(opts = {}) {
402
402
  말하기(`\n\n_(${ev.why} — 다시 답을 받습니다)_\n\n`);
403
403
  break;
404
404
 
405
+ // 답이 천장에서 잘렸고 더 못 올린다. 조용히 끝내면 에디터에는 중간에서
406
+ // 끊긴 답만 남아서, 사람은 모델이 대충 답한 줄 안다.
407
+ case 'capped':
408
+ 말하기(`\n\n_(답이 ${ev.cap.toLocaleString()} 토큰에서 잘렸습니다 — 터미널에서 \`/out 32k\` 로 상한을 올리거나, 파일은 나눠 쓰게 하세요)_\n\n`);
409
+ break;
410
+
405
411
  // 서버가 잠깐 막아 기다리는 중. 아직 흘러간 글이 없으니 답을 새로 시작하지는 않는다.
406
412
  case 'backoff':
407
413
  말하기(`\n\n_(${옮긴말('loop.backoff', 알림채움(ev))})_\n\n`);
package/src/agent/loop.js CHANGED
@@ -320,11 +320,30 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
320
320
  * 여기까지 오지 않는다 — 막히는 것은 글로 되묻고 턴을 닫는 쪽뿐이다.
321
321
  */
322
322
  let 민적 = false;
323
+ /*
324
+ * 인사에는 안 민다.
325
+ *
326
+ * 「안녕」 한 마디에 모델이 "안녕하세요! 무엇을 도와드릴까요?" 라고 답하면
327
+ * 그건 **맞는 답**이다. 그런데 그 말이 되묻는말에 걸려서 되밀렸다 —
328
+ *
329
+ * ❯ 안녕
330
+ * ▌ 안녕하세요! 무엇을 도와드릴까요?
331
+ * ↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다
332
+ * ▌ 안녕하세요! 반갑습니다.
333
+ * ── 6.8초
334
+ *
335
+ * 인사 한 마디에 모델을 두 번 부른다. 로컬 모델에서 그 왕복이 6.8초다.
336
+ * 되밀기는 「시킨 일을 안 하고 되돌려준」 자리를 잡으라고 만든 것인데,
337
+ * 인사는 시킨 일이 없다. 시킨 것이 없으면 안 한 것도 없다.
338
+ */
339
+ const 인사인가 = /^(안녕[가-힣]*|반(가|갑)[가-힣]*|하이|ㅎㅇ+|헬로[우가-힣]*|고마[가-힣]*|감사[가-힣]*|수고[가-힣]*|hi|hello|hey|yo|thanks?|thank you|테스트|test|ok(ay)?|네|응)[\s!.~?ㅎㅋ,]*$/i
340
+ .test(String(userText ?? '').trim());
323
341
  const 되묻는말 = /무엇을\s*도와|무엇을\s*해\s*드릴|원하시는\s*(작업|것)|어떤\s*(작업|것)\s*(을|부터)|말씀해\s*주세요|알려\s*주세요|지시해\s*주세요|어떻게\s*할까요|해\s*드릴까요|진행할까요|what would you like|how can i (help|assist)|let me know (what|which|how)|shall i\b|would you like me to/i;
324
342
 
325
343
  /** 이번 답을 되밀까. 밀 이유를 돌려주고, 아니면 null. */
326
344
  const 밀어줄까 = (글) => {
327
345
  if (민적 || 깊이) return null;
346
+ if (인사인가) return null; // 시킨 것이 없으면 안 한 것도 없다
328
347
  if (손댄파일.size) return null; // 뭐라도 바꿨으면 일은 한 것이다
329
348
  if (!모드.tools.includes('Write')) return null; // 안 바꾸는 모드는 그게 맞다
330
349
  const 말 = String(글 ?? '').trim();
@@ -479,15 +498,38 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
479
498
  */
480
499
  const full = Math.max(cap, fullCap(room));
481
500
  const 낮춘생각 = level === 'off' || level === 'low' ? level : shiftLevel(level, -1);
501
+ let 마지막상한 = cap;
482
502
  if (wasCut(msg) && (full > cap || 낮춘생각 !== level)) {
483
503
  yield {
484
504
  type: 'retry',
485
505
  why: full > cap ? '대답이 상한에서 잘렸습니다' : '대답이 잘렸습니다 — 생각을 줄여 자리를 냅니다',
486
506
  from: cap, to: full, think: 낮춘생각,
487
507
  };
508
+ 마지막상한 = full;
488
509
  yield* askModel(full, 낮춘생각);
489
510
  }
490
511
 
512
+ /*
513
+ * 우리가 할 수 있는 것을 다 했는데도 잘렸다 — 여기서 입을 다물면 안 된다.
514
+ *
515
+ * 상한도 올려 봤고 생각도 줄여 봤는데 여전히 잘렸으면, 남은 일은 사람
516
+ * 몫이다. 그런데 여태 그냥 조용히 끝냈다. 화면에는 중간에서 끊긴 답만
517
+ * 남는다 — 사람 눈에는 모델이 게을러서 대충 답한 것으로 보이니 같은 것을
518
+ * 다시 시키고, 같은 자리에서 또 잘린다. 몇 분씩 가는 로컬 모델에서
519
+ * 이 왕복은 비싸다.
520
+ *
521
+ * 안 되는 것은 **까닭과 손댈 자리를 같이** 말한다 (`/out`).
522
+ * 다시 부른 뒤에 보는 것이 중요하다. 부르기 전에 판정하면, 상한을 올려
523
+ * 멀쩡히 끝난 답에까지 경고가 붙는다 — 그러면 곧 아무도 안 읽는다.
524
+ */
525
+ if (wasCut(msg)) {
526
+ yield {
527
+ type: 'capped',
528
+ cap: 마지막상한,
529
+ 정한값: conn.maxTokens ?? conn.maxOut ?? null,
530
+ };
531
+ }
532
+
491
533
  /*
492
534
  * 빈 답을 성공으로 넘기지 않는다.
493
535
  *
@@ -50,6 +50,7 @@ const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드
50
50
  - 같은 도구를 같은 인자로 다시 부르지 않는다. 결과는 같다. 본 것은 기억하고 다음으로 넘어간다.
51
51
  - 사용자가 볼 범위를 못 박아 말하면 그 범위를 지킨다. 안 그러면 필요한 만큼 찾아본다.
52
52
  - 명령 실행이 필요하면 Bash 를 쓴다. 되돌릴 수 없는 명령은 막히니 다른 방법을 찾는다.
53
+ - 중간 파일이 필요하면 /tmp 말고 .deel/tmp/ 에 쓴다. 작업 폴더 밖은 막힌다.
53
54
  - 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.
54
55
 
55
56
  다음에도 쓸 것은 남긴다:
@@ -130,6 +131,7 @@ Rules:
130
131
  - Do not call the same tool with the same arguments twice. The result will be the same. Remember what you saw and move on.
131
132
  - If the user names the scope to look at, stay inside it. Otherwise look as far as you need.
132
133
  - Use Bash when you need to run a command. Commands that cannot be undone are blocked, so find another way.
134
+ - If you need a scratch file, write it under .deel/tmp/, not /tmp. Outside the working folder is blocked.
133
135
  - Answer the user in English, briefly. Do not paste whole files back — say what changed.
134
136
 
135
137
  Keep what will be needed again:
package/src/repl.js CHANGED
@@ -1700,6 +1700,20 @@ export async function chatLoop(opts = {}) {
1700
1700
  if (session.level === '개발자' && ev.from) say(` ${c.gray(clip(ev.from, 110))}`);
1701
1701
  break;
1702
1702
 
1703
+ /*
1704
+ * 답이 천장에서 잘렸고 우리가 더 해 줄 것이 없다.
1705
+ *
1706
+ * 조용히 넘기면 화면에는 중간에서 끊긴 답만 남는다 — 사람은 모델이
1707
+ * 대충 답한 줄 알고 같은 것을 다시 시키고, 같은 자리에서 또 잘린다.
1708
+ * 손댈 자리를 그 자리에서 알려 준다.
1709
+ */
1710
+ case 'capped':
1711
+ clearThinking();
1712
+ say(` ${mark.warn} ${c.gray(`답이 ${c.white(ev.cap.toLocaleString())} 토큰에서 잘렸습니다`)}`
1713
+ + `${ev.정한값 ? c.gray(' (직접 정하신 상한입니다)') : c.gray(' — 더 못 올리는 천장입니다')}`);
1714
+ say(` ${c.gray('한 번에 더 길게 받으려면')} ${c.cyan('/out 32k')}${c.gray('. 파일을 쓰는 중이었다면 Append 로 나눠 쓰게 하세요.')}`);
1715
+ break;
1716
+
1703
1717
  case 'compact_failed':
1704
1718
  접기멈춤();
1705
1719
  say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
@@ -194,6 +194,8 @@ export function checkCommand(cmd) {
194
194
  export function checkPaths(cmd, scope) {
195
195
  if (!scope) return true;
196
196
  for (const t of 경로낱말(String(cmd))) {
197
+ // 자료가 아닌 자리는 울타리를 안 묻는다 (봐주는자리 머리말).
198
+ if (봐주는자리(t)) continue;
197
199
  let abs;
198
200
  try { abs = scope.resolve(t); }
199
201
  catch (e) {
@@ -282,6 +284,65 @@ export function 코드조각인가(낱말) {
282
284
  return false;
283
285
  }
284
286
 
287
+ /*
288
+ * ── 울타리 밖이지만 막을 까닭이 없는 자리 ───────────────────────────────
289
+ *
290
+ * 울타리는 **사람의 자료**를 지키려고 있다. 그런데 두 가지가 자료가 아닌데도
291
+ * 같이 막혀서, 모델이 할 수 있는 일을 못 하게 만들고 있었다.
292
+ *
293
+ * ▶ Bash(soffice --convert-to txt 보고서.pptx > /dev/null 2>&1)
294
+ * └ 막힘 — 작업 범위 밖입니다: /dev/null
295
+ * ▶ Bash(ls -l /usr/bin/strings)
296
+ * └ 막힘 — 작업 범위 밖입니다: /usr/bin/strings
297
+ *
298
+ * 앞엣것은 **아무것도 저장되지 않는 자리**다. `2>/dev/null` 은 셸 명령 절반이
299
+ * 달고 다니는데, 그 한 조각 때문에 명령 전체가 거절됐다. 뒤엣것은 **이 PC 에
300
+ * 무엇이 깔렸는지 보는 것**이다. 그걸 못 보면 모델은 우회로를 못 찾는다 —
301
+ * 못 읽는 문서를 만났을 때 변환기가 있는지조차 확인할 수 없다.
302
+ *
303
+ * 실제로 그래서 헛돌았다. 문서를 못 읽었고, 변환하려니 막혔고, 남은 길이
304
+ * 없으니 같은 문을 계속 두드렸다. 1.5.8 에서 한 번 겪은 것과 같은 모양인데
305
+ * 그때는 걸린 명령 하나만 풀고 같은 갈래를 안 훑었다.
306
+ *
307
+ * **넓히는 것은 이 둘뿐이다.** 남의 홈 · /etc · /var · /tmp 는 그대로 막는다.
308
+ * 프로그램이 든 폴더는 자료가 아니고, 거기 쓰는 것은 어차피 OS 가 막는다.
309
+ */
310
+ const 버리는자리 = new Set([
311
+ '/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty',
312
+ 'nul', 'nul:', '\\\\.\\nul', // 윈도우
313
+ ]);
314
+
315
+ /**
316
+ * 프로그램이 사는 자리. 자료가 아니라 실행파일이다.
317
+ *
318
+ * **전부 소문자로 적는다** — 견줄 때 소문자로 낮춰서 보기 때문이다.
319
+ * `/Applications` 처럼 대문자를 섞어 적으면 그 줄만 조용히 안 걸린다.
320
+ */
321
+ const 프로그램자리 = [
322
+ '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/libexec',
323
+ '/usr/local/bin', '/usr/local/sbin',
324
+ '/opt/homebrew/bin', '/opt/homebrew/sbin', '/opt/local/bin',
325
+ '/snap/bin', '/applications', // 맥은 앱 꾸러미 안에 실행파일이 있다
326
+ 'c:/windows', 'c:/program files', 'c:/program files (x86)',
327
+ ];
328
+
329
+ /**
330
+ * 이 경로는 울타리를 안 물어도 되는 자리인가.
331
+ *
332
+ * @returns {string|null} 봐 주는 까닭. 아니면 null
333
+ */
334
+ export function 봐주는자리(경로) {
335
+ const p = String(경로 ?? '').replace(/\\/g, '/');
336
+ const 낮춤 = p.toLowerCase().replace(/\/+$/, '');
337
+ if (버리는자리.has(낮춤)) return '아무것도 저장되지 않는 자리';
338
+ // 한 칸 아래까지만 본다. `/usr/bin/strings` 는 되고 `/usr/bin/../../Users/남` 은
339
+ // 여기 오기 전에 resolve 로 펴져서 이 목록에 안 걸린다.
340
+ for (const 자리 of 프로그램자리) {
341
+ if (낮춤 === 자리 || 낮춤.startsWith(`${자리}/`)) return '프로그램이 있는 자리';
342
+ }
343
+ return null;
344
+ }
345
+
285
346
  /** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
286
347
  export function 경로낱말(cmd) {
287
348
  const out = [];
@@ -0,0 +1,287 @@
1
+ // 이미 깔려 있는 변환기를 빌려 쓴다. 아무것도 설치하지 않는다.
2
+ //
3
+ // ── 왜 이걸 만드나 ──────────────────────────────────────────────────────
4
+ //
5
+ // 사내 자료는 형식이 제각각이다. 이름은 .pptx 인데 속은 옛 .ppt 이고, .doc 이고,
6
+ // .rtf 다. deel 은 hwpx·docx·pptx·xlsx·pdf 를 직접 읽지만 그 밖은 못 읽는다.
7
+ // 그때 여태 이렇게 끝났다 —
8
+ //
9
+ // ◧ Read(보고서.pptx)
10
+ // └ pptx 모양이 아닙니다 — 깨졌거나 다른 형식입니다.
11
+ //
12
+ // 길이 없으니 모델은 같은 파일을 몇 번씩 다시 열었다. 그런데 정작 그 PC 에는
13
+ // LibreOffice 가 깔려 있었다. 사람이 그 파일을 열어 보는 바로 그 프로그램이다.
14
+ //
15
+ // ── 빌려 쓰기의 규칙 ────────────────────────────────────────────────────
16
+ //
17
+ // 1.6.0 에서 `rg` 를 빌려 쓴 것과 **같은 원칙**이다 (tools/fastgrep.js).
18
+ //
19
+ // · 아무것도 설치하지 않는다. 없으면 없다고 말하고 끝낸다.
20
+ // · 있는지 한 번만 본다. 매번 물으면 없는 PC 에서 그 실패가 계속 쌓인다.
21
+ // · 끌 수 있다 (`DEEL_CONVERT=off`). 결과가 의심될 때 견줄 자리가 있어야 한다.
22
+ // · 셸을 안 거친다. 파일 이름에 빈칸·따옴표·한글이 들어와도 그대로 넘긴다.
23
+ // · 결과는 **작업 폴더 안**(.deel/tmp/)에 떨군다. 밖에 쓰면 울타리를 우리
24
+ // 손으로 넘는 셈이고, 남긴 것을 거둘 자리도 없어진다.
25
+ //
26
+ // 바뀐 글만 쓰고 원본은 한 글자도 안 건드린다. 변환은 읽기의 곁길이다.
27
+ import { spawnSync } from 'node:child_process';
28
+ import {
29
+ existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync,
30
+ } from 'node:fs';
31
+ import { basename, extname, join } from 'node:path';
32
+
33
+ /** 변환해서 읽어 볼 만한 확장자. 여기 없는 것은 손대지 않는다. */
34
+ const 바꿔볼확장자 = new Set([
35
+ '.ppt', '.doc', '.xls', // 옛 Office (OLE 복합문서)
36
+ '.rtf', '.odt', '.odp', '.ods', '.wpd',
37
+ '.pptx', '.docx', '.xlsx', // 겉만 그 이름이고 속이 다른 것
38
+ '.hwp', // 구형 한글 — soffice 가 읽는 판이 있다
39
+ ]);
40
+
41
+ /**
42
+ * 우리가 **영영 직접은 못 읽는** 갈래. 바꿔볼확장자 의 부분집합이다.
43
+ *
44
+ * 둘을 가르는 이유가 있다. `.pptx` 가 안 읽히는 것은 「깨졌거나 속이 다르다」는
45
+ * 뜻이라 docs.js 가 이미 정체를 짚어 준다 — 그 말이 우리 말보다 낫다. 반면
46
+ * `.ppt` 는 정체가 분명하고 우리에게 길이 없다. 그때만 「없다」고 못 박는다.
47
+ */
48
+ const 직접못읽는확장자 = new Set([
49
+ '.ppt', '.doc', '.xls', '.xlt', '.rtf', '.odt', '.odp', '.ods', '.wpd', '.hwp',
50
+ ]);
51
+
52
+ /** 이 파일을 변환해서 읽어 볼까. */
53
+ export function 바꿔볼까(경로) {
54
+ return 바꿔볼확장자.has(extname(String(경로 ?? '')).toLowerCase());
55
+ }
56
+
57
+ /** 바꾸는 것 말고는 길이 없는 갈래인가. */
58
+ export function 직접못읽나(경로) {
59
+ return 직접못읽는확장자.has(extname(String(경로 ?? '')).toLowerCase());
60
+ }
61
+
62
+ /*
63
+ * 이 PC 에 깔린 soffice 를 찾는다.
64
+ *
65
+ * PATH 에 없는 자리가 흔하다 — 맥은 앱 꾸러미 안에, 윈도우는 Program Files
66
+ * 밑에 있고 둘 다 PATH 에 안 걸린다. 사람이 쓰는 그 프로그램이 눈앞에 있는데
67
+ * "없습니다" 라고 말하는 것이 제일 아깝다.
68
+ */
69
+ const soffice자리 = [
70
+ '/Applications/LibreOffice.app/Contents/MacOS/soffice',
71
+ 'C:\\Program Files\\LibreOffice\\program\\soffice.exe',
72
+ 'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe',
73
+ '/usr/bin/soffice', '/usr/local/bin/soffice', '/opt/homebrew/bin/soffice',
74
+ '/snap/bin/libreoffice',
75
+ ];
76
+
77
+ /*
78
+ * ── 윈도우에서 `.cmd` 는 그냥 못 띄운다 ─────────────────────────────────
79
+ *
80
+ * scoop·choco 로 깐 것은 `soffice.cmd` 같은 껍데기다. 그걸 spawnSync 로 그냥
81
+ * 부르면 **EINVAL** 이 난다(Node 20 부터 막혔다). 그러면 우리는 "이 PC 에
82
+ * 변환기가 없습니다" 라고 말하게 되는데 — 사람은 눈앞에서 그 프로그램을 쓰고
83
+ * 있다. 있다고 해 놓고 안 되는 것 다음으로 나쁜 것이 이것이다.
84
+ *
85
+ * `shell: true` 로 넘기면 되긴 하지만 node 가 DEP0190 경고를 내고, 그 지적이
86
+ * 맞다 — 경로에 빈칸이 있으면 그대로 깨진다. 사내 PC 의 `C:\Program Files\…`
87
+ * 가 정확히 그 꼴이다.
88
+ *
89
+ * 그래서 언어 서버 쪽에서 쓰는 것과 **같은 방법**을 쓴다 (lsp/client.js).
90
+ * `cmd /s /c` 는 뒤엣것이 따옴표로 시작해 따옴표로 끝나면 바깥 한 쌍을 떼어
91
+ * 내므로, 한 겹 더 둘러서 넘긴다.
92
+ */
93
+ function 부르기(cmd, 인자, opts = {}) {
94
+ const 셸필요 = process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(cmd));
95
+ if (!셸필요) return spawnSync(cmd, 인자, { encoding: 'utf8', windowsHide: true, ...opts });
96
+ const 몰아쓰기 = `""${cmd}" ${인자.map((a) => `"${a}"`).join(' ')}"`;
97
+ return spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 몰아쓰기], {
98
+ encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true, ...opts,
99
+ });
100
+ }
101
+
102
+ function 돌아가나(cmd, 인자) {
103
+ const r = 부르기(cmd, 인자, { timeout: 15000 });
104
+ return !r.error && r.status === 0;
105
+ }
106
+
107
+ let 본것 = null;
108
+
109
+ /**
110
+ * 쓸 수 있는 변환기. 한 번만 찾는다.
111
+ *
112
+ * @returns {{soffice: string|null, textutil: boolean, 왜: string|null}}
113
+ */
114
+ export function 변환기찾기({ 다시 = false, env = process.env, platform = process.platform } = {}) {
115
+ if (본것 && !다시) return 본것;
116
+ if (env.DEEL_CONVERT === 'off') {
117
+ 본것 = { soffice: null, textutil: false, 왜: 'DEEL_CONVERT=off 로 꺼 두었습니다' };
118
+ return 본것;
119
+ }
120
+ let soffice = null;
121
+ if (돌아가나('soffice', ['--version'])) soffice = 'soffice';
122
+ else {
123
+ for (const p of soffice자리) {
124
+ if (existsSync(p) && 돌아가나(p, ['--version'])) { soffice = p; break; }
125
+ }
126
+ }
127
+ // textutil 은 맥에만 있고 .doc·.rtf 를 아주 빨리 바꾼다. soffice 보다 먼저 쓴다.
128
+ const textutil = platform === 'darwin' && 돌아가나('textutil', ['-info', '/dev/null']);
129
+ 본것 = { soffice, textutil, 왜: null };
130
+ return 본것;
131
+ }
132
+
133
+ /** 검사가 원래대로 돌려놓을 때. */
134
+ export function 변환기잊기() { 본것 = null; return null; }
135
+
136
+ /** 사람에게 보여줄 한 줄. */
137
+ export function 변환기말(찾은것 = 변환기찾기()) {
138
+ if (찾은것.왜) return 찾은것.왜;
139
+ const 것들 = [];
140
+ if (찾은것.soffice) 것들.push('soffice');
141
+ if (찾은것.textutil) 것들.push('textutil');
142
+ return 것들.length ? `${것들.join(' · ')} 를 빌려 씁니다` : '이 PC 에 쓸 수 있는 변환기가 없습니다';
143
+ }
144
+
145
+ /*
146
+ * 못 바꿨을 때 하는 말.
147
+ *
148
+ * 여기가 「안 된다고 확실히 말하는」 자리다. 여태 이 자리에서 나간 말은
149
+ * `바이너리 파일입니다 — 텍스트로 읽을 수 없습니다` 한 줄이었다. 그 말에는
150
+ * 까닭도 길도 없어서, 모델은 같은 파일을 Read 로 또 열거나 Bash 로 우회로를
151
+ * 찾다 울타리에 막히고, 그 왕복으로 컨텍스트만 탔다.
152
+ *
153
+ * 그래서 세 가지를 한꺼번에 말한다 — 무엇이라서 못 읽는지, 이 PC 에 무엇이
154
+ * 없어서 못 바꾸는지, 사람이 무엇을 하면 되는지. 그리고 **다시 열지 말라**고
155
+ * 못 박는다. 다시 열어도 결과가 같은 것은 우리가 아는 사실이다.
156
+ */
157
+ export function 못바꿈말(보인이름, 확장자, 찾은것 = 변환기찾기()) {
158
+ const 갈래 = String(확장자 ?? '').replace(/^\./, '').toLowerCase();
159
+ const 없다 = !찾은것.soffice && !찾은것.textutil;
160
+ const 길 = 없다
161
+ ? '이 PC 에 LibreOffice(soffice)가 없어서 바꿔 읽을 수도 없습니다.'
162
+ : '이 PC 의 변환기로 바꿔 봤지만 글이 안 나왔습니다.';
163
+ return `${보인이름} 은 deel 이 직접 못 읽는 형식입니다 (.${갈래}).\n`
164
+ + `${길}\n`
165
+ + `해결: 원래 프로그램에서 ${새이름(갈래)} 로 저장한 뒤 다시 주세요.`
166
+ + (없다 ? ' 또는 LibreOffice 를 설치하면 deel 이 빌려 씁니다.' : '')
167
+ + '\n**같은 파일을 다시 Read 하지 마세요. 결과는 같습니다.**';
168
+ }
169
+
170
+ /** 그 갈래를 무엇으로 저장하면 읽히는지. */
171
+ function 새이름(갈래) {
172
+ if (갈래 === 'ppt') return 'pptx';
173
+ if (갈래 === 'doc' || 갈래 === 'rtf' || 갈래 === 'odt' || 갈래 === 'wpd') return 'docx';
174
+ if (갈래 === 'xls' || 갈래 === 'ods') return 'xlsx';
175
+ if (갈래 === 'odp') return 'pptx';
176
+ if (갈래 === 'hwp') return 'hwpx';
177
+ return 'pdf 나 txt';
178
+ }
179
+
180
+ /** 떨굴 자리. 작업 폴더 안이라야 한다. */
181
+ export function 임시자리(root) {
182
+ return join(root, '.deel', 'tmp');
183
+ }
184
+
185
+ /**
186
+ * 문서를 글로 바꿔서 읽는다.
187
+ *
188
+ * @param {string} abs 읽을 파일 (절대경로)
189
+ * @param {string} root 작업 폴더 — 바꾼 것을 여기 안에 떨군다
190
+ * @returns {{ok:true, text:string, 쓴것:string} | {ok:false, 왜:string, 없음?:boolean}}
191
+ */
192
+ export function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null } = {}) {
193
+ const 있는것 = 찾은것 ?? 변환기찾기();
194
+ if (!있는것.soffice && !있는것.textutil) {
195
+ return { ok: false, 없음: true, 왜: 있는것.왜 ?? '이 PC 에 변환기가 없습니다' };
196
+ }
197
+ if (!existsSync(abs)) return { ok: false, 왜: `없는 파일입니다: ${abs}` };
198
+
199
+ const 받을곳 = 임시자리(root);
200
+ try { mkdirSync(받을곳, { recursive: true }); } catch (err) {
201
+ return { ok: false, 왜: `바꿔 놓을 자리를 못 만들었습니다: ${err.message}` };
202
+ }
203
+
204
+ /*
205
+ * textutil 을 먼저 본다 (맥).
206
+ *
207
+ * soffice 는 처음 뜰 때 몇십 초가 걸린다 — 실제로 20초를 재 봤다. textutil 은
208
+ * OS 에 붙어 있는 것이라 곧바로 답한다. 둘 다 되는 자리면 빠른 쪽이 맞다.
209
+ */
210
+ const 확장자 = extname(abs).toLowerCase();
211
+ if (있는것.textutil && ['.doc', '.rtf', '.odt', '.docx'].includes(확장자)) {
212
+ const 나온것 = join(받을곳, `${basename(abs, 확장자)}.txt`);
213
+ const r = 부르기('textutil', ['-convert', 'txt', '-output', 나온것, abs], { timeout });
214
+ if (!r.error && r.status === 0 && existsSync(나온것)) {
215
+ const text = readFileSync(나온것, 'utf8');
216
+ // soffice 쪽과 같은 규칙 — 읽었으면 사본을 남기지 않는다.
217
+ try { rmSync(나온것, { force: true }); } catch { /* 임시치우기가 거둔다 */ }
218
+ return { ok: true, text, 쓴것: 'textutil', 파일: 나온것 };
219
+ }
220
+ }
221
+
222
+ if (!있는것.soffice) return { ok: false, 없음: true, 왜: '이 파일을 바꿀 변환기가 없습니다' };
223
+
224
+ /*
225
+ * soffice 는 결과 파일 이름을 제가 정한다.
226
+ *
227
+ * 그래서 넣기 전에 폴더에 무엇이 있었는지 적어 두고, 나온 뒤에 늘어난 것을
228
+ * 찾는다. 이름을 미리 짐작하면 확장자가 겹치거나 다른 판에서 어긋난다.
229
+ *
230
+ * -env:UserInstallation 을 따로 주는 까닭: soffice 는 이미 떠 있는 제 인스턴스가
231
+ * 있으면 새 부탁을 그쪽에 넘기고 **곧바로 끝나 버린다.** 사람이 LibreOffice 를
232
+ * 열어 둔 PC 에서 변환이 조용히 아무것도 안 하는 것이 그 모습이다. 우리 몫의
233
+ * 프로필을 따로 주면 그 일이 안 생긴다.
234
+ */
235
+ const 프로필 = join(받을곳, '.soffice-profile');
236
+ const 전 = new Set(existsSync(받을곳) ? readdirSync(받을곳) : []);
237
+ const r = 부르기(있는것.soffice, [
238
+ `-env:UserInstallation=file:///${프로필.replace(/\\/g, '/').replace(/^\/+/, '')}`,
239
+ '--headless', '--norestore',
240
+ '--convert-to', 'txt:Text',
241
+ '--outdir', 받을곳,
242
+ abs,
243
+ ], { timeout });
244
+
245
+ if (r.error) {
246
+ return { ok: false, 왜: `변환기를 못 돌렸습니다: ${r.error.message}` };
247
+ }
248
+ const 새로생긴것 = (existsSync(받을곳) ? readdirSync(받을곳) : [])
249
+ .filter((f) => !전.has(f) && f.toLowerCase().endsWith('.txt'));
250
+ if (!새로생긴것.length) {
251
+ const 끄트머리 = String(r.stderr || r.stdout || '').trim().split('\n').slice(-2).join(' ').slice(0, 200);
252
+ return { ok: false, 왜: `변환기가 글을 못 뽑았습니다${끄트머리 ? ` (${끄트머리})` : ''}` };
253
+ }
254
+ const 나온것 = join(받을곳, 새로생긴것[0]);
255
+ let text = '';
256
+ try { text = readFileSync(나온것, 'utf8'); } catch (err) {
257
+ return { ok: false, 왜: `바꾼 글을 못 읽었습니다: ${err.message}` };
258
+ } finally {
259
+ /*
260
+ * 읽었으면 곧바로 지운다.
261
+ *
262
+ * 글은 이미 손에 있다. 남겨 두면 **사람 문서의 알맹이가 사본으로 작업
263
+ * 폴더에 쌓인다** — 그대로 커밋되거나 압축되어 나갈 수 있는 자리다.
264
+ * 나중에 거두겠다는 약속은 세션이 죽으면 안 지켜진다. 지금 지운다.
265
+ */
266
+ try { rmSync(나온것, { force: true }); } catch { /* 못 지우면 임시치우기가 거둔다 */ }
267
+ }
268
+ return { ok: true, text, 쓴것: 'soffice', 파일: 나온것 };
269
+ }
270
+
271
+ /** 떨궈 둔 것을 거둔다. 검사와 세션 끝에서 부른다. */
272
+ export function 임시치우기(root) {
273
+ const 자리 = 임시자리(root);
274
+ if (!existsSync(자리)) return 0;
275
+ let 몇개 = 0;
276
+ for (const f of readdirSync(자리)) {
277
+ const p = join(자리, f);
278
+ try {
279
+ // 사람이 넣어 둔 것은 안 건드린다. 우리가 만든 것만 거둔다.
280
+ if (f === '.soffice-profile' || (statSync(p).isFile() && f.toLowerCase().endsWith('.txt'))) {
281
+ rmSync(p, { recursive: true, force: true });
282
+ 몇개++;
283
+ }
284
+ } catch { /* 못 지워도 다음에 지운다 */ }
285
+ }
286
+ return 몇개;
287
+ }
package/src/tools/docs.js CHANGED
@@ -56,6 +56,77 @@ export function looksOldHwp(경로, buf) {
56
56
  return buf.length >= 8 && buf.readUInt32LE(0) === 0xe011cfd0 && buf.readUInt32LE(4) === 0xe11ab1a1;
57
57
  }
58
58
 
59
+ /*
60
+ * ── 겉과 속이 다를 때, 속이 무엇인지 말한다 ─────────────────────────────
61
+ *
62
+ * 여태는 이랬다 —
63
+ *
64
+ * ◧ Read(보고서.pptx)
65
+ * └ pptx 모양이 아닙니다 — 겉은 pptx 인데 속이 zip 꾸러미가 아닙니다.
66
+ * 깨졌거나 다른 형식입니다.
67
+ *
68
+ * 「깨졌거나 다른 형식」 은 **아무것도 안 알려 준다.** 깨진 것이면 할 일이
69
+ * 없고, 다른 형식이면 바꾸면 되는데 둘을 안 갈라 줬다. 그래서 모델은 같은
70
+ * 파일을 몇 번씩 다시 열어 봤다 — 답이 매번 같은데도.
71
+ *
72
+ * 앞 몇 바이트만 보면 무엇인지 거의 다 안다. 알면 길도 같이 줄 수 있다.
73
+ * 옛 hwp 안내(아래)가 이미 그렇게 하고 있었는데, 이쪽만 안 하고 있었다.
74
+ */
75
+ const 서명들 = [
76
+ { 이름: '옛 Office 파일 (.ppt · .doc · .xls)', 바이트: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] },
77
+ { 이름: 'PDF', 바이트: [0x25, 0x50, 0x44, 0x46] }, // %PDF
78
+ { 이름: 'RTF 문서', 바이트: [0x7b, 0x5c, 0x72, 0x74, 0x66] }, // {\rtf
79
+ { 이름: 'PNG 그림', 바이트: [0x89, 0x50, 0x4e, 0x47] },
80
+ { 이름: 'JPEG 그림', 바이트: [0xff, 0xd8, 0xff] },
81
+ { 이름: 'gzip 압축', 바이트: [0x1f, 0x8b] },
82
+ { 이름: '7z 압축', 바이트: [0x37, 0x7a, 0xbc, 0xaf] },
83
+ { 이름: 'RAR 압축', 바이트: [0x52, 0x61, 0x72, 0x21] },
84
+ ];
85
+
86
+ /** 앞 몇 바이트로 정체를 짚는다. 모르면 null. */
87
+ export function 속내용(buf) {
88
+ if (!Buffer.isBuffer(buf) || buf.length < 2) return null;
89
+ for (const s of 서명들) {
90
+ if (s.바이트.every((b, i) => buf[i] === b)) return s.이름;
91
+ }
92
+ const 앞 = buf.subarray(0, 200).toString('latin1').trim().toLowerCase();
93
+ if (/^<(!doctype html|html|\?xml)/.test(앞)) return 앞.startsWith('<?xml') ? 'XML 글' : 'HTML 글';
94
+ // 남은 것이 전부 읽을 수 있는 글자면 그냥 글 파일이다.
95
+ const 이상한바이트 = buf.subarray(0, 512).filter((b) => b < 9 || (b > 13 && b < 32)).length;
96
+ if (!이상한바이트) return '그냥 글 파일';
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * 겉과 속이 다를 때 하는 말. **길을 같이 준다.**
102
+ *
103
+ * @param {string} 갈래 겉으로 본 갈래 (pptx · docx · hwpx)
104
+ * @param {string|null} 속 속내용() 이 짚은 것
105
+ */
106
+ export function 겉속다름말(갈래, 속) {
107
+ const 머리 = `겉은 ${갈래} 인데 속이 다릅니다`;
108
+ if (!속) {
109
+ return `${머리} — 무엇인지 알아보지 못했습니다 (zip 꾸러미가 아닙니다).\n`
110
+ + ' 파일이 깨졌을 수 있습니다. 이 파일로는 더 해 볼 것이 없으니 사용자에게 알리세요.';
111
+ }
112
+ if (속.startsWith('옛 Office')) {
113
+ return `${머리} — 실제로는 ${속} 입니다.\n`
114
+ + ` 이름만 ${갈래} 로 바뀐 옛 형식이라 이 도구로는 못 읽습니다.\n`
115
+ + ` PowerPoint·Word 에서 열어 ${갈래} 로 다시 저장하거나,\n`
116
+ + ` soffice 가 깔려 있으면 soffice --headless --convert-to ${갈래} <파일> 로 바꾸세요.`;
117
+ }
118
+ if (속 === 'PDF') {
119
+ return `${머리} — 실제로는 PDF 입니다.\n`
120
+ + ` 확장자만 ${갈래} 입니다. 파일 이름을 .pdf 로 바꿔서 Read 하면 그대로 읽힙니다.`;
121
+ }
122
+ if (속 === 'HTML 글' || 속 === 'XML 글' || 속 === '그냥 글 파일') {
123
+ return `${머리} — 실제로는 ${속} 입니다.\n`
124
+ + ' 글 파일이라 확장자를 .txt 로 바꾸거나 그대로 Bash 로 읽을 수 있습니다.';
125
+ }
126
+ return `${머리} — 실제로는 ${속} 입니다.\n`
127
+ + ` ${갈래} 로 읽을 수 있는 파일이 아닙니다. 이 파일로는 더 해 볼 것이 없으니 사용자에게 알리세요.`;
128
+ }
129
+
59
130
  /** 구형 hwp 를 만났을 때 하는 말. 길을 같이 준다. */
60
131
  export function 옛hwp안내(보인이름) {
61
132
  return `구형 hwp 형식이라 읽지 못합니다: ${보인이름}\n`
@@ -176,7 +247,7 @@ export function readDoc(경로또는버퍼) {
176
247
  }
177
248
  if (!갈래) return { ok: false, error: '어떤 문서인지 모르는 경로입니다' };
178
249
  if (!looksZip(buf)) {
179
- return { ok: false, error: `${갈래} 모양이 아닙니다 겉은 ${갈래} 인데 속이 zip 꾸러미가 아닙니다. 깨졌거나 다른 형식입니다.` };
250
+ return { ok: false, error: 겉속다름말(갈래, 속내용(buf)), 끝났다: true };
180
251
  }
181
252
 
182
253
  let 꾸러미;
@@ -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,8 @@ 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 { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침, 한쪽도못읽음말 } from './pdf.js';
27
28
  import { diffLines } from '../ui/diff.js';
28
29
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
29
30
  import { 도구설명EN } from './desc.en.js';
@@ -123,6 +124,56 @@ export function 붙박이그림줄이기(줄들) {
123
124
  return { 줄들: 나온것, 알림, 줄인바이트, 몇개 };
124
125
  }
125
126
 
127
+ /*
128
+ * ── 안 보여 준 것을 지우게 두면 안 된다 ─────────────────────────────────
129
+ *
130
+ * 위 붙박이그림줄이기 는 **읽을 때만** 그림을 뺀다. 파일은 그대로다. 그런데
131
+ * 그 다음이 문제였다 — 모델은 그렇게 받은 글을 손봐서 Write 로 통째로 덮어쓴다.
132
+ * 그러면 제가 못 본 그림은 새 내용에 없다. **사용자 파일에서 그림이 사라진다.**
133
+ *
134
+ * 실제로 이렇게 났다 (사내 보고서 HTML):
135
+ *
136
+ * ◧ Read(보고서.html) 1425줄 · 그림 8.0MB 생략
137
+ * ◈ Write(보고서.html) 1420줄 ← 8MB 짜리 그림 일곱 개가 여기서 없어진다
138
+ *
139
+ * 오류는 없다. 화면도 멀쩡하다. 파일을 열어 봐야 안다 — 그림이 있던 자리에
140
+ * 깨진 아이콘만 남는다. 몇 주 뒤에 알아채면 되돌릴 방법도 없다.
141
+ *
142
+ * 안 보여 준 것에 대한 책임은 우리에게 있다. 모델은 최선을 다한 것이다 —
143
+ * 있는 줄 몰랐던 것을 지킬 수는 없다. 그러니 여기서 막는다.
144
+ *
145
+ * 세는 방식이 중요하다. **생략 표시가 있나** 로만 보면 모자란다. 모델이 그
146
+ * 줄을 통째로 지워 버리는 경우가 더 흔하고, 그때는 표시도 같이 사라진다.
147
+ * 그래서 **개수를 견준다** — 있던 것보다 줄었으면 잃은 것이다.
148
+ */
149
+ export function 박힌그림수(글) {
150
+ return (String(글 ?? '').match(그림자리) ?? []).length;
151
+ }
152
+
153
+ /**
154
+ * 이 덮어쓰기가 박힌 그림을 잃게 하나.
155
+ *
156
+ * @returns {string|null} 막을 이유. 잃는 것이 없으면 null.
157
+ */
158
+ function 그림잃나(abs, 새내용) {
159
+ if (!existsSync(abs)) return null;
160
+ let 옛것;
161
+ try { 옛것 = readTextFull(abs).text; } catch { return null; }
162
+ const 있던것 = 박힌그림수(옛것);
163
+ if (!있던것) return null;
164
+ const 남는것 = 박힌그림수(새내용);
165
+ if (남는것 >= 있던것) return null;
166
+
167
+ const 잃는것 = 있던것 - 남는것;
168
+ const 생략표시 = /…\([\d.]+\s*[KMG]?B 생략\)…/.test(새내용);
169
+ return `이 파일에 박혀 있는 그림 ${있던것}개 중 ${잃는것}개가 새 내용에 없습니다 — 덮어쓰면 사라집니다.\n`
170
+ + (생략표시
171
+ ? ' Read 가 보여준 「…(생략)…」 표시를 그대로 되쓰셨습니다. 그건 그림이 아니라 자리 표시입니다.\n'
172
+ : ' Read 는 그림 자리를 빼고 보여줍니다. 못 본 것이라 새로 쓸 때 빠진 것입니다.\n')
173
+ + ' 통째로 덮어쓰지 말고 **Edit 으로 고칠 자리만** 바꾸세요. 그러면 그림은 파일에 그대로 남습니다.\n'
174
+ + ' 정말 그림을 빼는 것이 목적이면 사용자에게 먼저 확인하세요.';
175
+ }
176
+
126
177
  // 엑셀 파일에 쓰려 할 때 하는 말. 왜 안 되는지와, 그럼 어떻게 하는지를 같이 준다.
127
178
  /**
128
179
  * 이 자리에 글을 써 넣으면 안 되는 파일인가.
@@ -279,7 +330,18 @@ function 엑셀은못고침(보인이름) {
279
330
  */
280
331
  async function 엑셀읽기(abs, args, ctx) {
281
332
  const r = await readExcel(abs, { askPassword: ctx.askPassword ?? null });
282
- if (!r.ok) return { error: r.error };
333
+ if (!r.ok) {
334
+ /*
335
+ * 옛 `.xls` 는 엑셀 COM 말고는 길이 없었다. 그래서 엑셀이 없는 PC —
336
+ * 맥과 리눅스가 전부 여기다 — 에서는 그냥 실패로 끝났다. 이 PC 에
337
+ * LibreOffice 가 있으면 그것이 곧 길이다. 표는 아니고 글이지만,
338
+ * 아무것도 못 읽는 것보다 낫다.
339
+ */
340
+ const 빌린것 = 빌려읽기(abs, ctx, r.error);
341
+ if (빌린것) return 빌린것;
342
+ if (직접못읽나(abs)) return { error: 못바꿈말(ctx.scope.show(abs), extname(abs)), 끝났다: true };
343
+ return { error: r.error };
344
+ }
283
345
 
284
346
  const { text, 잘림 } = excelText(r.sheets);
285
347
  const 말 = [...(r.notes ?? []), ...잘림];
@@ -299,9 +361,55 @@ async function 엑셀읽기(abs, args, ctx) {
299
361
  * 엑셀읽기와 같은 규칙 — ctx.seen 에 안 넣는다. 넣으면 Edit 이 '이 파일 고칠
300
362
  * 수 있다' 고 오해한다. 문서는 이 도구로 고치는 물건이 아니다.
301
363
  */
364
+ /**
365
+ * 우리가 못 읽는 문서를 **이미 깔린 변환기**로 한 번 더 해 본다 (tools/convert.js).
366
+ *
367
+ * `rg` 를 빌려 쓰는 것과 같은 원칙이다 — 아무것도 설치하지 않고, 있으면 쓰고,
368
+ * 없으면 없다고 말한다. 여기가 붙기 전에는 이렇게 끝났다:
369
+ *
370
+ * ◧ Read(보고서.pptx)
371
+ * └ pptx 모양이 아닙니다 — 깨졌거나 다른 형식입니다.
372
+ *
373
+ * 정작 그 PC 에는 LibreOffice 가 깔려 있었다. 사람이 그 파일을 열어 보는 바로
374
+ * 그 프로그램이다. 그런데 모델은 그걸 부를 수도 없었고(울타리에 막혔다),
375
+ * 있는지 볼 수도 없었다.
376
+ *
377
+ * @returns {object|null} 읽어냈으면 도구 결과. 못 하면 null (부르는 쪽이 원래 오류를 낸다)
378
+ */
379
+ function 빌려읽기(abs, ctx, 원래오류) {
380
+ if (!바꿔볼까(abs)) return null;
381
+ const root = ctx.scope?.root;
382
+ if (!root) return null;
383
+
384
+ // ctx.변환기 는 검사에서 가짜 변환기를 끼우는 자리다. 진짜 LibreOffice 를
385
+ // 깔아야만 확인되는 검사는 아무도 안 돌린다.
386
+ const 있는것 = ctx.변환기 ?? 변환기찾기();
387
+ if (!있는것.soffice && !있는것.textutil) return null;
388
+
389
+ const r = 글로바꾸기(abs, root, { 찾은것: 있는것 });
390
+ if (!r.ok || !r.text.trim()) return null;
391
+
392
+ const 줄수 = r.text.split('\n').length;
393
+ return {
394
+ content: clip(
395
+ `${r.text}\n\n(deel 이 직접 못 읽는 형식이라 이 PC 의 ${r.쓴것} 로 글만 뽑아 보여준 것입니다.`
396
+ + ' 원본은 한 글자도 안 바뀌었습니다. 이 파일은 Edit/Write 로 고칠 수 없습니다 —'
397
+ + ` 고쳐야 하면 새 파일에 쓰세요.)\n(원래 못 읽은 까닭: ${String(원래오류).split('\n')[0]})`,
398
+ ),
399
+ summary: `${r.쓴것} 로 바꿔 읽음 · ${줄수}줄`,
400
+ };
401
+ }
402
+
302
403
  function 문서읽기(abs, ctx) {
303
404
  const r = readDoc(abs);
304
- if (!r.ok) return { error: r.error };
405
+ if (!r.ok) {
406
+ // 우리가 못 읽는다고 끝이 아니다. 이 PC 에 변환기가 있으면 빌려 본다.
407
+ const 빌린것 = 빌려읽기(abs, ctx, r.error);
408
+ if (빌린것) return 빌린것;
409
+ // 끝났다 를 그대로 넘긴다. 여기서 떨구면 docs.js 가 「다시 열어도 같다」고
410
+ // 판정해 놓은 것이 루프까지 못 가서, 되풀이 억제가 안 걸린다.
411
+ return { error: r.error, ...(r.끝났다 ? { 끝났다: true } : {}) };
412
+ }
305
413
  const { text, 잘림 } = docText(r.덩이들);
306
414
  return {
307
415
  content: clip(
@@ -328,6 +436,19 @@ function pdf읽기(abs, ctx) {
328
436
  if (!r.ok) return { error: r.error };
329
437
  const { text, 잘림 } = pdfText(r);
330
438
  const 못 = 못읽은말(r);
439
+ /*
440
+ * 한 쪽도 못 읽었으면 **실패다.** 성공으로 돌려주면 안 된다.
441
+ *
442
+ * 여태 「글이 하나도 없는 PDF 입니다」를 본문으로 돌려줬다. 그러면 도구는
443
+ * 성공한 것이 되어 되풀이 감지도, 배움도, 재시도 억제도 하나도 안 걸린다.
444
+ * 그래서 모델은 같은 파일을 몇 번씩 다시 열었고, 그때마다 같은 답을 받았다.
445
+ */
446
+ // text 로 재면 안 된다 — 못 읽은 쪽에도 `--- 3쪽 ---` 와 「못 읽었습니다」가
447
+ // 들어가서 글이 있는 것처럼 보인다. 실제 문단이 하나라도 나왔는지로 가른다.
448
+ const 글나온쪽 = (r.덩이들 ?? []).some((d) => (d.문단들 ?? []).some((p) => String(p).trim()));
449
+ if (!글나온쪽) {
450
+ return { error: 한쪽도못읽음말(r, ctx.scope.show(abs)), 끝났다: true };
451
+ }
331
452
  return {
332
453
  content: clip(
333
454
  `${text || '(글이 하나도 없는 PDF 입니다.)'}
@@ -503,6 +624,9 @@ function 한파일쓰기(args, ctx) {
503
624
  // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
504
625
  const 바이너리막기 = 바이너리인가(abs);
505
626
  if (바이너리막기) return { error: 바이너리막기 };
627
+ // 우리가 안 보여 준 그림을 모델이 지우게 두지 않는다 (그림잃나 머리말).
628
+ const 그림막기 = 그림잃나(abs, args.content);
629
+ if (그림막기) return { error: 그림막기 };
506
630
  ctx.history.snapshot(abs, 'Write');
507
631
  const existed = existsSync(abs);
508
632
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
@@ -796,10 +920,32 @@ export const TOOLS = {
796
920
  if (abs.toLowerCase().endsWith('.hwp')) {
797
921
  try {
798
922
  const 머리 = readFileSync(abs);
799
- if (looksOldHwp(abs, 머리)) return { error: 옛hwp안내(ctx.scope.show(abs)) };
923
+ if (looksOldHwp(abs, 머리)) {
924
+ // 여기도 먼저 빌려 본다. 이 PC 에 변환기가 있으면 안내문보다 글이 낫다.
925
+ const 안내 = 옛hwp안내(ctx.scope.show(abs));
926
+ return 빌려읽기(abs, ctx, 안내) ?? { error: 안내 };
927
+ }
800
928
  } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
801
929
  }
802
930
 
931
+ /*
932
+ * 옛 Office(.ppt·.doc·.xls·.rtf·.odt…)가 여기서 걸린다.
933
+ *
934
+ * 위의 갈래들과 달리 우리는 이걸 아예 못 읽는다. 그냥 두면 아래 일반
935
+ * 읽기가 `바이너리 파일입니다` 로 끝냈다 — 까닭도 길도 없는 거절이라
936
+ * 모델이 같은 문을 계속 두드린다. 사내 자료에 제일 흔한 갈래가 하필
937
+ * 이것들이다.
938
+ *
939
+ * 그래서 여기서 두 갈래로 끝낸다. 이 PC 에 변환기가 있으면 빌려 읽고,
940
+ * 없으면 **없다고 못 박고 끝낸다**(끝났다: true → 되풀이 억제가 걸린다).
941
+ */
942
+ if (직접못읽나(abs)) {
943
+ const 보인이름 = ctx.scope.show(abs);
944
+ const 빌린것 = 빌려읽기(abs, ctx, `${보인이름} 을 deel 이 직접 못 읽습니다.`);
945
+ if (빌린것) return 빌린것;
946
+ return { error: 못바꿈말(보인이름, extname(abs)), 끝났다: true };
947
+ }
948
+
803
949
  const 읽음 = readTextFull(abs);
804
950
  // 무엇으로 읽었는지 기억해 둔다. 나중에 고칠 때 같은 것으로 되돌려 써야 한다.
805
951
  // 안 그러면 사내 CP949 문서가 한 번 고치는 것만으로 UTF-8 이 되어 버린다.
@@ -816,9 +962,23 @@ export const TOOLS = {
816
962
  const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
817
963
  ctx.seen.add(abs);
818
964
  const 별난인코딩 = 읽음.encoding !== 'utf-8';
965
+ /*
966
+ * 요약에는 **실제로 건넨 줄 수**를 적는다.
967
+ *
968
+ * 여태 파일의 전체 줄 수를 적었다. 그래서 32k 모델에서 1,425줄짜리
969
+ * 파일을 열면 모델은 384줄만 받는데 화면에는 `1425줄` 이 찍혔다 —
970
+ * 사람은 다 읽은 줄 알고 "왜 저걸 못 고치지" 를 되풀이한다. 잘렸다는
971
+ * 말은 모델에게 가는 본문에만 있었고, 보는 사람에게는 없었다.
972
+ *
973
+ * budget.js 의 읽을줄수 머리말에 이 함정이 그대로 적혀 있는데
974
+ * (「화면에는 2,000줄이라고만 떠서 사람은 잘 읽은 줄 안다」) 정작
975
+ * 이 줄은 안 고쳐져 있었다.
976
+ */
977
+ const 준줄수 = slice.length;
978
+ const 다못줌 = start + count < lines.length || start > 0;
819
979
  return {
820
980
  content: clip(body + more + 줄인것.알림),
821
- summary: `${lines.length}줄`
981
+ summary: (다못줌 ? `${준줄수}/${lines.length}줄 (일부만)` : `${lines.length}줄`)
822
982
  + (줄인것.줄인바이트 ? ` · 그림 ${몇KB(줄인것.줄인바이트)} 생략` : '')
823
983
  + (별난인코딩 ? ` · ${encLabel(읽음.encoding)}` : ''),
824
984
  };
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
  /**