deel-local-cli 1.20.12 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -11,7 +11,9 @@
11
11
  // · 사설·로컬 주소는 거절. 사내 서버를 모델이 긁어 오게 두지 않는다.
12
12
  // · 오프라인이면 아예 거절.
13
13
  // · 받은 것은 글자만 뽑고 길이를 자른다.
14
- import { allowTemporarily, isOffline, isLocalHost, 사설로풀리나 } from '../safety/network.js';
14
+ // isLocalHost 가 아니라 안쪽주소인가 로 막는다 — 봉인이 열어 주는 「이 안」 보다 막을 곳이 넓다
15
+ // (100.64/10 의 클라우드 메타데이터 · fec0::/10 · IPv4 를 싼 IPv6). network.js 머리말 참고.
16
+ import { allowTemporarily, isOffline, 안쪽주소인가, 사설로풀리나 } from '../safety/network.js';
15
17
  import { 원시요청, 몸읽기 } from '../backend/http.js';
16
18
  import { decode as decodeBytes } from './encoding.js';
17
19
  import { 웹글자수 } from '../agent/budget.js';
@@ -28,6 +30,33 @@ function 웹글읽기(buf, 머리글) {
28
30
  try { return new TextDecoder(머리글, { fatal: false }).decode(buf); }
29
31
  catch { /* 이 Node 가 모르는 이름이면 아래에서 알아서 본다 */ }
30
32
  }
33
+ /*
34
+ * ── utf-8 이라고 **적어 둔** 페이지 (사냥4 W4) ──────────────────────────
35
+ *
36
+ * 여기는 머리글이 utf-8 이면 곧장 내용 짐작(encoding.js)으로 넘겼다. 그런데 짐작은
37
+ * 엄격하다 — UTF-8 로 **한 바이트라도** 어긋나면 UTF-8 이 아니라고 보고 다른
38
+ * 인코딩을 고른다. 멀쩡한 한글 UTF-8 페이지에 깨진 바이트 하나(잘린 광고 조각,
39
+ * 옛 글 붙여넣기)가 섞이면 페이지 **전체가** windows-1252 로 읽혀 `안녕` 이 됐다.
40
+ * 아래 webFetch 의 되읽기(깨진 글자 세기)도 못 잡는다 — 1252 는 � 를 안 내놓는다.
41
+ *
42
+ * 그래서 적힌 utf-8 을 먼저 믿어 본다. 어긋난 바이트는 � 로 바꾸고, 그 수가 제대로
43
+ * 읽힌 UTF-8 글자에 비해 **드물면**(스무 자에 하나 이하) 그대로 쓴다.
44
+ *
45
+ * 「적힌 대로」 만 믿지 않는 까닭 — 머리글만 utf-8 이고 알맹이는 EUC-KR 인 옛 사내
46
+ * 위키가 흔하다(서버 기본값이 붙는다). 거기서는 거의 모든 바이트가 어긋나서 비율로
47
+ * 갈린다. 그때만 여태처럼 내용 짐작으로 넘어간다.
48
+ */
49
+ if (머리글) {
50
+ const 읽은것 = new TextDecoder('utf-8', { fatal: false }).decode(buf);
51
+ let 깨진 = 0;
52
+ let 멀쩡한 = 0;
53
+ for (const 글자 of 읽은것) {
54
+ const n = 글자.codePointAt(0);
55
+ if (n === 0xfffd) 깨진 += 1;
56
+ else if (n >= 0x80) 멀쩡한 += 1;
57
+ }
58
+ if (깨진 === 0 || 깨진 * 20 <= 멀쩡한) return 읽은것;
59
+ }
31
60
  // euc-kr 인 페이지를 위해 힌트를 준다. 내용이 분명하면 내용이 이긴다.
32
61
  return decodeBytes(buf, { fallback: 'euc-kr' }).text;
33
62
  }
@@ -130,6 +159,45 @@ function 얼마나쉬라나(머리, 회차) {
130
159
  return Math.min(초, 10);
131
160
  }
132
161
 
162
+ /*
163
+ * 이름으로 적힌 글자(&middot; 같은 것). 번호로 둔다 — 글자를 소스에 박으면 보이지 않는
164
+ * 글자(nbsp)가 섞여도 눈으로 못 가린다.
165
+ *
166
+ * 여태는 nbsp·amp·lt·gt·quot·apos 여섯만 풀었다(사냥4 W16). 그래서 흔한 `&middot;`
167
+ * `&hellip;` `&copy;` 가 원문 그대로 모델에게 가서, 목록의 가운뎃점·말줄임표 자리가 전부
168
+ * `&middot;` 로 찍혔다. 모르는 이름은 여전히 **손대지 않는다.**
169
+ *
170
+ * 뿌리 없는 객체다(8회차 · 바깥). 보통 객체면 `&toString;` `&constructor;` `&valueOf;`
171
+ * 같은 이름이 **Object.prototype 의 함수**를 집는다. 그것을 글자 번호로 쓰려다
172
+ * `RangeError: Invalid code point NaN` 이 나고, 그 예외는 태그벗기기 밖으로 새서
173
+ * **페이지 전체가 오류 한 줄**이 됐다 — 남의 페이지에 한 낱말 적혀 있으면 되는 일이다.
174
+ * 뿌리가 없으면 그런 이름도 그냥 없는 이름이라, 위 규칙(손대지 않는다)이 그대로 산다.
175
+ */
176
+ const 이름글자 = Object.assign(Object.create(null), {
177
+ nbsp: 0x20, amp: 0x26, lt: 0x3c, gt: 0x3e, quot: 0x22, apos: 0x27,
178
+ middot: 0xb7, hellip: 0x2026, copy: 0xa9, reg: 0xae, trade: 0x2122,
179
+ mdash: 0x2014, ndash: 0x2013, lsquo: 0x2018, rsquo: 0x2019, ldquo: 0x201c, rdquo: 0x201d,
180
+ laquo: 0xab, raquo: 0xbb, bull: 0x2022, euro: 0x20ac, times: 0xd7, divide: 0xf7, deg: 0xb0,
181
+ plusmn: 0xb1, sect: 0xa7, para: 0xb6, cent: 0xa2, pound: 0xa3, yen: 0xa5,
182
+ larr: 0x2190, rarr: 0x2192, uarr: 0x2191, darr: 0x2193,
183
+ });
184
+
185
+ /*
186
+ * 번호로 적혀 와도 **모델에게 넘기지 않을** 글자 (사냥4 W16).
187
+ *
188
+ * 여기는 32 밑만 막았다. 그래서 이런 것이 번호 한 줄로 그대로 풀려 들어갔다:
189
+ * · C1 제어(0x80–0x9F) · DEL — `&#x9b;31m` 은 터미널에서 CSI 다. 대화 기록을 cat 하면 색이 바뀐다
190
+ * · 방향 뒤집기(U+202A–202E · U+2066–2069) — 글이 보이는 순서와 읽히는 순서를 갈라놓는다.
191
+ * 「파일을 지우지 마세요」 가 사람 눈에는 반대로 보이게 만드는 데 쓰는 글자다
192
+ * · 외톨이 대리쌍(D800–DFFF) — 글자가 아니다. JSON 으로 게이트웨이에 실으면 넘어지는 곳이 있다
193
+ * 못 넣을 번호는 여태처럼 원문(`&#x202e;`)을 그대로 둔다 — 보이는 글자로 남으니 안전하다.
194
+ */
195
+ const 못넣을번호 = (n) => n < 32 || n > 0x10ffff
196
+ || (n >= 0x7f && n <= 0x9f)
197
+ || (n >= 0xd800 && n <= 0xdfff)
198
+ || (n >= 0x202a && n <= 0x202e)
199
+ || (n >= 0x2066 && n <= 0x2069);
200
+
133
201
  function 태그벗기기(html) {
134
202
  let 글 = String(html);
135
203
  // script·style·주석을 겹쳐 쓰거나 안 닫은 모양으로 흘려 보내는 페이지가
@@ -138,31 +206,44 @@ function 태그벗기기(html) {
138
206
  // 화면에 그리지 않으니, 여기서 다 못 걸러도 실행되는 것은 아니다.
139
207
  for (let i = 0; i < 5; i += 1) {
140
208
  const 전 = 글;
141
- 글 = 글
142
- .replace(/<script[\s\S]*?<\/script\s*>/gi, ' ')
143
- .replace(/<style[\s\S]*?<\/style\s*>/gi, ' ')
144
- .replace(/<!--[\s\S]*?-->/g, ' ');
209
+ /*
210
+ * 셋을 **한 무늬로, 왼쪽부터** 지운다 (6회차 Gemini 웹받기 W4).
211
+ *
212
+ * script → style → 주석 차례로 따로 지웠더니, 주석 안에 적힌 `<script>` 가 뒤에 오는
213
+ * 진짜 `</script>` 까지 본문을 통째로 먹고 `<!--` 만 남겼다. 브라우저는 **먼저 열린 쪽**을
214
+ * 따른다 — 한 무늬의 갈래로 두면 정규식도 왼쪽에서 먼저 걸리는 쪽을 따른다.
215
+ */
216
+ 글 = 글.replace(/<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi, ' ');
145
217
  if (글 === 전) break;
146
218
  }
219
+ /*
220
+ * 안 닫힌 script·style·주석은 **끝까지**다 (W3). 브라우저도 닫는 태그가 안 오면 나머지를 전부
221
+ * 그 안으로 읽는다. 상한에서 잘린 페이지가 흔히 이 꼴이라, 안 지우면 스크립트 본문이 글로 간다.
222
+ */
223
+ 글 = 글.replace(/<!--[\s\S]*$|<(?:script|style)\b[\s\S]*$/i, ' ');
147
224
  return 글
148
225
  // 닫는 태그만 줄로 바꾸면, 닫는 태그를 안 적은 페이지가 통째로 한 줄이
149
226
  // 된다 — `<p>첫째<p>둘째` 나 `<ul><li>하나<li>둘</ul>` 이 흔하다.
150
227
  // HTML 이 그것을 허락하므로 옛 사내 페이지에는 정말로 그렇게 적혀 있다.
151
228
  // 여는 태그도 같이 줄로 바꾼다.
152
229
  .replace(/<\/?(p|div|section|article|li|tr|h[1-6]|br)\b[^>]*>/gi, '\n')
153
- .replace(/<[^>]+>/g, ' ')
230
+ // 태그는 `<` 바로 뒤가 글자(·`/`·`!`·`?`)일 때만이다 (W5). `1 < 5 && 10 > 2` 의 `< 5 … >` 는
231
+ // HTML 에서도 글이다 — 태그로 보고 지우면 식이 `1 2` 가 된다.
232
+ .replace(/<\/?[a-z!?][^>]*>/gi, ' ')
154
233
  // 한 번에 찾아서 한 번에 바꾼다 — 차례로 바꾸면(`&amp;` 를 먼저 `&` 로
155
234
  // 풀고 나서 `&lt;` 를 다시 찾는 식) `&amp;lt;` 처럼 두 겹 씌운 것이
156
235
  // 두 번 풀려서 `<` 로 튀어나온다(글자로 남아야 하는데 태그처럼 보이게 됨).
157
236
  // 숫자 꼴(`&#48712;` · `&#x27;`)과 `&apos;` 도 같은 한 판에서 푼다.
158
237
  // 안 풀면 한글 페이지 하나가 통째로 숫자 나열로 모델에게 간다.
159
- .replace(/&(nbsp|amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-f]{1,6});/gi, (온것, 이름) => {
160
- const 표 = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
238
+ .replace(/&(#\d{1,7}|#x[0-9a-f]{1,6}|[a-z][a-z0-9]{1,7});/gi, (온것, 이름) => {
161
239
  const 키 = 이름.toLowerCase();
162
- if (표[키] !== undefined) return 표[키];
240
+ if (!키.startsWith('#')) {
241
+ const 번 = 이름글자[이름] ?? 이름글자[키];
242
+ return 번 === undefined ? 온것 : String.fromCodePoint(번);
243
+ }
163
244
  const 번호 = 키.startsWith('#x') ? parseInt(키.slice(2), 16) : parseInt(키.slice(1), 10);
164
- // 못 읽을 번호면 **손대지 않는다.** 지어낸 글자를 넣느니 원문이 낫다.
165
- if (!Number.isFinite(번호) || 번호 < 32 || 번호 > 0x10ffff) return 온것;
245
+ // 못 읽을 번호·못 넣을 번호면 **손대지 않는다.** 지어낸 글자를 넣느니 원문이 낫다.
246
+ if (!Number.isFinite(번호) || 못넣을번호(번호)) return 온것;
166
247
  try { return String.fromCodePoint(번호); } catch { return 온것; }
167
248
  })
168
249
  .replace(/[ \t]+/g, ' ')
@@ -200,7 +281,8 @@ export function 웹되돌림(다음, { allowPrivate = false } = {}) {
200
281
  if (다음.protocol !== 'http:' && 다음.protocol !== 'https:') {
201
282
  throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
202
283
  }
203
- if (isLocalHost(다음.hostname) && !allowPrivate) {
284
+ // 안쪽주소인가 — 100.100.100.200(알리바바 메타데이터)·fec0::/10 까지 본다 (사냥4 W9).
285
+ if (안쪽주소인가(다음.hostname) && !allowPrivate) {
204
286
  throw new Error(`이 컴퓨터·사내망 주소(${다음.hostname})로 되돌립니다 — 따라가지 않습니다`);
205
287
  }
206
288
  }
@@ -248,7 +330,7 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
248
330
  return { error: `${u.protocol} 는 읽지 않습니다. http/https 만 됩니다.` };
249
331
  }
250
332
  // 사내망·로컬을 모델이 훑게 두지 않는다. 웹을 읽는 도구지 내부 정찰 도구가 아니다.
251
- if (isLocalHost(u.hostname) && !allowPrivate) {
333
+ if (안쪽주소인가(u.hostname) && !allowPrivate) {
252
334
  return { error: `이 컴퓨터·사내망 주소는 이 도구로 읽지 않습니다: ${u.hostname}\n 파일은 Read, 사내 서버는 사람이 직접 확인하세요.` };
253
335
  }
254
336
  /*
@@ -280,6 +362,10 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
280
362
  let res = null;
281
363
  let 쉰시간 = 0;
282
364
  for (let 회차 = 0; ; 회차++) {
365
+ // 회차마다 처음 주소부터 다시 간다. 여기서 안 되돌리면, 앞 회차에만 되돌림이
366
+ // 있었을 때 되돌림이 **없던** 회차까지 앞 회차의 주소로 기록·보고된다 —
367
+ // 심사서의 「어디로 나갔나」 와 오류 문구가 가 본 적 없는 집을 가리킨다.
368
+ 닿은곳 = u;
283
369
  res = await 한집씩(u.origin, () => 원시요청(u.href, {
284
370
  method: 'GET', // 보내는 건 없다
285
371
  headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
@@ -338,7 +424,12 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
338
424
  return { error: `HTTP ${res.status} — ${집} 가 지금 힘들어합니다.`
339
425
  + `\n ${다시횟수}번 다시 불러 봤습니다(${쉰시간}초). 잠시 뒤에 다시 해 보세요.` };
340
426
  }
341
- return { error: `HTTP ${res.status} — ${닿은곳.href}` };
427
+ /*
428
+ * 되돌림을 못 따라간 까닭이 있으면 같이 말한다 (사냥4 W19 · http.js 의 되돌림탈).
429
+ * 「HTTP 302 — 주소」 한 줄로는 제자리를 도는 서버인지, Location 이 깨졌는지 모른다 —
430
+ * 모델은 같은 주소를 또 부른다.
431
+ */
432
+ return { error: `HTTP ${res.status} — ${닿은곳.href}${res.되돌림탈 ? `\n ${res.되돌림탈}` : ''}` };
342
433
  }
343
434
 
344
435
  const type = (res.headers.get('content-type') ?? '').toLowerCase();
@@ -354,14 +445,46 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
354
445
  const 뒷말 = 갈래.split('/')[1] ?? '';
355
446
  const 글인가 = /^text\//.test(갈래)
356
447
  || /(^|[-+.])(json|xml|javascript|ecmascript|yaml|toml|csv|ndjson|graphql)$/.test(뒷말);
357
- if (!글인가) {
448
+ // 갈래가 **아예 없는** 답은 여기서 거절하지 않는다 — 아래에서 알맹이를 보고 가른다.
449
+ if (!글인가 && 갈래) {
358
450
  await res.버리기?.();
359
- return { error: `글이 아닌 내용입니다 (${type || '알 수 없음'}). 이 도구는 글만 읽습니다.` };
451
+ return { error: `글이 아닌 내용입니다 (${type}). 이 도구는 글만 읽습니다.` };
360
452
  }
361
453
 
362
454
  const buf = await 몸읽기(res.res.body, MAX_BYTES);
363
455
  if (!buf) return { error: `너무 큽니다 (${MAX_BYTES / 1024 / 1024}MB 넘음) — 받다 말았습니다. 범위를 좁힌 주소를 쓰세요.` };
364
456
 
457
+ /*
458
+ * ── Content-Type 이 없는 200 (사냥4 W18) ──────────────────────────────
459
+ *
460
+ * 여태는 「글이 아닌 내용입니다 (알 수 없음)」 으로 거절했다. 옛 사내 서버·정적 파일
461
+ * 서버·짧은 CGI 가 머리글을 안 붙이는 일이 흔해서, 멀쩡한 글 페이지가 통째로 막혔다.
462
+ * 브라우저도 이때는 알맹이를 보고 가른다(MIME sniffing). 앞 8KB 에 NUL 이 있거나
463
+ * PDF 머리면 바이너리로 보고 거절하고, 아니면 글로 읽는다. `<html` 로 시작하면
464
+ * 태그를 벗긴다. 상한(MAX_BYTES)은 위에서 이미 걸렸다 — 보고 나서 버려도 크게 안 받는다.
465
+ */
466
+ /*
467
+ * 볼갈래도 **낱말**이다 (8회차 · 바깥). 여기에 머리글 원문을 담았더니
468
+ * `application/json; name="index.html"` 이 아래 `/html/` 에 걸려서 **JSON
469
+ * 본문의 태그를 벗겼다** — `{"a":"<b>굵게</b>"}` 가 `{"a":" 굵게 "}` 가 되고,
470
+ * 값이 깎였다는 말은 어디에도 안 나온다. 위에서 이미 갈래를 낱말로 갈라 뒀다.
471
+ */
472
+ let 볼갈래 = 갈래;
473
+ if (!갈래) {
474
+ const 앞 = buf.subarray(0, 8192);
475
+ if (앞.includes(0) || 앞.subarray(0, 5).toString('latin1') === '%PDF-') {
476
+ return { error: '글이 아닌 내용입니다 (Content-Type 이 없고 알맹이가 바이너리입니다). 이 도구는 글만 읽습니다.' };
477
+ }
478
+ const 머리떼고 = 앞.subarray(앞[0] === 0xef && 앞[1] === 0xbb && 앞[2] === 0xbf ? 3 : 0).toString('latin1');
479
+ /*
480
+ * 엿보는 목록은 WHATWG MIME Sniffing 표준의 「HTML 서명」 그대로다 (6회차 Gemini 웹받기 W9).
481
+ * doctype·html·head·body 넷만 봐서, 주석이나 `<div>` 로 시작하는 옛 페이지는 태그·스크립트째
482
+ * 글로 넘어갔다. 표준처럼 이름 뒤에 빈칸이나 `>` 가 와야 한다 — `<a` 가 `<abc` 에 걸리지 않게.
483
+ */
484
+ 볼갈래 = /^\s*(?:<!--|<(?:!doctype\s+html|html|head|script|iframe|h1|div|font|table|a|style|title|b|body|br|p)[\s>])/i.test(머리떼고)
485
+ ? 'text/html' : 'text/plain';
486
+ }
487
+
365
488
  /*
366
489
  * 무엇으로 쓰여 있는지 알아보고 읽는다.
367
490
  *
@@ -375,7 +498,7 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
375
498
  */
376
499
  const 머리글 = /charset=["']?([\w-]+)/i.exec(type)?.[1]?.toLowerCase() ?? null;
377
500
  let text = 웹글읽기(buf, 머리글);
378
- if (/html/.test(type)) text = 태그벗기기(text);
501
+ if (/html/.test(볼갈래)) text = 태그벗기기(text);
379
502
  /*
380
503
  * `<meta charset>` 이 머리글과 **다르게** 적혀 있는 페이지가 있다.
381
504
  *
@@ -386,13 +509,25 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
386
509
  *
387
510
  * 다시 읽어서 **덜 깨진 쪽**을 쓴다. 세어 보고 고르니 더 나빠질 일이 없다.
388
511
  */
512
+ /*
513
+ * 그런데 문을 여는 조건이 `깨진수(text)` 하나라, **정작 그 판에서 한 번도 안 돌았다**
514
+ * (막판 훑기). 한 바이트짜리 인코딩(`iso-8859-1` · `windows-1252` · `us-ascii`)은
515
+ * 모든 바이트에 글자가 있어서, 아무리 엉뚱하게 읽어도 � 가 0 이다 — 이 파일이 위
516
+ * 웹글읽기 머리말에서 스스로 「1252 는 � 를 안 내놓는다」 고 적어 둔 그것이다.
517
+ * 그리고 서버 기본값으로 제일 흔히 붙는 것이 바로 그 이름들이다.
518
+ *
519
+ * 그러니 머리글이 그 부류면 � 수는 아무것도 안 말해 준다. 그때는 문을 열고,
520
+ * 같은 수로 나와도 **문서가 제 입으로 적어 둔 쪽**을 쓴다. 더 깨지면 안 쓴다 —
521
+ * 세어 보고 고르는 것은 그대로다.
522
+ */
389
523
  const 깨진수 = (그것) => (그것.match(/�/g) ?? []).length;
390
- if (깨진수(text)) {
524
+ const 셀수없나 = !!머리글 && /^(iso-?8859-\d+|windows-125\d|cp125\d|us-ascii|ascii|latin1)$/i.test(머리글);
525
+ if (깨진수(text) || 셀수없나) {
391
526
  const meta = /<meta[^>]+charset=["']?([\w-]+)/i.exec(buf.toString('latin1').slice(0, 2000))?.[1]?.toLowerCase();
392
527
  if (meta && meta !== 머리글) {
393
528
  let 다시 = 웹글읽기(buf, meta);
394
- if (/html/.test(type)) 다시 = 태그벗기기(다시);
395
- if (깨진수(다시) < 깨진수(text)) text = 다시;
529
+ if (/html/.test(볼갈래)) 다시 = 태그벗기기(다시);
530
+ if (깨진수(다시) < 깨진수(text) || (셀수없나 && 깨진수(다시) === 깨진수(text))) text = 다시;
396
531
  }
397
532
  }
398
533
  /*
@@ -409,7 +544,7 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
409
544
  * 2) 그래도 넘치면 **깨진 JSON 이라고 분명히 말한다.** 그리고 무엇을
410
545
  * 하면 되는지 — 범위를 좁히거나 max_chars 를 올리거나 — 같이 준다.
411
546
  */
412
- const json쪽 = /json/.test(type);
547
+ const json쪽 = /json/.test(볼갈래);
413
548
  let 눌렀나 = false;
414
549
  if (json쪽 && text.length > max) {
415
550
  try {
@@ -420,11 +555,20 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
420
555
 
421
556
  const 원래길이 = text.length;
422
557
  const cut = 원래길이 > max;
423
- if (cut) text = text.slice(0, max);
558
+ if (cut) {
559
+ /*
560
+ * 자르는 자리가 **대리쌍 한가운데**면 한 칸 앞에서 자른다 (사냥4 W17).
561
+ * `slice` 는 UTF-16 칸으로 자른다. 이모지·옛 한자처럼 두 칸짜리 글자가 경계에
562
+ * 걸리면 앞 반쪽(D800–DBFF)만 남는데, 그건 글자가 아니다 — JSON 으로 게이트웨이에
563
+ * 실으면 넘어지는 곳이 있고, 화면에는 � 로 찍힌다.
564
+ */
565
+ const 앞칸 = text.charCodeAt(max - 1);
566
+ text = text.slice(0, 앞칸 >= 0xd800 && 앞칸 <= 0xdbff ? max - 1 : max);
567
+ }
424
568
 
425
569
  let 꼬리 = '';
426
570
  if (cut) {
427
- const 남은것 = (원래길이 - max).toLocaleString();
571
+ const 남은것 = (원래길이 - text.length).toLocaleString();
428
572
  꼬리 = json쪽
429
573
  ? `\n\n(여기서 잘렸습니다 — ${남은것}자가 더 있습니다.`
430
574
  + '\n 잘린 JSON 은 그대로 읽을 수 없습니다. 다음 중 하나를 하세요:'
@@ -442,7 +586,20 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
442
586
  + (cut ? ` (잘림 — ${원래길이.toLocaleString()}자 중)` : ''),
443
587
  };
444
588
  } catch (err) {
445
- const m = String(err?.message ?? err);
589
+ /*
590
+ * 넘어진 까닭은 **속에** 있다 (8회차 · 바깥).
591
+ *
592
+ * fetch 는 무엇이 잘못됐든 겉으로는 `TypeError: fetch failed` 한 줄이고, 진짜
593
+ * 까닭(getaddrinfo ENOTFOUND · connect ECONNREFUSED · 인증서)은 `err.cause` 에
594
+ * 들어 있다. 겉만 보던 때는 아래 DNS 갈래가 한 번도 안 걸렸고, 사람과 모델은
595
+ * 「fetch failed」 만 받아 주소가 틀렸는지 문이 닫혔는지 모른 채 같은 주소를 또 불렀다.
596
+ */
597
+ const 까닭들 = [];
598
+ for (let e = err; e && 까닭들.length < 4; e = e.cause) {
599
+ const 한줄 = String(e?.message ?? e);
600
+ if (한줄 && !까닭들.includes(한줄)) 까닭들.push(한줄);
601
+ }
602
+ const m = 까닭들.join(' — ') || String(err);
446
603
  /*
447
604
  * 멈춤이 먼저다.
448
605
  *
@@ -471,7 +628,9 @@ export const WEB_FETCH_TOOL = {
471
628
  type: 'object',
472
629
  properties: {
473
630
  url: { type: 'string', description: '읽을 주소 (http/https)' },
474
- max_chars: { type: 'number', description: '가져올 최대 글자 수. 안 주면 모델 크기에 맞춰 정해진다. 자료가 잘리면 여기를 올린다 (최대 120000)' },
631
+ // 상한은 **첫 문장**에 둔다. 좁은 창에서는 설명을 문장 단위로 줄이는데(tools/index.js 의
632
+ // 설명줄이기) 끝에 붙여 두면 그 수가 먼저 잘려, 모델이 상한을 모른 채 max_chars 를 정했다.
633
+ max_chars: { type: 'number', description: '가져올 최대 글자 수 (최대 120000). 안 주면 모델 크기에 맞춰 정해진다. 자료가 잘리면 여기를 올린다' },
475
634
  },
476
635
  required: ['url'],
477
636
  },