deel-local-cli 1.20.7 → 1.20.8

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%2C491%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-9%2C587%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.7 ⌂ 이 안
304
+ deel 1.20.8 ⌂ 이 안
305
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
306
306
  ```
307
307
 
@@ -1289,6 +1289,7 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1289
1289
 
1290
1290
  | 판 | 무엇이 바뀌었나 |
1291
1291
  |---|---|
1292
+ | [1.20.8](docs/ko/releases/1.20.md#1208) | 못 쟀다는 것을 안 된다고 적고 있었습니다 |
1292
1293
  | [1.20.7](docs/ko/releases/1.20.md#1207) | 서버가 말해 준 것과 반대를 배우고 있었습니다 |
1293
1294
  | [1.20.6](docs/ko/releases/1.20.md#1206) | 2차 리뷰가 판을 조용히 잃고 있었습니다 |
1294
1295
  | [1.20.5](docs/ko/releases/1.20.md#1205) | `aws_access_token` 이 안 잡히고 있었습니다 |
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%2C491%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-9%2C587%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.7 ⌂ inside
377
+ deel 1.20.8 ⌂ inside
378
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
379
379
  ```
380
380
 
@@ -1350,6 +1350,7 @@ so one run tells you everything.
1350
1350
 
1351
1351
  | Version | What changed |
1352
1352
  |---|---|
1353
+ | [1.20.8](docs/en/releases/1.20.md#1208) | We were writing down "can't" where the truth was "couldn't measure" |
1353
1354
  | [1.20.7](docs/en/releases/1.20.md#1207) | We were learning the opposite of what the server said |
1354
1355
  | [1.20.6](docs/en/releases/1.20.md#1206) | The second review was losing rounds silently |
1355
1356
  | [1.20.5](docs/en/releases/1.20.md#1205) | `aws_access_token` was not being caught |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.20.7",
3
+ "version": "1.20.8",
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",
@@ -105,7 +105,9 @@ export const 캐시일때비울몫 = 0.10;
105
105
  * 늦어질 뿐이고, 있는데 없다고 치면 방금 만든 캐시를 깬다. 그래서 모르는
106
106
  * 쪽은 덜 잃는 쪽으로 기울인다.
107
107
  *
108
- * 넘게 불렀는데도 읽힌 것이 하나도 없으면, 그때는 정말 없는 것이다.
108
+ * **두 번** 불렀는데도 읽힌 것이 하나도 없으면, 그때는 정말 없는 것이다.
109
+ * (여기가 「두 번 넘게」 라고 적혀 있었다. 아래 셈은 두 번째에 이미 단정한다 —
110
+ * 한 번의 차이지만, 적힌 것과 하는 것이 다르면 다음 사람이 그 값을 못 믿는다.)
109
111
  */
110
112
  export const 재볼기회 = 2;
111
113
 
@@ -163,7 +165,37 @@ export function foldImages(session, { keep = KEEP_IMAGES } = {}) {
163
165
  const 자리 = [];
164
166
  ms.forEach((m, i) => { if (그림장수(m)) 자리.push(i); });
165
167
 
166
- const 뺄것 = 자리.slice(0, Math.max(0, 자리.length - keep));
168
+ /*
169
+ * ── 남기는 것은 **장수**다, 메시지 수가 아니다 ─────────────────────────
170
+ *
171
+ * `KEEP_IMAGES` 는 위 머리말대로 「최근 몇 **장**」 이다. 그런데 자리를
172
+ * 메시지 단위로 세고 거기서 keep 을 빼고 있었다. 그래서 한 메시지에 여러
173
+ * 장이 실리면(`@a.png @b.png @c.png` 를 한 줄로 붙이면 그렇게 된다)
174
+ * **남는 장수가 keep 을 훌쩍 넘었다.**
175
+ *
176
+ * 석 장 실린 메시지 + 한 장 실린 메시지 → 자리가 둘이라 한 장도 안 뺀다
177
+ * (남는 것은 넉 장. keep 은 둘)
178
+ *
179
+ * 몇 MB 짜리 사진 셋이 「최근 둘만 남긴다」 는 이름 아래 그대로 남는다.
180
+ *
181
+ * ── 그러면서도 한 덩이는 쪼개지 않는다 ────────────────────────────────
182
+ *
183
+ * 사람이 한 번에 붙인 사진들은 한 이야기다. 그 안에서 어느 장이 더 오래된
184
+ * 것인지 알 길도 없다. 그래서 **마지막 덩이는 몇 장이든 남기고**, 그보다
185
+ * 앞엣것은 장수를 더해 keep 을 안 넘을 때만 남긴다. 마지막 덩이를 빼지
186
+ * 않는 까닭: 방금 보여 준 사진을 그 자리에서 없애면 그 턴이 헛턴이 된다.
187
+ */
188
+ let 남긴장수 = 0;
189
+ let 첫남길자리 = 자리.length;
190
+ for (let k = 자리.length - 1; k >= 0; k--) {
191
+ const 이번장수 = 그림장수(ms[자리[k]]);
192
+ // 마지막 덩이는 무조건 남긴다. 그 뒤로는 넘치면 거기서 끊는다.
193
+ if (k < 자리.length - 1 && 남긴장수 + 이번장수 > keep) break;
194
+ 남긴장수 += 이번장수;
195
+ 첫남길자리 = k;
196
+ }
197
+
198
+ const 뺄것 = 자리.slice(0, 첫남길자리);
167
199
  const 뺀것들 = [];
168
200
  for (const i of 뺄것) {
169
201
  const m = ms[i];
@@ -174,7 +206,14 @@ ${접힘표} 그림 ${장수}장은 자리를 비우려고 뺐습니다. 필요
174
206
  delete ms[i].images;
175
207
  뺀것들.push({ 장수 });
176
208
  }
177
- return { 뺀것: 뺄것.length, 뺀것들 };
209
+ /*
210
+ * `뺀것` 은 **장수**다.
211
+ *
212
+ * 여기가 `뺄것.length` — 곧 **메시지 수**였다. 화면은 이 값을 「그림 N장을
213
+ * 뺐습니다」 로 적으므로, 한 메시지에서 넉 장을 뺀 판에 「1장」 이라고
214
+ * 떴다. 사람이 세어 볼 수 있는 숫자라 틀리면 바로 거짓이 된다.
215
+ */
216
+ return { 뺀것: 뺀것들.reduce((a, x) => a + x.장수, 0), 뺀메시지: 뺄것.length, 뺀것들 };
178
217
  }
179
218
 
180
219
  export function shouldFold(session, at = null) {
@@ -241,8 +280,21 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
241
280
  * 같은 것을 두 번 뽑을 까닭이 없다 — 한 번 뽑아서 같이 들고 간다.
242
281
  */
243
282
  const 아는것 = 이름표.get(첫?.id) ?? null;
283
+ /*
284
+ * 이름은 **한 메시지에 실린 것 전부**를 들고 간다.
285
+ *
286
+ * 아래 「마지막 할 일 목록은 안 접는다」 가 이 이름을 본다. 그런데
287
+ * 첫 것만 들고 가면, 모델이 한 턴에 `Read` 와 `TodoWrite` 를 같이 부른
288
+ * 판에서 — Anthropic 꼴은 그 결과 둘을 **한 메시지**에 싣는다 —
289
+ * 이름이 'Read' 로 잡혀 할 일 목록이 지킴 목록에서 빠진다. 그러면
290
+ * 그 자리가 접히면서 남은 항목이 어디에도 없어진다.
291
+ *
292
+ * 아래 머리말이 「접는 순간 남은 항목이 어디에도 없어진다」 고 적어 둔
293
+ * 바로 그 일이, 도구를 둘 같이 부른 판에서만 조용히 일어났다 (35차 리뷰).
294
+ */
295
+ const 이름들 = 것들.map((x) => 이름표.get(x?.id)?.name ?? x?.name ?? '').filter(Boolean);
244
296
  자리.push({
245
- i, 글,
297
+ i, 글, 이름들,
246
298
  이름: 아는것?.name ?? 첫?.name ?? '',
247
299
  args: 아는것?.args ?? {},
248
300
  });
@@ -262,7 +314,7 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
262
314
  * 남겨 둘수록 해롭다. 지키는 것은 **마지막 하나**뿐이고, 목록 하나는
263
315
  * 몇백 글자라 자리도 거의 안 먹는다.
264
316
  */
265
- const 지킬할일 = 자리.filter((x) => x.이름 === 'TodoWrite').at(-1)?.i ?? -1;
317
+ const 지킬할일 = 자리.filter((x) => x.이름들.includes('TodoWrite')).at(-1)?.i ?? -1;
266
318
 
267
319
  const 접을것 = 자리
268
320
  .slice(0, Math.max(0, 자리.length - keep))
@@ -296,7 +348,31 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
296
348
  * 100자도 안 된다). 그래서 이 수는 언제나 **새로 접을 것**의 수다.
297
349
  */
298
350
  const 예상이득 = 접을것.reduce((a, x) => a + estimateTokens(x.글), 0);
299
- if (예상이득 < 넘을것 || 접을것.length < keep) {
351
+ /*
352
+ * ── 개수를 기다리는 것이 손해가 되는 판 ────────────────────────────────
353
+ *
354
+ * 위 개수 문턱은 「3천 토큰 아끼려고 앞머리를 깨지 말라」 는 말이다. 맞는
355
+ * 말인데, **아낄 것이 3만 토큰일 때도** 똑같이 기다렸다. 큰 파일 읽기 셋이
356
+ * 쌓인 판에서 접기를 미루면 그 다음 안전망은 80% 요약 압축이고, 그쪽은
357
+ * 대화를 **통째로 요약으로 갈아 치우는** 훨씬 비싼 일이다. 개수를 기다리다
358
+ * 더 큰 것을 치르는 셈이다.
359
+ *
360
+ * 그래서 기다림을 **값으로** 판단한다. 앞머리를 깨는 값은 **접은 뒤에**
361
+ * 그 자리부터 다시 보내야 하는 토큰이다 — 캐시에서 싸게 읽던 것을 정가로
362
+ * 다시 쓴다. 아끼는 것이 그보다 크면 지금 접는 것이 이득이라, 개수를 더
363
+ * 기다리지 않는다.
364
+ *
365
+ * 접기 전 양에서 아끼는 양을 뺀다. 접을 글 자체를 대가로 세면 안 된다 —
366
+ * 그건 접은 뒤에 사라지는 것이라, 세는 순간 늘 「기다려라」 가 나온다.
367
+ */
368
+ const 깰자리 = 접을것[0]?.i ?? -1;
369
+ const 깨는값 = 깰자리 < 0 ? Infinity
370
+ : Math.max(0, ms.slice(깰자리).reduce((a, m) => a + estimateTokens(
371
+ 도구결과인가(m) ? 결과들(m).map((x) => x.글).join('\n')
372
+ : (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '')),
373
+ ), 0) - 예상이득);
374
+ const 기다릴까 = 접을것.length < keep && 예상이득 <= 깨는값;
375
+ if (예상이득 < 넘을것 || 기다릴까) {
300
376
  return {
301
377
  접은것: 0, 아낀토큰: 0, 접은것들: [], 미룸: true,
302
378
  모인것: 예상이득, 모인개수: 접을것.length, 넘을것,
@@ -352,7 +428,18 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
352
428
  */
353
429
  return {
354
430
  접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들,
355
- 캐시다시씀: 캐시가도나(session),
431
+ /*
432
+ * 화면에 「캐시를 다시 씁니다」 를 적을까.
433
+ *
434
+ * `캐시가도나()` 를 그대로 쓰고 있었다. 그 함수는 **결정**을 위해 모르는
435
+ * 쪽을 「있다」 로 기울인다(그 머리말) — 그래서 첫 턴에는, 캐시가 아예
436
+ * 없는 창구에서도 늘 참이었다. 그러면 화면은 「값과 지연이 한 번 튑니다」
437
+ * 라고 겁을 주는데 잃을 캐시가 애초에 없다.
438
+ *
439
+ * 결정은 기울여도 되지만 **화면은 아는 것만 적는다.** 캐시를 정말 읽어
440
+ * 본 적이 있을 때만 말한다 (35차 리뷰).
441
+ */
442
+ 캐시다시씀: (session?.usage?.cacheRead ?? 0) > 0,
356
443
  };
357
444
  }
358
445
 
@@ -525,7 +612,15 @@ export async function compact(session, { auto = false, signal = null, onBackoff
525
612
  * 「앞에서 읽은 그대로입니다」 를 내밀면 모델은 대화에 없는 글을 가리키는
526
613
  * 쪽지만 받는다. 통째로 다시 싣는 것보다 훨씬 나쁘다.
527
614
  */
528
- if (folded > 0) session.파일기억?.잊기();
615
+ /*
616
+ * 읽은 파일 수도 같이 내린다.
617
+ *
618
+ * 아래 성공한 길은 `filesRead.clear()` 도 하는데 이쪽은 안 했다. 그 표는
619
+ * `/context` 가 「도구 결과 N개」 로 세는 자리라(agent/session.js), 물러선
620
+ * 뒤에는 **대화에 없는 파일까지 세어 보여 줬다.** 사람이 자리를 왜 이만큼
621
+ * 쓰는지 그 화면으로 판단하는데, 거기 없는 것이 섞여 있으면 안 된다.
622
+ */
623
+ if (folded > 0) { session.파일기억?.잊기(); session.filesRead?.clear?.(); }
529
624
  const after = session.breakdown().used;
530
625
  return {
531
626
  ok: folded > 0, folded, before, after, fallback: true,
@@ -298,10 +298,27 @@ export class 배움 {
298
298
  return out.length > 1 ? out.join('\n') : null;
299
299
  }
300
300
 
301
- /** 사람이 지우라고 한 것. 파일째 비운다. */
301
+ /**
302
+ * 사람이 지우라고 한 것. 파일째 비운다.
303
+ *
304
+ * ── `'전선'` 은 왜 따로 있나 ───────────────────────────────────────────
305
+ *
306
+ * 전선 모양은 **틀리게 배울 수 있는 유일한 것**이다 (backend/wire.js 의
307
+ * 배울전선: "잘못 배우는 것은 못 배우는 것보다 나쁘다"). 게이트웨이가
308
+ * 한동안 잘못 답했거나 우리가 문구를 잘못 읽으면, 멀쩡한 기능이 꺼진 채
309
+ * 디스크에 굳는다 — 그리고 그 창구에서는 영영 그대로다.
310
+ *
311
+ * 그런데 되돌릴 길이 **「쌓아 둔 것 전부 비우기」 하나**였다. 그러면 몇 주
312
+ * 걸려 쌓은 명령 겪음과 토큰 보정까지 같이 날아간다. 그 값이 아까워서
313
+ * 사람은 안 비우고, 안 비우니 꺼진 기능을 그냥 안고 쓴다.
314
+ *
315
+ * 그래서 전선만 지운다. 다음 요청에서 짐작으로 다시 시작하고, 서버가
316
+ * 여전히 거절하면 같은 것을 다시 배운다 — 잃을 것이 없다.
317
+ */
302
318
  지우기(어디 = '전부') {
303
319
  if (어디 === '전부' || 어디 === '폴더') { this.폴더 = 빈것(); this.#저장폴더(); }
304
320
  if (어디 === '전부' || 어디 === '모델') { this.집 = 빈것(); this.#저장집(); }
321
+ if (어디 === '전선') { this.집.전선 = {}; this.#저장집(); }
305
322
  return this;
306
323
  }
307
324
 
@@ -120,7 +120,16 @@ export function 규격이름(shape) {
120
120
  export function 주소붙이기(base, 길) {
121
121
  const b2 = String(base ?? '');
122
122
  const i = b2.indexOf('?');
123
- if (i < 0) return b2 + 길;
123
+ /*
124
+ * 끝 빗금은 **물음표가 있을 때만** 떼고 있었다.
125
+ *
126
+ * 사람이 주소를 적을 때 `…/v1/` 처럼 끝에 빗금을 하나 붙이는 것은 흔한
127
+ * 일이고, 그러면 `…/v1//chat/completions` 가 나갔다.
128
+ * `//` 는 게이트웨이마다 **다른 길**이라 404 다 — 그리고 그 404 는 화면에서
129
+ * 모델 이름을 잘못 적은 것과 구별이 안 된다. 물음표가 있는 쪽에서는 이미
130
+ * 떼고 있었으니, 같은 주소를 물음표 있고 없고에 따라 다르게 다듬던 셈이다.
131
+ */
132
+ if (i < 0) return b2.replace(/\/+$/, '') + 길;
124
133
  return b2.slice(0, i).replace(/\/+$/, '') + 길 + b2.slice(i);
125
134
  }
126
135
 
@@ -174,7 +183,7 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
174
183
  */
175
184
  else if (think === 'off') body.think = false;
176
185
  else if (think !== undefined) {
177
- const 눈금 = 강도말(think);
186
+ const 눈금 = 나가는눈금(shape, 카드, think);
178
187
  if (눈금) body.think = 눈금;
179
188
  }
180
189
  return body;
@@ -272,7 +281,7 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
272
281
  * 전선에서도 high 밖에 못 나간다 — 화면에는 max 라고 떠 있는 채로.
273
282
  * 카드가 있으면 카드가 아는 눈금으로, 없으면 여태처럼 좁은 쪽으로 맞춘다.
274
283
  */
275
- const 눈금 = 카드 ? (카드.생각형식 === 'effort' ? 눈금맞추기(카드, think) : null) : 강도말(think);
284
+ const 눈금 = 나가는눈금(shape, 카드, think);
276
285
  if (눈금) body.reasoning_effort = 눈금;
277
286
  }
278
287
  /*
@@ -369,6 +378,52 @@ export function 강도말(강도) {
369
378
  return 전선눈금[String(강도)] ?? null;
370
379
  }
371
380
 
381
+ /**
382
+ * 이 전선에 **눈금 글자가 정말로 실려 나가나** — 나가면 그 글자, 안 나가면 null.
383
+ *
384
+ * ── 왜 따로 함수인가 ────────────────────────────────────────────────────
385
+ *
386
+ * `/think` 화면이 「지금 나가는 값 high」 를 적으려면 이것을 알아야 하는데,
387
+ * 여태 화면은 `눈금맞추기(카드, 강도)` 를 직접 불렀다. 그 함수는 **카드가 아는
388
+ * 눈금으로 낮추는 일**만 한다 — 그 눈금이 실릴 칸이 있는지는 모른다. 그래서
389
+ * 화면은 이런 자리에서 거짓을 적었다.
390
+ *
391
+ * budget (Anthropic 옛 판) 눈금이 아니라 **토큰 수**가 나간다
392
+ * boolean (ollama) 카드 눈금 표를 안 쓴다 — 이 규격이 아는 말로 옮긴다
393
+ * none 실을 칸이 아예 없다
394
+ * adaptive + 효력칸 null 서버가 output_config 를 안 받는다고 말해 준 판
395
+ *
396
+ * 앞의 세 자리에서 화면은 「지금 나가는 값 high」 라고 적고 전선에는 아무
397
+ * 눈금도 안 나갔다. `/think high` 를 치고 화면이 받아 준 것을 본 사람은 그
398
+ * 값이 먹었다고 믿는다 — 안 먹은 것보다 나쁘다.
399
+ *
400
+ * 그래서 **몸을 만드는 세 갈래가 모두 이 함수를 부른다.** 화면도 이 함수를
401
+ * 부른다. 둘이 갈라질 수 없는 모양으로 묶어 두는 것이 이 함수의 목적이라,
402
+ * 여기 갈래를 늘릴 때는 buildBody 쪽에 같은 갈래를 또 적지 않는다.
403
+ *
404
+ * @param {string|null} 규격 conn.kind — 눈금이 실릴 칸은 규격이 정한다
405
+ * @param {object|null} 카드 backend/wire.js 의 전선 카드 (없으면 짐작)
406
+ * @param {*} 강도 사람이 정한 세기 ('off' · 'low' … 'max' · 참거짓 · undefined)
407
+ * @returns {string|null}
408
+ */
409
+ export function 나가는눈금(규격, 카드, 강도) {
410
+ // 안 정했거나 참거짓이면 눈금 글자가 나갈 일이 없다 (참거짓은 켜고 끄는 말이다).
411
+ if (강도 === undefined || typeof 강도 === 'boolean') return null;
412
+ // 이 규격은 카드 눈금 표를 안 쓴다 — 위 ollama 갈래가 강도말() 로 옮겨 싣는다.
413
+ if (규격 === 'ollama') return 강도 === 'off' ? null : 강도말(강도);
414
+ if (규격 === 'anthropic') {
415
+ // 끄라는 말에는 thinking 칸 자체를 안 싣는다 — 눈금도 같이 안 나간다.
416
+ if (강도 === 'off') return null;
417
+ // budget 은 눈금이 아니라 숫자로 준다(생각예산). 눈금은 그 숫자를 고르는 데만 쓴다.
418
+ if ((카드?.생각형식 ?? 'budget') !== 'adaptive') return null;
419
+ if (카드?.효력칸 !== 'output_config') return null;
420
+ return 눈금맞추기(카드, 강도);
421
+ }
422
+ // openai 꼴. 카드가 없으면(검사·진단) 여태처럼 좁은 쪽으로 맞춘다.
423
+ if (!카드) return 강도말(강도);
424
+ return 카드.생각형식 === 'effort' ? 눈금맞추기(카드, 강도) : null;
425
+ }
426
+
372
427
  export function 생각예산(강도, maxTokens) {
373
428
  const 바라는것 = 강도별예산[String(강도)];
374
429
  if (!바라는것) return 0;
@@ -434,8 +489,9 @@ function anthropic몸({ model, messages, tools, stream, json, think, maxTokens,
434
489
  if (형식 === 'adaptive') {
435
490
  if (think !== undefined && think !== false && think !== 'off') {
436
491
  body.thinking = { type: 'adaptive' };
437
- const 눈금 = 눈금맞추기(카드, think);
438
- if (눈금 && 카드?.효력칸 === 'output_config') body.output_config = { effort: 눈금 };
492
+ // 효력칸을 볼지 말지도 나가는눈금() 이 안다 — 여기서 또 보면 둘이 갈라진다.
493
+ const 눈금 = 나가는눈금('anthropic', 카드, think);
494
+ if (눈금) body.output_config = { effort: 눈금 };
439
495
  }
440
496
  } else if (형식 === 'budget') {
441
497
  const 예산 = 생각예산(think, maxTokens);
@@ -746,7 +802,18 @@ export function assistantMessage(shape, { content = '', thinking = '', toolCalls
746
802
  if (toolCalls.length) m.tool_calls = toolCalls.map((t) => ({ function: { name: t.name, arguments: t.args } }));
747
803
  return m;
748
804
  }
749
- const m = { role: 'assistant', content: content || null };
805
+ /*
806
+ * `content: null` 은 **도구를 부를 때만** 옳다.
807
+ *
808
+ * 이 규격에서 assistant 의 content 는 「tool_calls 가 없으면 있어야 하는」
809
+ * 칸이다. 그런데 빈 답에 도구도 없는 턴(거절·상한에 걸려 아무 말도 못 한
810
+ * 턴)에서 `content: null` 만 실어 이력에 넣었고, 그 이력을 다음 요청에
811
+ * 그대로 보내면 그 턴이 거절된다. 옆 갈래(anthropic)는 같은 자리에서
812
+ * 자리표시를 넣어 막아 뒀는데 이쪽만 안 막혀 있었다.
813
+ *
814
+ * 없는 말을 지어 넣지는 않는다 — 빈 글자로 칸만 채운다.
815
+ */
816
+ const m = { role: 'assistant', content: content || (toolCalls.length ? null : '') };
750
817
  if (toolCalls.length) {
751
818
  m.tool_calls = toolCalls.map((t) => ({
752
819
  id: t.id, type: 'function',
@@ -1490,7 +1557,14 @@ function anthropic흡수(obj, acc, out) {
1490
1557
  // 캐시 수치는 여기 한 번만 온다. message_delta 에는 안 실린다 —
1491
1558
  // 여기서 안 챙기면 흘려받기에서는 캐시가 영영 0 으로 보인다.
1492
1559
  const c = 캐시읽기(u);
1493
- acc.usage = { in: u.input_tokens ?? 0, out: u.output_tokens ?? 0, cacheRead: c.읽음, cacheWrite: c.씀 };
1560
+ /*
1561
+ * `잰것` 을 여기만 안 적고 있었다 — ollama·openai 갈래는 둘 다 적는다.
1562
+ *
1563
+ * 이 값은 **usage 가 진짜로 왔나**를 뜻한다. 잠잠한지 보는 자(자란만큼)가
1564
+ * 그걸 「살아 있다」 의 한 표로 세는데, Anthropic 흘려받기에서는 usage 가
1565
+ * 제일 먼저 오는 소식이라 하필 그 한 표를 못 셌다.
1566
+ */
1567
+ acc.usage = { 잰것: true, in: u.input_tokens ?? 0, out: u.output_tokens ?? 0, cacheRead: c.읽음, cacheWrite: c.씀 };
1494
1568
  }
1495
1569
  return out;
1496
1570
  }
@@ -1586,8 +1660,18 @@ function 도구마무리(acc) {
1586
1660
  // 스트리밍은 마지막 조각이 안 오면 여기서 늘 깨진 채로 끝난다.
1587
1661
  acc.toolCalls = acc._raw.filter(Boolean).map((c, i) => {
1588
1662
  const call = { id: c.id ?? `call_${i + 1}`, name: c.name, args: {} };
1589
- if (!c.args) return call;
1590
- try { call.args = JSON.parse(c.args); }
1663
+ /*
1664
+ * 것은 깨진 것이 아니다 — 인자가 아예 없는 도구가 있다.
1665
+ *
1666
+ * 한 번에 받는 쪽(normalizeCalls)은 `trim()` 을 하고 나서 비었나 보는데
1667
+ * 여기는 안 했다. 그래서 창구가 인자 자리에 공백 한 칸(`" "` · `"\n"`)을
1668
+ * 흘려 주면 흘려받을 때만 **깨진 도구 부름**이 됐다 — 같은 모델, 같은
1669
+ * 도구인데 흘려받나 아니냐로 갈렸다. 같은 일을 하는 두 자가 같은 것을
1670
+ * 빈 것으로 봐야 한다.
1671
+ */
1672
+ const 원문 = typeof c.args === 'string' ? c.args.trim() : c.args;
1673
+ if (!원문) return call;
1674
+ try { call.args = JSON.parse(원문); }
1591
1675
  catch { call.argsBroken = true; call.rawArgs = c.args; }
1592
1676
  return call;
1593
1677
  });
@@ -86,9 +86,35 @@ export function 메모읽기(root) {
86
86
  } catch { return {}; }
87
87
  }
88
88
 
89
- /** 적어 둔다. 못 적어도 던지지 않는다 — 다음번에 다시 띄우면 그만이다. */
90
- export function 메모쓰기(root, 서버들) {
89
+ /**
90
+ * 적어 둔다. 못 적어도 던지지 않는다 — 다음번에 다시 띄우면 그만이다.
91
+ *
92
+ * ── 이미 적혀 있던 것을 지우지 않는다 ──────────────────────────────────
93
+ *
94
+ * 여기가 파일을 **통째로 새로** 썼다. 그런데 부르는 쪽은 이번에 정말 띄운
95
+ * 것만 넘긴다(대기 중인 것은 적은때를 새로 찍으면 「일주일이면 다시 본다」
96
+ * 가 영영 안 오므로 일부러 뺀다). 둘이 합쳐지면 **대기 중이던 서버의 메모가
97
+ * 지워진다.**
98
+ *
99
+ * 서버 A·B 를 쓰다가 B 의 설정만 고치면 — 그 판에서 A 는 대기, B 는 새로
100
+ * 뜸 → A 의 메모가 날아간다. 다음 판에는 A 를 띄우고 B 가 대기 → 이번엔
101
+ * B 가 날아간다. 그 뒤로 **매번 서버 하나가 반드시 즉시 뜬다.** 지연 로딩이
102
+ * 없애려던 기동 지연(위 머리말의 「서버 넷이면 2초」)이 영구히 돌아오는데,
103
+ * 화면에는 ● 하나 ◐ 하나가 떠서 설정을 안 고쳤는데 왜 매번 남의 프로세스가
104
+ * 뜨는지 알 길이 없다.
105
+ *
106
+ * 그래서 **겹쳐 쓴다.** 넘긴 것만 갈아 끼우고 나머지는 적힌 그대로 둔다.
107
+ *
108
+ * @param 남길이름 지금 설정에 있는 서버 이름들. 주면 설정에서 빠진 서버의
109
+ * 메모를 걷는다 — 안 걷으면 지운 서버가 파일에 영영 쌓인다.
110
+ */
111
+ export function 메모쓰기(root, 서버들, { 남길이름 = null } = {}) {
112
+ const 있던것 = 메모읽기(root);
91
113
  const servers = {};
114
+ for (const [이름, 것] of Object.entries(있던것)) {
115
+ if (남길이름 && !남길이름.includes(이름)) continue;
116
+ servers[이름] = 것;
117
+ }
92
118
  for (const s of 서버들) {
93
119
  if (!s.도구?.length) continue; // 못 띄운 것은 안 적는다
94
120
  servers[s.이름] = {
@@ -128,11 +154,38 @@ export function 설정읽기(root) {
128
154
  }
129
155
  const 표 = j.mcpServers ?? j.servers ?? {};
130
156
  const 서버들 = [];
157
+ /*
158
+ * ── 안 받는 항목도 **적어서 내놓는다** ────────────────────────────────
159
+ *
160
+ * 여기서 그냥 `continue` 했다. 그러면 적어 둔 서버가 화면 어디에도 안
161
+ * 나온다 — 켤 때 뜨는 줄은 붙은 것만 세고(repl.js), `/mcp` 목록도 붙은 것만
162
+ * 세운다. 사람 쪽에서는 **목록이 완전해 보인다.**
163
+ *
164
+ * 이 파일이 제일 흔하게 받는 것이 다른 도구의 설정을 그대로 붙여넣은
165
+ * 것이고, 거기에는 `"type": "sse"` 나 `url` 만 있는 항목이 섞여 있다.
166
+ * 그게 조용히 사라지면 「왜 그 도구가 없지」 를 영영 알 수 없다 — 아래
167
+ * 다붙이기 머리말이 스스로 약속한 바로 그 자리다. 못 뜬 다른 갈래들
168
+ * (오프라인·안 믿는 폴더)은 다 적어서 내놓는데 여기만 안 적었다.
169
+ *
170
+ * `disabled` 는 안 적는다. 그건 사람이 스스로 끈 것이라 알려 줄 것이 없다.
171
+ */
172
+ const 못받은것 = [];
131
173
  for (const [이름, v] of Object.entries(표)) {
132
174
  if (v?.disabled === true) continue;
133
175
  // stdio 만 받는다. http/sse 규격은 바깥으로 나가는 것이라 자물쇠와 부딪힌다.
134
- if (v?.type && v.type !== 'stdio') continue;
135
- if (!v?.command) continue;
176
+ if (v?.type && v.type !== 'stdio') {
177
+ 못받은것.push({ 이름, 왜: `"${v.type}" 규격은 아직 못 붙입니다 — stdio(command 로 띄우는 서버) 받습니다` });
178
+ continue;
179
+ }
180
+ if (!v?.command) {
181
+ 못받은것.push({
182
+ 이름,
183
+ 왜: v?.url
184
+ ? 'url 로 붙는 서버는 아직 못 붙입니다 — command 로 띄우는 stdio 서버만 받습니다'
185
+ : 'command 가 없습니다 — 무엇을 띄울지 적어야 합니다',
186
+ });
187
+ continue;
188
+ }
136
189
  서버들.push({
137
190
  이름,
138
191
  command: String(v.command),
@@ -141,7 +194,7 @@ export function 설정읽기(root) {
141
194
  cwd: v.cwd ? String(v.cwd) : root,
142
195
  });
143
196
  }
144
- return { 서버들, 자리: p, 있음: true };
197
+ return { 서버들, 못받은것, 자리: p, 있음: true };
145
198
  }
146
199
 
147
200
  /*
@@ -209,6 +262,13 @@ export class MCP서버 {
209
262
  this.깨우는중 = null;
210
263
  /** 깨우고 나서 목록이 달라졌으면 그 사실. 화면이 이걸 말한다. */
211
264
  this.달라짐 = null;
265
+ /*
266
+ * 메모를 어디에 적나. 메모로세우기() 가 넣어 준다.
267
+ *
268
+ * 설정.cwd 로 갈음할 수 없다 — 사람이 cwd 를 따로 적어 두면 그것은
269
+ * 폴더 뿌리가 아니고, 그러면 남의 폴더에 메모를 적는다.
270
+ */
271
+ this.뿌리 = null;
212
272
  }
213
273
 
214
274
  살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
@@ -221,7 +281,8 @@ export class MCP서버 {
221
281
  * 여기서 하는 일은 「이 서버에 이런 도구가 있다고 지난번에 봤다」 를 들고
222
282
  * 있는 것뿐이다. 진짜로 뜨는 것은 그 도구를 처음 부를 때다(깨우기).
223
283
  */
224
- 메모로세우기(메모) {
284
+ 메모로세우기(메모, 뿌리 = null) {
285
+ this.뿌리 = 뿌리;
225
286
  this.도구 = 메모.도구 ?? [];
226
287
  this.정보 = 메모.정보 ?? null;
227
288
  this.잘림 = 메모.잘림 ?? 0;
@@ -241,6 +302,8 @@ export class MCP서버 {
241
302
  if (this.죽음) return false;
242
303
  if (this.깨우는중) return this.깨우는중;
243
304
  const 적어둔것 = this.도구.map((t) => t.name).join('\0');
305
+ // 속까지 견주려면 정의를 그대로 들고 있어야 한다 (아래 바뀐것).
306
+ const 적어둔도구 = this.도구;
244
307
  this.깨우는중 = (async () => {
245
308
  // 죽음 은 붙기() 가 실패하며 남긴다. 다시 붙으려면 지워 두고 시작한다.
246
309
  this.죽음 = null;
@@ -255,6 +318,32 @@ export class MCP서버 {
255
318
  없어진것: 적어둔것.split('\0').filter((n) => !this.도구.some((t) => t.name === n)),
256
319
  };
257
320
  }
321
+ /*
322
+ * ── 이름은 같은데 **속이 달라진** 도구 ─────────────────────────────
323
+ *
324
+ * 위 맞춰보기는 이름만 본다. 그런데 제일 조용한 어긋남은 이름이 그대로인
325
+ * 쪽이다 — `wiki_search` 가 필수 인자 `repo` 를 새로 받기 시작하면,
326
+ * 적어 둔 옛 스키마를 그대로 모델에게 실어 보내고 모델은 `repo` 없이
327
+ * 부른다. 서버가 기본값으로 넘어가는 구현이면 **엉뚱한 저장소를 검색한
328
+ * 그럴듯한 결과**가 돌아온다. 400 이 나는 것보다 나쁘다.
329
+ */
330
+ const 옛것 = new Map((적어둔도구 ?? []).map((t) => [t.name, JSON.stringify(t.inputSchema ?? null)]));
331
+ const 바뀐것 = this.도구
332
+ .filter((t) => 옛것.has(t.name) && 옛것.get(t.name) !== JSON.stringify(t.inputSchema ?? null))
333
+ .map((t) => t.name);
334
+ if (바뀐것.length) this.달라짐 = { 늘어난것: [], 없어진것: [], ...(this.달라짐 ?? {}), 바뀐것 };
335
+ /*
336
+ * ── 맞춰 본 것을 **적어 둔다** ─────────────────────────────────────
337
+ *
338
+ * 이 파일 머리말이 「정말 띄운 뒤에 맞춰 본다. 다르면 그 자리에서 고쳐
339
+ * 적고」 라고 약속했는데, 「고쳐 적고」 가 없었다. 그래서 옛 목록이
340
+ * **매 실행마다 다시** 모델에게 나갔다 — 지문이 같고 이레가 안 지났으니
341
+ * 다음 판에도 같은 옛것을 쓰고, 같은 실패를 되풀이한다.
342
+ *
343
+ * 여기서 적은때를 새로 찍는 것은 맞다. 방금 **정말로 띄워서** 확인한
344
+ * 목록이라, 이레 시계는 이 순간부터 세는 것이 옳다.
345
+ */
346
+ if (this.뿌리) { try { 메모쓰기(this.뿌리, [this]); } catch { /* 못 적어도 대화는 계속된다 */ } }
258
347
  return true;
259
348
  })();
260
349
  return this.깨우는중;
@@ -494,7 +583,9 @@ export function 이름풀기(전체) {
494
583
  export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null, env = process.env } = {}) {
495
584
  const 설정 = 설정읽기(root);
496
585
  if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
497
- if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
586
+ // 규격 때문에 받은 것은 어느 길로 끝나든 같이 내놓는다 (설정읽기 머리말).
587
+ const 안받은것 = 설정.못받은것 ?? [];
588
+ if (!설정.서버들.length) return { 서버들: [], 못한것: [...안받은것], 설정 };
498
589
 
499
590
  /*
500
591
  * ── 믿는 폴더에서만 띄운다 ──────────────────────────────────────────
@@ -521,10 +612,10 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
521
612
  if (!믿나(root, { env })) {
522
613
  return {
523
614
  서버들: [],
524
- 못한것: 설정.서버들.map((s) => ({
615
+ 못한것: [...안받은것, ...설정.서버들.map((s) => ({
525
616
  이름: s.이름,
526
617
  왜: '믿는 폴더가 아닙니다 — 남의 저장소에 딸려 온 설정일 수 있어 안 띄웁니다 (deel trust)',
527
- })),
618
+ }))],
528
619
  설정,
529
620
  안믿음: true,
530
621
  };
@@ -535,7 +626,7 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
535
626
  if (offline) {
536
627
  return {
537
628
  서버들: [],
538
- 못한것: 설정.서버들.map((s) => ({ 이름: s.이름, 왜: '오프라인 잠금 중에는 안 띄웁니다' })),
629
+ 못한것: [...안받은것, ...설정.서버들.map((s) => ({ 이름: s.이름, 왜: '오프라인 잠금 중에는 안 띄웁니다' }))],
539
630
  설정,
540
631
  잠김: true,
541
632
  };
@@ -556,13 +647,13 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
556
647
  const 메모들 = 게으르게 ? 메모읽기(root) : {};
557
648
 
558
649
  const 붙은것 = [];
559
- const 못한것 = [];
650
+ const 못한것 = [...안받은것];
560
651
  let 새로띄운게있나 = false;
561
652
  await Promise.all(설정.서버들.map(async (s) => {
562
653
  const 서버 = new MCP서버(s);
563
654
  const 메모 = 쓸만한메모(메모들[s.이름], s);
564
655
  if (메모) {
565
- 붙은것.push(서버.메모로세우기(메모));
656
+ 붙은것.push(서버.메모로세우기(메모, root));
566
657
  audit?.write?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length, 대기: true });
567
658
  return;
568
659
  }
@@ -584,7 +675,11 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
584
675
  * 매번 새로 찍혀서 「일주일이면 다시 확인한다」 가 영영 안 온다. 그건 세 겹
585
676
  * 그물 중 하나를 우리 손으로 걷는 것이다.
586
677
  */
587
- if (게으르게 && 새로띄운게있나) 메모쓰기(root, 붙은것.filter((s) => !s.대기));
678
+ if (게으르게 && 새로띄운게있나) {
679
+ // 대기 중인 것은 안 넘긴다(적은때를 새로 찍으면 안 된다). 그래도 그 메모는
680
+ // 안 지워진다 — 메모쓰기 가 겹쳐 쓴다(그 머리말).
681
+ 메모쓰기(root, 붙은것.filter((s) => !s.대기), { 남길이름: 설정.서버들.map((s) => s.이름) });
682
+ }
588
683
  return { 서버들: 붙은것, 못한것, 설정, 게으르게 };
589
684
  }
590
685
 
@@ -168,7 +168,27 @@ export async function probe(conn, onStep = () => {}) {
168
168
  let thinkingModel = false;
169
169
  let retried = false;
170
170
 
171
- if (basic.ok && !got.content && got.thinking) {
171
+ /*
172
+ * ── 사고를 **글로 안 내주는** 모델도 여기로 온다 ──────────────────────
173
+ *
174
+ * 갈래가 `got.thinking` — 즉 사고가 글로 보일 때만 열렸다. 그런데 요즘
175
+ * 추론 모델 중에는 사고를 아예 안 내주는 쪽이 더 흔하다(게이트웨이 뒤
176
+ * OpenAI 계열). 그쪽은 본문도 비고 사고도 비어서 이 갈래에 못 들어오고,
177
+ * 바로 아래에서 **「응답이 비어 있습니다 — 모델 이름을 확인하세요」** 가 된다.
178
+ * 그러고는 남은 여덟 줄이 전부 「기본 대화가 안 되어 확인 불가」 로 건너뛰고,
179
+ * 그 판정이 프로필에 `streaming·tools·json·think·vision` 전부 false 로 남는다.
180
+ *
181
+ * 주소·열쇠·모델 이름은 처음부터 다 맞았다. 256토큰이 생각에 다 쓰인 것뿐이다.
182
+ * 사람은 맞는 이름을 몇 번씩 다시 넣어 본다 — 이 파일 머리말이 「붙기는
183
+ * 붙으니 아무도 고장이라고 생각하지 않는다」 고 적어 둔 그 모양이다.
184
+ *
185
+ * 손에 이미 증거가 있다. **상한에 걸려 끝났다**(stopped)거나 **생각 토큰을
186
+ * 썼다**(usage.reasoning)면 빈 답의 까닭이 이름이 아니다.
187
+ */
188
+ const 상한에걸렸나 = (e) => e?.stopped === 'length' || e?.stopped === 'max_tokens';
189
+ const 생각만했나 = (e) => !!e?.thinking || 상한에걸렸나(e) || (e?.usage?.reasoning ?? 0) > 0;
190
+
191
+ if (basic.ok && !got.content && 생각만했나(got)) {
172
192
  thinkingModel = true;
173
193
  retried = true;
174
194
  const second = await call({ ...ASK, maxTokens: 1024, think: false, timeout: 90000 });
@@ -196,7 +216,7 @@ export async function probe(conn, onStep = () => {}) {
196
216
  detail: basicOk
197
217
  ? `응답 "${basicText.trim().slice(0, 24)}"` + (thinkingModel ? c_note(retried) : '')
198
218
  : basic.ok
199
- ? got.thinking
219
+ ? got.thinking || 생각만했나(got)
200
220
  ? '사고만 나오고 본문이 안 나옵니다 — 토큰 상한을 크게 올려야 합니다'
201
221
  : '응답이 비어 있습니다 — 모델 이름을 확인하세요'
202
222
  // 못 붙은 까닭을 아는 자리가 하나 있다. 상태 코드만 남기면 사람은
@@ -296,12 +316,28 @@ export async function probe(conn, onStep = () => {}) {
296
316
  // 예전에는 여기서 OpenAI 날모양(function.arguments)을 직접 팠다 —
297
317
  // 그러면 Anthropic 은 도구를 제대로 불러도 「안 불렀다」 로 읽힌다.
298
318
  const argOk = gotCall && JSON.stringify(tcalls[0]?.args ?? '').includes('config');
319
+ /*
320
+ * ── 우리 상한에 잘린 것을 모델 탓으로 적었다 ──────────────────────────
321
+ *
322
+ * 인자 JSON 이 위 `maxTokens: 512` 안에서 잘리면 normalizeCalls 가
323
+ * `args = {}` 로 두고 **`argsBroken` 을 세운다.** 그러면 여기서 `config` 를
324
+ * 못 찾아 「인자가 부정확, 편집 신뢰성 작업이 더 필요합니다」 가 된다 —
325
+ * 모델은 경로를 제대로 쓰고 있었고 원문은 `rawArgs` 에 그대로 있다.
326
+ *
327
+ * 그 한 줄이 이 모델의 인자 품질 판정으로 보고서에 남는다. 우리가 만든
328
+ * 잘림을 남의 흠으로 적지 않는다 (34차 리뷰).
329
+ */
330
+ const 잘린인자 = gotCall && tcalls[0]?.argsBroken === true;
331
+ const 원문에있나 = 잘린인자 && String(tcalls[0]?.rawArgs ?? '').includes('config');
299
332
  add({
300
333
  id: 'tools',
301
334
  label: '도구 호출',
302
- status: gotCall ? (argOk ? 'ok' : 'warn') : 'no',
335
+ status: gotCall ? (argOk || 원문에있나 ? 'ok' : 'warn') : 'no',
303
336
  detail: gotCall
304
- ? `${tcalls[0]?.name} 호출됨${argOk ? '' : ' — 인자가 부정확, 편집 신뢰성 작업이 더 필요합니다'}`
337
+ ? `${tcalls[0]?.name} 호출됨${argOk ? ''
338
+ : 원문에있나 ? ` — 인자가 우리 상한(${512}토큰)에 잘렸을 뿐, 값은 제대로 왔습니다`
339
+ : 잘린인자 ? ' — 인자 JSON 이 잘려 왔습니다 (상한을 올려 다시 보세요)'
340
+ : ' — 인자가 부정확, 편집 신뢰성 작업이 더 필요합니다'}`
305
341
  : tl.ok ? '도구를 안 부르고 글로만 답합니다' : serverMessage(tl),
306
342
  ms: tl.ms,
307
343
  });
@@ -409,25 +445,63 @@ export async function probe(conn, onStep = () => {}) {
409
445
  * 알고 싶은 것은 **서버가 그림이 든 메시지를 받아 주느냐** 하나다.
410
446
  * 못 받는 서버는 400 이나 415 로 거절한다.
411
447
  */
412
- const 눈 = await call({
448
+ let 눈 = await call({
413
449
  ...quiet,
414
450
  messages: [눈검사메시지(shape)],
415
451
  maxTokens: 32,
416
452
  timeout: 60000,
417
453
  });
418
- const 눈있음 = !!(눈.ok && 읽기(눈).content);
454
+ /*
455
+ * ── 32토큰 상한에 걸린 것을 「안 보인다」 로 적었다 ────────────────────
456
+ *
457
+ * 위 머리말이 「답의 내용은 안 본다 — 알고 싶은 것은 서버가 그림이 든
458
+ * 메시지를 받아 주느냐 하나다」 라고 적어 놓고, 아래에서 **본문이 비면 못
459
+ * 보는 것**으로 단정했다. 추론 모델은 그 32토큰을 생각에 다 쓰므로 본문이
460
+ * 빈다 — 400 도 415 도 안 났는데, 즉 **받아 준 것인데** 안 보인다고 적혔다.
461
+ *
462
+ * 그 판정은 프로필에 `vision: false` 로 남고, 그 뒤로 화면 사진을 영영 안
463
+ * 보낸다(agent/loop.js 가 Read 설명에서 그림 이야기를 뺀다). 노란 경고 한
464
+ * 줄이라 고장으로 안 읽힌다.
465
+ *
466
+ * 상한에 걸린 것이면 한 번 더 넉넉히 묻는다 — 기본 대화 칸이 이미 그렇게
467
+ * 한다. 그래도 비면 그때는 못 쟀다고 적는다(아래).
468
+ */
469
+ let 눈읽은것 = 읽기(눈);
470
+ if (눈.ok && !눈읽은것.content && 상한에걸렸나(눈읽은것)) {
471
+ const 다시 = await call({
472
+ ...quiet, messages: [눈검사메시지(shape)], maxTokens: 512, think: false, timeout: 90000,
473
+ });
474
+ if (다시.ok) { 눈 = 다시; 눈읽은것 = 읽기(다시); }
475
+ }
476
+ const 눈있음 = !!(눈.ok && 눈읽은것.content);
477
+ // 받아 주긴 했는데 본문을 못 본 판 — 「안 보인다」 와 다르다.
478
+ const 눈못쟀나 = !눈있음 && 눈.ok && 상한에걸렸나(눈읽은것);
419
479
  add({
420
480
  id: 'vision',
421
481
  label: '그림 보기',
422
482
  status: 눈있음 ? 'ok' : 눈.ok ? 'warn' : 'no',
423
483
  detail: 눈있음
424
484
  ? `1×1 PNG 를 받아서 답함 — Read·@ 로 화면 사진을 보여 줄 수 있습니다`
425
- : 눈.ok
426
- ? '그림을 받긴 했는데 답이 비었습니다 보이는 것으로 칩니다'
427
- : `${serverMessage(눈)} — 그림은 안 보냅니다`,
485
+ : 눈못쟀나
486
+ ? '그림이 메시지는 받아 줬는데 답이 토큰 상한에 걸려 못 쟀습니다 채로 둡니다'
487
+ : 눈.ok
488
+ ? '그림을 받긴 했는데 답이 비었습니다 — 안 보이는 것으로 칩니다'
489
+ : `${serverMessage(눈)} — 그림은 안 보냅니다`,
428
490
  ms: 눈.ms ?? 0,
429
491
  });
430
- facts.vision = 눈있음;
492
+ /*
493
+ * 못 쟀으면 **켠 채로** 둔다.
494
+ *
495
+ * 둘 중 하나는 틀릴 수밖에 없는 자리다. 틀리는 값이 다르다 —
496
+ *
497
+ * false 로 적으면 볼 수 있는 모델에 그림을 영영 안 보낸다. 사람은 왜
498
+ * 화면 사진이 안 먹는지 알 길이 없다(되돌릴 길도 없다).
499
+ * true 로 적으면 못 보는 서버가 400·415 로 말해 주고, 그 한 번으로
500
+ * 화면에 까닭이 뜬다.
501
+ *
502
+ * 되돌아올 수 있는 쪽으로 틀린다.
503
+ */
504
+ facts.vision = 눈있음 || 눈못쟀나;
431
505
 
432
506
  /*
433
507
  * 7. 추론 강도 조절 — 낮음/높음이 실제로 다른 결과를 내느냐.
@@ -458,7 +532,10 @@ export async function probe(conn, onStep = () => {}) {
458
532
  // 토큰 수와 끝난 까닭도 규격마다 이름이 다르다. 읽는 자리를 하나로 모은다 —
459
533
  // 여기서 OpenAI 이름만 보면 Anthropic 은 늘 0 이고 늘 '안 잘림' 이 된다.
460
534
  out: e.usage?.out ?? 0,
461
- capped: e.stopped === 'length' || e.stopped === 'max_tokens',
535
+ // 「안 왔다」 「0」 을 가른다 (adapter.js 잰것). 이걸 안 보면
536
+ // usage 를 안 주는 창구에서 못 잰 것을 「차이 없음」 으로 적는다.
537
+ 잰것: e.usage?.잰것 === true,
538
+ capped: 상한에걸렸나(e),
462
539
  });
463
540
  } else {
464
541
  seen.push({ lv, ms: r.ms, err: serverMessage(r) });
@@ -471,7 +548,20 @@ export async function probe(conn, onStep = () => {}) {
471
548
  const outGap = bothOk ? Math.abs(seen[0].out - seen[1].out) : 0;
472
549
  const differs = bothOk && !capped &&
473
550
  (thoughtGap > Math.max(80, seen[0].thought * 0.2) || outGap > Math.max(20, seen[0].out * 0.2));
474
- const fmt = (s) => `${s.lv === 'low' ? '낮음' : '높음'} 사고 ${s.thought}자/출력 ${s.out}토큰/${s.ms}ms`;
551
+ /*
552
+ * ── 잴 칸이 없었던 판 ──────────────────────────────────────────────────
553
+ *
554
+ * 사고도 글로 안 오고 usage 도 안 오는 창구가 있다(호환 게이트웨이·프록시).
555
+ * 그러면 `thought` 와 `out` 이 넷 다 0 이고 `thoughtGap = outGap = 0` 이라
556
+ * **「차이 없음」** 이 된다 — 두 요청의 걸린 시간이 세 배로 벌어져 있어도
557
+ * 그렇다. 강도는 먹고 있었고 우리가 잴 칸이 없었을 뿐이다.
558
+ *
559
+ * 그 판정이 프로필에 `think: false` 로 남으면 `/think` 가 「이 모델은 조절
560
+ * 안 됩니다」 라고 하고 상태줄도 회색으로 굳는다. 안 되는 것을 안 된다고
561
+ * 적는 것과, **못 쟀는데 안 된다고 적는 것**은 다르다.
562
+ */
563
+ const 잴것이있었나 = bothOk && seen.every((s) => s.잰것 || s.thought > 0);
564
+ const fmt = (s) => `${s.lv === 'low' ? '낮음' : '높음'} 사고 ${s.thought}자/출력 ${s.잰것 ? `${s.out}토큰` : '토큰 안 옴'}/${s.ms}ms`;
475
565
  add({
476
566
  id: 'think',
477
567
  label: '추론 강도 조절',
@@ -482,10 +572,14 @@ export async function probe(conn, onStep = () => {}) {
482
572
  ? `둘 다 토큰 상한(${THINK_CAP})에 걸려 비교 불가 — 루프 층에서 조절합니다`
483
573
  : differs
484
574
  ? `${fmt(seen[0])} · ${fmt(seen[1])}`
485
- : `차이 없음 (${fmt(seen[0])} · ${fmt(seen[1])}) — 루프 층에서 조절합니다`,
575
+ : 잴것이있었나
576
+ ? `차이 없음 (${fmt(seen[0])} · ${fmt(seen[1])}) — 루프 층에서 조절합니다`
577
+ : `못 쟀습니다 — 사고도 usage 도 안 주는 창구입니다 (${fmt(seen[0])} · ${fmt(seen[1])}).`
578
+ + ' 조절은 켠 채로 둡니다 — 안 받는 창구면 서버가 거절하면서 말해 주고, 그때 배웁니다.',
486
579
  ms: seen.reduce((a, s) => a + (s.ms ?? 0), 0),
487
580
  });
488
- facts.think = differs;
581
+ // 쟀으면 켠 채로 둔다 — 되돌아올 수 있는 쪽으로 틀린다 (위 그림 칸 머리말).
582
+ facts.think = differs || !잴것이있었나;
489
583
 
490
584
  // 8. 컨텍스트 길이 — 파일을 몇 개까지 한 번에 읽힐 수 있느냐.
491
585
  //
@@ -1054,6 +1054,24 @@ export function 전선말(카드) {
1054
1054
  none: 말('wire.none'),
1055
1055
  }[카드.캐시] ?? 카드.캐시;
1056
1056
  조각.push(`${말('wire.cache')} ${캐시}`);
1057
+ /*
1058
+ * ── 짐작한 것과 **배운 것**을 갈라 적는다 ──────────────────────────────
1059
+ *
1060
+ * 여태 이 줄은 둘을 똑같이 보여 줬다. 그러면 「이 모델은 생각을 안 한다」
1061
+ * 와 「3주 전 400 한 번을 우리가 그렇게 읽었다」 가 화면에서 구별이 안
1062
+ * 된다. 뒤쪽은 **틀릴 수 있는 것**이고(이 파일의 배울전선 머리말), 틀렸을
1063
+ * 때 사람이 알아볼 유일한 단서가 이 표다.
1064
+ *
1065
+ * 칸 이름을 새로 옮기지 않는다 — 위에서 이미 옮긴 이름표를 그대로 쓴다.
1066
+ * 그래야 말 하나를 고칠 때 두 자리가 갈라지지 않는다.
1067
+ */
1068
+ const 배운것 = new Set(카드.배운칸 ?? []);
1069
+ const 배운이름 = [
1070
+ ['생각형식', 말('wire.think')],
1071
+ ['눈금', 말('wire.rungs')],
1072
+ ['캐시', 말('wire.cache')],
1073
+ ].filter(([칸]) => 배운것.has(칸)).map(([, 이름]) => 이름);
1074
+ if (배운이름.length) 조각.push(`${말('wire.learned')} ${배운이름.join('·')}`);
1057
1075
  return 조각.join(' · ');
1058
1076
  }
1059
1077
 
package/src/commands.js CHANGED
@@ -29,7 +29,7 @@ import { 종, 알릴만한초 } from './ui/notify.js';
29
29
  import { 말, 말 as 옮긴말, 언어, 언어들, 언어정하기, 언어고르기, 옮긴만큼, 지시말, 지시말정하기, 지시말따로정했나 } from './i18n/index.js';
30
30
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
31
31
  import { allowTemporarily } from './safety/network.js';
32
- import { chat, 규격이름, 더할머리 } from './backend/adapter.js';
32
+ import { chat, 규격이름, 더할머리, 나가는눈금 } from './backend/adapter.js';
33
33
  import { 잠잠기본, 무소식기본 } from './backend/http.js';
34
34
  import { 인증서설정, 인증서말 } from './backend/clientcert.js';
35
35
  import { 알림채움, 알림말 } from './backend/retry.js';
@@ -64,7 +64,7 @@ export function 적용하기(고른것) {
64
64
  끔설정(값 === '끔');
65
65
  }
66
66
  import { PROFILES, LEVELS as THINK_LEVELS, normalizeProfile, table as effortTable, 가벼운강도, shiftLevel } from './agent/effort.js';
67
- import { 전선붙이기, 전선말, 눈금맞추기 } from './backend/wire.js';
67
+ import { 전선붙이기, 전선말 } from './backend/wire.js';
68
68
  import { scanLocal, toProfiles } from './backend/scan.js';
69
69
  import { list as listSessions } from './agent/store.js';
70
70
  import { MODES as WORK_MODES, ORDER as WORK_ORDER, DEFAULT as WORK_DEFAULT, normalize as normWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
@@ -1934,7 +1934,15 @@ function showThink(session, { 자세히 = false } = {}) {
1934
1934
  }
1935
1935
  if (session.conn?.전선) {
1936
1936
  const 카드 = session.conn.전선;
1937
- const 나가는것 = 눈금맞추기(카드, session.think);
1937
+ /*
1938
+ * **정말로 나가는 것만** 적는다.
1939
+ *
1940
+ * 여태 여기서 `눈금맞추기()` 를 직접 불렀다. 그 함수는 카드가 아는 눈금으로
1941
+ * 낮추는 일만 하고 그 눈금이 실릴 칸이 있는지는 모른다 — 그래서 눈금이
1942
+ * 아예 안 나가는 전선(budget·boolean·none)에서도 「지금 나가는 값 high」
1943
+ * 가 떴다. 몸을 만드는 자리와 같은 함수를 쓴다(backend/adapter.js).
1944
+ */
1945
+ const 나가는것 = 나가는눈금(session.conn.kind, 카드, session.think);
1938
1946
  /*
1939
1947
  * ── 「끈다」 가 정말로 꺼지는 창구인가 ────────────────────────────
1940
1948
  *
@@ -2516,6 +2524,23 @@ function 배움명령(session, ctx, arg = '') {
2516
2524
  return;
2517
2525
  }
2518
2526
 
2527
+ /*
2528
+ * ── 전선만 지우는 길 ────────────────────────────────────────────────────
2529
+ *
2530
+ * 전선 모양은 틀리게 배울 수 있는 유일한 것이고(backend/wire.js), 틀리면
2531
+ * 멀쩡한 기능이 꺼진 채 굳는다. 그런데 되돌릴 길이 「전부 비우기」 하나라,
2532
+ * 몇 주 쌓은 명령 겪음까지 같이 날아갔다 — 그게 아까워서 사람은 안 비우고,
2533
+ * 안 비우니 꺼진 기능을 그냥 안고 쓴다. 전선만 지우면 잃을 것이 없다.
2534
+ */
2535
+ if (/^(전선|wire)\s*(지우기|clear|forget|비우기)?$/i.test(String(arg).trim())) {
2536
+ 배움.지우기('전선');
2537
+ if (session.conn) 전선붙이기(session.conn, 배움);
2538
+ say(` ${mark.ok} ${c.gray('전선에서 배운 것만 비웠습니다 — 다음 요청부터 짐작으로 다시 시작합니다.')}`);
2539
+ say(` ${c.gray('겪어 본 명령과 토큰 보정은 그대로 둡니다.')}`);
2540
+ say('');
2541
+ return;
2542
+ }
2543
+
2519
2544
  if (/^(지우기|clear|forget|비우기)$/i.test(String(arg).trim())) {
2520
2545
  배움.지우기('전부');
2521
2546
  session.배움요약 = null;
@@ -2580,8 +2605,29 @@ function 배움명령(session, ctx, arg = '') {
2580
2605
  } else {
2581
2606
  say(` ${c.gray('아직 프롬프트에 실을 만큼 확실한 것은 없습니다 — 두 번 이상 겪어야 싣습니다.')}`);
2582
2607
  }
2608
+ /*
2609
+ * ── 전선에서 배운 것도 여기 보인다 ──────────────────────────────────────
2610
+ *
2611
+ * 쌓인 것 중 **제일 크게 작용하는 것**이 전선 모양인데(무엇을 실어 보낼지
2612
+ * 가 여기서 갈린다) 이 화면에 아예 안 떴다. 그래서 잘못 배운 판에서 사람이
2613
+ * 「배운 것을 보자」 고 여기를 열면, 정작 문제인 줄만 안 보였다.
2614
+ */
2615
+ const 전선 = session.conn?.전선;
2616
+ if (전선) {
2617
+ const 배운칸 = 전선.배운칸 ?? [];
2618
+ say(` ${c.bold('이 창구에 실어 보내는 모양')}`);
2619
+ say(` ${c.gray(전선말(전선))}`);
2620
+ if (배운칸.length) {
2621
+ say(` ${c.gray('「배움」 은 짐작이 아니라 서버가 거절해서 고친 자리입니다 — 틀릴 수도 있습니다.')}`);
2622
+ }
2623
+ say('');
2624
+ }
2625
+
2583
2626
  say('');
2584
2627
  say(` ${c.gray('지우려면')} ${c.cyan('/learned 지우기')}`);
2628
+ if (전선?.배운칸?.length) {
2629
+ say(` ${c.gray('전선에서 배운 것만 되돌리려면')} ${c.cyan('/learned 전선 지우기')}`);
2630
+ }
2585
2631
  say('');
2586
2632
  }
2587
2633
 
package/src/i18n/en.js CHANGED
@@ -542,6 +542,7 @@ export const en = {
542
542
  'wire.cacheMarked': 'marked',
543
543
  'wire.cacheKey': 'key',
544
544
  'wire.cacheAuto': 'server-side',
545
+ 'wire.learned': 'learned',
545
546
  'cost.cache': 'Cache',
546
547
  'cost.think': 'Thinking',
547
548
  'cost.thinkLine': '{토큰} tokens · {몫}% of output',
package/src/i18n/ko.js CHANGED
@@ -589,6 +589,7 @@ export const ko = {
589
589
  'wire.cacheMarked': '표식',
590
590
  'wire.cacheKey': '열쇠',
591
591
  'wire.cacheAuto': '서버자동',
592
+ 'wire.learned': '배움',
592
593
  'cost.cache': '캐시',
593
594
  'cost.think': '생각',
594
595
  'cost.thinkLine': '{토큰} 토큰 · 답의 {몫}%',
package/src/repl.js CHANGED
@@ -2279,8 +2279,9 @@ export async function chatLoop(opts = {}) {
2279
2279
 
2280
2280
  case 'images_folded': {
2281
2281
  clearThinking();
2282
- const 장수 = (ev.뺀것들 ?? []).reduce((a2, x) => a2 + x.장수, 0);
2283
- say(` ${c.cyan('◲')} ${c.gray(`오래된 그림 ${장수}장을 뺐습니다 사람이 말은 그대로입니다.`)}`);
2282
+ // 장수는 접는 쪽이 세어 준다 (agent/compact.js foldImages).
2283
+ // 여기서 세면 한쪽만 고쳐지고, 그때부터 화면이 다른 수를 적는다.
2284
+ say(` ${c.cyan('◲')} ${c.gray(`오래된 그림 ${ev.뺀것}장을 뺐습니다 — 사람이 쓴 말은 그대로입니다.`)}`);
2284
2285
  break;
2285
2286
  }
2286
2287
 
package/src/tools/jobs.js CHANGED
@@ -384,6 +384,35 @@ class 일감 {
384
384
  while (this.바이트 > 출력상한 && this.조각들.length > 1) {
385
385
  this.바이트 -= this.조각들.shift().length;
386
386
  }
387
+ /*
388
+ * 한 조각이 **혼자** 상한을 넘으면 그 조각의 뒤만 남긴다.
389
+ *
390
+ * 위 되풀이는 조각이 둘 이상일 때만 버린다. 그래서 한 조각이 혼자 넘치면
391
+ * **아무것도 못 버리는데 아래에서 잘렸다고 적는다.** 그 사이에 읽으면
392
+ * 멀쩡한 글에 "앞이 잘렸습니다" 가 붙고 읽은 자리도 0 으로 돌아간다 —
393
+ * 모델은 없는 앞을 찾으러 처음부터 다시 읽는다. (다음 조각이 들어오면
394
+ * 그때는 둘이 되어 버려지므로, 거짓말은 그 한 판만이다. 그래도 그 한
395
+ * 판이 되풀이를 부르는 자리다 — 읽기() 머리말 참고.)
396
+ *
397
+ * 그리고 버릴 수 있게 만들어 두는 것이 맞다. 상한 위에 남겨 두면 그
398
+ * 다음 조각 하나에 256KB 가 통째로 날아간다.
399
+ *
400
+ * 베껴 담는다. subarray 는 원래 덩이를 그대로 붙잡고 있어서, 큰 조각
401
+ * 하나가 들어온 판에서는 놓으려는 그 메모리가 안 놓인다.
402
+ */
403
+ if (this.바이트 > 출력상한 && this.조각들.length === 1) {
404
+ const 하나 = this.조각들[0];
405
+ this.조각들[0] = Buffer.from(하나.subarray(하나.length - 출력상한));
406
+ this.바이트 = this.조각들[0].length;
407
+ }
408
+ /*
409
+ * 여기 오면 **반드시 무엇인가 버렸다** — 그래서 「안 버렸으면」 갈래를
410
+ * 두지 않는다. 위에서 상한을 넘은 것을 확인했고, 조각이 둘 이상이면
411
+ * 되풀이가 버리고 하나뿐이면 그 하나를 줄였다. 늘 참인 조건을 남기면
412
+ * 어긋내기가 「그 줄을 지워도 아무 검사도 안 깨진다」 고 말하는데, 그건
413
+ * 맞는 말이라 고칠 수도 없다 — 대신 읽는 사람은 안 버리는 길이 따로
414
+ * 있는 줄로 읽는다.
415
+ */
387
416
  this.앞잘림 = true;
388
417
  this.막버렸다 = true;
389
418
  this.읽은글자 = 0;
@@ -516,10 +545,24 @@ export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500,
516
545
 
517
546
  kid.stdout?.on('data', (b) => j.담기(b));
518
547
  kid.stderr?.on('data', (b) => j.담기(b));
519
- kid.on('error', (err) => { j.담기(Buffer.from(`\n[띄우기 실패: ${err.message}]\n`)); j.상태 = '끝남'; j.종료코드 = -1; });
548
+ kid.on('error', (err) => {
549
+ j.담기(Buffer.from(`\n[띄우기 실패: ${err.message}]\n`));
550
+ j.상태 = '끝남';
551
+ j.종료코드 = -1;
552
+ // 왜 표를 세우나 — 바로 아래 close 머리말.
553
+ j.띄우기실패 = true;
554
+ });
520
555
  kid.on('close', (code, sig) => {
521
556
  if (j.상태 === '죽임') return;
522
557
  j.상태 = '끝남';
558
+ /*
559
+ * 띄우기 자체가 실패한 판은 **덮지 않는다.**
560
+ *
561
+ * 명령이 없으면(ENOENT) Node 는 error 를 낸 다음 close 를 code=null 로
562
+ * 부른다. 그러면 위에서 세워 둔 -1 이 null 로 덮이고, null 은 이 자리에서
563
+ * 「시그널로 죽었다」 와 구별이 안 된다. 안 뜬 까닭이 종료코드에서 지워진다.
564
+ */
565
+ if (j.띄우기실패) { 끝난것치우기(); return; }
523
566
  j.종료코드 = sig ? null : code;
524
567
  j.시그널 = sig ?? null;
525
568
  // 끝난 것이 쌓이지 않게. 도는 것은 최대일감 이 따로 막는다.
@@ -628,7 +671,17 @@ export async function 끝내기(번호) {
628
671
  */
629
672
  await 죽는말기다리기(j.kid, 죽는말기다림);
630
673
  }
631
- 파이프끊기(j.kid);
674
+ /*
675
+ * ── 안 죽은 놈의 파이프는 **끊지 않는다** ──────────────────────────────
676
+ *
677
+ * 여기서 늘 끊고 있었다. 그런데 바로 아래에서 「그러고도 안 죽었으면 목록에
678
+ * 남긴다 — 다시 시킬 수 있어야 한다」 고 한다. 파이프를 이미 끊어 놓고
679
+ * 남기면, 목록에는 `도는중` 으로 뜨는데 그놈이 뱉는 말은 다시는 안 들어온다.
680
+ * 사람은 화면을 보며 기다리고, 자식은 다음 쓰기에서 EPIPE 를 맞는다.
681
+ * 남겨 두기로 한 까닭이 「다시 볼 수 있게」 인데 정작 볼 길을 끊던 셈이다.
682
+ */
683
+ const 죽었나 = !아직사나(j.kid);
684
+ if (죽었나) 파이프끊기(j.kid);
632
685
  const 남은 = j.읽기({});
633
686
  /*
634
687
  * ★ 그러고도 안 죽었으면 **그렇다고 말한다.**
@@ -640,7 +693,7 @@ export async function 끝내기(번호) {
640
693
  *
641
694
  * 안 죽었으면 목록에 남긴다. 그래야 사람이 한 번 더 시킬 수 있다.
642
695
  */
643
- if (아직사나(j.kid)) {
696
+ if (!죽었나) {
644
697
  j.상태 = '도는중'; // 지우지 않는다 — 다시 시킬 수 있어야 한다
645
698
  return { 이미: false, 안죽음: true, 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
646
699
  }
@@ -723,6 +776,8 @@ export function 일감인자(args) {
723
776
  };
724
777
  const 날번호 = 하나씩('번호');
725
778
  return {
779
+ // 날것을 같이 내놓는다 — 숫자가 아닐 때 **무엇을 받았는지** 적어야 한다.
780
+ 날번호,
726
781
  번호: 날번호 == null ? null : Number(날번호),
727
782
  끝내기: !!하나씩('끝내기'),
728
783
  처음부터: !!하나씩('처음부터'),
@@ -791,11 +846,34 @@ export const JOBS_TOOL = {
791
846
  + ' 번호 없이 부르면 목록입니다.',
792
847
  };
793
848
  }
849
+ /*
850
+ * 몇 개만 못 알아들은 판도 **말해 준다.**
851
+ *
852
+ * 위 갈래는 「하나도」 못 알아들었을 때만 걸린다. 그래서
853
+ * `{job: 1, action: 'stop'}` 은 걸리지 않고 — `action` 을 조용히 버린 뒤
854
+ * 출력만 읽어 **성공으로** 돌려줬다. 끄라고 시킨 모델은 껐다고 여기고
855
+ * 넘어가는데 서버는 그대로 돈다. 위 머리말이 없애겠다고 적어 둔 바로 그
856
+ * 고장이, 인자를 하나 더 붙인 판에 그대로 남아 있었다 (34차 리뷰).
857
+ *
858
+ * 오류로 막지는 않는다 — 알아들은 것은 하라고 시킨 것이 맞다. 대신 버린
859
+ * 이름을 적어 보내서, 뜻이 거기 있었으면 다음 걸음에서 고쳐 부르게 한다.
860
+ */
861
+ const 버린말 = 받은것.모르는것.length
862
+ ? `\n(못 알아들어 버린 인자: ${받은것.모르는것.join(', ')} — Jobs 는 job · stop · from_start 만 받습니다.`
863
+ + ' 끝내려던 것이면 stop: true 로 다시 불러라.)'
864
+ : '';
794
865
  const 번호 = 받은것.번호;
795
866
  // 숫자가 아닌 것을 받으면 NaN 이 되고, 그대로 두면 `NaN번 일감이 없습니다`
796
867
  // 라는 말이 모델에게 나간다. 무엇이 잘못됐는지 안 알려 주는 오류다.
797
868
  if (번호 != null && !Number.isFinite(번호)) {
798
- return { error: `번호는 숫자여야 합니다 (받은 것: ${JSON.stringify(args?.번호)}). 번호 없이 Jobs 를 불러 목록을 보세요.` };
869
+ /*
870
+ * 받은 것을 **실제로 읽은 그 칸**에서 적는다.
871
+ *
872
+ * 여기가 `args?.번호` 였다. 설명서에 실린 이름은 `job` 이라 모델은 거의
873
+ * 늘 `job` 으로 보내는데, 그러면 이 말은 `(받은 것: undefined)` 가 된다.
874
+ * 무엇을 잘못 보냈는지 알려 주려고 있는 줄이 정작 그것만 안 알려 줬다.
875
+ */
876
+ return { error: `번호는 숫자여야 합니다 (받은 것: ${JSON.stringify(받은것.날번호)}). 번호 없이 Jobs 를 불러 목록을 보세요.` };
799
877
  }
800
878
 
801
879
  if (번호 == null) {
@@ -830,7 +908,7 @@ export const JOBS_TOOL = {
830
908
  * 그러면 포트가 물려서 안 뜨고, 왜 안 되는지도 모른다.
831
909
  */
832
910
  if (치운것) 줄들.push(` (오래된 것 ${치운것}개는 자리를 위해 지웠습니다)`);
833
- return { content: 줄들.join('\n'), summary: 세말('count', ls.length), 일감수: ls.length };
911
+ return { content: 줄들.join('\n') + 버린말, summary: 세말('count', ls.length), 일감수: ls.length };
834
912
  }
835
913
 
836
914
  if (받은것.끝내기) {
@@ -854,7 +932,7 @@ export const JOBS_TOOL = {
854
932
  }
855
933
  return {
856
934
  content: `${번호}번을 끝냈습니다 (${r.초}초 돌았습니다): ${r.명령}`
857
- + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : ''),
935
+ + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : '') + 버린말,
858
936
  summary: 이어(말('sum.jobStopped'), 말('unit.sec', { n: r.초 })),
859
937
  };
860
938
  }
@@ -870,7 +948,7 @@ export const JOBS_TOOL = {
870
948
  ? (r.앞잘림 ? '(앞부분은 너무 길어 잘렸습니다 — 뒤쪽만 남았습니다)\n' : '') + 잘라낸.글
871
949
  : '(지난번 읽은 뒤로 새 출력이 없습니다)';
872
950
  return {
873
- content: `${머리}\n\n${몸}`,
951
+ content: `${머리}\n\n${몸}${버린말}`,
874
952
  summary: (r.상태 === '도는중' ? 이어(말('sum.jobRunning'), 말('unit.sec', { n: r.초 })) : 끝난말(r))
875
953
  + (잘라낸.줄임 ? ' · 일부만' : ''),
876
954
  // 끝난 일감이 종료코드 0 이 아니면 실패로 물들인다 — Bash 와 같은 규칙이다.