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
@@ -24,12 +24,12 @@
24
24
  * 제 마음대로 파일을 읽고 쓸 수 있다. /mcp 화면에서 그렇다고 말한다.
25
25
  */
26
26
  import { spawn } from 'node:child_process';
27
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
28
28
  import { createHash } from 'node:crypto';
29
- import { join } from 'node:path';
29
+ import { join, normalize, isAbsolute, resolve } from 'node:path';
30
30
  import { VERSION } from '../version.js';
31
31
  // 남의 저장소에 딸려 온 mcp.json 으로 남의 프로그램을 띄우지 않는다 (다붙이기 머리말).
32
- import { 믿나 } from '../safety/trust.js';
32
+ import { 믿나, BOM떼기 } from '../safety/trust.js';
33
33
 
34
34
  // 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
35
35
  export const 붙기제한 = 8000;
@@ -40,6 +40,8 @@ export const 부르기제한 = 60000;
40
40
  export const 도구최대 = 24;
41
41
  // 한 줄(JSON 한 통)의 최대 크기. 미친 서버가 stdout 을 쏟아부어도 안 죽게.
42
42
  const 줄최대 = 4 * 1024 * 1024;
43
+ // tools/list 를 몇 쪽까지 따라갈까. 끝없이 다음 쪽을 주는 서버에 붙들리지 않게.
44
+ const 목록쪽최대 = 10;
43
45
 
44
46
  export const 설정자리 = (root) => join(root, '.deel', 'mcp.json');
45
47
 
@@ -81,7 +83,12 @@ export function 지문(설정) {
81
83
  /** 적어 둔 목록을 읽는다. 못 읽으면 빈 것으로 본다 — 그러면 그냥 띄운다. */
82
84
  export function 메모읽기(root) {
83
85
  try {
84
- const j = JSON.parse(readFileSync(메모자리(root), 'utf8'));
86
+ /*
87
+ * BOM 을 뗀다(safety/trust.js 의 BOM떼기). 못 읽으면 빈 것으로 보는 자리라
88
+ * 넘어져도 아무 말이 없다 — 대신 **매번 서버를 다시 띄워서** 지연 로딩이
89
+ * 조용히 꺼진다. 메모를 편집기나 스크립트로 한 번 만지면 앞에 U+FEFF 가 붙는다.
90
+ */
91
+ const j = JSON.parse(BOM떼기(readFileSync(메모자리(root), 'utf8')));
85
92
  return j?.servers && typeof j.servers === 'object' ? j.servers : {};
86
93
  } catch { return {}; }
87
94
  }
@@ -125,7 +132,16 @@ export function 메모쓰기(root, 서버들, { 남길이름 = null } = {}) {
125
132
  도구: s.도구,
126
133
  };
127
134
  }
128
- if (!Object.keys(servers).length) return false;
135
+ /*
136
+ * 남는 것이 하나도 없어도 **적힌 것이 있었으면 적는다.**
137
+ *
138
+ * 여기서 그냥 돌아섰다. 그런데 이 함수는 적기만 하는 것이 아니라 위에서 설정에서
139
+ * 빠진 서버를 **걷기도** 한다. 걷고 나서 아무것도 안 남는 판(설정의 서버를 다
140
+ * 빼거나, 남은 한 대가 도구 0개로 떴을 때)에서는 그 걷기가 통째로 취소돼서 지운
141
+ * 서버의 메모가 파일에 영영 남았다 — 다음 판에 지연 로딩이 그 이름으로 도구 목록을
142
+ * 도로 세운다. 처음부터 아무것도 안 적혀 있던 때만 안 적는다.
143
+ */
144
+ if (!Object.keys(servers).length && !Object.keys(있던것).length) return false;
129
145
  try {
130
146
  mkdirSync(join(root, '.deel'), { recursive: true });
131
147
  writeFileSync(메모자리(root), JSON.stringify({ version: 1, servers }, null, 2) + '\n', 'utf8');
@@ -149,10 +165,24 @@ export function 설정읽기(root) {
149
165
  const p = 설정자리(root);
150
166
  if (!existsSync(p)) return { 서버들: [], 자리: p, 있음: false };
151
167
  let j;
152
- try { j = JSON.parse(readFileSync(p, 'utf8')); } catch (e) {
168
+ // BOM 을 뗀다 — 파워셸 5.1 로 저장한 mcp.json 이 「못 읽었습니다」 가 되어 서버가 통째로 안 떴다.
169
+ // 설정·훅·정책은 이미 떼고 있었고 여기만 빠져 있었다(safety/trust.js 의 BOM떼기).
170
+ try { j = JSON.parse(BOM떼기(readFileSync(p, 'utf8'))); } catch (e) {
153
171
  return { 서버들: [], 자리: p, 있음: true, 오류: `mcp.json 을 못 읽었습니다: ${e.message}` };
154
172
  }
173
+ /*
174
+ * JSON 으로는 멀쩡해도 **표가 아닐** 수 있다 (6회차 Gemini 엠씨피 M5).
175
+ *
176
+ * `null` 은 위 parse 를 지나 `null.mcpServers` 에서 TypeError 로 던졌다 — 이 함수를 부르는
177
+ * 자리가 통째로 넘어진다. `"mcpServers": [ … ]` 는 배열 번호 `0` 이 서버 이름이 됐다.
178
+ * 깨진 JSON 과 같이 「못 읽었습니다」 로 까닭을 돌려준다.
179
+ */
180
+ const 표아님 = (v) => v === null || typeof v !== 'object' || Array.isArray(v);
181
+ if (표아님(j)) return { 서버들: [], 자리: p, 있음: true, 오류: 'mcp.json 을 못 읽었습니다: 맨 바깥이 { … } 표가 아닙니다' };
155
182
  const 표 = j.mcpServers ?? j.servers ?? {};
183
+ if (표아님(표)) {
184
+ return { 서버들: [], 자리: p, 있음: true, 오류: 'mcp.json 을 못 읽었습니다: mcpServers 는 { "이름": { "command": … } } 모양의 표여야 합니다' };
185
+ }
156
186
  const 서버들 = [];
157
187
  /*
158
188
  * ── 안 받는 항목도 **적어서 내놓는다** ────────────────────────────────
@@ -186,6 +216,21 @@ export function 설정읽기(root) {
186
216
  });
187
217
  continue;
188
218
  }
219
+ /*
220
+ * ── 도로 못 가르는 이름은 안 받는다 (사냥4 W13) ──────────────────────
221
+ *
222
+ * 모델에게는 `mcp__<서버>__<도구>` 로 보이고, 부르면 이름풀기 가 그 이름을 도로
223
+ * 가른다. 서버 이름에 `__` 가 들었거나 `_` 로 시작·끝나면 가르는 자리가 어긋난다 —
224
+ * `my__srv` 의 도구는 「my 서버가 붙어 있지 않습니다」, `_srv` 는 「MCP 도구 이름
225
+ * 꼴이 아닙니다」 가 됐다. 목록에는 멀쩡히 서고 부르면 없다고 하는, 제일 알아채기
226
+ * 어려운 꼴이다. 이름풀기 가 받는 꼴(밑줄 **하나로만** 이은 낱말)과 같은 잣대로 거른다.
227
+ * 이름풀기 를 느슨하게 고치는 대신 여기서 막는 까닭 — 도구 이름에도 `__` 가 올 수 있어
228
+ * (`a_b` 서버의 `c__d`) 어느 쪽 `__` 에서 갈라야 할지 이름만으로는 영영 모른다.
229
+ */
230
+ if (!/^[^_]+(?:_[^_]+)*$/.test(이름)) {
231
+ 못받은것.push({ 이름, 왜: '서버 이름에 `__` 가 들었거나 `_` 로 시작·끝납니다 — 모델에게 보이는 mcp__<서버>__<도구> 를 도로 가를 수 없어 부를 수 없습니다. 이름을 바꿔 주세요' });
232
+ continue;
233
+ }
189
234
  서버들.push({
190
235
  이름,
191
236
  command: String(v.command),
@@ -250,6 +295,8 @@ export class MCP서버 {
250
295
  this.정보 = null;
251
296
  this.죽음 = null; // 왜 죽었나 (사람에게 보여 줄 말)
252
297
  this.잘림 = 0; // 도구최대 를 넘어 자른 개수
298
+ this.넘침 = false; // 한 통이 줄최대 를 넘어 귀를 닫았나 (받음)
299
+ this.셸로띄움 = false; // 윈도우에서 cmd.exe 를 거쳐 띄웠나 (띄울모양 · 닫기)
253
300
  /*
254
301
  * 적어 둔 목록으로 서 있는 상태 — 아직 안 띄웠다.
255
302
  *
@@ -298,9 +345,16 @@ export class MCP서버 {
298
345
  * 도구가 왜 없어졌는지 아무도 설명 못 한다.
299
346
  */
300
347
  async 깨우기({ timeout = 붙기제한 } = {}) {
348
+ /*
349
+ * 깨우는 중인지를 **먼저** 본다 (6회차 Gemini 엠씨피 MB1).
350
+ *
351
+ * 붙기 첫 줄에서 아이는 이미 떠 있어서, 인사(initialize)에 답이 오기 전에도 `살아있나()` 는
352
+ * 참이다. 그걸 먼저 봤더니 함께 들어온 두 번째 부름이 곧장 true 를 받아 tools/call 을
353
+ * 인사보다 먼저 보냈다 — 모델이 도구를 한꺼번에 둘 부르면 늘 나는 꼴이다.
354
+ */
355
+ if (this.깨우는중) return this.깨우는중;
301
356
  if (this.살아있나()) return true;
302
357
  if (this.죽음) return false;
303
- if (this.깨우는중) return this.깨우는중;
304
358
  const 적어둔것 = this.도구.map((t) => t.name).join('\0');
305
359
  // 속까지 견주려면 정의를 그대로 들고 있어야 한다 (아래 바뀐것).
306
360
  const 적어둔도구 = this.도구;
@@ -310,7 +364,18 @@ export class MCP서버 {
310
364
  const ok = await this.붙기({ timeout });
311
365
  this.대기 = false;
312
366
  this.깨우는중 = null;
313
- if (!ok) return false;
367
+ if (!ok) {
368
+ /*
369
+ * 못 깨웠으면 **거둔다** (사냥4 W12).
370
+ *
371
+ * 켤 때 붙는 길(다붙이기)은 실패하면 닫기() 를 부르는데, 여기는 안 불렀다. 그래서
372
+ * initialize 에 끝내 답을 안 하는 서버는 「못 띄웠습니다」 로 끝난 **뒤에도** 살아
373
+ * 명부에 남았다. 죽음 이 서서 다시 깨울 일도 없으니, 세션 내내 아무도 안 쓰는
374
+ * 남의 프로세스 하나를 붙들고 있는 셈이다.
375
+ */
376
+ this.닫기();
377
+ return false;
378
+ }
314
379
  const 지금것 = this.도구.map((t) => t.name).join('\0');
315
380
  if (적어둔것 && 지금것 !== 적어둔것) {
316
381
  this.달라짐 = {
@@ -351,14 +416,19 @@ export class MCP서버 {
351
416
 
352
417
  async 붙기({ timeout = 붙기제한 } = {}) {
353
418
  try {
354
- this.kid = spawn(this.설정.command, this.설정.args, {
419
+ // 설정에 적힌 env 만 얹는다. 우리 환경변수를 통째로 넘기면
420
+ // 게이트웨이 열쇠(DEEL_*)까지 남의 프로세스로 넘어간다.
421
+ const 환경 = { ...깨끗한환경(), ...(this.설정.env ?? {}) };
422
+ // 윈도우의 .cmd · 확장자 없이 적은 `npx` 는 그대로는 못 띄운다 (띄울모양 머리말).
423
+ const 모양 = 띄울모양(this.설정.command, this.설정.args, 환경, this.설정.cwd);
424
+ this.셸로띄움 = 모양.셸;
425
+ this.kid = spawn(모양.파일, 모양.인자, {
355
426
  cwd: this.설정.cwd,
356
- // 설정에 적힌 env 만 얹는다. 우리 환경변수를 통째로 넘기면
357
- // 게이트웨이 열쇠(DEEL_*)까지 남의 프로세스로 넘어간다.
358
- env: { ...깨끗한환경(), ...(this.설정.env ?? {}) },
427
+ env: 환경,
359
428
  stdio: ['pipe', 'pipe', 'pipe'],
360
429
  windowsHide: true,
361
430
  shell: false,
431
+ ...모양.옵션,
362
432
  });
363
433
  } catch (e) {
364
434
  this.죽음 = `띄우지 못했습니다: ${e.message}`;
@@ -391,10 +461,48 @@ export class MCP서버 {
391
461
  }
392
462
 
393
463
  try {
394
- const r = await this.보내고기다리기('tools/list', {}, timeout);
395
- const 다 = Array.isArray(r?.tools) ? r.tools : [];
464
+ /*
465
+ * ── 목록이 여러 쪽으로 오는 서버 (사냥4 W10) ────────────────────────
466
+ *
467
+ * 규격상 tools/list 는 `nextCursor` 로 다음 쪽이 있다고 알린다. 첫 쪽만 받고
468
+ * 끝냈더니, 도구를 쪽으로 나눠 주는 서버의 뒷쪽 도구가 **조용히** 빠졌다 — 잘림 도
469
+ * 0 이라 /mcp 에도 「다 받았다」 로 보였다. 끝까지 따라가되, 같은 커서를 또 주거나
470
+ * 끝없이 주는 서버에 붙들리지 않게 쪽 수(목록쪽최대)와 시한을 같이 건다. 거기서
471
+ * 멈추면 뒤에 더 있다는 뜻으로 잘림 을 하나 더 센다 — 「다 받았다」 로 안 보이게.
472
+ */
473
+ const 다 = [];
474
+ const 본커서 = new Set();
475
+ const 마감 = Date.now() + timeout;
476
+ let 커서 = null;
477
+ let 덜받음 = false;
478
+ for (let 쪽 = 0; ; 쪽++) {
479
+ let r;
480
+ /*
481
+ * 쪽을 넘기다 탈이 나면 **받아 둔 것까지 버리지는 않는다.**
482
+ *
483
+ * 이 되풀이가 통째로 바깥 try 안에 있어서, 첫 쪽을 다 받아 놓고도 둘째 쪽에서
484
+ * 시한이 지나면 그 예외가 아래 catch 로 가 서버가 통째로 안 붙었다. 쪽을 나눠
485
+ * 주는 것은 대개 도구가 많은 큰 서버다 — 뒷쪽 하나가 늦다고 그 서버의 도구를
486
+ * 한 개도 안 쓰는 것은, 이 파일의 「하나가 안 떠도 나머지는 쓴다」 와 반대다.
487
+ * 받은 데까지 쓰고 뒤에 더 있다고 적는다(잘림). 첫 쪽부터 못 받았으면 받은 것이
488
+ * 없으니 여느 때처럼 통째로 실패다.
489
+ */
490
+ try {
491
+ r = await this.보내고기다리기('tools/list', 커서 ? { cursor: 커서 } : {}, Math.max(1000, 마감 - Date.now()));
492
+ } catch (e) {
493
+ if (!다.length) throw e;
494
+ 덜받음 = true;
495
+ break;
496
+ }
497
+ if (Array.isArray(r?.tools)) 다.push(...r.tools);
498
+ const 다음 = typeof r?.nextCursor === 'string' && r.nextCursor ? r.nextCursor : null;
499
+ if (!다음 || 본커서.has(다음)) break;
500
+ if (쪽 + 1 >= 목록쪽최대) { 덜받음 = true; break; }
501
+ 본커서.add(다음);
502
+ 커서 = 다음;
503
+ }
396
504
  this.도구 = 다.slice(0, 도구최대);
397
- this.잘림 = Math.max(0, 다.length - this.도구.length);
505
+ this.잘림 = Math.max(0, 다.length - this.도구.length) + (덜받음 ? 1 : 0);
398
506
  } catch (e) {
399
507
  this.끝냄(`도구 목록을 못 받았습니다: ${e.message}`);
400
508
  return false;
@@ -403,11 +511,9 @@ export class MCP서버 {
403
511
  }
404
512
 
405
513
  받음(덩이) {
514
+ // 넘친 뒤로는 받지 않는다 (아래 머리말).
515
+ if (this.넘침) return;
406
516
  this.찌꺼기 += 덩이;
407
- if (this.찌꺼기.length > 줄최대) {
408
- this.끝냄('한 통이 너무 큽니다 — 규격에 안 맞는 서버입니다');
409
- return;
410
- }
411
517
  let i = this.찌꺼기.indexOf('\n');
412
518
  while (i >= 0) {
413
519
  const 줄 = this.찌꺼기.slice(0, i).trim();
@@ -415,12 +521,44 @@ export class MCP서버 {
415
521
  if (줄) this.한통(줄);
416
522
  i = this.찌꺼기.indexOf('\n');
417
523
  }
524
+ /*
525
+ * ── 넘치면 **귀를 닫고 거둔다** (사냥4 W1) ─────────────────────────────
526
+ *
527
+ * 여기는 넘치면 끝냄() 만 부르고 돌아갔다. 그런데 귀(stdout 의 data)는 그대로 열려
528
+ * 있어서 다음 조각이 오면 또 이어 붙였고, 끝냄() 은 두 번째부터 아무것도 안 한다.
529
+ * 줄바꿈 없이 끝없이 쏟는 서버 하나에 버퍼가 몇백 MB 로 자라다 **RangeError 로
530
+ * deel 이 통째로 죽었다** — 남의 프로그램이 멋대로 굴어도 우리는 안 죽는다는 이
531
+ * 파일의 약속이 거기서 깨졌다. 넘친 서버는 쓸 수 없으니 버퍼를 비우고, 더 안 읽고,
532
+ * 프로세스까지 거둔다(닫기).
533
+ */
534
+ if (this.찌꺼기.length > 줄최대) {
535
+ this.넘침 = true;
536
+ this.찌꺼기 = '';
537
+ this.끝냄('한 통이 너무 큽니다 — 규격에 안 맞는 서버입니다');
538
+ try { this.kid?.stdout?.destroy(); } catch { /* 이미 닫혔으면 그만 */ }
539
+ this.닫기();
540
+ }
418
541
  }
419
542
 
420
543
  한통(줄) {
421
544
  let j;
422
545
  try { j = JSON.parse(줄); } catch { return; } // 규격 밖의 잡소리는 버린다
423
- if (j.id == null) return; // 알림은 아직 안 쓴다
546
+ // `null` · 숫자 한 줄도 JSON 이다. 거기서 j.id 를 읽으면 받는 귀에서 던져 deel 이 죽는다.
547
+ if (!j || typeof j !== 'object') return;
548
+ /*
549
+ * ── 답인지 물음인지는 **method 로** 가른다 (사냥4 W5) ──────────────────
550
+ *
551
+ * JSON-RPC 의 번호는 **양쪽이 따로** 센다. 서버가 우리에게 ping 을 물으면서 제 번호
552
+ * 1 을 쓰면, 그 1 은 우리가 보낸 tools/call 의 1 과 겹친다. 번호만 보고 갈랐더니
553
+ * 서버의 ping 이 우리 물음의 답으로 먹혀 **빈 결과**가 모델에게 갔고, ping 에는 아무도
554
+ * 답을 안 해서 서버는 진짜 답을 끝내 안 줬다. 답에는 method 가 없고 물음에는 있다 —
555
+ * 언어 서버 쪽(lsp/client.js)이 이미 이렇게 가른다.
556
+ */
557
+ if (j.method !== undefined) {
558
+ if (j.id != null) this.물음에답(j);
559
+ return; // 알림은 아직 안 쓴다
560
+ }
561
+ if (j.id == null) return;
424
562
  const 기다림 = this.기다리는것.get(j.id);
425
563
  if (!기다림) return;
426
564
  this.기다리는것.delete(j.id);
@@ -444,7 +582,14 @@ export class MCP서버 {
444
582
  */
445
583
  보내고기다리기(method, params, timeout = 부르기제한, signal = null) {
446
584
  return new Promise((성공, 실패) => {
447
- if (!this.kid || this.kid.exitCode !== null) return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
585
+ /*
586
+ * 죽은 것은 세 가지로 알아본다 (6회차 Gemini 엠씨피 MB2). 시그널로 죽은 아이는 exitCode 가
587
+ * null 이라 그것만 보면 지나간다 — 죽은 관에 써 놓고, 아래 시한은 unref 라 아무것도 안 붙든
588
+ * 판에서는 영영 안 풀렸다(검사 프로세스가 「안 끝난 await」 로 끝났다). 끝냄 이 적은 죽음 도 본다.
589
+ */
590
+ if (!this.kid || this.kid.exitCode !== null || this.kid.signalCode !== null || this.죽음) {
591
+ return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
592
+ }
448
593
  // 이미 멈췄으면 보내지도 않는다. 보내 놓고 버리면 남의 서버는 그 일을 끝까지 한다.
449
594
  if (signal?.aborted) return 실패(new Error('중단했습니다'));
450
595
  const id = this.다음번호++;
@@ -484,6 +629,19 @@ export class MCP서버 {
484
629
  try { this.kid?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
485
630
  }
486
631
 
632
+ /**
633
+ * 서버가 **우리에게** 묻는 것에 답한다. ping 은 받고, 나머지는 「안 받습니다」 로.
634
+ *
635
+ * 말없이 두면 서버는 그 답을 기다리느라 우리 물음에 답을 안 할 수 있다. 우리는
636
+ * roots·sampling 같은 능력을 안 댔으니(initialize 의 capabilities) 규격대로 -32601 이다.
637
+ */
638
+ 물음에답(j) {
639
+ const 답 = j.method === 'ping'
640
+ ? { jsonrpc: '2.0', id: j.id, result: {} }
641
+ : { jsonrpc: '2.0', id: j.id, error: { code: -32601, message: `deel 은 ${String(j.method).slice(0, 80)} 을(를) 받지 않습니다` } };
642
+ try { this.kid?.stdin?.write(JSON.stringify(답) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
643
+ }
644
+
487
645
  async 부르기(도구이름, args, { timeout = 부르기제한, signal = null } = {}) {
488
646
  /*
489
647
  * 대기 중이면 **여기서** 띄운다. 이게 지연 로딩의 전부다.
@@ -509,10 +667,28 @@ export class MCP서버 {
509
667
  const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout, signal);
510
668
  // 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
511
669
  const 조각 = Array.isArray(r?.content) ? r.content : [];
670
+ /*
671
+ * ── 글이 들어 있는데 한 낱말로 줄이지 않는다 (사냥4 W11) ─────────────────
672
+ *
673
+ * text 가 아닌 조각은 전부 `[갈래]` 로 줄였다. 그런데 박힌 자원(resource)은 제
674
+ * 글(resource.text)을 **통째로** 들고 온다 — 파일 읽기·문서 조회 서버가 흔히 이렇게
675
+ * 준다. 그걸 「[resource]」 한 낱말로 바꿔 모델에게 줬으니, 모델은 받은 것이 없는
676
+ * 줄 알고 같은 도구를 또 불렀다. 그리고 결과를 structuredContent 로만 주는 서버는
677
+ * 빈 글이 됐다(「빈 답」 으로 찍혔다). 둘 다 들어 있는 것을 싣는다.
678
+ */
512
679
  const 글 = 조각
513
- .map((p) => (p?.type === 'text' ? p.text : p?.type ? `[${p.type}]` : ''))
680
+ .map((p) => {
681
+ if (p?.type === 'text') return p.text;
682
+ if (p?.type === 'resource') {
683
+ return typeof p.resource?.text === 'string' ? p.resource.text : `[resource ${p.resource?.uri ?? ''}]`;
684
+ }
685
+ if (p?.type === 'resource_link') return `[resource_link ${p.uri ?? ''}]`;
686
+ return p?.type ? `[${p.type}]` : '';
687
+ })
514
688
  .filter(Boolean).join('\n');
515
- return { text: 글, isError: r?.isError === true };
689
+ // 규격은 structuredContent 를 준 서버에게 같은 것을 text 로도 주라고 **권할** 뿐이다.
690
+ const 구조 = !글 && r?.structuredContent != null ? JSON.stringify(r.structuredContent) : '';
691
+ return { text: 글 || 구조, isError: r?.isError === true };
516
692
  }
517
693
 
518
694
  끝냄(왜) {
@@ -531,7 +707,23 @@ export class MCP서버 {
531
707
  띄운것들.delete(this);
532
708
  try {
533
709
  this.kid?.stdin?.end();
534
- this.kid?.kill();
710
+ /*
711
+ * cmd.exe 를 거쳐 띄운 것(.cmd · npx)은 **나무째** 거둔다 (사냥4 W7 과 같이).
712
+ * kill() 은 cmd.exe 하나만 죽이고 그 밑의 진짜 서버(node …)는 살아남는다 — 도는
713
+ * 동안 닫은 서버가 작업 관리자에 하나씩 쌓인다. taskkill /T 는 부모가 살아 있어야
714
+ * 나무를 따라가므로 kill() 은 그 뒤에 한다. (프로그램이 끝날 때는 libuv 의 job 이
715
+ * 나무째 거둔다 — 이건 도는 동안의 몫이다.)
716
+ */
717
+ const 아이 = this.kid;
718
+ if (this.셸로띄움 && process.platform === 'win32' && 아이?.pid && 아이.exitCode === null) {
719
+ const 뒤에죽이기 = () => { try { 아이.kill(); } catch { /* 이미 죽었다 */ } };
720
+ const 나무 = spawn('taskkill', ['/pid', String(아이.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
721
+ 나무.once('error', 뒤에죽이기);
722
+ 나무.once('exit', 뒤에죽이기);
723
+ 나무.unref();
724
+ } else {
725
+ this.kid?.kill();
726
+ }
535
727
  // 자식이 살아 있으면 우리 프로그램이 안 끝난다.
536
728
  this.kid?.unref?.();
537
729
  } catch { /* 이미 죽었다 */ }
@@ -552,6 +744,123 @@ export function 깨끗한환경() {
552
744
  return out;
553
745
  }
554
746
 
747
+ /*
748
+ * ── 윈도우에서 무엇을 어떻게 띄우나 (사냥4 W7) ─────────────────────────
749
+ *
750
+ * 복사해 붙이는 mcp.json 의 절반이 `"command": "npx"` 다. 그런데 윈도우에서 둘 다 안 떴다.
751
+ *
752
+ * `npx` 셸 없이 띄우면 PATHEXT 를 안 본다 → `npx.cmd` 를 못 찾아 ENOENT
753
+ * `C:\…\x.cmd` 노드는 셸 없이 .cmd·.bat 를 안 띄운다(CVE-2024-27980 뒤로) → EINVAL
754
+ *
755
+ * 화면에는 「띄우지 못했습니다: spawn EINVAL」 한 줄뿐이라, 사람은 명령이 틀린 줄 안다.
756
+ *
757
+ * `shell: true` 로 넘기면 되긴 한다. 그런데 그러면 인자가 **안 감싸진 채** 이어 붙어서
758
+ * (노드가 DEP0190 으로 경고하는 그 자리) 빈칸·`&`·`%` 가 든 경로가 깨지고, 인자 하나로
759
+ * cmd.exe 명령을 이어 붙일 수 있게 된다. 그래서 언어 서버 쪽(lsp/client.js)처럼 **.cmd ·
760
+ * .bat 만** cmd.exe 로 직접 부르되, 인자는 cmd.exe 가 다시 해석해도 뜻이 안 바뀌게 감싼다
761
+ * (cross-spawn 이 오래 다듬어 온 규칙 그대로다 — 따옴표·역슬래시를 먼저 겹치고, 통째로
762
+ * 따옴표로 두른 뒤 cmd 특수 글자에 ^ 를 단다. npm 이 만든 node_modules\.bin 의 .cmd 는
763
+ * 안에서 한 번 더 해석하므로 ^ 를 두 겹 단다). 언어 서버 쪽은 인자가 우리 표에서 오지만
764
+ * 여기는 사람이 적은 설정에서 오므로 더 촘촘히 감싼다.
765
+ *
766
+ * 이름만 적은 명령은 **PATH 에서 우리가 찾아** 전체 경로로 넘긴다. cmd.exe 에게 찾게 두면
767
+ * 지금 폴더(cwd — 남의 저장소일 수 있다)를 PATH 보다 먼저 뒤진다.
768
+ */
769
+ const cmd특수 = /([()\][%!^"`<>&|;, *?])/g;
770
+
771
+ /** @returns {{ 파일: string, 인자: string[], 옵션: object, 셸: boolean }} */
772
+ export function 띄울모양(command, args = [], env = process.env, cwd = process.cwd(), platform = process.platform) {
773
+ const 인자 = (args ?? []).map(String);
774
+ if (platform !== 'win32') return { 파일: command, 인자, 옵션: {}, 셸: false };
775
+ const 찾은것 = 윈도우명령찾기(String(command), env ?? {}, cwd) ?? String(command);
776
+ if (!/\.(cmd|bat)$/i.test(찾은것)) return { 파일: 찾은것, 인자, 옵션: {}, 셸: false };
777
+ /*
778
+ * ── 줄바꿈 든 인자는 cmd.exe 로 못 넘긴다 (Gemini 웹4) ─────────────────────
779
+ *
780
+ * cmd.exe 는 명령줄을 줄바꿈에서 끊는다. 재어 보니 `["a<LF>b", "after"]` 가 `["a"]` 하나로 왔다 —
781
+ * 뒤 인자가 **말없이 통째로** 사라지고, CR 은 그냥 지워진다. 감쌀 길이 없다. 틀린 인자로 서버를
782
+ * 띄워 엉뚱하게 도는 것보다, 안 띄우고 까닭을 말하는 편이 낫다(붙기 가 죽음 으로 보여 준다).
783
+ */
784
+ const 줄바꿈자리 = 인자.findIndex((a) => /[\r\n]/.test(a));
785
+ if (줄바꿈자리 !== -1) {
786
+ throw new Error(`${줄바꿈자리 + 1}번째 인자에 줄바꿈이 있어 .cmd·.bat 로는 그대로 넘길 수 없습니다 — cmd.exe 가 그 자리에서 명령줄을 끊습니다`);
787
+ }
788
+ const 두겹 = 배치가다시읽나(찾은것);
789
+ const 줄 = [normalize(찾은것).replace(cmd특수, '^$1'), ...인자.map((a) => cmd인자감싸기(a, 두겹))].join(' ');
790
+ return {
791
+ 파일: process.env.ComSpec || 'cmd.exe',
792
+ // /s /c 는 바깥 따옴표 한 쌍을 떼고 나머지를 그대로 돌린다 — 그래서 한 겹 두른다.
793
+ 인자: ['/d', '/s', '/c', `"${줄}"`],
794
+ 옵션: { windowsVerbatimArguments: true },
795
+ 셸: true,
796
+ };
797
+ }
798
+
799
+ /*
800
+ * ── ^ 를 두 겹 다는 배치 (Gemini 웹4) ─────────────────────────────────────
801
+ *
802
+ * `%*` 로 받은 인자를 넘기는 배치는 그 줄을 **한 번 더** 해석한다. cross-spawn 을 따라
803
+ * node_modules\.bin 의 .cmd 만 두 겹으로 봤는데, 전역 npx.cmd(Node 설치 폴더)·npm 전역 설치
804
+ * 쉼(%APPDATA%\npm)·pnpm 쉼도 똑같이 `%*` 로 넘긴다 — `"command": "npx"` 가 가는 곳이 바로 거기다.
805
+ *
806
+ * 한 겹이면 cmd 가 모르는 `\"` 가 든 인자 하나가 따옴표 상태를 뒤집어, **그 뒤 인자의 & 가 명령으로
807
+ * 돌았다**(재어 봄: `["a\"b", "p & echo made>MARK.txt"]` → MARK.txt 가 생겼다) · 뒤 인자의 ^ 는
808
+ * 사라졌다. 거꾸로 `%~1` 로 받는 배치는 두 겹이면 ^ 가 글자로 남아 인자가 전부 틀린다.
809
+ *
810
+ * 그래서 배치 글을 보고 가른다 — `%*` 가 있으면 두 겹, 없으면 한 겹. 못 읽거나 지나치게 크면
811
+ * 예전 규칙(node_modules\.bin)으로 간다. `CALL x.cmd %*` 처럼 한 번 더 넘기는 배치는 cmd 가 ^ 를
812
+ * 다시 겹치고 % 를 또 풀어서 어느 쪽으로도 못 맞춘다 — 그런 쉼은 드물다.
813
+ */
814
+ function 배치가다시읽나(파일) {
815
+ if (/node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(파일)) return true;
816
+ try {
817
+ if (statSync(파일).size > 1024 * 1024) return false;
818
+ // REM · :: 줄은 뺀다 — 쓰는 법을 적은 주석의 %* 에 속아 %~1 배치에 두 겹을 달았다(Gemini 화면5).
819
+ return readFileSync(파일, 'latin1').split(/\r?\n/)
820
+ .some((줄) => !/^\s*@?\s*(?:rem(?:\s|$)|::)/i.test(줄) && 줄.includes('%*'));
821
+ } catch {
822
+ return false;
823
+ }
824
+ }
825
+
826
+ function cmd인자감싸기(a, 두겹) {
827
+ const 겹친 = String(a)
828
+ .replace(/(\\*)"/g, '$1$1\\"') // 따옴표 앞 역슬래시는 두 배로, 따옴표는 \" 로
829
+ .replace(/(\\*)$/, '$1$1'); // 끝 역슬래시는 두 배로 — 곧 붙일 닫는 따옴표를 안 먹게
830
+ const 한겹 = `"${겹친}"`.replace(cmd특수, '^$1');
831
+ return 두겹 ? 한겹.replace(cmd특수, '^$1') : 한겹;
832
+ }
833
+
834
+ /** 윈도우가 실제로 돌릴 파일을 찾는다. 확장자 붙은 것을 먼저 본다 (lsp/servers.js 의 어디있나 와 같은 순서). */
835
+ function 윈도우명령찾기(명령, env, cwd) {
836
+ /*
837
+ * 빈 PATHEXT 는 **안 적은 것과 같이** 본다 (lsp/servers.js 의 어디있나 와 같은 자리).
838
+ *
839
+ * `??` 는 빈 글을 안 막는다. `PATHEXT=` 로 비워 둔 판(또는 `;;` 만 든 판)에서는 이
840
+ * 목록이 통째로 비고, 그러면 아래 붙여보기 의 되풀이가 **한 번도 안 돌아** 무엇을
841
+ * 물어도 null 이었다 — `npx` 는 못 찾아 ENOENT 로 안 뜨고, `x.cmd` 처럼 확장자까지
842
+ * 적은 완전한 이름조차 못 찾아(`이미붙음` 도 빈 목록에서는 거짓이다) 전체 경로 대신
843
+ * 이름만 cmd.exe 로 넘어간다. 그러면 위 머리말이 막으려던 자리로 되돌아간다 —
844
+ * cmd.exe 는 PATH 보다 지금 폴더(남의 저장소일 수 있다)를 먼저 뒤진다.
845
+ */
846
+ const 기본확장 = '.COM;.EXE;.BAT;.CMD';
847
+ const 적힌것 = String(env.PATHEXT ?? process.env.PATHEXT ?? 기본확장).split(';').filter(Boolean);
848
+ const 확장들 = 적힌것.length ? 적힌것 : 기본확장.split(';');
849
+ const 파일인가 = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
850
+ const 이미붙음 = 확장들.some((e) => 명령.toLowerCase().endsWith(e.toLowerCase()));
851
+ const 붙여보기 = (밑) => {
852
+ if (이미붙음 && 파일인가(밑)) return 밑;
853
+ for (const e of 확장들) if (파일인가(밑 + e)) return 밑 + e;
854
+ return null;
855
+ };
856
+ if (/[\\/]/.test(명령) || isAbsolute(명령)) return 붙여보기(resolve(cwd ?? process.cwd(), 명령));
857
+ for (const 길 of String(env.PATH ?? env.Path ?? '').split(';').filter(Boolean)) {
858
+ const 찾음 = 붙여보기(join(길.replace(/^"|"$/g, ''), 명령));
859
+ if (찾음) return 찾음;
860
+ }
861
+ return null;
862
+ }
863
+
555
864
  /*
556
865
  * Bash·Jobs 가 자식에게 넘길 환경은 **여기 없다** — safety/shellenv.js 다.
557
866
  *
@@ -46,8 +46,17 @@ export const 낡는날 = 180;
46
46
  * 대신 숫자가 아닌 것·음수는 받지 않고, 왜 못 받았는지 말로 남긴다.
47
47
  */
48
48
  function 값읽기(x) {
49
- if (x == null || x === '') return null;
50
- const v = typeof x === 'string' ? Number(x.trim()) : Number(x);
49
+ /*
50
+ * 숫자와 숫자를 적은 글자만 받는다 (6회차 요금6be-a PR1·PR2).
51
+ *
52
+ * 형을 안 가리고 Number() 에 넘기던 때는 빈칸만 든 글자 " " · false · [] 가 0(공짜)이 되고,
53
+ * true 는 1, [5] 는 5 가 됐다 — 설정에 잘못 적은 한 칸이 **그럴듯한 금액**으로 찍혔다.
54
+ * 빈 글자 검사도 trim 앞이라 빈칸은 걸러지지 않았다.
55
+ */
56
+ let v;
57
+ if (typeof x === 'number') v = x;
58
+ else if (typeof x === 'string' && x.trim()) v = Number(x.trim());
59
+ else return null; // 불리언·배열·빈칸 글자는 금액이 아니다
51
60
  if (!Number.isFinite(v) || v < 0) return null;
52
61
  return v;
53
62
  }
@@ -66,7 +75,7 @@ function 걸리나(열쇠, 모델) {
66
75
  return { 맞나: 무늬.test(모델), 딱: false };
67
76
  }
68
77
 
69
- function 한칸읽기(열쇠, 값, 어디서) {
78
+ function 한칸읽기(열쇠, 값, 어디서, 탈들 = []) {
70
79
  const 입 = 값읽기(값?.입력 ?? 값?.in ?? 값?.input);
71
80
  const 출 = 값읽기(값?.출력 ?? 값?.out ?? 값?.output);
72
81
  if (입 == null || 출 == null) {
@@ -81,12 +90,31 @@ function 한칸읽기(열쇠, 값, 어디서) {
81
90
  * 화면에서 사라진다 — 고쳐 놨는데 고친 표가 안 나는 셈이다.
82
91
  *
83
92
  * 그렇다고 배수를 여기 박지는 않는다. 이 파일이 요금을 안 박는 것과
84
- * 같은 까닭이다(머리말). 모르면 정가로 세고 **모른다고 말한다** —
85
- * 그 금액은 실제보다 **크다**. 큰 쪽으로 틀리는 것은 사람이 놀라고
86
- * 끝나지만, 작은 쪽으로 틀리면 예산을 그 숫자로 잡는다.
93
+ * 같은 까닭이다(머리말). 모르면 정가로 세고 **모른다고 말한다.**
94
+ *
95
+ * 여기가 「그 금액은 실제보다 **크다**」 고 단정하고 있었는데, 그건 **읽기 쪽만**
96
+ * 맞는 말이다 (제미니 2차 눈). 캐시 읽기는 정가의 한 자릿수 퍼센트라 정가로 세면
97
+ * 부풀지만, 캐시 **쓰기**는 Anthropic 기준 정가의 1.25배라 정가로 세면 오히려
98
+ * **작게** 나온다. 어느 쪽으로 틀리는지 모르니 틀린 방향을 약속하지 않는다 —
99
+ * 대신 **쓴 쪽마다** 모른다고 말한다 (아래 돈셈 의 읽기모름·쓰기모름).
100
+ * 그래서 사람이 그 숫자를 예산으로 잡기 전에 `?` 를 먼저 본다.
101
+ */
102
+ /*
103
+ * 적어 뒀는데 못 읽은 캐시 칸은 **말해 준다** (위 값읽기 와 같은 까닭).
104
+ *
105
+ * 입력·출력은 못 읽으면 위에서 탈을 남기는데 캐시 칸은 조용히 null 이 됐다. 그러면
106
+ * 그 몫이 정가로 물려 금액이 실제보다 커지는데, 화면에는 `?` 하나만 붙고 **왜** 그런지는
107
+ * 아무 데도 없다. 오타 하나로 금액이 몇 배가 되는데 사람이 볼 실마리가 없는 셈이다.
108
+ * 값을 버리는 것은 그대로다 — 잘못 적은 값으로 셈하는 것이 더 나쁘다.
87
109
  */
88
- const 캐시읽기 = 값읽기(값?.캐시읽기 ?? 값?.cacheRead ?? 값?.cache_read);
89
- const 캐시쓰기 = 값읽기(값?.캐시쓰기 ?? 값?.cacheWrite ?? 값?.cache_write);
110
+ const 캐시칸 = (적힌, 이름) => {
111
+ if (적힌 == null) return null; // 안 적은 것은 잘못 적은 것이 아니다
112
+ const v = 값읽기(적힌);
113
+ if (v === null) 탈들.push(`${어디서} 의 '${열쇠}' ${이름} 요금이 숫자가 아닙니다 — 그 칸은 없는 것으로 치고 정가로 셉니다`);
114
+ return v;
115
+ };
116
+ const 캐시읽기 = 캐시칸(값?.캐시읽기 ?? 값?.cacheRead ?? 값?.cache_read, '캐시읽기');
117
+ const 캐시쓰기 = 캐시칸(값?.캐시쓰기 ?? 값?.cacheWrite ?? 값?.cache_write, '캐시쓰기');
90
118
  const 기준 = 값?.기준 ?? 값?.asOf ?? null;
91
119
  return {
92
120
  입력: 입, 출력: 출, 캐시읽기, 캐시쓰기,
@@ -116,7 +144,7 @@ export function 요금찾기(모델, { 설정 = null, 제공자 = null, 이제 =
116
144
  for (const [열쇠, 값] of Object.entries(표)) {
117
145
  const g = 걸리나(열쇠, 이름);
118
146
  if (!g.맞나 || !!g.딱 !== 딱먼저) continue;
119
- const 읽은 = 한칸읽기(열쇠, 값, 어디서);
147
+ const 읽은 = 한칸읽기(열쇠, 값, 어디서, 탈들);
120
148
  if (읽은.탈) { 탈들.push(읽은.탈); continue; }
121
149
  const 기준 = 읽은.기준 ?? (어디서 === '딸려 온 표' ? 제공자?.요금기준 ?? null : null);
122
150
  return { ...읽은, 기준, 낡았나: 낡았나(기준, 이제), 탈들 };
@@ -181,7 +209,15 @@ export function 돈셈({ in: 입 = 0, out: 출 = 0, prompt: 보냄 = 0, cacheRea
181
209
  const 총계 = n(보냄) || n(입);
182
210
  const 정가몫 = Math.max(0, 총계 - 읽 - 썼);
183
211
 
184
- const 캐시값있나 = 값.캐시읽기 != null || 값.캐시쓰기 != null;
212
+ /*
213
+ * 모르는 것은 **쪽마다** 센다.
214
+ *
215
+ * 여기가 「캐시 값이 하나라도 있나」 였다. 그래서 캐시읽기만 적고 캐시쓰기를 안 적은
216
+ * 표에서, 캐시 쓰기 몫을 정가로 물려 놓고 `캐시모름:false` — 즉 「이 금액은 정확합니다」 —
217
+ * 라고 적었다. 상태줄의 `?` 도 안 붙는다. 실제로 쓴 쪽의 값을 모르면 모른다고 해야 한다.
218
+ */
219
+ const 읽기모름 = 읽 > 0 && 값.캐시읽기 == null;
220
+ const 쓰기모름 = 썼 > 0 && 값.캐시쓰기 == null;
185
221
  const 읽기값 = 값.캐시읽기 ?? 값.입력;
186
222
  const 쓰기값 = 값.캐시쓰기 ?? 값.입력;
187
223
 
@@ -193,8 +229,9 @@ export function 돈셈({ in: 입 = 0, out: 출 = 0, prompt: 보냄 = 0, cacheRea
193
229
  입력: 정가돈,
194
230
  출력: 출돈,
195
231
  캐시: 캐시돈,
196
- // 캐시를 쓴 적이 있는데 그 값을 모르면, 이 금액은 실제보다 **크다**.
197
- 캐시모름: (읽 + 썼) > 0 && !캐시값있나,
232
+ // 캐시를 쓴 적이 있는데 그 값을 모르면, 이 금액은 **어느 쪽으로든** 틀린다
233
+ // (읽기는 크게·쓰기는 작게 — 위 한칸읽기 머리말). 그래서 모른다고 말한다.
234
+ 캐시모름: 읽기모름 || 쓰기모름,
198
235
  };
199
236
  }
200
237
 
@@ -206,7 +243,18 @@ export function 돈셈({ in: 입 = 0, out: 출 = 0, prompt: 보냄 = 0, cacheRea
206
243
  * 돌린다. 자릿수가 안 되면 반올림하지 말고 미만이라고 적는다.
207
244
  */
208
245
  export function 돈말(달러) {
209
- const v = Number(달러);
246
+ /*
247
+ * 형을 가린다 — `Number()` 는 **금액이 아닌 것들을 0 으로 받는다** (위 값읽기 와 같은 까닭).
248
+ *
249
+ * `Number(null)` · `Number(false)` · `Number('')` · `Number([])` 가 전부 0 이고, 여기서
250
+ * 0 은 `$0` 으로 찍힌다 — 화면에서 「공짜」 라는 뜻이다. 셈이 한 자리 어긋나 null 이
251
+ * 흘러 들어오면, 화면은 요금을 모르는 것이 아니라 **공짜라고** 말한다. 값읽기 는 바로
252
+ * 그 까닭으로 불리언·빈 글자를 막아 두었는데 이 함수만 안 막고 있었다.
253
+ */
254
+ let v;
255
+ if (typeof 달러 === 'number') v = 달러;
256
+ else if (typeof 달러 === 'string' && 달러.trim()) v = Number(달러.trim());
257
+ else return null;
210
258
  if (!Number.isFinite(v) || v < 0) return null;
211
259
  if (v === 0) return '$0';
212
260
  if (v < 0.0001) return '<$0.0001';