deel-local-cli 1.20.10 → 1.20.11

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%2C847%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-9%2C967%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.10 ⌂ 이 안
304
+ deel 1.20.11 ⌂ 이 안
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.11](docs/ko/releases/1.20.md#12011) | 안전망이 있다고 말하는데 없었습니다 |
1292
1293
  | [1.20.10](docs/ko/releases/1.20.md#12010) | 답하지 않았는데 답한 것이 되어 있었습니다 |
1293
1294
  | [1.20.9](docs/ko/releases/1.20.md#1209) | 안 보이는 것이 아니라 다른 것이 보이고 있었습니다 |
1294
1295
  | [1.20.8](docs/ko/releases/1.20.md#1208) | 못 쟀다는 것을 안 된다고 적고 있었습니다 |
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%2C847%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-9%2C967%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.10 ⌂ inside
377
+ deel 1.20.11 ⌂ 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.11](docs/en/releases/1.20.md#12011) | The safety net said it was there when it wasn't |
1353
1354
  | [1.20.10](docs/en/releases/1.20.md#12010) | Nobody answered, yet an answer had been given |
1354
1355
  | [1.20.9](docs/en/releases/1.20.md#1209) | Nothing was missing from the screen — something else was on it |
1355
1356
  | [1.20.8](docs/en/releases/1.20.md#1208) | We were writing down "can't" where the truth was "couldn't measure" |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.20.10",
3
+ "version": "1.20.11",
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",
package/src/agent/loop.js CHANGED
@@ -90,8 +90,25 @@ function 빈답인가(msg) {
90
90
  * 3) 둘 다 없으면 **없다고 말한다.** 침묵은 지어내기를 부른다.
91
91
  */
92
92
  export function 실을글(result) {
93
- if (result?.error) return `오류: ${result.error}`;
94
93
  const 글 = result?.content;
94
+ /*
95
+ * ── 오류에도 **알아낸 것이 딸려 온다** ──────────────────────────
96
+ *
97
+ * 여태 오류면 그 한 줄만 싣고 content 를 버렸다. 그런데 오류와 글이
98
+ * 같이 오는 자리가 셋이나 된다 —
99
+ *
100
+ * · 끊긴 Bash 가 죽기 직전에 뱉은 몇 줄
101
+ * · 시간 초과로 끊긴 명령이 그때까지 찍은 것
102
+ * · 여덟 개를 서로 다른 까닭으로 다 실패한 Write·Edit 의 줄별 사유
103
+ *
104
+ * 마지막 것이 제일 아프다. 결과에는 여덟 줄이 다 적혀 있는데 모델은
105
+ * **첫 줄 하나**만 받고, 나머지 일곱은 왜 안 됐는지 모른 채 그대로
106
+ * 다시 보낸다. 오류는 오류대로 말하고, 알아낸 것은 같이 싣는다.
107
+ */
108
+ if (result?.error) {
109
+ const 딸린것 = String(글 ?? '').trim();
110
+ return 딸린것 ? `오류: ${result.error}\n${딸린것}` : `오류: ${result.error}`;
111
+ }
95
112
  if (String(글 ?? '').trim()) return String(글);
96
113
  const 요약 = String(result?.summary ?? '').trim();
97
114
  if (요약) return 요약;
@@ -288,14 +305,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
288
305
  const 이카드 = ctx?.카드 ?? null;
289
306
  const MAX_SAME = 이카드?.조정?.같은것한계 ?? 3;
290
307
  let 멈출까 = null;
291
- const 막힘셈 = (call, 이유) => {
308
+ const 막힘셈 = (call, 이유, { 몫 = 1 } = {}) => {
292
309
  // 파일 하나가 아니라 **그 파일** 을 센다. 이름만 세면 서로 다른 파일 세 개를
293
310
  // 고치다 실패한 것이 한 덩어리로 뭉쳐 턴이 죽는다. 다섯 군데 중 두 군데만
294
311
  // 고쳐 놓고 '헛돌고 있어 멈췄습니다' 가 되는 것이 그 모습이다.
295
312
  const 어디 = call.args?.file_path ?? call.args?.path ?? call.args?.pattern ?? call.args?.command
296
313
  ?? call.args?.purpose ?? call.args?.목적 ?? '';
297
314
  const 서명 = `${call.name}|${이유}|${String(어디).slice(0, 200)}`;
298
- const n = (막힘.get(서명) ?? 0) + 1;
315
+ // 몫: 도구가 「다시 해도 같다」(끝났다) 말했으면 번을 두 번으로 센다.
316
+ const n = (막힘.get(서명) ?? 0) + 몫;
299
317
  막힘.set(서명, n);
300
318
  return n >= MAX_SAME;
301
319
  };
@@ -1932,7 +1950,24 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1932
1950
  // 같은 도구가 같은 이유로 계속 실패하면 헛돌고 있는 것이다.
1933
1951
  // 오류의 첫 줄만 본다 — 뒤에 붙는 경로·숫자는 매번 달라도 원인은 같다.
1934
1952
  const 이유 = String(result.error).split('\n')[0].slice(0, 60);
1935
- if (막힘셈(call, 이유)) 멈출까 = `${call.name} 이 같은 이유로 계속 실패합니다 — ${이유}`;
1953
+ /*
1954
+ * ── `끝났다` 는 **아무도 안 읽고 있었다** ──────────────────
1955
+ *
1956
+ * 도구 쪽 열대여섯 자리가 이 표를 달아 보낸다. 뜻은 「다시 물어도
1957
+ * 답이 같다」 — 옛 hwp, 못 읽는 확장자, 없는 폴더처럼 되풀이가
1958
+ * 아무 소용 없는 자리들이다. tools/index.js 의 주석은 아예
1959
+ * `끝났다: true → 되풀이 억제가 걸린다` 라고 적어 뒀는데,
1960
+ * 저장소를 통틀어 이 값을 **읽는 자리가 한 곳도 없었다.**
1961
+ * 걸린다고 적어 놓고 아무것도 안 걸고 있었다.
1962
+ *
1963
+ * 그래서 여기서 읽는다. 세 번을 기다리지 않고 두 몫으로 센다 —
1964
+ * 도구가 스스로 「이건 끝이다」 라고 말한 것이라, 같은 자리를 한
1965
+ * 번 더 두드리면 그것으로 충분하다. 그래도 곧바로 멈추지는
1966
+ * 않는다. 모델이 다른 길로 갈 여지는 남겨 둔다.
1967
+ */
1968
+ if (막힘셈(call, 이유, { 몫: result.끝났다 ? 2 : 1 })) {
1969
+ 멈출까 = `${call.name} 이 같은 이유로 계속 실패합니다 — ${이유}`;
1970
+ }
1936
1971
  }
1937
1972
  if (call.name === 'Read' && result.content) session.noteRead(call.args.file_path, result.content);
1938
1973
  // 실제로 파일이 바뀐 것만 적는다. 턴 끝에 이 목록을 디스크와 견준다.
@@ -185,11 +185,37 @@ function 기본규칙(ctx) {
185
185
  ? (짧게 ? BASE_RULES_짧게_EN : BASE_RULES_EN)
186
186
  : (짧게 ? BASE_RULES_짧게 : BASE_RULES);
187
187
  if (시키는말 === 언어()) return 글;
188
+ /*
189
+ * ── 말이 넷인데 갈래가 둘이었다 ──────────────────────────────────────
190
+ *
191
+ * 화면 말은 ko·en·ja·zh 넷이다(i18n/index.js 의 언어들). 그런데 여기는
192
+ * 「한국어면 한국어, 아니면 영어」 두 갈래였다. 그래서 일본어로 켠 사람은
193
+ * 규칙 끝에 이런 꼬리를 받았다 —
194
+ *
195
+ * **Answer in English.** The rules above are in Korean…
196
+ *
197
+ * 두 군데가 틀렸다. 답은 일본어여야 하고, 위 규칙은 (시키는말이 en 이라)
198
+ * 한국어가 아니라 영어다. 화면은 일본어인데 모델은 영어로 답한다 —
199
+ * 실패가 아니라 **다른 것을 해 놓고 아무 말도 안 하는** 쪽이라, 쓰는
200
+ * 사람은 이게 설정 탓인지 모델 탓인지 알 길이 없다.
201
+ */
202
+ const 말이름 = { ko: '한국어', en: 'English', ja: '日本語', zh: '中文' };
203
+ const 규칙말 = 말이름[시키는말] ?? 시키는말;
204
+ const 답할말 = 말이름[언어()] ?? 언어();
188
205
  return `${글}\n\n${언어() === 'ko'
189
- ? '**답은 한국어로 해라.** 위 규칙이 영어로 적혀 있어도 사용자에게 하는 말은 한국어다.'
190
- : '**Answer in English.** The rules above are in Korean, but what you say to the user is English.'}`;
206
+ ? `**답은 한국어로 해라.** 위 규칙이 ${규칙말} 적혀 있어도 사용자에게 하는 말은 한국어다.`
207
+ : `**Answer in ${답할말}.** The rules above are in ${규칙말}, but what you say to the user is ${답할말}.`}`;
191
208
  }
192
209
 
210
+ /**
211
+ * 규칙 파일에서 프롬프트에 실을 최대 글자 수.
212
+ *
213
+ * 여기 실리는 글은 **매 요청에 통째로** 나간다. 상한이 없으면 규칙 파일
214
+ * 하나가 8k 모델의 창을 다 먹는다. 그래서 상한은 두되, 넘으면 반드시
215
+ * 말한다 (#loadRules 의 규칙잘림).
216
+ */
217
+ const 규칙최대 = 20000;
218
+
193
219
  export class Session {
194
220
  constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = null } = {}) {
195
221
  this.conn = conn;
@@ -325,6 +351,13 @@ export class Session {
325
351
  this.startedAt = Date.now();
326
352
  /** 규칙 파일이 있는데 못 읽었나. `{이름, 까닭}` — /status 가 이걸 말한다. */
327
353
  this.규칙못읽음 = null;
354
+ /**
355
+ * 규칙 파일을 **잘라서** 실었나. `{이름, 원본, 실린}`
356
+ *
357
+ * 못 읽은 것과 반만 읽은 것은 사람에게 같은 상태다 — 적어 뒀는데 안
358
+ * 걸렸다. 그래서 옆자리에 나란히 둔다.
359
+ */
360
+ this.규칙잘림 = null;
328
361
  this.rules = this.#loadRules();
329
362
  /*
330
363
  * 이 폴더가 무슨 프로젝트인가 (agent/project.js).
@@ -365,10 +398,40 @@ export class Session {
365
398
  * 안 걸린 채로 일이 돈다. 「운영 DB 는 건드리지 마라」 를 적어 놓고 그게
366
399
  * 안 걸린 것이 여기서 나올 수 있는 제일 나쁜 모양이다.
367
400
  * 못 읽은 것은 적어 두고 /status 가 '없음' 대신 그 까닭을 말한다.
401
+ *
402
+ * ── **반만 읽는 것**도 같은 모양이다 ──────────────────────────────────
403
+ *
404
+ * 바로 위 문단이 「운영 DB 는 건드리지 마라 를 적어 놓고 그게 안 걸린
405
+ * 것이 여기서 나올 수 있는 제일 나쁜 모양이다」 라고 적어 두고서, 정작
406
+ * 2만 자에서 자르는 것은 아무 데도 안 알렸다. 21,000자짜리 DEEL.md 의
407
+ * 마지막 줄에 그 문장이 있으면 모델에게는 **한 글자도 안 간다.**
408
+ *
409
+ * /status → 규칙 DEEL.md (이름만. 잘렸다는 말이 없다)
410
+ * /context → 규칙 (DEEL.md) 19,998 (자른 뒤 크기라 표도 멀쩡하다)
411
+ * 규칙못읽음 → null (이 칸은 오류만 담는다)
412
+ *
413
+ * 상한 자체는 그대로 둔다 — 여기 실리는 글은 매 요청에 통째로 나가서,
414
+ * 8k 모델이면 규칙 하나가 창을 다 먹는다. 대신 잘랐으면 **화면에도
415
+ * 모델에게도** 말한다.
368
416
  */
369
417
  #loadRules() {
370
418
  for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
371
- try { return { name, text: readFileSync(join(this.root, name), 'utf8').slice(0, 20000) }; }
419
+ try {
420
+ const 통째 = readFileSync(join(this.root, name), 'utf8');
421
+ if (통째.length <= 규칙최대) return { name, text: 통째 };
422
+ this.규칙잘림 = { 이름: name, 원본: 통째.length, 실린: 규칙최대 };
423
+ /*
424
+ * 자른 자리에 **모델도 읽을 한 줄**을 남긴다.
425
+ *
426
+ * 화면에만 적으면 모델은 제가 반쪽 규칙을 들고 있다는 것을 모른다.
427
+ * 그러면 안 실린 규칙을 어긴 뒤에도 「규칙대로 했다」 고 말한다.
428
+ */
429
+ return {
430
+ name,
431
+ text: `${통째.slice(0, 규칙최대)}\n\n(이 규칙 파일은 ${통째.length.toLocaleString()}자라 앞 ${규칙최대.toLocaleString()}자만 실렸다.`
432
+ + ' 뒷부분에 적힌 규칙은 지금 네게 안 보인다 — 규칙에 걸릴 만한 일을 하기 전에 사용자에게 확인해라.)',
433
+ };
434
+ }
372
435
  catch (err) {
373
436
  // 없으면 그냥 없는 것이다 — 말할 일이 아니다. existsSync 로 먼저 보지
374
437
  // 않는 이유도 여기 있다: 보고 나서 읽는 사이에 지워지면 그 ENOENT 를
@@ -732,9 +795,50 @@ export class Session {
732
795
  * 적어 보내게 된다. 여기서 한 줄 지우면 그냥 통째로 다시 읽는다.
733
796
  */
734
797
  this.파일기억?.잊기();
798
+ /*
799
+ * 남은 할 일과 시킨 말도 같이 버린다 — clear() 와 **똑같은 까닭**이다.
800
+ *
801
+ * 바로 위 문단이 「clear() 가 하는 것과 같은 까닭이다」 라고 적어 놓고
802
+ * 정작 이 둘은 안 버리고 있었다. 이 둘은 접거나 줄일 때 다시 박히는
803
+ * 것들이라(못박을것), 되감은 뒤에도 들고 있으면 **되돌린 그 일**이
804
+ * 첫 접힘에서 되살아나 프롬프트에 붙는다. 모델은 그걸 지금 시킨
805
+ * 것으로 알고 방금 되돌린 일을 다시 하러 간다 — /undo 를 안 한 것만
806
+ * 못하다.
807
+ *
808
+ * 이번요청은 다음 턴에 loop.js 가 새 말로 덮지만, 그 사이에 /compact
809
+ * 를 치면 그대로 박힌다. 할 일 목록은 턴을 넘어 사는 값이라 덮이지도
810
+ * 않는다 — 이쪽이 실제로 밟히는 길이다.
811
+ */
812
+ this.할일 = [];
813
+ this.이번요청 = '';
814
+ this.검증확인지우기();
815
+ /*
816
+ * 읽은 파일 표도 같이 턴다.
817
+ *
818
+ * 파일기억 은 바로 위에서 잊었는데 이 표는 남아 있었다. 사무실 화면이
819
+ * 「읽은 파일 N개」 를 이 표의 크기로 적으므로(ui/office.js), 걷어낸
820
+ * 대화에서 읽은 파일이 계속 세어진다.
821
+ */
822
+ this.filesRead?.clear?.();
735
823
  return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
736
824
  }
737
825
 
826
+ /**
827
+ * 「확인했다」 표시를 지운다 (/clear · /undo).
828
+ *
829
+ * 상태줄의 초록 `✓5` 는 「돌려 봤고 아무 탈 없었다」 는 뜻이다. 그런데
830
+ * 그 확인은 **그때 그 코드**를 확인한 것이라, 대화를 비우거나(/clear)
831
+ * 되돌리면(/undo) 확인한 대상이 사라진다. 그대로 두면 새로 시작한 일의
832
+ * 화면에, 그리고 방금 없앤 코드에 대해 초록 체크가 그대로 서 있다.
833
+ *
834
+ * 돈(usage)과 바뀐 파일(changes)은 안 지운다 — 돈은 실제로 썼고 파일은
835
+ * 실제로 바뀐 채다. 검증만 다르다.
836
+ */
837
+ 검증확인지우기() {
838
+ if (this.검증) this.검증 = { 돈횟수: 0, 확인: 0, 탈: 0, 못확인: 0 };
839
+ return this;
840
+ }
841
+
738
842
  push(msg) {
739
843
  if (this.#다음턴 != null) { this.#턴표.push({ 턴: this.#다음턴, 표: msg }); this.#다음턴 = null; }
740
844
  this.messages.push(msg);
@@ -755,6 +859,7 @@ export class Session {
755
859
  */
756
860
  this.할일 = [];
757
861
  this.이번요청 = '';
862
+ this.검증확인지우기();
758
863
  this.#턴표 = [];
759
864
  this.#다음턴 = null;
760
865
  return this;
@@ -769,10 +874,27 @@ export class Session {
769
874
  * 스무 번 고치면 그것만으로 수십 MB 다. 여기서는 숫자만 센다.
770
875
  */
771
876
  noteChange(path, d) {
772
- if (!path || !d) return;
877
+ if (!path) return;
878
+ /*
879
+ * ── 「몇 줄인지 모른다」 와 「안 바뀌었다」 는 다르다 ────────────────
880
+ *
881
+ * `!d` 로 같이 걸러 내고 있었다. 그런데 바뀐 **경로는 주는데 몇 줄인지
882
+ * 안 주는** 도구가 있다 — Move(파일 하나)와 hwpx Write 가 그렇다.
883
+ * 파일은 진짜로 옮겨졌는데 이 집합에 안 들어가고, 그러면
884
+ *
885
+ * /diff 목록에 없다
886
+ * 상태줄 ✎ 수에 안 든다
887
+ * /commit 담는 목록에 없다 (commit.js 가 이 집합만 본다)
888
+ *
889
+ * 커밋은 초록으로 끝나는데 옮긴 파일만 안 담긴다. 실패가 아니라
890
+ * **다른 것을 해 놓고 아무 말도 안 하는** 쪽이라 알아채기가 어렵다.
891
+ *
892
+ * 셈을 모르면 0으로 적는다 — 0줄 바뀐 것이 아니라 「몇 줄인지 못 셌다」
893
+ * 는 뜻이고, 바뀌었다는 사실 자체는 남는다.
894
+ */
773
895
  const 앞 = this.changes.get(path) ?? { added: 0, removed: 0, times: 0 };
774
- 앞.added += d.added ?? 0;
775
- 앞.removed += d.removed ?? 0;
896
+ 앞.added += d?.added ?? 0;
897
+ 앞.removed += d?.removed ?? 0;
776
898
  앞.times += 1;
777
899
  this.changes.set(path, 앞);
778
900
  }
@@ -831,7 +953,24 @@ export class Session {
831
953
  const 추정 = this.#원추정().used;
832
954
  if (추정 < 200) return null;
833
955
  const 비율 = n / 추정;
834
- if (비율 < 0.5 || 비율 > 2) return null;
956
+ /*
957
+ * 못 믿을 표본은 안 쓴다 — 그런데 **버린 것도 센다.**
958
+ *
959
+ * 여태 그냥 return 이었다. 그래서 두 가지가 화면에서 똑같아졌다 —
960
+ *
961
+ * ① 아직 한 번도 못 재 봤다 (usage 를 안 주는 창구)
962
+ * ② 재 봤는데 우리 추정의 세 배라 못 믿어서 안 썼다
963
+ *
964
+ * 둘 다 `/context` 에 「추정입니다」 한 줄로 뜬다. ②는 우리 추정이
965
+ * 크게 틀렸다는 **단서**인데, 그것이 매번 조용히 버려지니 게이지는
966
+ * 영영 짐작인 채로 돈다. 바로 위 못잰것 칸이 「0 을 더하면 합계는
967
+ * 그대로라 화면에 아무 표가 안 난다」 고 적어 둔 것과 같은 잣대다.
968
+ */
969
+ if (비율 < 0.5 || 비율 > 2) {
970
+ this.보정버림 = (this.보정버림 ?? 0) + 1;
971
+ this.보정마지막버린비율 = 비율;
972
+ return null;
973
+ }
835
974
  // 첫 번은 그대로 받고, 그 뒤로는 천천히 따라간다. 한 번 튄 값에 안 휘둘린다.
836
975
  this.보정 = this.보정잰것 ? this.보정 + (비율 - this.보정) * 0.3 : 비율;
837
976
  this.보정잰것++;
@@ -840,20 +979,49 @@ export class Session {
840
979
 
841
980
  /** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
842
981
  #원추정() {
843
- // 폴더 지문도 매 요청에 통째로 나간다. 시스템 프롬프트 쪽에 같이 센다 —
844
- // 안 세면 '남은 자리' 가 그만큼 뻥튀기되고, effort.js 가 그 값으로 출력
845
- // 상한을 잡으므로 답이 조용히 잘리기 시작한다.
846
- const sys = estimateTokens(기본규칙(this.conn?.ctx)) + estimateTokens(`작업 폴더: ${this.root}`)
847
- + estimateTokens(모드말(this.effectiveWork(), this.conn?.ctx) ?? '')
848
- + estimateTokens(this.프로젝트 ?? '');
849
982
  const rules = this.rules ? estimateTokens(this.rules.text) : 0;
850
983
  const listed = this.listedSkills();
851
984
  const skills = listed.length
852
985
  ? estimateTokens(listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
853
986
  : 0;
987
+ const 배움 = this.배움요약 ? estimateTokens(this.배움요약) : 0;
988
+ // 기억도 매 요청에 통째로 나간다 — 아래 시스템 칸에서 빼려면 여기서 먼저 잰다.
989
+ const 기억 = this.memory ? estimateTokens(this.memory) : 0;
990
+ const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
991
+
992
+ /*
993
+ * ── 시스템 칸은 **실제로 나가는 글**에서 잰다 ──────────────────────
994
+ *
995
+ * 여태 여기서 조각을 손으로 다시 나열했다. 그리고 셋이 빠져 있었다 —
996
+ * 셸 안내, 급말(작은 모델에 붙는 문단), 그리고 **사람이 못 박은 글**.
997
+ * 못 박은 글은 사람이 얼마든지 길게 쓸 수 있는 자리다.
998
+ *
999
+ * 바로 이 자리 주석이 「안 세면 남은 자리가 뻥튀기되고, effort.js 가 그
1000
+ * 값으로 출력 상한을 잡으므로 답이 조용히 잘리기 시작한다」 라고 적어
1001
+ * 두고서 셋을 빠뜨렸다. 손으로 나열하는 한 또 빠진다 — 프롬프트에
1002
+ * 한 줄을 더할 때 이 자리를 같이 고칠 사람은 없다.
1003
+ *
1004
+ * 그래서 **한 곳만 본다.** 진짜 시스템 글을 통째로 재고, 아래 표에서
1005
+ * 따로 줄을 갖는 것들(규칙·기억·배움·스킬)을 빼면 남는 것이 시스템
1006
+ * 칸이다. 앞으로 무엇이 더 붙어도 저절로 세어진다.
1007
+ */
1008
+ const sys = Math.max(0, estimateTokens(this.systemPrompt()) - (rules + 기억 + 배움 + skills));
854
1009
 
855
1010
  let history = 0;
856
1011
  let files = 0;
1012
+ /*
1013
+ * 도구 결과가 **몇 개**인지도 센다.
1014
+ *
1015
+ * 표의 라벨에 `this.filesRead.size` 를 넘기고 있었다. 그건 Read 로 연
1016
+ * 파일 수다. 그런데 토큰 합(files)은 Bash·Grep·Glob 을 포함한 **모든**
1017
+ * 도구 결과다. Bash 만 열 번 돌린 판에서 표는 이렇게 나온다 —
1018
+ *
1019
+ * 도구 결과 (파일 0개) 2,150
1020
+ *
1021
+ * 무엇을 접어야 할지 보라고 만든 표가 「0개인데 2,150토큰」 이라고
1022
+ * 말한다. 세는 것과 적는 것이 달랐다.
1023
+ */
1024
+ let 도구결과수 = 0;
857
1025
  for (const m of this.messages) {
858
1026
  /*
859
1027
  * 그림은 글자 수로 세지 않는다.
@@ -871,16 +1039,12 @@ export class Session {
871
1039
  // 도구 결과는 규격마다 다른 자리에 온다. `role` 만 보면 Anthropic 에서는
872
1040
  // 도구 결과가 통째로 '대화' 로 세어져서, /context 가 「도구 결과 0 토큰」
873
1041
  // 이라고 적는다 — 무엇을 접어야 할지 보라고 만든 표가 거꾸로 가리킨다.
874
- if (도구결과인가(m)) files += t; else history += t;
1042
+ if (도구결과인가(m)) { files += t; 도구결과수 += 1; } else history += t;
875
1043
  }
876
1044
 
877
1045
  // 도구 정의도 매 요청에 실려 나간다. 세는 값이라기보다 '이미 나간 값' 이다.
878
1046
  const 도구 = this.#도구토큰();
879
1047
 
880
- // 기억도 매 요청에 통째로 나간다. 안 세면 '남은 자리' 가 그만큼 뻥튀기되고,
881
- // effort.js 가 그 값으로 출력 상한을 잡으므로 답이 조용히 잘리기 시작한다.
882
- const 기억 = this.memory ? estimateTokens(this.memory) : 0;
883
- const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
884
1048
 
885
1049
  /*
886
1050
  * 이름은 화면에 그대로 나간다(`/context`). 그래서 여기서 말 표를 거친다.
@@ -892,11 +1056,13 @@ export class Session {
892
1056
  { label: 옮긴말('ctx.system'), n: sys },
893
1057
  { label: this.rules ? 옮긴말('ctx.rules', { 이름: this.rules.name }) : 옮긴말('ctx.rulesNone'), n: rules },
894
1058
  { label: 옮긴말('ctx.memory', { n: 기억줄 }), n: 기억 },
895
- { label: 옮긴말('ctx.learned'), n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
896
- { label: 옮긴말('ctx.skills', { 실림: listed.length, 전체: this.skills.length }), n: skills },
1059
+ { label: 옮긴말('ctx.learned'), n: 배움 },
1060
+ // 전체는 **켜 것**만 센다. 시스템 글에 실릴 있는 것이 그것뿐이라,
1061
+ // 꺼 둔 스킬까지 세면 「40개 중 12개 실림」 처럼 영영 안 채워질 수가 뜬다.
1062
+ { label: 옮긴말('ctx.skills', { 실림: listed.length, 전체: this.skills.filter((s) => s.enabled !== false).length }), n: skills },
897
1063
  { label: 옮긴말('ctx.tools'), n: 도구 },
898
1064
  { label: 옮긴말('ctx.history'), n: history },
899
- { label: 옮긴말('ctx.toolResults', { n: this.filesRead.size }), n: files },
1065
+ { label: 옮긴말('ctx.toolResults', { n: 도구결과수 }), n: files },
900
1066
  ];
901
1067
  const used = rows.reduce((a, r) => a + r.n, 0);
902
1068
  /*
@@ -908,8 +1074,10 @@ export class Session {
908
1074
  * 상태줄이 모델 급은 짐작일 때 `◈ 보통?` 으로 흐리게 적는다 — 창 크기도
909
1075
  * 같은 규칙을 따라야 한다(ui/status.js).
910
1076
  */
911
- const 잰것 = this.conn.ctx != null;
912
- const total = this.conn.ctx ?? 32768;
1077
+ // 자리만 `?.` 가 빠져 있었다 — 다른 다섯 자리는 전부 this.conn?.ctx 다.
1078
+ // conn 없이 만든 세션에서 breakdown() 이 TypeError 로 죽는다.
1079
+ const 잰것 = this.conn?.ctx != null;
1080
+ const total = this.conn?.ctx ?? 32768;
913
1081
  return { rows, used, total, 총잰것: 잰것, left: Math.max(0, total - used) };
914
1082
  }
915
1083
 
@@ -921,7 +1089,15 @@ export class Session {
921
1089
  */
922
1090
  breakdown() {
923
1091
  const 날것 = this.#원추정();
924
- if (!(this.보정잰것 > 0) || this.보정 === 1) return 날것;
1092
+ /*
1093
+ * 보정이 1 이어도 **잰 것은 잰 것**이다.
1094
+ *
1095
+ * 여기서 날것을 그대로 돌려주면 그 객체에는 보정잰것 칸이 없다. 그래서
1096
+ * 디스크에 남은 배수가 정확히 1.0 인 판에서는 /context 가 「추정입니다」
1097
+ * 갈래로 간다 — 숫자는 맞는데 얼마나 믿을 값인지만 틀리게 적힌다.
1098
+ */
1099
+ if (!(this.보정잰것 > 0)) return 날것;
1100
+ if (this.보정 === 1) return { ...날것, 보정: 1, 보정잰것: this.보정잰것 };
925
1101
  const rows = 날것.rows.map((r) => ({ ...r, n: Math.round(r.n * this.보정) }));
926
1102
  const used = rows.reduce((a, r) => a + r.n, 0);
927
1103
  return {
@@ -947,7 +1123,9 @@ export class Session {
947
1123
  const mcp수 = (this.mcp ?? []).reduce((n, s) => n + (s.도구?.length ?? 0), 0);
948
1124
  // 창 크기도 열쇠에 넣는다. 설명을 창에 맞춰 줄여 싣기 때문에(budget.js),
949
1125
  // /ctx 로 창을 다시 잡으면 이 값도 달라져야 한다. 안 넣으면 옛 값이 남는다.
950
- const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
1126
+ // 유무도 열쇠에 넣는다. 아래에서 vision 넘겨 쓰기 때문에 값이
1127
+ // 바뀌면 스키마 크기도 바뀐다 — 안 넣으면 모델을 갈아 끼워도 옛 값이 남는다.
1128
+ const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}|v${this.conn?.vision === true ? 1 : 0}`;
951
1129
  if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
952
1130
  let n = 0;
953
1131
  try {
@@ -964,7 +1142,30 @@ export class Session {
964
1142
  vision: this.conn?.vision === true,
965
1143
  });
966
1144
  n = estimateTokens(JSON.stringify(list));
967
- } catch { n = 0; }
1145
+ this.도구못쟀나 = null;
1146
+ } catch (err) {
1147
+ /*
1148
+ * 못 잰 것을 **0 으로** 넘기고 있었다.
1149
+ *
1150
+ * 도구 정의는 매 요청에 통째로 실려 나간다. 여기가 0 이면 /context 는
1151
+ * 그만큼 빈자리가 있다고 말하고, effort.js 는 그 값으로 출력 상한을
1152
+ * 잡는다 — 바로 위 주석이 「답이 이유 없이 짧아진다」 고 적어 둔 그
1153
+ * 길이다. 던지는 자리는 MCP 서버가 이상한 스키마를 준 판이라
1154
+ * 드물지도 않다.
1155
+ *
1156
+ * 0 대신 **못 쟀다고 남긴다.** 값은 여전히 0 이지만, 0 인 것과 못
1157
+ * 잰 것이 구별된다 (/status 가 이 표를 읽는다).
1158
+ */
1159
+ /*
1160
+ * 실패값은 **캐시에 굳히지 않는다.**
1161
+ *
1162
+ * 여기서 같이 넣어 두면 그 세션에서 이 열쇠는 영영 0 이다. MCP 서버가
1163
+ * 다시 붙어 스키마가 멀쩡해져도 0 이 나온다 — 고쳐진 뒤에도 안 고쳐진
1164
+ * 값을 쓴다. 다음 번에 한 번 더 재 보는 값이 훨씬 싸다.
1165
+ */
1166
+ this.도구못쟀나 = String(err?.message ?? err);
1167
+ return 0;
1168
+ }
968
1169
  this.#도구잰것.set(열쇠, n);
969
1170
  return n;
970
1171
  }
@@ -1073,11 +1274,27 @@ export function safeCut(messages, i) {
1073
1274
  * 머리 쪽 자르는 자리.
1074
1275
  * 머리가 '결과를 기다리는 도구 호출' 로 끝나면 그 결과가 접혀 없어져 짝이 깨진다.
1075
1276
  * 그런 assistant 는 머리에서 뺀다 — 접히는 쪽에 같이 넘긴다.
1277
+ *
1278
+ * ── **결과 한가운데**에서 끊는 것도 같은 고장이다 ──────────────────────
1279
+ *
1280
+ * 여태 부름만 보고 물러섰다. 그런데 한 번에 여러 도구를 부르면 결과도
1281
+ * 여러 줄이라, 그 사이에서 끊기면 부름 둘에 결과 하나가 남는다.
1282
+ *
1283
+ * [사람, assistant(A,B), 결과A, 결과B, …] 에서 safeHead(_, 3)
1284
+ * → messages[2] 는 결과A 라 부름이 0개 → 그대로 멈춤
1285
+ * → 머리 = 사람 + assistant(A,B) + 결과A ← 결과B 가 없다
1286
+ *
1287
+ * 그 뒤 요청은 통째로 400 이다. 접기가 물러설 자리로 쓰는 길이라,
1288
+ * 서버가 흔들릴 때 하필 여기서 대화가 죽는다.
1289
+ *
1290
+ * 결과 줄에서도 물러선다. 그러면 반드시 부름 앞까지 밀려나므로, 머리는
1291
+ * 언제나 **짝이 온전한 자리**에서 끝난다. 머리에서 한둘 더 빠지는 값은
1292
+ * 치른다 — 그쪽으로 틀리는 편이 낫다.
1076
1293
  */
1077
1294
  export function safeHead(messages, k) {
1078
1295
  let h = Math.max(0, Math.min(k, messages.length));
1079
- // 부름이 담긴 자리도 규격마다 다르다 — safeCut 과 같은 까닭이다.
1080
- while (h > 0 && 부른것들(messages[h - 1]).length) h--;
1296
+ // 부름도 결과도 규격마다 다른 자리에 온다 — safeCut 과 같은 까닭이다.
1297
+ while (h > 0 && (부른것들(messages[h - 1]).length || 도구결과인가(messages[h - 1]))) h--;
1081
1298
  return h;
1082
1299
  }
1083
1300
 
@@ -1107,12 +1324,41 @@ export function safeHead(messages, k) {
1107
1324
  *
1108
1325
  * @returns {{messages: object[], 고친것: number}} 고친것 = 걷어낸 호출·결과 수
1109
1326
  */
1110
- /** 남길 부름만 남긴 새 메시지. 규격마다 부름이 담긴 자리가 다르다. */
1111
- function 부름줄이기(m, 남길id) {
1327
+ /**
1328
+ * 남길 부름만 남긴 새 메시지. 규격마다 부름이 담긴 자리가 다르다.
1329
+ *
1330
+ * ── **id 가 아예 없는 규격**이 있다 ────────────────────────────────────
1331
+ *
1332
+ * Ollama 는 도구 부름에 id 를 안 붙인다. 그래서 위에서 짝지을 때도
1333
+ * `부름.slice(0, 결과.length)` 로 **순서**를 봤다. 그런데 여기는 id 로만
1334
+ * 걸렀다 — id 가 없으니 `남길id` 는 늘 비어 있고, 필터는 전부 떨어진다.
1335
+ *
1336
+ * assistant(bash, read) + tool(bash) 하나
1337
+ * → 남길부름 = [bash] (여기까지는 맞다)
1338
+ * → 남길id = {} (id 가 없으니까)
1339
+ * → 결과 = 부름 0개짜리 assistant + 결과 1개
1340
+ *
1341
+ * 짝 깨짐을 없애겠다는 함수가 짝 깨짐을 **만들어** 내고 있었다. 그 이력을
1342
+ * 그대로 보내면 서버가 400 으로 거절하고, 사람 눈에는 이어하기가 고장 난
1343
+ * 것으로 보인다.
1344
+ *
1345
+ * 그래서 짝지을 때와 **같은 잣대**로 고른다 — id 가 있으면 id 로, 없으면
1346
+ * 앞에서부터 남길수 개로.
1347
+ */
1348
+ function 부름줄이기(m, 남길id, 남길수) {
1349
+ const 순서로 = 남길id.size === 0;
1350
+ const 고르기 = (있는것, 부름인가) => {
1351
+ let 센것 = 0;
1352
+ return 있는것.filter((x) => {
1353
+ if (!부름인가(x)) return true;
1354
+ if (순서로) return 센것++ < 남길수;
1355
+ return 남길id.has(x?.id);
1356
+ });
1357
+ };
1112
1358
  if (Array.isArray(m.content)) {
1113
- return { ...m, content: m.content.filter((b) => b?.type !== 'tool_use' || 남길id.has(b.id)) };
1359
+ return { ...m, content: 고르기(m.content, (b) => b?.type === 'tool_use') };
1114
1360
  }
1115
- return { ...m, tool_calls: (m.tool_calls ?? []).filter((c) => 남길id.has(c?.id)) };
1361
+ return { ...m, tool_calls: 고르기(m.tool_calls ?? [], () => true) };
1116
1362
  }
1117
1363
 
1118
1364
  /** 이 메시지가 사람에게 한 말만. 부름 블록은 뺀다. */
@@ -1174,7 +1420,7 @@ export function repairToolPairs(messages) {
1174
1420
  고친것 += (부름.length - 남길부름.length) + (결과.length - 남길결과.length);
1175
1421
 
1176
1422
  if (남길부름.length) {
1177
- out.push(남길부름.length === 부름.length ? m : 부름줄이기(m, 남길id));
1423
+ out.push(남길부름.length === 부름.length ? m : 부름줄이기(m, 남길id, 남길부름.length));
1178
1424
  out.push(...남길결과);
1179
1425
  continue;
1180
1426
  }