deel-local-cli 1.20.5 → 1.20.7

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.ko.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-9%2C315%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-9%2C491%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -301,7 +301,7 @@ deel --offline
301
301
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
302
302
 
303
303
  ```
304
- deel 1.20.5 ⌂ 이 안
304
+ deel 1.20.7 ⌂ 이 안
305
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
306
306
  ```
307
307
 
@@ -1289,6 +1289,8 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1289
1289
 
1290
1290
  | 판 | 무엇이 바뀌었나 |
1291
1291
  |---|---|
1292
+ | [1.20.7](docs/ko/releases/1.20.md#1207) | 서버가 말해 준 것과 반대를 배우고 있었습니다 |
1293
+ | [1.20.6](docs/ko/releases/1.20.md#1206) | 2차 리뷰가 판을 조용히 잃고 있었습니다 |
1292
1294
  | [1.20.5](docs/ko/releases/1.20.md#1205) | `aws_access_token` 이 안 잡히고 있었습니다 |
1293
1295
  | [1.20.4](docs/ko/releases/1.20.md#1204) | 붙임표 마디를 하나만 세고 있었습니다 |
1294
1296
  | [1.20.3](docs/ko/releases/1.20.md#1203) | 울타리를 옮긴 자리가 어디까지 흔들렸는지 한 판에 보이게 했습니다 |
package/README.md CHANGED
@@ -20,7 +20,7 @@ Vendor APIs connect too — **only when you say so**
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-9%2C315%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-9%2C491%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -374,7 +374,7 @@ deel --offline
374
374
  The destination is printed at the top of every session:
375
375
 
376
376
  ```
377
- deel 1.20.5 ⌂ inside
377
+ deel 1.20.7 ⌂ inside
378
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
379
379
  ```
380
380
 
@@ -1350,6 +1350,8 @@ so one run tells you everything.
1350
1350
 
1351
1351
  | Version | What changed |
1352
1352
  |---|---|
1353
+ | [1.20.7](docs/en/releases/1.20.md#1207) | We were learning the opposite of what the server said |
1354
+ | [1.20.6](docs/en/releases/1.20.md#1206) | The second review was losing rounds silently |
1353
1355
  | [1.20.5](docs/en/releases/1.20.md#1205) | `aws_access_token` was not being caught |
1354
1356
  | [1.20.4](docs/en/releases/1.20.md#1204) | Only one hyphen segment was being counted |
1355
1357
  | [1.20.3](docs/en/releases/1.20.md#1203) | How far a moved fence shook is now visible in one run |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.20.5",
3
+ "version": "1.20.7",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,6 +55,7 @@
55
55
  "coverage": "node test/coverage.mjs",
56
56
  "mutate": "node tools/mutate.mjs",
57
57
  "review2": "node tools/review2.mjs",
58
+ "review2눈금": "node tools/리뷰눈금.mjs",
58
59
  "prepublishOnly": "npm run check && npm test"
59
60
  },
60
61
  "dependencies": {},
@@ -115,6 +115,8 @@ const 되돌림상태 = new Set([301, 302, 303, 307, 308]);
115
115
  * 되돌림(다음URL) — 되돌림을 따라가기 **전에** 부른다. 던지면 안 따라간다.
116
116
  * 부르는 쪽이 제 규칙(사내망 거절 등)을 여기서 건다.
117
117
  * @returns 흘려 받기면 { ok, status, headers, res: { body(getReader), headers, text() }, ms }
118
+ * — 다만 **실패한 응답에는 body 가 없다** (읽을 것이 흐름이 아니라 거절 글이다).
119
+ * 부르는 쪽은 `ok` 를 먼저 보고 body 를 만져야 한다 (adapter 의 거절읽기 참고).
118
120
  * 아니면 { ok, status, headers, bytes, text, json, ms }
119
121
  * 프록시가 407 로 막으면 { ok:false, status:407, error } (몸은 없다)
120
122
  */
@@ -617,13 +619,28 @@ function 터널(hostname, port, 프록시, sig, 왜끊겼나, 인증서 = null)
617
619
  host: 프록시.host, port: 프록시.port, agent: false,
618
620
  method: 'CONNECT', path: `${hostname}:${port}`, headers: 머리,
619
621
  });
620
- const 끊기 = () => rq.destroy(왜끊겼나());
622
+ /*
623
+ * 중단이 닿아야 할 자리가 판마다 다르다.
624
+ *
625
+ * · CONNECT 를 기다리는 동안 → 요청
626
+ * · 그 위에 TLS 를 올리는 동안 → 터널 소켓
627
+ *
628
+ * 예전에는 CONNECT 답을 받은 **그 자리에서** 귀를 떼 버렸다. 그래서 200 만
629
+ * 주고 입을 다무는 프록시를 만나면(뒷단이 죽은 프록시·들여다보는 프록시)
630
+ * 중단도 시계도 그 소켓에 안 닿았다. 부르는 쪽은 제때 깨지는데 소켓이 남아
631
+ * — 답을 찍고도 프로세스가 안 끝나는 모양이 됐다(아래 close 머리말과 같은 탈).
632
+ * 귀는 그대로 두고 **끊을 자리만 바꿔** 쥔다.
633
+ */
634
+ let 끊을것 = () => rq.destroy(왜끊겼나());
635
+ const 끊기 = () => 끊을것();
621
636
  sig.addEventListener('abort', 끊기, { once: true });
622
637
  const 정리 = () => sig.removeEventListener('abort', 끊기);
623
638
 
624
639
  rq.once('connect', (res, socket, head) => {
625
- 정리();
640
+ // 끊긴 뒤에 답이 도착한 판 — 소켓을 받아 놓고 아무도 안 쥐면 그대로 남는다.
641
+ if (sig.aborted) { 정리(); socket.destroy(); return reject(왜끊겼나()); }
626
642
  if (res.statusCode !== 200) {
643
+ 정리();
627
644
  socket.destroy();
628
645
  const e = new Error(res.statusCode === 407
629
646
  ? 인증말(res.headers['proxy-authenticate'], 프록시)
@@ -636,8 +653,10 @@ function 터널(hostname, port, 프록시, sig, 왜끊겼나, 인증서 = null)
636
653
  const 이름 = /^[\d.]+$|:/.test(hostname) ? undefined : hostname;
637
654
  // 우리 인증서도 이 위에서 낸다 — 프록시 뒤에서 mTLS 가 안 되면
638
655
  // 사내(프록시 필수 + 인증서 필수)에서는 붙는 방법이 아예 없다.
639
- const tls = tlsConnect({ socket, host: hostname, servername: 이름, ...(인증서 ?? {}) }, () => resolve(tls));
640
- tls.once('error', reject); // 대상 인증서 문제 프록시 탓이 아니라 표시를 안 붙인다
656
+ const tls = tlsConnect({ socket, host: hostname, servername: 이름, ...(인증서 ?? {}) }, () => { 정리(); resolve(tls); });
657
+ // 이제부터 중단은 **이 소켓**을 끊어야 한다 (위 머리말).
658
+ 끊을것 = () => { const e = 왜끊겼나(); tls.destroy(e); socket.destroy(); reject(e); };
659
+ tls.once('error', (e) => { 정리(); reject(e); }); // 대상 인증서 문제 — 프록시 탓이 아니라 표시를 안 붙인다
641
660
  // TLS 층이 닫혀도 밑의 프록시 소켓은 저절로 안 닫힌다. 그대로 두면 요청마다 소켓이
642
661
  // 하나씩 남아 프로세스가 안 끝난다 (deel run 이 답을 찍고도 안 나가는 모양이 된다).
643
662
  tls.once('close', () => socket.destroy());
@@ -661,8 +680,22 @@ function 다읽기(res) {
661
680
  // 고치라고 하면 고쳐도 안 바뀐다 (평가에서 잡혔다).
662
681
  function 인증말(도전, 프록시) {
663
682
  const s = String(도전 ?? '');
664
- if (/ntlm|negotiate/i.test(s)) {
665
- return `프록시(${프록시.url})가 ${/ntlm/i.test(s) ? 'NTLM' : 'Negotiate'} 인증을 요구합니다 이 방식은 지원하지 않습니다.`
683
+ /*
684
+ * 사내 프록시는 흔히 **여러 방식을 한꺼번에** 내민다
685
+ * Proxy-Authenticate: Negotiate, Basic realm="corp"
686
+ * 앞엣것만 보고 "이 방식은 지원하지 않습니다, 담당자에게 문의하세요" 로 끝내면,
687
+ * 바로 옆에 우리가 갈 수 있는 Basic 이 열려 있는데도 사람을 돌려보내는 셈이다.
688
+ * 이 파일이 애초에 생긴 까닭이 그거다 — 적힌 탈출구가 막혀 있는 것이 제일 나쁘다.
689
+ *
690
+ * 방식 이름은 쉼표로 나뉜 **앞자리**에만 온다. realm="basic-corp" 같은 글에
691
+ * 걸리면 안 되니 자리까지 본다. 그리고 자리를 보려면 **따옴표 속을 먼저
692
+ * 지워야** 한다 — realm="internal, basic auth" 의 쉼표는 방식을 나누는
693
+ * 쉼표가 아닌데, 안 지우면 그것만으로 「Basic 도 준다」 로 읽힌다.
694
+ */
695
+ const 베이직 = /(?:^|,)\s*basic\b/i.test(s.replace(/"[^"]*"/g, '""'));
696
+ const 못하는것 = /ntlm/i.test(s) ? 'NTLM' : (/negotiate/i.test(s) ? 'Negotiate' : null);
697
+ if (못하는것 && !베이직) {
698
+ return `프록시(${프록시.url})가 ${못하는것} 인증을 요구합니다 — 이 방식은 지원하지 않습니다.`
666
699
  + ' 사내 담당자에게 Basic 인증이나 인증 없는 프록시 주소를 문의하세요.';
667
700
  }
668
701
  const 주소 = `http://user:pw@${프록시.host}:${프록시.port}`;
@@ -670,7 +703,8 @@ function 인증말(도전, 프록시) {
670
703
  ? `설정 파일의 "proxy": "${주소}"`
671
704
  : `${프록시.출처 ?? 'HTTPS_PROXY'}=${주소}`;
672
705
  return `프록시(${프록시.url})가 인증을 요구합니다 (407${s ? ` · ${s.slice(0, 60)}` : ''})`
673
- + ` — 프록시 주소에 user:pw@ 를 넣으세요: ${자리}`;
706
+ + ` — 프록시 주소에 user:pw@ 를 넣으세요: ${자리}`
707
+ + (못하는것 ? ` (${못하는것} 도 같이 요구하지만 그건 못 합니다 — Basic 으로 가세요)` : '');
674
708
  }
675
709
 
676
710
  // fetch 가 던진 것에서 코드 하나를 뽑는다. undici 는 원인을 cause 에 싸서 준다.
@@ -739,7 +773,17 @@ export function normalizeError(err) {
739
773
  * 그래서 두 가지를 한 자리에서 잡아 **우리 문장으로 갈아 끼운다.** 원문을
740
774
  * 안 보여 주는 것이 여기서는 친절이 아니라 안전이다.
741
775
  */
742
- if (/ByteString|character at index|invalid header (value|name)/i.test(m)) return '열쇠(또는 헤더)에 한글·특수문자가 섞여 있습니다 — API 키는 영문·숫자만 실립니다. 붙여넣을 때 따옴표나 줄바꿈이 딸려 오지 않았는지 보세요';
776
+ /*
777
+ * 셋째 말투 — **프록시를 켠 사람만 다른 답을 받고 있었다.**
778
+ *
779
+ * 곧장 가는 길은 fetch(undici)라 위의 두 말투가 나온다. 그런데 프록시를 켜면
780
+ * node:http 로 가고, 거기서는 말투가 아예 다르다:
781
+ * Invalid character in header content ["Authorization"] (code ERR_INVALID_CHAR)
782
+ * 열쇠 값은 안 들어 있어 새지는 않는다. 대신 **안내가 안 뜬다** — 같은 열쇠,
783
+ * 같은 실수인데 프록시가 있고 없고에 따라 한 쪽만 한국어 안내를 받았다.
784
+ */
785
+ if (/ByteString|character at index|invalid header (value|name)|invalid character in header|must be a valid HTTP token/i.test(m)
786
+ || /^ERR_INVALID_(CHAR|HTTP_TOKEN)$/.test(코드)) return '열쇠(또는 헤더)에 한글·특수문자가 섞여 있습니다 — API 키는 영문·숫자만 실립니다. 붙여넣을 때 따옴표나 줄바꿈이 딸려 오지 않았는지 보세요';
743
787
  if (/fetch failed/i.test(m)) return '연결 실패 — 주소·포트·프록시를 확인하세요';
744
788
  return m;
745
789
  })();
@@ -134,7 +134,25 @@ export function 클로드인가(모델) {
134
134
  export function 추론형오픈AI(모델) {
135
135
  const s = String(모델 ?? '').toLowerCase();
136
136
  if (/gpt-?4o/.test(s)) return false;
137
- return /(^|[^a-z])o[1-9]([^a-z]|$)|gpt-?5|gpt-?6/.test(s);
137
+ /*
138
+ * ── 이 물음은 이 파일에 **한 자리만** 있어야 한다 ──────────────────────
139
+ *
140
+ * 여태 두 자리에서 따로 물었다 — 여기(생각 눈금을 받나)와 아래 출력칸
141
+ * 갈래(새 이름을 받나). 둘 다 「추론 시대 OpenAI 모델인가」 를 묻는데
142
+ * 무늬가 서로 달라서 **같은 이름을 두고 다른 답**을 냈다.
143
+ *
144
+ * custom-gpt-50b-chat 여기만 참 → 크기 표기를 세대로 읽었다
145
+ * gpt5 · gpt6 여기만 참 → 붙임표 없는 이름
146
+ * gpt-7 저기만 참 → 다음 세대
147
+ *
148
+ * 무늬를 두 번 고치는 대신 자리를 하나로 줄인다. 그래야 다음에 세대가
149
+ * 하나 늘 때 고칠 곳이 하나다(30차 · 31차 리뷰).
150
+ *
151
+ * 뒤 경계는 있어야 한다 — 없으면 `gpt-50b` 의 크기 표기가 세대로 읽힌다.
152
+ * 세대는 5 부터 9 까지 열어 둔다. 다음 세대도 추론 시대일 것이 거의
153
+ * 확실하고, 틀려도 첫 400 에서 배운다(배울전선).
154
+ */
155
+ return /(^|[^a-z0-9])o[1-9]([^a-z0-9]|$)|(^|[^a-z0-9])gpt-?[5-9]([^a-z0-9]|$)/.test(s);
138
156
  }
139
157
 
140
158
  /**
@@ -152,8 +170,14 @@ export function 기본카드(conn) {
152
170
  규격,
153
171
  // 생각을 어떻게 켜나
154
172
  생각형식: 'none',
155
- // 전선이 받는 눈금
156
- 눈금: 회사 ? (회사눈금[회사] ?? []) : [],
173
+ /*
174
+ * 전선이 받는 눈금.
175
+ *
176
+ * **베껴 넣는다.** 모듈 표를 그대로 물리면 카드 하나를 손댄 것이 그 회사
177
+ * 전체 표를 고친다 — 그 다음에 켜는 세션이 조용히 다른 눈금을 갖는다
178
+ * (31차 리뷰). 카드는 세션마다 하나씩 사는 물건이라 제 것이어야 한다.
179
+ */
180
+ 눈금: 회사 ? [...(회사눈금[회사] ?? [])] : [],
157
181
  끄는말: 회사 ? (끄는말[회사] ?? null) : null,
158
182
  // 눈금을 어느 칸에 싣나 — adaptive 는 output_config.effort 에 싣는다
159
183
  효력칸: null,
@@ -223,7 +247,7 @@ export function 기본카드(conn) {
223
247
  const v = 세대(모델);
224
248
  카드.생각형식 = v === null ? 'adaptive' : (v >= 4.6 ? 'adaptive' : 'budget');
225
249
  if (카드.생각형식 === 'adaptive') 카드.효력칸 = 'output_config';
226
- if (!카드.눈금.length) 카드.눈금 = 회사눈금.anthropic;
250
+ if (!카드.눈금.length) 카드.눈금 = [...회사눈금.anthropic];
227
251
  return 카드;
228
252
  }
229
253
 
@@ -270,7 +294,9 @@ export function 기본카드(conn) {
270
294
  * 추론 모델은 옛 이름을 튕긴다. 이름으로 알아본다 — o1·o3·o4·gpt-5·gpt-6.
271
295
  * 앞에 무엇이 붙어 있어도(`openai/gpt-5`, `azure-gpt-5-chat`) 잡히게 둔다.
272
296
  */
273
- if (/(^|[^a-z0-9])(o[1-9]|gpt-[5-9])([^a-z0-9]|$)/i.test(모델)) 카드.출력칸 = '새것';
297
+ // 같은 물음은 한 자리에서만 묻는다 (추론형오픈AI 머리말). 여기 따로 적어 둔
298
+ // 무늬가 그쪽과 어긋나서 `gpt5`·`gpt-7` 이 두 갈래에서 다르게 읽혔다(31차 리뷰).
299
+ if (추론형오픈AI(모델)) 카드.출력칸 = '새것';
274
300
  if (클로드인가(모델)) {
275
301
  카드.캐시 = 'explicit';
276
302
  카드.캐시최소 = 회사 === 'bedrock' ? 4096 : 1024;
@@ -324,7 +350,15 @@ export function 기본카드(conn) {
324
350
  * 잘못 좁히면 손해가 「생각을 덜 한다」 로 끝나고, 잘못 넓히면 손해가
325
351
  * 「그 턴이 죽는다」 다. 확인이 안 되면 죽지 않는 쪽으로 간다.
326
352
  */
327
- 카드.눈금 = 회사눈금.openai;
353
+ /*
354
+ * `minimal` 은 **OpenAI 전용** 눈금이다. Claude 에는 그 말이 없어서,
355
+ * 이 창구에 실어 보내면 그 턴이 400 이다. 위 머리말이 정해 둔 「확인이
356
+ * 안 되면 죽지 않는 쪽으로」 를 그대로 따른다 — 여태는 좁히려고 이 줄을
357
+ * 쓰면서 `minimal` 을 **되레 하나 더 얹고** 있었다(30차 리뷰).
358
+ */
359
+ 카드.눈금 = 클로드인가(모델)
360
+ ? 회사눈금.openai.filter((x) => x !== 'minimal')
361
+ : [...회사눈금.openai];
328
362
  } else {
329
363
  카드.생각형식 = 회사 === 'gemini' ? 'effort' : (추론형오픈AI(모델) ? 'effort' : 'none');
330
364
  }
@@ -400,6 +434,42 @@ export function 배울전선(문구, 규격) {
400
434
  */
401
435
  const 거절 = /not supported|unsupported|unrecognized|unknown|unexpected|invalid|not permitted|not allowed|removed|deprecated|cannot be used|no longer|extra (?:fields|inputs)|additional propert/i;
402
436
 
437
+ /*
438
+ * ── 「칸 이야기」 와 「지금은 안 된다」 도 다른 말이다 ────────────────
439
+ *
440
+ * 위 `거절` 은 낱말이 넓어서 칸과 아무 상관 없는 문장에도 걸린다. 실제로
441
+ * 걸린 자리가 이것이다 —
442
+ *
443
+ * Adaptive rate limit exceeded: request not allowed
444
+ *
445
+ * 할당량 이야기인데 `not allowed` 와 `adaptive` 가 같이 있어서 「생각형식을
446
+ * 안 받는구나」 로 배우고 디스크에 남겼다. 멀쩡한 설정이 꺼진 채 굳는다 —
447
+ * 「잘못 배우는 것은 못 배우는 것보다 나쁘다」 의 또 한 판이다(30차 리뷰).
448
+ *
449
+ * 그래서 **칸을 두고 하는 말인지**를 한 번 더 본다. 낱말 하나가 아니라
450
+ * 칸을 가리키는 말이 같이 있어야 한다.
451
+ */
452
+ /*
453
+ * 여기 **거절 낱말을 섞으면 안 된다.** 처음엔 `not permitted`·`unrecognized`
454
+ * 를 같이 넣었는데, 그 둘은 「칸을 가리키는 말」 이 아니라 「거절한다는 말」
455
+ * 이다. 그래서 아래 두 줄이 그대로 새어 나갔다(31차 리뷰 — 제 고침 안의 틈).
456
+ *
457
+ * Adaptive rate limit exceeded: request not permitted
458
+ * Adaptive quota unrecognized for this account
459
+ *
460
+ * 이 자리가 할 일은 `거절` 과 **다른 것**을 보는 것이다. 겹치는 낱말을 넣으면
461
+ * 두 울타리가 같은 것을 보게 되고, 그러면 하나는 없는 것과 같다.
462
+ */
463
+ const 칸낱말 = /\b(?:parameter|property|field|argument|attribute|input|tag)s?\b|extra inputs|does not match any of the expected/i;
464
+ /*
465
+ * 칸 이름을 낱말로 안 적고 **경로로** 적어 주는 창구가 많다 — `thinking.type`,
466
+ * `metadata.user_id`, `messages.0.content`. 그것도 칸 이야기다. 이 갈래를
467
+ * 빼놨다가 「thinking.type 'adaptive' 는 안 된다」 는 정직한 문장을 못 배웠다
468
+ * (죽은규칙 검사가 잡았다 — 안 걸리는 규칙을 재는 판이 제 고침을 잡은 것이다).
469
+ */
470
+ const 칸경로 = /\b[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\b/i;
471
+ const 칸이야기 = { test: (글) => 칸낱말.test(글) || 칸경로.test(글) };
472
+
403
473
  /*
404
474
  * ── 「그 칸을 안 받는다」 와 「그 값이 틀렸다」 는 다른 말이다 ─────────
405
475
  *
@@ -420,41 +490,132 @@ export function 배울전선(문구, 규격) {
420
490
  * 그래서 값 탓으로 읽히면 아무것도 안 배운다.
421
491
  */
422
492
  const 값탓 = /must be (?:greater|less|at least|at most|between|larger|smaller|higher|lower|a |an |of type)|(?:minimum|maximum|min_?tokens|max_?value)\b|out of range|expected [^\n]{0,40}to have|too (?:large|small|long|short|many|few)|>=|<=/i;
493
+ /*
494
+ * ── 열거형 값 오류도 **값 탓**이다 ────────────────────────────────────
495
+ *
496
+ * 위 무늬는 숫자 범위만 안다. 그래서 받는 말이 둘뿐인 모델(`low`·`high` 만
497
+ * 받는 창구)에 `medium` 을 보냈을 때 이런 것들이 전부 「그 칸을 안 받는다」
498
+ * 로 읽혔다 —
499
+ *
500
+ * Unsupported value: reasoning_effort does not support medium
501
+ *
502
+ * 그러면 생각형식을 `none` 으로 배우고, 딸린 자리가 눈금까지 비우고, 그게
503
+ * 디스크에 남는다. 서버가 거절한 것은 **값 하나**였는데 기능이 통째로
504
+ * 죽는다 — 그 모델에서 `/think` 는 그날부터 아무 일도 안 한다.
505
+ *
506
+ * 값 이름을 대며 거절하는 말투를 같이 잡는다. 받는 값을 세어 주는 문장은
507
+ * 이 아래 `목록` 갈래가 **먼저** 보므로 배울 것은 그대로 배운다.
508
+ */
509
+ const 값탓열거 = /\b(?:invalid|unsupported|unknown|unexpected|not a valid|not an? accepted)\s+value|\bis not one of\b|does not support\b|must be (?:one of )?['\"\u2018\u201c]/i;
423
510
 
424
511
  // ── 받는 값의 목록을 통째로 알려 주는 자리 ──────────────────────────
425
512
  //
426
513
  // 값 탓 중에서 **이것 하나만** 배울 것이 있다. 서버가 받는 값을 세어 주면
427
514
  // 그건 짐작을 사실로 갈아탈 수 있는 유일한 문장이다. 그래서 값탓보다 먼저 본다.
428
- const 목록 = /(?:reasoning_effort|effort)[^\n]{0,60}?(?:must be one of|one of|expected one of|valid values are)\s*[:\s]*([a-z0-9_,'"\s|-]+)/i.exec(s);
515
+ /*
516
+ * ── 칸 이름을 안 적어 주는 창구가 더 많다 ─────────────────────────────
517
+ *
518
+ * 여태 이 갈래는 문장 앞에 `reasoning_effort` 가 **적혀 있어야** 걸렸다.
519
+ * 그런데 OpenAI 규격은 칸 이름을 문장이 아니라 형제 칸(`param`)에 담고,
520
+ * 그 칸은 여기까지 오지 않는다. 게다가 실제 말투가 목록에 없었다 —
521
+ *
522
+ * Invalid value: 'minimal'. Supported values are: 'none', 'low', 'medium', and 'high'.
523
+ *
524
+ * 그래서 「제일 값진 문장」 이라고 적어 둔 이 갈래가 **정작 제일 흔한 창구
525
+ * 에서는 한 번도 안 걸렸다.** 그 문장은 아래로 굴러떨어져 「칸을 안 받는다」
526
+ * 가 됐다 — 생각이 통째로 꺼졌다.
527
+ *
528
+ * 칸 이름 조건을 떼는 대신 **세어 준 값이 우리 눈금 말인지**를 본다. 그게
529
+ * 더 단단한 조건이다: 우리가 아는 눈금 말이 둘 이상 들어 있는 목록이면
530
+ * 그 문장은 눈금 이야기다. 칸 이름에 기대는 것보다 창구를 안 가린다.
531
+ */
532
+ const 셈말투 = /(?:must be one of|is not one of|one of|expected one of|valid values are|supported values are|allowed values(?: are)?|permitted values(?: are)?)\s*[:\s]*([a-z0-9_,'\"\s|-]+)/i;
533
+ const 목록 = 셈말투.exec(s);
429
534
  if (목록) {
430
535
  // 쉼표로 세는 창구도 있고 띄어쓰기로만 세는 창구도 있다. 둘 다 받는다 —
431
536
  // 한쪽만 보면 다른 쪽에서는 이 문장이 아래 「안 받는다」 로 굴러떨어진다.
432
537
  const 값들 = 목록[1]
433
538
  .split(/[,|\s]+/)
434
- .map((x) => x.replace(/['"`.]/g, '').toLowerCase())
539
+ .map((x) => x.replace(/['\"`.]/g, '').toLowerCase())
435
540
  .filter((x) => /^[a-z]+$/.test(x) && x.length <= 12 && x !== 'or' && x !== 'and');
436
- if (값들.length >= 2) return { 무엇: '눈금', 값: 값들, 왜: 짧게(s) };
541
+ /*
542
+ * 눈금 말이 아닌 목록은 우리 것이 아니다. 예전에는 칸 이름으로 걸렀는데,
543
+ * 그걸 떼면 아무 열거 문장이나(도구 이름·모델 이름 목록) 눈금으로 주워
544
+ * 담는다. 그래서 **아는 말과 겹치는지**로 본다.
545
+ */
546
+ /*
547
+ * 겹침은 **진짜 눈금 말**로만 센다. 처음엔 'auto'·'none'·'default' 까지
548
+ * 넣었는데, 그러면 `tool_choice must be one of: auto, none, required` 를
549
+ * 눈금 목록으로 주워 담는다(제 고침 안의 틈 — 검사가 잡았다). 눈금에만
550
+ * 쓰이는 말이어야 판별에 쓸 수 있다.
551
+ *
552
+ * `none` 은 배울 값으로는 멀쩡하다(gemini 눈금에 있다) — 다만 **판별에는**
553
+ * 안 쓴다. 배우는 것과 알아보는 것은 다른 일이다.
554
+ */
555
+ const 눈금다운말 = new Set([...눈금차례, 'minimal', 'xlow']);
556
+ const 눈금말 = 값들.filter((x) => 눈금다운말.has(x));
557
+ if (값들.length >= 2 && 눈금말.length >= 2) return { 무엇: '눈금', 값: 값들, 왜: 짧게(s) };
437
558
  }
438
559
 
439
560
  // 값 탓이면 여기서 끝낸다. 칸을 끄는 것은 값을 고쳐서 될 일이 아니다.
440
561
  if (값탓.test(s)) return null;
441
562
 
442
563
  // ── 생각 칸 ─────────────────────────────────────────────────────────
443
- if (/budget_tokens/i.test(s) && 거절.test(s)) {
564
+ /*
565
+ * 아래 `adaptive` 갈래와 **똑같은 가드가 여기에도 있어야 한다.** 한쪽만
566
+ * 고쳐 놓고 쌍둥이를 두고 왔다(31차 하네스가 짚었다).
567
+ *
568
+ * `budget_tokens` 는 Anthropic 규격의 칸이다. 그런데 OpenAI 규격으로 붙인
569
+ * 프록시가 우리 `reasoning_effort` 를 제 나름대로 `thinking.budget_tokens`
570
+ * 로 옮겨 올리고 상단이 그걸 거절하면, 그 문장이 그대로 내려온다. 그걸
571
+ * 배우면 `생각형식` 이 `adaptive` 가 되고 — OpenAI 몸에는 그 형식이 없어서
572
+ * **그 다음부터 세기가 아예 안 나간다.** 그 턴은 200 이라 아무도 안 알아챈다.
573
+ */
574
+ if (/budget_tokens/i.test(s) && 거절.test(s) && 규격 === 'anthropic') {
444
575
  return { 무엇: '생각형식', 값: 'adaptive', 왜: 짧게(s) };
445
576
  }
446
- if (/adaptive/i.test(s) && 거절.test(s)) {
577
+ /*
578
+ * `adaptive` 는 Anthropic 규격의 생각 형식 이름이다. 그래서 **낱말만 보고**
579
+ * 갈아타면 안 된다 — 두 가지로 틀린다.
580
+ *
581
+ * 1. "Adaptive rate limit exceeded: request not allowed" 는 칸 이야기가
582
+ * 아니라 **할당량** 이야기다. 그런데 여태 이 한 줄로 생각형식을
583
+ * budget 으로 배우고 디스크에 남겼다.
584
+ * 2. `budget` 은 Anthropic 전용 형식이다. OpenAI 규격 창구에 그것을
585
+ * 배워 두면 그 창구는 그때부터 못 쓰는 칸을 싣는다 — `cache_control`
586
+ * 갈래에서 이미 한 번 고친 부류다(규격을 보고 갈아탄다).
587
+ *
588
+ * 그래서 **칸 이야기일 때만**, 그리고 **Anthropic 규격일 때만** 배운다.
589
+ *
590
+ * 처음엔 `규격 !== 'openai'` 로 적었다. 그러면 gemini·ollama·규격 모름 에도
591
+ * budget 을 배운다 — 그 규격들에도 그 칸이 없으니 똑같이 잘못 배운 것이다.
592
+ * 「아닌 것을 하나 뺀다」 가 아니라 **「맞는 것 하나만」** 이어야 한다(31차 리뷰).
593
+ */
594
+ if (/adaptive/i.test(s) && 거절.test(s) && 칸이야기.test(s) && 규격 === 'anthropic') {
447
595
  return { 무엇: '생각형식', 값: 'budget', 왜: 짧게(s) };
448
596
  }
597
+ /*
598
+ * ── 열거형 값 오류 판정은 **형식 갈아타기 뒤에** 온다 ──────────────────
599
+ *
600
+ * 「값이 틀렸다」 는 말에도 두 종류가 있다.
601
+ *
602
+ * Unsupported value: reasoning_effort does not support medium
603
+ * → 눈금 하나가 틀린 것. 칸을 끄면 안 된다.
604
+ * Invalid value: thinking.type 'adaptive' is not supported by this model
605
+ * → 우리가 **고른 형식**이 틀린 것. 이건 갈아타야 배운 것이 된다.
606
+ *
607
+ * 둘을 한 자리에서 막으면 뒤엣것까지 막힌다 — 실제로 그렇게 막았고 죽은규칙
608
+ * 검사가 잡았다. 그래서 형식을 고르는 갈래(budget_tokens · adaptive)를 먼저
609
+ * 지나게 하고, 그 뒤에 값 탓을 본다.
610
+ */
611
+ if (값탓열거.test(s)) return null;
612
+
449
613
  if (/reasoning_effort/i.test(s) && 거절.test(s)) {
450
614
  return { 무엇: '생각형식', 값: 'none', 왜: 짧게(s) };
451
615
  }
452
616
  if (/output_config/i.test(s) && 거절.test(s)) {
453
617
  return { 무엇: '효력칸', 값: null, 왜: 짧게(s) };
454
618
  }
455
- if (/\bthinking\b/i.test(s) && 거절.test(s)) {
456
- return { 무엇: '생각형식', 값: 'none', 왜: 짧게(s) };
457
- }
458
619
 
459
620
  /*
460
621
  * ── 출력 상한 칸 ───────────────────────────────────────────────────
@@ -467,6 +628,29 @@ export function 배울전선(문구, 규격) {
467
628
  * `max_tokens` 라는 낱말만으로도 걸려서, 「128k 를 64k 로 줄였다」 는
468
629
  * 엉뚱한 배움을 남기고 같은 400 을 다시 맞았다.
469
630
  */
631
+ /*
632
+ * ── 서버가 **쓸 이름을 말해 주는** 판이 먼저다 ──────────────────────
633
+ *
634
+ * OpenAI 가 o1·gpt-5 계열에 실제로 내는 문구는 두 이름을 **한 줄에 같이**
635
+ * 적는다.
636
+ *
637
+ * Unsupported parameter: 'max_tokens' is not supported with this model.
638
+ * Use 'max_completion_tokens' instead.
639
+ *
640
+ * 아래 두 갈래는 「어느 낱말이 보이나」 로만 갈랐다. 그래서 이 문장에서
641
+ * `max_completion_tokens` 가 보인다는 이유로 **옛것으로 갈아타라**고 읽었다 —
642
+ * 서버가 말한 것과 정반대다. 그리고 그 값은 디스크에 남는다. 그 창구는
643
+ * 그때부터 매 턴 400 이고, 사람이 배움 파일을 지우기 전에는 안 낫는다.
644
+ * 「잘못 배우는 것은 못 배우는 것보다 나쁘다」 의 제일 비싼 판이다.
645
+ *
646
+ * 서버가 쓸 이름을 말해 줬으면 그것을 그대로 따른다. 두 말투를 다 받는다 —
647
+ * `Use 'X' instead` 와 `X must be used instead`.
648
+ */
649
+ const 쓰라는것 = /\buses?\s+['"`]?(max_completion_tokens|max_tokens)\b/i.exec(s)
650
+ ?? /['"`]?\b(max_completion_tokens|max_tokens)\b['"`]?\s+(?:must|should|can)[^\n]{0,12}\bused\b/i.exec(s);
651
+ if (쓰라는것 && 거절.test(s)) {
652
+ return { 무엇: '출력칸', 값: 쓰라는것[1].toLowerCase() === 'max_completion_tokens' ? '새것' : '옛것', 왜: 짧게(s) };
653
+ }
470
654
  if (/max_completion_tokens/i.test(s) && 거절.test(s)) {
471
655
  return { 무엇: '출력칸', 값: '옛것', 왜: 짧게(s) };
472
656
  }
@@ -551,7 +735,16 @@ export function 배울전선(문구, 규격) {
551
735
  * 밑줄은 낱말 글자라 `\b` 가 제 일을 한다 — `user_agent` 의 `user` 뒤는
552
736
  * 경계가 아니고, `username` 도 마찬가지다. 따옴표·빈칸·점 앞에서만 걸린다.
553
737
  */
554
- if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?\b(?:user|metadata)\b['"`]?/i.test(s) && 거절.test(s)) {
738
+ /*
739
+ * 붙임표도 낱말 글자가 아니다 — `\b` 만으로는 안 막힌다.
740
+ *
741
+ * Unrecognized request argument supplied: user-agent → 걸렸다
742
+ *
743
+ * 위 주석은 밑줄(`user_agent`)만 적어 두고 붙임표를 빼먹었다. 그런데
744
+ * 게이트웨이가 헤더 이름을 오류 문장에 얹어 주는 자리가 있고, 그 한 줄이
745
+ * 멀쩡히 되던 세션 이름을 **디스크에 꺼진 채로** 남긴다(30차 리뷰).
746
+ */
747
+ if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?(?<!-)\b(?:user|metadata)\b(?!-)['"`]?/i.test(s) && 거절.test(s)) {
555
748
  return { 무엇: '세션자리', 값: null, 왜: 짧게(s) };
556
749
  }
557
750
  if (/['"`](?:user|metadata)['"`][^\n]{0,40}(?:not supported|unsupported|unknown|unexpected|invalid)/i.test(s)) {
@@ -569,6 +762,40 @@ export function 배울전선(문구, 규격) {
569
762
  return { 무엇: '스트림usage', 값: false, 왜: 짧게(s) };
570
763
  }
571
764
 
765
+ /*
766
+ * ── 제일 헐거운 갈래는 **맨 뒤에** 온다 ───────────────────────────────
767
+ *
768
+ * `thinking` 한 낱말은 여태 이 사다리의 **위쪽**에 있었다. 그래서 문장에
769
+ * 그 낱말이 한 번 보이면 아래의 진짜 원인 갈래(출력칸·캐시·세션자리·
770
+ * stream_options)에 영영 못 닿았다.
771
+ *
772
+ * 그리고 그 낱말은 **모델 이름에도 들어 있다.** LiteLLM 계열은 오류 문장
773
+ * 끝에 모델 이름을 붙여 준다 —
774
+ *
775
+ * Unrecognized request argument supplied: stream_options
776
+ * (Received Model Group=qwen3-235b-a22b-thinking-2507)
777
+ *
778
+ * 그러면 두 가지가 같이 일어났다. ① 진짜 원인인 `stream_options` 를 안
779
+ * 껐으니 같은 400 이 매 턴 다시 뜬다 — 화면에 「배웠습니다」 가 떠도 안
780
+ * 낫는다. ② 그러면서 생각이 꺼지고 그 값이 디스크에 남는다. 못 고친 오류
781
+ * 하나에 멀쩡한 기능 하나가 딸려 죽는 셈이다(31차 하네스).
782
+ *
783
+ * 그래서 두 가지를 한다. 자리를 맨 뒤로 내리고, 모델 이름을 적어 주는 토막을
784
+ * 먼저 잘라낸다.
785
+ */
786
+ const 이름토막뺀것 = s
787
+ .replace(/\b(?:received\s+)?model(?:[\s_-]?(?:group|name|id))?\s*[=:]\s*[^\s,;)\]]+/gi, ' ')
788
+ .replace(/\bmodel\s+['\"`][^'\"`]*['\"`]/gi, ' ');
789
+ /*
790
+ * 여기 `칸이야기` 를 걸었다가 뺐다. 그걸 걸면 **진짜 생각 거절까지** 막힌다 —
791
+ * `Extended thinking is not supported on this model` 에는 칸을 가리키는 낱말이
792
+ * 하나도 없다. 이름만 걸린 판은 위에서 이름 토막을 잘라내는 것으로 이미
793
+ * 갈린다. 울타리를 둘 겹치면 그중 하나는 늘 엉뚱한 것을 막는다.
794
+ */
795
+ if (/\bthinking\b/i.test(이름토막뺀것) && 거절.test(s)) {
796
+ return { 무엇: '생각형식', 값: 'none', 왜: 짧게(s) };
797
+ }
798
+
572
799
  return null;
573
800
  }
574
801
 
@@ -585,6 +812,21 @@ export function 카드고치기(카드, 고침) {
585
812
  // 배운 칸을 적어 둔다. 남길 때 **배운 것만** 남기려고 센다 (카드저장꼴).
586
813
  const 배운것 = new Set(카드.배운칸 ?? []);
587
814
  배운것.add(고침.무엇);
815
+ /*
816
+ * 서버가 **직접 말해 준** 칸만 따로 센다.
817
+ *
818
+ * 아래 딸린 자리들(생각형식을 바꾸면 효력칸·눈금이 따라간다)은 우리가
819
+ * 맞춘 것이지 서버가 말해 준 것이 아니다. 둘을 `배운칸` 하나에 섞어 두면
820
+ * 「서버가 안 받는다고 했으니 도로 켜지 말자」 는 규칙이 **우리가 딸려 맞춘
821
+ * 것에도 걸린다** — 그게 효력칸을 영영 null 로 굳혔다.
822
+ *
823
+ * 이름을 「서버가 끈 칸」 으로 붙였다가 고쳤다. 서버가 말해 주는 것은 끄는
824
+ * 것만이 아니라 **갈아타라는 것도** 있어서(출력칸), 그 이름은 담는 것과
825
+ * 어긋났다. 어긋난 이름은 다음에 읽는 사람을 틀리게 한다(31차 리뷰).
826
+ */
827
+ const 서버가말한칸 = new Set(카드.서버가말한칸 ?? []);
828
+ 서버가말한칸.add(고침.무엇);
829
+ 새.서버가말한칸 = [...서버가말한칸];
588
830
  if (고침.무엇 === '생각형식') {
589
831
  /*
590
832
  * 생각형식을 바꾸면 효력칸도 따라간다. 다만 **이미 안 받는다고 배운
@@ -596,8 +838,26 @@ export function 카드고치기(카드, 고침) {
596
838
  * 표까지 달고 디스크에 남는다. 서버가 안 받는다고 말해 준 칸을 우리가
597
839
  * 배웠다고 적어 두는 셈이다.
598
840
  */
599
- const 효력끈적있나 = (카드.배운칸 ?? []).includes('효력칸') && 카드.효력칸 === null;
600
- if (!효력끈적있나) {
841
+ /*
842
+ * ── 「서버가 끄라고 했다」 와 「우리가 딸려 껐다」 는 다르다 ──────────
843
+ *
844
+ * 위 규칙이 보는 것은 `배운칸에 효력칸이 있고 값이 null` 인데, 바로 아래
845
+ * 줄이 **생각형식을 배울 때마다 효력칸도 배운 것으로 적는다.** 그래서
846
+ * 처음 budget 을 배운 순간(효력칸 null) 그 표가 달리고, 그 뒤에 adaptive
847
+ * 를 배워도 이 갈래가 막혀 효력칸이 **영영 null 로 굳는다.** adaptive 인데
848
+ * 눈금을 실을 칸이 없으니 생각 세기가 조용히 안 나간다 — 화면에는 그대로
849
+ * 떠 있다(30차 리뷰).
850
+ *
851
+ * 그래서 **서버가 그 칸을 거절한 것만** 끈 것으로 센다. 딸려 따라간
852
+ * 것은 표를 달지 않는다.
853
+ */
854
+ /*
855
+ * 값까지 안 본다. `배울전선` 이 효력칸에 넣는 값은 null 하나뿐이라
856
+ * `&& 효력칸 === null` 은 늘 참이었다 — 어긋내기가 「그 줄을 지워도
857
+ * 초록」 이라고 알려 줬다. 안 걸리는 조건은 없느니만 못하다.
858
+ */
859
+ const 서버가껐나 = (카드.서버가말한칸 ?? []).includes('효력칸');
860
+ if (!서버가껐나) {
601
861
  새.효력칸 = 고침.값 === 'adaptive' ? 'output_config' : null;
602
862
  배운것.add('효력칸');
603
863
  }
@@ -632,6 +892,15 @@ export function 카드저장꼴(카드) {
632
892
  if (!배운것.length) return null;
633
893
  const 남길것 = {};
634
894
  for (const k of 배운것) 남길것[k] = 카드[k];
895
+ /*
896
+ * 「서버가 직접 거절했다」 는 표도 같이 남긴다.
897
+ *
898
+ * 이 표가 안 남으면 다시 켤 때마다 그 표가 사라지고, 생각형식을 하나 배우는
899
+ * 순간 서버가 안 받는다고 말해 준 칸이 되살아난다 — 켤 때마다 400 을 한 판씩
900
+ * 더 맞는 셈이다. 값은 위에서 남기는데 「누가 껐는지」 를 안 남겨서 생기는 틈이다.
901
+ */
902
+ const 끈것 = Array.isArray(카드.서버가말한칸) ? 카드.서버가말한칸.filter((k) => 카드칸들.includes(k)) : [];
903
+ if (끈것.length) 남길것.서버가말한칸 = 끈것;
635
904
  return 남길것;
636
905
  }
637
906
 
@@ -645,6 +914,12 @@ export function 카드합치기(기본, 남긴것) {
645
914
  }
646
915
  // 이어서 또 배우면 앞서 배운 것과 함께 남는다.
647
916
  새.배운칸 = [...배운것];
917
+ // 「서버가 직접 껐다」 는 표도 이어받는다 (카드저장꼴 머리말).
918
+ const 끈것 = new Set(기본?.서버가말한칸 ?? []);
919
+ if (Array.isArray(남긴것.서버가말한칸)) {
920
+ for (const k of 남긴것.서버가말한칸) if (카드칸들.includes(k)) 끈것.add(k);
921
+ }
922
+ 새.서버가말한칸 = [...끈것];
648
923
  return 새;
649
924
  }
650
925
 
@@ -759,7 +1034,19 @@ export function 전선말(카드) {
759
1034
  boolean: 'think', none: 말('wire.none'),
760
1035
  }[카드.생각형식] ?? 카드.생각형식;
761
1036
  조각.push(`${말('wire.think')} ${생각}`);
762
- if (카드.눈금?.length) 조각.push(`${말('wire.rungs')} ${카드.눈금.join('·')}`);
1037
+ /*
1038
+ * 생각을 안 켠 전선에서는 눈금을 **안 적는다.**
1039
+ *
1040
+ * `gpt-4o` 카드는 생각형식이 none 인데 회사 눈금 넷을 그대로 들고 있어서,
1041
+ * 화면에 「생각 없음 · 눈금 minimal·low·medium·high」 라고 떴다. 읽는 사람은
1042
+ * 눈금을 고를 수 있다고 믿지만 나가는 칸이 아예 없다. 배워서 none 이 된
1043
+ * 카드는 눈금을 비우는데(카드고치기) 짐작으로 none 인 카드는 안 비워서,
1044
+ * 같은 파일 두 갈래가 화면에 서로 다른 말을 하고 있었다(30차 리뷰).
1045
+ *
1046
+ * 값을 비우지 않고 화면만 가리는 까닭: 나중에 생각형식을 켜게 되면 그 눈금이
1047
+ * 다시 뜻을 갖는다. 안 쓰는 값을 지우면 그때 실을 것이 없어진다.
1048
+ */
1049
+ if (카드.생각형식 !== 'none' && 카드.눈금?.length) 조각.push(`${말('wire.rungs')} ${카드.눈금.join('·')}`);
763
1050
  const 캐시 = {
764
1051
  explicit: 말('wire.cacheMarked'),
765
1052
  key: 말('wire.cacheKey'),