deel-local-cli 1.17.3 → 1.17.4

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-7%2C861%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-7%2C937%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)
@@ -268,7 +268,7 @@ deel --offline
268
268
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
269
269
 
270
270
  ```
271
- deel 1.17.3 ⌂ 이 안
271
+ deel 1.17.4 ⌂ 이 안
272
272
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
273
273
  ```
274
274
 
@@ -1185,7 +1185,7 @@ deel stats # 이 폴더에서 실제로 무엇을 했나 (.de
1185
1185
  ## 개발
1186
1186
 
1187
1187
  ```bash
1188
- npm test 전체 검증 (7,861항목 — 몇몇은 터미널에 따라 갈립니다)
1188
+ npm test 전체 검증 (7,937항목 — 몇몇은 터미널에 따라 갈립니다)
1189
1189
  npm run coverage 검사가 소스의 어디를 밟았는지
1190
1190
  npm run verify 반입·통신 검증만
1191
1191
  npm run bench 편집 성공률 측정
@@ -1254,6 +1254,7 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1254
1254
 
1255
1255
  | 판 | 무엇이 바뀌었나 |
1256
1256
  |---|---|
1257
+ | [1.17.4](docs/ko/releases/1.17.md#1174) | 같은 요청을 한 번 더 보내던 자리들 — 그리고 배운 것이 스스로를 봉인하던 자리 |
1257
1258
  | [1.17.3](docs/ko/releases/1.17.md#1173) | 「같은 대화다」 를 적는 표준 이름이 없습니다 — 아는 이름을 다 답니다 |
1258
1259
  | [1.17.2](docs/ko/releases/1.17.md#1172) | 다른 눈으로 한 번 더 — 한글이 못 여는 hwpx · 이름 없이 나가던 요청 둘 |
1259
1260
  | [1.17.1](docs/ko/releases/1.17.md#1171) | 내 PC 에서만 초록이던 것 둘 — 지운 폴더에 쓰기 · 소스에 박힌 날 NUL |
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-7%2C861%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-7%2C937%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)
@@ -339,7 +339,7 @@ deel --offline
339
339
  The destination is printed at the top of every session:
340
340
 
341
341
  ```
342
- deel 1.17.3 ⌂ inside
342
+ deel 1.17.4 ⌂ inside
343
343
  Sends to this machine 127.0.0.1:11434 ← nowhere else
344
344
  ```
345
345
 
@@ -1258,7 +1258,7 @@ Stored in `~/.deel/config.json`. A `.deel/config.json` in the project folder tak
1258
1258
  ## Development
1259
1259
 
1260
1260
  ```bash
1261
- npm test Full suite (7,861 checks; a few are TTY-dependent)
1261
+ npm test Full suite (7,937 checks; a few are TTY-dependent)
1262
1262
  npm run coverage Which lines the tests actually execute
1263
1263
  npm run verify Import + network checks only
1264
1264
  npm run bench Edit success rate
@@ -1313,6 +1313,7 @@ so one run tells you everything.
1313
1313
 
1314
1314
  | Version | What changed |
1315
1315
  |---|---|
1316
+ | [1.17.4](docs/en/releases/1.17.md#1174) | The places that sent the same request twice — and where what it learned sealed itself in |
1316
1317
  | [1.17.3](docs/en/releases/1.17.md#1173) | No standard name for "same conversation" — it sends every name it knows |
1317
1318
  | [1.17.2](docs/en/releases/1.17.md#1172) | A second pair of eyes — an hwpx Hancom cannot open · two requests going out unnamed |
1318
1319
  | [1.17.1](docs/en/releases/1.17.md#1171) | Two things only green on one machine — writing into a deleted directory · raw NUL in the source |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.17.3",
3
+ "version": "1.17.4",
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",
@@ -40,6 +40,81 @@ export const KEEP_RECENT = 4; // 최근 도구 결과 이만큼은 원문
40
40
  export const FOLD_MIN = 300; // 이보다 작으면 접어도 자리가 안 준다 (글자 수)
41
41
  export const 접힘표 = '(접힘)';
42
42
 
43
+ /*
44
+ * ── 캐시가 도는 연결에서는 접기가 **돈을 쓴다** ─────────────────────────
45
+ *
46
+ * 위 글은 「읽는 값은 싸고, 하던 일을 잊는 값은 비싸다」 로 끝난다. 캐시가
47
+ * 없는 자리에서는 그 말이 그대로 맞다 — 안 접으면 옛 파일 내용을 걸음마다
48
+ * 전액 다시 보낸다.
49
+ *
50
+ * 캐시가 도는 자리에서는 셈이 뒤집힌다. 안 접으면 그 옛 파일 내용은
51
+ * **캐시에서 읽힌다.** 우리가 재 본 게이트웨이에서 쓰기는 읽기의 12.5배였다
52
+ * (읽기 $0.50 · 쓰기 $6.25 / MTok). 그러니 접는 순간 그 자리 뒤가 전부 새 글이
53
+ * 되어, **싸게 읽히던 것을 비싸게 다시 쓴다.**
54
+ *
55
+ * 손익분기를 풀면 이렇다. P = 접기 전 프리픽스, S = 비운 토큰, k = 쓰기/읽기 비,
56
+ * N = 본전을 뽑기까지 도는 턴 수라고 하면
57
+ *
58
+ * 이번에 더 내는 값 = (P−S)·k − P 턴당 아끼는 값 = S
59
+ * N턴 안에 본전을 뽑으려면 S ≥ P·(k−1)/(N+k)
60
+ *
61
+ * k=12.5, N=4 를 넣으면 S ≥ 0.7P 다. 프리픽스의 7할을 비워야 넉 턴 만에 본전인데,
62
+ * 오래된 도구 결과만으로 7할을 비우는 대화는 드물다. 실제로 잰 판에서는
63
+ * 65,619 토큰을 비우고 본전까지 아홉 턴이 걸렸고, 그 아홉 턴이 오기 전에 0.8
64
+ * 요약 압축이 와서 캐시가 한 번 더 끊겼다.
65
+ *
66
+ * 그래서 캐시가 도는 연결에서는 **접기를 뒤로 민다.** 다만 요약 압축(0.8)에
67
+ * 너무 붙이면 안 된다 — 접고 한두 턴 만에 요약이 오면 캐시를 연달아 두 번
68
+ * 깬다. 0.72 는 그 사이다.
69
+ *
70
+ * 그리고 문턱을 토큰 수가 아니라 **창에 견준 몫**으로 받는다. 2,000토큰은
71
+ * 32k 창에서는 6% 지만 200k 창에서는 1% 다. 1% 를 비우려고 프리픽스를 통째로
72
+ * 다시 쓰는 것이 여태 하던 일이었다.
73
+ *
74
+ * 「캐시가 돈다」 는 카드를 보고 정하지 않는다 — **읽힌 것을 봤나**로 정한다.
75
+ * 카드는 우리가 표식을 붙였다는 뜻일 뿐이고, 서버가 실제로 잡았는지는 다른
76
+ * 이야기다. 한 번도 안 읽힌 연결에서는 여태 하던 대로 0.55 에서 접는다.
77
+ */
78
+ export const FOLD_AT_캐시 = 0.72;
79
+
80
+ /*
81
+ * 창의 이만큼은 비워야 접을 값어치가 있다.
82
+ *
83
+ * 순수 캐시 셈으로만 보면 이 문턱은 훨씬 높아야 한다 — 7할을 비워도 본전이
84
+ * 넉 턴이다. 그런데 **견줄 상대가 「안 접기」 가 아니다.** 0.72 는 요약
85
+ * 압축(0.80) 코앞이고, 요약은 캐시를 어차피 깨면서 대화까지 잃는다.
86
+ * 그러니 여기서 재야 하는 것은 「접기가 공짜인가」 가 아니라 「접어서 요약을
87
+ * 미룰 수 있나」 다.
88
+ *
89
+ * 창의 한 할이면 0.72 에서 0.62 로 내려간다 — 요약까지 두 배로 멀어진다.
90
+ * 그보다 적게 비우는 접기는 요약을 못 미루면서 캐시만 깬다.
91
+ */
92
+ export const 캐시일때비울몫 = 0.10;
93
+
94
+ /**
95
+ * 이 연결에서 캐시가 도나.
96
+ *
97
+ * **모르는 동안에는 돈다고 친다.** 이게 이 함수에서 제일 중요한 줄이다.
98
+ *
99
+ * 읽힌 것을 봤나로 정하는 것이 옳지만, 첫 턴에는 볼 기회가 없다 — 첫 요청은
100
+ * 캐시를 쓰기만 하고 읽지는 못한다. 그 자리에서 「캐시가 없다」 로 읽으면,
101
+ * 첫 턴에 큰 파일 몇 개를 읽어 창이 반쯤 찬 대화가 **두 번째 턴 시작하자마자
102
+ * 방금 비싸게 만들어 둔 앞머리를 스스로 부순다.**
103
+ *
104
+ * 틀렸을 때 잃는 것이 양쪽으로 다르다. 캐시가 없는데 있다고 치면 접기가 좀
105
+ * 늦어질 뿐이고, 있는데 없다고 치면 방금 만든 캐시를 깬다. 그래서 모르는
106
+ * 쪽은 덜 잃는 쪽으로 기울인다.
107
+ *
108
+ * 두 번 넘게 불렀는데도 읽힌 것이 하나도 없으면, 그때는 정말 없는 것이다.
109
+ */
110
+ export const 재볼기회 = 2;
111
+
112
+ export function 캐시가도나(session) {
113
+ const u = session?.usage ?? {};
114
+ if ((u.cacheRead ?? 0) > 0) return true;
115
+ return (u.calls ?? 0) < 재볼기회;
116
+ }
117
+
43
118
  /*
44
119
  * ── 접기는 **묶음으로** 한다 ────────────────────────────────────────────
45
120
  *
@@ -102,9 +177,22 @@ ${접힘표} 그림 ${장수}장은 자리를 비우려고 뺐습니다. 필요
102
177
  return { 뺀것: 뺄것.length, 뺀것들 };
103
178
  }
104
179
 
105
- export function shouldFold(session, at = FOLD_AT) {
180
+ export function shouldFold(session, at = null) {
106
181
  const b = session.breakdown();
107
- return b.total > 0 && b.used / b.total >= at;
182
+ const 문턱 = at ?? (캐시가도나(session) ? FOLD_AT_캐시 : FOLD_AT);
183
+ return b.total > 0 && b.used / b.total >= 문턱;
184
+ }
185
+
186
+ /**
187
+ * 이 연결에서 「이만큼은 비워야 접는다」 는 값 (토큰).
188
+ *
189
+ * 캐시가 안 도는 자리에서는 여태 쓰던 고정값(최소이득)이다. 도는 자리에서는
190
+ * 창에 견준 몫으로 잰다 — 위 머리말의 손익분기 때문이다.
191
+ */
192
+ export function 접을이득문턱(session) {
193
+ if (!캐시가도나(session)) return 최소이득;
194
+ const 창 = session?.breakdown?.()?.total ?? 0;
195
+ return 창 > 0 ? Math.max(최소이득, Math.floor(창 * 캐시일때비울몫)) : 최소이득;
108
196
  }
109
197
 
110
198
  /**
@@ -115,8 +203,10 @@ export function shouldFold(session, at = FOLD_AT) {
115
203
  *
116
204
  * @returns {{접은것: number, 아낀토큰: number}}
117
205
  */
118
- export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN, 이득문턱 = 최소이득 } = {}) {
206
+ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN, 이득문턱 = null } = {}) {
119
207
  const ms = session.messages ?? [];
208
+ // 안 적어 주면 이 연결에 맞는 값으로 (위 접을이득문턱 — 캐시가 도는지가 가른다).
209
+ const 넘을것 = 이득문턱 ?? 접을이득문턱(session);
120
210
 
121
211
  /*
122
212
  * 호출 쪽에서 이름과 인자를 가져온다. 결과 메시지에는 그게 안 실려 있다.
@@ -206,10 +296,10 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
206
296
  * 100자도 안 된다). 그래서 이 수는 언제나 **새로 접을 것**의 수다.
207
297
  */
208
298
  const 예상이득 = 접을것.reduce((a, x) => a + estimateTokens(x.글), 0);
209
- if (예상이득 < 이득문턱 || 접을것.length < keep) {
299
+ if (예상이득 < 넘을것 || 접을것.length < keep) {
210
300
  return {
211
301
  접은것: 0, 아낀토큰: 0, 접은것들: [], 미룸: true,
212
- 모인것: 예상이득, 모인개수: 접을것.length,
302
+ 모인것: 예상이득, 모인개수: 접을것.length, 넘을것,
213
303
  };
214
304
  }
215
305
 
@@ -253,7 +343,17 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
253
343
  접은것들.push({ 도구: 도구이름, 곳, 경로, 줄수, 토큰: Math.max(0, 아낀것) });
254
344
  }
255
345
 
256
- return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들 };
346
+ /*
347
+ * 캐시를 다시 쓰게 된다는 것도 같이 올린다.
348
+ *
349
+ * 접기는 값이 드는 일인데(위 손익분기), 화면에는 「몇 토큰을 비웠다」 만
350
+ * 나왔다. 그러면 사람은 이득만 보고 대가는 며칠 뒤 청구서에서 본다.
351
+ * 캐시가 도는 자리에서만 참이므로, 실제로 읽힌 것을 봤을 때만 적는다.
352
+ */
353
+ return {
354
+ 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들,
355
+ 캐시다시씀: 캐시가도나(session),
356
+ };
257
357
  }
258
358
 
259
359
  /**
@@ -36,6 +36,17 @@
36
36
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
37
37
  import { join, dirname } from 'node:path';
38
38
  import { estimateTokens } from './session.js';
39
+ /*
40
+ * 「이걸 사실로 말해도 되나」 는 **여기서 안 정한다** (agent/신뢰도.js).
41
+ *
42
+ * 이 파일은 무엇을 겪었나를 센다. 그 셈을 얼마나 믿을지는 다른 일이고,
43
+ * 섞어 두면 셈을 고칠 때마다 믿음의 문턱이 같이 흔들린다 — 그리고 그건
44
+ * 화면에 안 나타난다.
45
+ */
46
+ import { 삭힘, 며칠됐나, 뭐라할까, 하루할인된다 } from './신뢰도.js';
47
+ // 파일에서 읽은 것이 **우리가 적은 모양이 맞나** (agent/성한기록.js).
48
+ // 통계와 문지기는 다른 일이라 따로 둔다.
49
+ import { 거른표 } from './성한기록.js';
39
50
 
40
51
  export const 최대토큰 = 220; // 프롬프트에 실을 상한
41
52
  const 명령최대 = 40; // 표가 끝없이 자라지 않게
@@ -43,11 +54,23 @@ const 모델최대 = 10;
43
54
 
44
55
  const 빈것 = () => ({ 판: 1, 명령: {}, 모델: {} });
45
56
 
57
+ /*
58
+ * 파일에서 읽은 것을 **그대로 믿지 않는다** (agent/성한기록.js 의 거른표).
59
+ *
60
+ * 이 표의 열쇠는 시스템 프롬프트에 그대로 실린다. 그러니 여기가 프롬프트로
61
+ * 들어가는 문이고, 문에는 자물쇠가 있어야 한다. 손으로 고쳤을 수도, 쓰다 만
62
+ * 채로 남았을 수도, 다른 판이 다른 모양으로 적어 뒀을 수도 있다.
63
+ *
64
+ * 걸러낸 것은 **버린다** — 파일에는 그대로 두고 이 판에서 안 쓸 뿐이다.
65
+ * 모양이 안 맞는 것을 고쳐서 쓰려 들면 무엇을 어떻게 고쳤는지가 또 안 보인다.
66
+ */
46
67
  function 읽기(파일) {
47
68
  try {
48
69
  if (!existsSync(파일)) return 빈것();
49
70
  const j = JSON.parse(readFileSync(파일, 'utf8'));
50
- return { ...빈것(), ...(j && typeof j === 'object' ? j : {}) };
71
+ const 것 = { ...빈것(), ...(j && typeof j === 'object' ? j : {}) };
72
+ 것.명령 = 거른표(것.명령);
73
+ return 것;
51
74
  } catch { return 빈것(); }
52
75
  }
53
76
 
@@ -99,8 +122,20 @@ export class 배움 {
99
122
  const 열쇠 = (없는프로그램 ? 낱말.slice(0, 1) : 낱말.slice(0, 2)).join(' ').slice(0, 40);
100
123
  if (!열쇠) return this;
101
124
  const 표 = this.폴더.명령;
102
- const r = 표[열쇠] ?? { ok: 0, no: 0, at: this.지금 };
103
- if (됐나) r.ok++; else r.no++;
125
+ const = 표[열쇠] ?? { ok: 0, no: 0, at: this.지금 };
126
+ /*
127
+ * 지난 셈을 **오늘 기준으로 삭혀서** 이어 센다 (agent/신뢰도.js 의 삭힘).
128
+ *
129
+ * 그냥 더하면 석 달 전 실패와 오늘 실패가 같은 무게가 된다. 그러면 한번
130
+ * 「안 된다」 로 굳은 것이 영영 안 풀린다 — 프롬프트가 부르지 말라고 하니
131
+ * 안 부르고, 안 부르니 성공 셈이 안 늘고, 그래서 그 줄이 안 사라진다.
132
+ *
133
+ * 삭히면 그 고리가 저절로 끊긴다. 다시 안 겪은 것은 힘이 빠지고, 힘이
134
+ * 빠지면 말하기를 멈추고, 그러면 모델이 다시 해 본다. 잊는 것이 곧 다시
135
+ * 해 보는 것이다.
136
+ */
137
+ const r = { ...옛, ...삭힘(옛, 며칠됐나(this.지금, 옛.at)) };
138
+ if (됐나) r.ok += 1; else r.no += 1;
104
139
  r.at = this.지금;
105
140
  표[열쇠] = r;
106
141
  줄이기(표, 명령최대);
@@ -210,18 +245,36 @@ export class 배움 {
210
245
  요약(모델 = '', 상한 = 최대토큰) {
211
246
  const 줄 = [];
212
247
 
213
- // 되는 명령 / 안 되는 명령. 이 폴더의 사실이다.
248
+ /*
249
+ * 되는 명령 / 안 되는 명령. 이 폴더의 사실이다.
250
+ *
251
+ * 문턱은 **여기서 안 정한다** (agent/신뢰도.js 의 뭐라할까). 여태 이 자리에
252
+ * `ok>=2 && no===0` 이 박혀 있었는데, 그 두 숫자가 어디서 왔는지는 아무 데도
253
+ * 안 적혀 있었고 섞인 증거(다섯 번 되고 한 번 안 됨)를 아예 못 다뤘다.
254
+ * 지금은 표본이 적으면 깎아서 보고(윌슨), 다시 안 겪은 것은 힘이 빠진다(할인).
255
+ */
214
256
  const 되는것 = [];
215
257
  const 안되는것 = [];
216
258
  for (const [열쇠, r] of Object.entries(this.폴더.명령 ?? {})) {
217
- if (r.ok >= 2 && r.no === 0) 되는것.push(열쇠);
218
- else if (r.no >= 2 && r.ok === 0) 안되는것.push(열쇠);
259
+ const 판정 = 뭐라할까(r, this.지금);
260
+ if (판정 === '된다') 되는것.push(열쇠);
261
+ else if (판정 === '안된다') 안되는것.push(열쇠);
219
262
  }
220
263
  if (되는것.length) 줄.push(`- 여기서 되는 명령: ${되는것.slice(0, 6).map((x) => `\`${x}\``).join(' · ')}`);
221
264
  if (안되는것.length) 줄.push(`- 이 PC 에서 안 되는 명령(다시 부르지 마라): ${안되는것.slice(0, 6).map((x) => `\`${x}\``).join(' · ')}`);
222
265
 
223
266
  // 모델 버릇. 걸음이 쌓여야 말이 된다.
224
- const m = this.집.모델?.[String(모델 ?? '')];
267
+ //
268
+ // 버릇도 늙는다. 같은 이름의 모델이 게이트웨이 뒤에서 조용히 판이 오른다 —
269
+ // 석 달 전에 인자를 잘라 먹던 그 이름이 지금도 그런다는 보장이 없다.
270
+ //
271
+ // 여기도 삭힌 값으로 잰다. 이름이 같은 모델도 게이트웨이 뒤에서 조용히
272
+ // 판이 오른다 — 석 달 전에 인자를 잘라 먹던 그 이름이 지금도 그런다는
273
+ // 보장이 없다. 걸음 수가 삭아서 열 아래로 내려가면 말하기를 멈춘다.
274
+ const m0 = this.집.모델?.[String(모델 ?? '')];
275
+ const 삭은몫 = m0 ? Math.pow(하루할인된다, 며칠됐나(this.지금, m0.at)) : 0;
276
+ const m = m0 ? Object.fromEntries(Object.entries(m0)
277
+ .map(([k, v]) => [k, typeof v === 'number' ? v * 삭은몫 : v])) : null;
225
278
  if (m && (m.걸음 ?? 0) >= 10) {
226
279
  if ((m.잘린인자 ?? 0) / m.걸음 >= 0.15) {
227
280
  줄.push('- 이 모델은 큰 인자를 자주 잘라 먹었다. 파일은 Write 한 번에 다 쓰지 말고 Append 로 나눠 써라.');
@@ -254,10 +307,31 @@ export class 배움 {
254
307
 
255
308
  /** 화면에 뿌릴 것. */
256
309
  현황(모델 = '') {
310
+ /*
311
+ * 나이와 **판정**을 같이 준다.
312
+ *
313
+ * 화면이 「됨 3 · 안 됨 0」 만 보여 주면, 그게 오늘 잰 것인지 두 달 전
314
+ * 것인지 사람이 못 가른다. 그런데 그 둘은 **다르게 취급된다** — 삭은
315
+ * 것은 프롬프트에 안 실린다(agent/신뢰도.js). 화면과 실제가 갈리면
316
+ * 사람은 안 실린 줄을 실린 줄로 읽는다.
317
+ *
318
+ * 판정도 여기서 같이 낸다. 화면이 문턱을 제 나름대로 다시 계산하면
319
+ * 언젠가 한쪽만 고쳐지고, 그때부터 화면은 실제와 다른 것을 말한다.
320
+ */
257
321
  const 명령 = Object.entries(this.폴더.명령 ?? {})
258
- .map(([이름, r]) => ({ 이름, ...r }))
322
+ .map(([이름, r]) => ({
323
+ 이름,
324
+ ...r,
325
+ 나이: 며칠됐나(this.지금, r?.at),
326
+ 판정: 뭐라할까(r, this.지금),
327
+ }))
259
328
  .sort((a, b) => (b.ok + b.no) - (a.ok + a.no));
260
329
  const m = this.집.모델?.[String(모델 ?? '')] ?? null;
261
- return { 명령, 모델: m, 모델이름: String(모델 ?? '') };
330
+ return {
331
+ 명령,
332
+ 모델: m,
333
+ 모델이름: String(모델 ?? ''),
334
+ 모델나이: m ? 며칠됐나(this.지금, m.at) : Infinity,
335
+ };
262
336
  }
263
337
  }
package/src/agent/loop.js CHANGED
@@ -12,7 +12,7 @@ import { 살린쓰기 } from './salvage.js';
12
12
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
13
13
  import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것, 접힌파일열쇠 } from './compact.js';
14
14
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
15
- import { Session } from './session.js';
15
+ import { Session, estimateTokens } from './session.js';
16
16
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
17
17
  import { 찾기 as 에이전트찾기, 할일합치기, 도구줄이기 } from './agents.js';
18
18
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
@@ -111,6 +111,21 @@ function thinkFor(conn, level) {
111
111
  // 그래서 '읽기만 하는 것' 이라고 확실한 도구만 묶는다.
112
112
  const 읽기전용 = new Set(['Read', 'Glob', 'Grep', 'Skill', 'WebFetch']);
113
113
 
114
+ /*
115
+ * 잘린 답을 「생각을 줄여」 다시 부를 때 쓰는 두 값.
116
+ *
117
+ * 눈금 한 칸을 내리면 생각 예산은 대략 네 할이 준다(agent/effort.js 의 배분).
118
+ * 그렇게 되찾은 자리가 **1,024토큰은 돼야** 답이 한 문단이라도 더 나온다.
119
+ * 그 아래에서 다시 부르면 같은 자리에서 또 잘리는데, 그 한 번이 프리픽스
120
+ * 전체와 출력 상한 전체를 다시 낸다.
121
+ *
122
+ * 상한에 견준 비율(예전의 15%)로 안 재는 까닭: 같은 15% 가 16k 상한에서는
123
+ * 2,400토큰이고 2k 상한에서는 300토큰이다. 같은 잣대인 척하면서 실제로는
124
+ * 상한이 클수록 헐거워진다.
125
+ */
126
+ export const 되찾을몫 = 0.4;
127
+ export const 쓸모있는자리 = 1024;
128
+
114
129
  /**
115
130
  * 호출 목록을 '같이 돌려도 되는 덩어리' 로 자른다.
116
131
  * 읽기 전용이 이어지면 한 덩어리, 그 밖의 것은 하나씩 따로.
@@ -803,6 +818,38 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
803
818
  try {
804
819
  yield* askModel(cap, level);
805
820
 
821
+ /*
822
+ * ── 게이트웨이가 이번 요청을 **다른 자리로** 보냈나 ─────────────────
823
+ *
824
+ * 같은 모델이 여러 자리에 올라가 있으면 프리픽스 캐시는 **자리마다**
825
+ * 따로다. 다음 요청이 다른 자리로 가는 순간, 우리가 보낸 것이 한 글자도
826
+ * 안 달라져도 캐시는 0% 가 되고 그 턴은 전액 다시 나간다.
827
+ *
828
+ * 여태 이건 화면에 안 나왔다. 사람이 볼 수 있는 것은 며칠 뒤 청구서의
829
+ * 튄 줄 하나뿐이고, 그 줄만 보면 원인이 우리 쪽인지 게이트웨이 쪽인지
830
+ * 가를 방법이 없다. 서버가 말해 줄 때는 그 자리에서 말한다.
831
+ *
832
+ * 서버가 안 알려 주면 **아무 말도 안 한다.** 모르는 것을 「안 바뀌었다」
833
+ * 로 적지 않는다 (backend/adapter.js 의 간자리).
834
+ */
835
+ if (msg?.간자리) {
836
+ if (session.간자리 && session.간자리 !== msg.간자리) {
837
+ /*
838
+ * **한 대화에 한 번만** 말한다.
839
+ *
840
+ * 세션 고정 없이 돌아가며 나눠 주는 게이트웨이에서는 이게 걸음마다
841
+ * 바뀐다 — 그리고 그건 사실이다. 다만 사실이라고 걸음마다 찍으면
842
+ * 그 줄은 곧 아무도 안 읽고, 그러면 정작 한 번만 튀는 자리도 같이
843
+ * 안 읽힌다. 한 번 말하면 사람이 알아야 할 것은 다 말한 셈이다.
844
+ */
845
+ if (!session.자리바뀐것을말했나) {
846
+ session.자리바뀐것을말했나 = true;
847
+ yield { type: '자리바뀜', 몇번째: steps };
848
+ }
849
+ }
850
+ session.간자리 = msg.간자리;
851
+ }
852
+
806
853
  /*
807
854
  * 대답이 잘렸다면 한 번은 다시 부른다.
808
855
  *
@@ -821,9 +868,68 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
821
868
  * 그 경우는 아래 '살려 쓰기' 가 받는다.
822
869
  */
823
870
  const full = Math.max(cap, fullCap(room));
824
- const 낮춘생각 = level === 'off' || level === 'low' ? level : shiftLevel(level, -1);
871
+ /*
872
+ * `low` 에서도 한 칸 더 내려간다 — `off` 로.
873
+ *
874
+ * 여태 여기서 멈춰 있었다. 그런데 지금은 「생각이 실제로 자리를 먹었을
875
+ * 때만 다시 부른다」 이므로, low 인데 생각이 1,024토큰어치 자리를 먹은
876
+ * 판에서 다시 부르기를 거절하는 것은 앞뒤가 안 맞는다. 되찾을 자리가
877
+ * 있다고 재 놓고 안 되찾는 셈이다.
878
+ *
879
+ * 이 턴 하나만 끈다. 다음 걸음은 사람이 정한 눈금으로 되돌아간다.
880
+ */
881
+ const 낮춘생각 = level === 'off' ? level : shiftLevel(level, -1);
825
882
  let 마지막상한 = cap;
826
- if (wasCut(msg) && (full > cap || 낮춘생각 !== level)) {
883
+ /*
884
+ * ── 생각을 줄이는 것이 **자리를 실제로 내는가** ──────────────────────
885
+ *
886
+ * 위 글이 「생각을 줄이면 예산이 실제로 남는다」 고 적어 뒀다. 맞는
887
+ * 말인데, **생각을 안 한 턴에는 안 맞는다.** 상한을 못 올리는 자리에서
888
+ * 이 조건은 `낮춘생각 !== level` 하나로 걸리므로, 생각이 0토큰이었어도
889
+ * 눈금이 medium 이기만 하면 다시 부른다. 그러면 같은 상한으로 같은 답을
890
+ * 다시 받고, 같은 자리에서 또 잘린다 — 바로 위 글이 「그건 그냥 낭비다」
891
+ * 라고 적어 둔 그 일이다.
892
+ *
893
+ * 실제로 그 값을 봤다. 16,384 에서 잘린 답을 같은 16,384 로 다시 받아
894
+ * 또 잘렸고, 그 한 번이 프리픽스 122K 와 출력 16K 를 통째로 다시 냈다.
895
+ *
896
+ * 그래서 **잰다.** 생각이 이번 답에서 자리를 얼마나 먹었는지 보고,
897
+ * 눈금 한 칸으로 되찾을 만한 몫일 때만 다시 부른다. 서버가 추론 토큰을
898
+ * 세어 주면 그 값을 쓰고, 안 세어 주면 흘러온 생각 글로 어림한다.
899
+ * 둘 다 없으면 생각은 자리를 안 먹은 것이다 — 없는 것을 있다고 치고
900
+ * 한 번 더 부르지 않는다.
901
+ */
902
+ /*
903
+ * 생각이 얼마나 먹었나를 **세 갈래로** 잰다. 하나만 보면 못 잰다.
904
+ *
905
+ * 1. 서버가 세어 준 추론 토큰 — 있으면 제일 정확하다
906
+ * 2. 흘러온 생각 글 — 세어 주지 않는 창구에서 쓴다
907
+ * 3. **낸 토큰에서 보이는 글을 뺀 것** — 위 둘이 다 없을 때
908
+ *
909
+ * 3번이 중요하다. 게이트웨이가 생각 블록을 지워서 넘기면 1도 2도 0이
910
+ * 되는데, 그때 「생각을 안 했다」 고 읽으면 정말로 생각에 예산을 다 쓴
911
+ * 턴에서 다시 부르기를 그만둔다. 관측이 없는 것과 사건이 없는 것은
912
+ * 다르다 — 그런데 **낸 토큰은 지워지지 않는다.** 답으로 나온 글이
913
+ * 짧은데 낸 토큰이 상한에 닿았다면, 그 차이가 곧 안 보이는 생각이다.
914
+ */
915
+ const 낸것 = Number(msg?.usage?.out ?? 0) || 0;
916
+ const 보인글 = estimateTokens(String(msg?.content ?? ''));
917
+ const 생각몫 = Math.max(
918
+ Number(msg?.usage?.reasoning ?? 0) || 0,
919
+ estimateTokens(String(msg?.thinking ?? '')),
920
+ 낸것 > 0 ? 낸것 - 보인글 : 0,
921
+ );
922
+ /*
923
+ * 그 몫에서 **눈금 한 칸이 실제로 되찾아 줄 자리**가 쓸 만한가.
924
+ *
925
+ * 상한에 견준 비율로 재면 안 된다 — 같은 15% 가 16k 상한에서는 2,400
926
+ * 토큰이고 2k 상한에서는 300토큰이라, 같은 잣대인 척하면서 다르게 잰다.
927
+ * 되찾은 자리가 한 문단이라도 더 나올 만큼인지가 재야 할 것이고,
928
+ * 그건 절대량이다.
929
+ */
930
+ const 생각이자리를먹었나 = 생각몫 * 되찾을몫 >= 쓸모있는자리;
931
+ const 다시부를까 = wasCut(msg) && (full > cap || (낮춘생각 !== level && 생각이자리를먹었나));
932
+ if (다시부를까) {
827
933
  yield {
828
934
  type: 'retry',
829
935
  why: full > cap ? '대답이 상한에서 잘렸습니다' : '대답이 잘렸습니다 — 생각을 줄여 자리를 냅니다',
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 배운 것을 파일에서 읽어들일 때 **모양을 잰다.**
3
+ *
4
+ * ── 왜 따로 있나 ────────────────────────────────────────────────────────
5
+ *
6
+ * 신뢰도.js 는 「이 셈을 얼마나 믿을까」 를 정한다. 여기는 그 앞이다 —
7
+ * 「이게 애초에 우리가 적은 모양이 맞나」. 통계와 문지기는 다른 일이고,
8
+ * 섞어 두면 통계 문턱을 만질 때마다 문지기도 같이 흔들린다.
9
+ *
10
+ * ── 왜 문지기가 필요한가 ────────────────────────────────────────────────
11
+ *
12
+ * `배운것.json` 은 그냥 파일이다. 사람이 열어 고칠 수 있고, 다른 판이 다른
13
+ * 모양으로 적어 뒀을 수도 있고, 쓰다 만 채로 남았을 수도 있다.
14
+ *
15
+ * 그런데 그 안의 **열쇠는 시스템 프롬프트에 그대로 실린다** (evolve.js 의
16
+ * 요약 — 역따옴표로 감싸서 나간다). 그러니 이 파일을 읽는 자리가 곧 프롬프트로
17
+ * 들어가는 문이고, 문에는 자물쇠가 있어야 한다.
18
+ *
19
+ * 여기서 재는 것은 「참인가」 가 아니라 **「실려도 되는 꼴인가」** 다.
20
+ * 참인지는 신뢰도.js 가 정한다.
21
+ */
22
+
23
+ const 열쇠최대 = 40;
24
+
25
+ /*
26
+ * 무엇을 막을지가 아니라 **무엇만 받을지**를 적는다.
27
+ *
28
+ * 막을 것을 적으면 언젠가 빠뜨린다. 이 열쇠는 명령 이름 두 낱말이라 받을 것이
29
+ * 좁게 정해져 있다 — 글자·숫자·띄어쓰기와 경로에 쓰는 몇 개뿐이다.
30
+ *
31
+ * 역따옴표와 줄바꿈이 이 목록에 없는 것이 요점이다. 이 글자는 프롬프트에서
32
+ * 역따옴표로 감싸여 나가므로, 역따옴표 하나면 그 자리에서 틀이 깨지고
33
+ * 그다음 글이 명령 이름 행세를 한다.
34
+ */
35
+ const 받을꼴 = /^[A-Za-z0-9 _.:@+/-]+$/;
36
+
37
+ export function 성한열쇠인가(열쇠) {
38
+ const s = String(열쇠 ?? '');
39
+ if (!s || s.length > 열쇠최대) return false;
40
+ return 받을꼴.test(s);
41
+ }
42
+
43
+ export function 성한기록인가(r) {
44
+ if (!r || typeof r !== 'object') return false;
45
+ for (const 칸 of ['ok', 'no']) {
46
+ const v = Number(r[칸]);
47
+ if (!Number.isFinite(v) || v < 0) return false;
48
+ }
49
+ // 날짜가 없거나 못 읽으면 못 믿는다. 나이를 모르는 주장은 안 싣는다
50
+ // (신뢰도.js 의 며칠됐나 가 그때 무한대를 돌려준다).
51
+ return Number.isFinite(Date.parse(String(r.at ?? '')));
52
+ }
53
+
54
+ /**
55
+ * 표에서 모양이 안 맞는 것을 걷어낸 **사본**을 돌려준다.
56
+ *
57
+ * 고쳐서 쓰지 않는다. 모양이 안 맞는 것을 우리 짐작으로 고쳐 놓으면, 무엇이
58
+ * 어떻게 바뀌었는지가 또 아무 데도 안 남는다.
59
+ */
60
+ export function 거른표(표) {
61
+ const out = {};
62
+ for (const [k, v] of Object.entries(표 ?? {})) {
63
+ if (성한열쇠인가(k) && 성한기록인가(v)) out[k] = v;
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 겪은 것을 **얼마나 믿을까.**
3
+ *
4
+ * ── 왜 따로 있나 ────────────────────────────────────────────────────────
5
+ *
6
+ * evolve.js 는 「무엇을 겪었나」 를 센다. 이 파일은 그 셈을 보고 「그것을 사실로
7
+ * 말해도 되나」 를 정한다. 두 가지는 자꾸 섞이는데, 섞어 두면 셈을 고칠 때마다
8
+ * 믿음의 문턱이 같이 흔들린다. 그리고 문턱이 흔들리는 것은 **화면에 안 나타난다.**
9
+ *
10
+ * ── 무엇이 문제였나 ─────────────────────────────────────────────────────
11
+ *
12
+ * 자기가 쓴 기록을 자기가 다시 읽는 프로그램은 두 가지로 썩는다.
13
+ *
14
+ * 1. **작은 표본을 확신으로 읽는다.** 두 번 실패한 것을 「이 PC 에서 안 되는
15
+ * 명령」 이라고 프롬프트에 적어 보냈다. 두 번은 우연일 수 있다.
16
+ *
17
+ * 2. **틀린 부정이 스스로를 봉인한다.** 「다시 부르지 마라」 가 프롬프트에
18
+ * 들어가면 모델이 안 부르고, 안 부르니 성공 셈이 영영 안 늘고, 그래서
19
+ * 그 줄이 영영 안 사라진다. 지난달에 없던 프로그램을 오늘 깔아도
20
+ * deel 은 없다고 우긴다. 고쳐질 길이 **구조적으로** 막혀 있었다.
21
+ *
22
+ * 둘 다 새로운 문제가 아니다. 남들이 이미 넘은 자리라 그 방식을 그대로 쓴다.
23
+ *
24
+ * ── 빌려 온 방식 ────────────────────────────────────────────────────────
25
+ *
26
+ * **윌슨 점수 아래끝 (Wilson score lower bound)**
27
+ * 별점 정렬에서 「5점 하나짜리」 가 「4.8점 천 개짜리」 를 이기던 문제를 푼
28
+ * 방식이다. 비율을 그대로 믿지 않고, 표본이 적으면 그만큼 **깎아서** 본다.
29
+ * 두 번 성공은 두 번어치만 믿는다. 근거: Evan Miller,
30
+ * "How Not To Sort By Average Rating" (evanmiller.org).
31
+ *
32
+ * **시간 할인 (discounted counts · Discounted-UCB)**
33
+ * 세상이 안 멈춰 있는 자리에서 옛 관측을 조금씩 잊는 방식이다. 관측마다
34
+ * `γ^(지난 날수)` 를 곱해 두면, 다시 안 겪은 사실은 저절로 힘이 빠진다.
35
+ * 근거: Garivier & Moulines, "On Upper-Confidence Bound Policies for
36
+ * Switching Bandit Problems" (Discounted-UCB · Sliding-Window UCB).
37
+ *
38
+ * **이 둘을 겹치면 2번이 저절로 풀린다.** 「안 된다」 는 다시 안 겪으면 힘이
39
+ * 빠지고, 힘이 빠지면 말하기를 멈추고, 그러면 모델이 다시 해 보고, 그 결과가
40
+ * 새 관측이 된다. 봉인을 따로 깨는 장치를 넣을 필요가 없다 — 잊는 것이 곧
41
+ * 다시 해 보는 것이다.
42
+ *
43
+ * ── 여기 없는 것 ────────────────────────────────────────────────────────
44
+ *
45
+ * 파일에서 읽은 것이 **애초에 우리가 적은 모양이 맞나**는 여기서 안 본다
46
+ * (agent/성한기록.js). 통계와 문지기는 다른 일이고, 섞어 두면 통계 문턱을
47
+ * 만질 때마다 문지기도 같이 흔들린다.
48
+ *
49
+ * ── 안 하는 것 ──────────────────────────────────────────────────────────
50
+ *
51
+ * **지우지 않는다.** 못 믿겠다는 것과 없던 일이라는 것은 다르다. 지워 버리면
52
+ * 다시 겪을 때 처음부터 두 번을 채워야 하고, 그 사이에 또 우회로를 탄다.
53
+ * 여기가 정하는 것은 「말할까」 하나뿐이고, 셈은 evolve.js 가 그대로 들고 있다.
54
+ */
55
+
56
+ /*
57
+ * 믿음의 폭. z=1 은 약 68% 구간이다.
58
+ *
59
+ * 95%(z≈1.96)로 조이면 열 번을 겪어야 겨우 말할 수 있게 되는데, 사람이 한
60
+ * 폴더에서 같은 명령을 열 번 돌리기 전에 대화가 끝난다. 그러면 이 기능이
61
+ * 켜져 있으나 마나가 된다 — 안 배우느니만 못한 것이 아니라, 그냥 안 배운다.
62
+ *
63
+ * z=1 은 「두 번 겪으면 말하고, 한 번은 말하지 않는다」 에 딱 맞는다. 여태
64
+ * 쓰던 규칙(`ok>=2 && no===0`)이 우연히 서 있던 자리이기도 하다. 다른 점은,
65
+ * 이제 그 자리가 **어디서 온 숫자인지** 적혀 있다는 것이다.
66
+ */
67
+ export const z = 1;
68
+
69
+ /** 이 위로 올라가야 「된다」 고 말한다 (성공률 아래끝). */
70
+ export const 좋음문턱 = 0.6;
71
+ /** 이 아래로 내려가야 「안 된다」 고 말한다 (성공률 위끝). */
72
+ export const 나쁨문턱 = 0.4;
73
+
74
+ /*
75
+ * ── 반감기가 둘인 까닭 ──────────────────────────────────────────────────
76
+ *
77
+ * 다시 안 겪은 관측이 절반으로 줄기까지의 날수인데, **「된다」 와 「안 된다」 에
78
+ * 다른 값을 준다.** 언뜻 자의적으로 보이지만 근거가 있다.
79
+ *
80
+ * 틀린 긍정("이 명령이 된다")은 한 번 해 보면 그 자리에서 들통난다. 값은
81
+ * 실패한 명령 한 번이고, 그 실패가 곧 새 관측이 되어 스스로를 고친다.
82
+ *
83
+ * 틀린 부정("안 된다")은 **해 보는 일 자체를 막는다.** 프롬프트가 부르지
84
+ * 말라고 하니 모델이 안 부르고, 안 부르니 관측이 안 생기고, 그래서 영영 안
85
+ * 고쳐진다. 들통날 기회를 자기가 없앤다.
86
+ *
87
+ * 그래서 부정은 더 빨리 잊는다. 반감기 사흘이면 **두 번 겪은 「안 된다」 는
88
+ * 하루쯤 뒤에** 입을 다문다. 그러면 모델이 다시 해 보고, 정말 안 되면 그날
89
+ * 다시 쌓인다 — 다섯 번 겪은 것은 닷새쯤 간다. 하루에 한 번 헛부름은
90
+ * 「한 달 내내 틀린 말을 프롬프트에 싣는 것」 보다 훨씬 싸다.
91
+ *
92
+ * 긍정은 세 주로 둔다. 이레로 뒀더니 **금요일에 확인한 명령이 월요일 아침에
93
+ * 백지가 됐다** — 그건 잊는 것이 아니라 안 배우는 것이다. 세 주면 두 번 겪은
94
+ * 사실이 아흐레쯤 가고, 다섯 번 겪은 사실은 한 달을 넘긴다.
95
+ */
96
+ export const 반감기된다 = 21;
97
+ export const 반감기안된다 = 3;
98
+
99
+ /** 하루에 곱해지는 값. 반감기에서 나온다 — 두 곳에 따로 적으면 언젠가 갈린다. */
100
+ export const 하루할인된다 = Math.pow(0.5, 1 / 반감기된다);
101
+ export const 하루할인안된다 = Math.pow(0.5, 1 / 반감기안된다);
102
+
103
+ /**
104
+ * 며칠 지났나. 못 재면 무한대다.
105
+ *
106
+ * **모르는 것을 「오늘」 로 치지 않는다.** 옛 판에서 넘어왔거나 손으로 고쳐진
107
+ * 기록에는 날짜가 없을 수 있는데, 그것을 오늘 겪은 것으로 치면 나이를 알 수
108
+ * 없는 주장이 통째로 되살아난다 — 이 파일이 막으려는 바로 그 오염이다.
109
+ *
110
+ * 미래에 적힌 것은 0일로 친다. 시계가 틀어진 PC 에서 음수 날수가 나오면
111
+ * 할인이 1보다 커져서 **겪지도 않은 것이 더 세진다.**
112
+ */
113
+ export function 며칠됐나(지금, 그때) {
114
+ const a = Date.parse(String(지금 ?? ''));
115
+ const b = Date.parse(String(그때 ?? ''));
116
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return Infinity;
117
+ return Math.max(0, (a - b) / 86400000);
118
+ }
119
+
120
+ /**
121
+ * 지난 셈을 오늘 기준으로 삭힌다 (Discounted-UCB 의 할인).
122
+ *
123
+ * 관측 하나하나에 날짜를 달아 두지 않아도 된다. 마지막으로 손댄 날부터
124
+ * 오늘까지의 할인을 통째로 한 번 곱하면 같은 값이 된다 — 그래서 기록은
125
+ * `{ok, no, at}` 세 칸 그대로다.
126
+ *
127
+ * 아주 작아진 값은 0 으로 떨군다. 안 그러면 10^-9 짜리 부스러기가 표에 남아
128
+ * 자리만 차지하고, 그 부스러기가 `줄이기` 에서 진짜 기록을 밀어낸다.
129
+ */
130
+ export const 부스러기 = 0.01;
131
+
132
+ export function 삭힘({ ok = 0, no = 0 } = {}, 날수 = 0) {
133
+ if (!Number.isFinite(날수)) return { ok: 0, no: 0 };
134
+ const a = (Number(ok) || 0) * Math.pow(하루할인된다, 날수);
135
+ const b = (Number(no) || 0) * Math.pow(하루할인안된다, 날수);
136
+ return { ok: a < 부스러기 ? 0 : a, no: b < 부스러기 ? 0 : b };
137
+ }
138
+
139
+ /*
140
+ * ── 윌슨 점수 구간 ──────────────────────────────────────────────────────
141
+ *
142
+ * 성공 s 번, 전체 n 번일 때 진짜 성공률이 어느 사이에 있는가. 평균을 그대로
143
+ * 쓰지 않는 까닭은 n 이 작을 때 평균이 거짓말을 하기 때문이다 — 1승 0패의
144
+ * 평균은 100% 지만, 그 100% 는 아무것도 안 말해 준다.
145
+ *
146
+ * ── 소수 n 을 넣는 것에 대해 (알고 넘어가는 어긋남) ─────────────────────
147
+ *
148
+ * 윌슨 점수는 **정수 시행**을 전제로 한 근사다. 우리가 넣는 n 은 삭힌 값이라
149
+ * 소수이고, 그건 엄밀히는 맞지 않는다. 가중된 관측의 유효 표본 수는
150
+ * `(Σw)² / Σw²` 인데 그 값은 언제나 `Σw` 보다 작으므로, `Σw` 를 그대로 쓰면
151
+ * **분산을 덜 잡는다** — 즉 우리는 실제보다 조금 더 확신하는 쪽으로 틀린다.
152
+ *
153
+ * 제대로 하려면 관측마다 무게를 들고 있다가 베타 사후분포의 분위수를 봐야
154
+ * 하는데, 그러려면 기록이 `{ok, no, at}` 세 칸으로 안 끝난다. 그 값을 치를
155
+ * 만큼의 정밀도가 아니다 — 여기서 나오는 답은 「말한다 / 안 한다」 둘뿐이다.
156
+ *
157
+ * 대신 **어긋나는 방향을 알고 문턱을 그만큼 넉넉히 잡았다.** 두 번 겪어야
158
+ * 말하기 시작하는데, 덜 잡힌 분산은 그 둘을 하나 반쯤으로 보이게 하는
159
+ * 정도다. 지어낸 정밀도로 한 번 더 계산하느니, 어긋남을 적어 두는 편이 낫다.
160
+ */
161
+ function 구간(s, n) {
162
+ if (!(n > 0)) return { 아래: 0, 위: 1 };
163
+ const p = Math.min(1, Math.max(0, s / n));
164
+ const z2 = z * z;
165
+ const 나눔 = 1 + z2 / n;
166
+ const 가운데 = (p + z2 / (2 * n)) / 나눔;
167
+ const 폭 = (z / 나눔) * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n));
168
+ return { 아래: Math.max(0, 가운데 - 폭), 위: Math.min(1, 가운데 + 폭) };
169
+ }
170
+
171
+ /** 성공률의 아래끝. 표본이 적으면 그만큼 깎인다. */
172
+ export function 윌슨아래(s, n) { return 구간(s, n).아래; }
173
+ /** 성공률의 위끝. 「안 된다」 를 말해도 되는지는 이쪽으로 잰다. */
174
+ export function 윌슨위(s, n) { return 구간(s, n).위; }
175
+
176
+ /**
177
+ * 이 기록을 보고 무엇을 말할 수 있나.
178
+ *
179
+ * @returns {'된다'|'안된다'|'모름'}
180
+ *
181
+ * 「모름」 이 제일 흔한 답이고, 그래야 맞다. 우리가 프롬프트에 한 줄을 더하는
182
+ * 것은 모델의 판단을 그쪽으로 미는 일이라, 확실하지 않으면 안 미는 편이 낫다.
183
+ */
184
+ export function 뭐라할까(기록, 지금) {
185
+ const { ok, no } = 삭힘(기록, 며칠됐나(지금, 기록?.at));
186
+ const n = ok + no;
187
+ if (!(n > 0)) return '모름';
188
+ if (윌슨아래(ok, n) >= 좋음문턱) return '된다';
189
+ if (윌슨위(ok, n) <= 나쁨문턱) return '안된다';
190
+ return '모름';
191
+ }
@@ -1016,6 +1016,58 @@ async function 미리비키기(opts, conn) {
1016
1016
  }
1017
1017
 
1018
1018
  // 한 번에 받기.
1019
+ /*
1020
+ * ── 이 요청이 **어느 자리로 갔나** ──────────────────────────────────────
1021
+ *
1022
+ * 게이트웨이 뒤에는 대개 같은 모델이 여러 자리에 올라가 있다(리전 여럿,
1023
+ * 열쇠 여럿). 그런데 **프리픽스 캐시는 자리마다 따로다.** 그래서 어제까지
1024
+ * 92% 로 읽히던 대화가, 다음 요청이 다른 자리로 가는 순간 0% 가 된다 —
1025
+ * 우리가 보낸 것은 한 글자도 안 달라졌는데.
1026
+ *
1027
+ * 실제로 그 줄을 봤다. 같은 122.4K 프리픽스가 3분 41초 만에 다시 나갔고,
1028
+ * 그 한 번이 그날 제일 비싼 요청이었다. 화면에는 아무 말도 안 났다.
1029
+ *
1030
+ * 그래서 **서버가 말해 줄 때만** 적어 둔다. LiteLLM 은 어느 자리가 받았는지
1031
+ * 를 응답 머리로 알려 준다(`x-litellm-model-id`) — 이 값이 턴 사이에 바뀌면
1032
+ * 캐시가 끊겼다는 뜻이고, 그건 짐작이 아니라 잰 것이다.
1033
+ *
1034
+ * 없으면 아무 말도 안 한다. 모르는 머리는 그냥 없는 것이고, 없는 것을 두고
1035
+ * 「자리가 안 바뀌었다」 고 말하지도 않는다.
1036
+ *
1037
+ * **값은 화면에 안 찍는다.** 이 값은 사내 배포 이름이거나 리전 주소일 수
1038
+ * 있다. 우리가 쓸 것은 「바뀌었나」 하나뿐이라, 바뀌었다는 사실만 올린다.
1039
+ */
1040
+ /*
1041
+ * ── 왜 이름이 하나뿐인가 ────────────────────────────────────────────────
1042
+ *
1043
+ * 세션 이름을 **내보낼** 때는 아는 이름을 다 단다(위 세션머리). 머리는 모르면
1044
+ * 무시되니 이름이 늘어도 잃을 것이 없어서다. 여기는 반대다 — **받아서 읽는**
1045
+ * 자리라, 이름 하나를 잘못 고르면 없는 일을 있다고 말하게 된다.
1046
+ *
1047
+ * 그래서 「어느 자리가 받았나」 를 뜻한다고 **문서에 적힌** 이름만 읽는다.
1048
+ * 그럴듯한 일반형들을 같이 읽어 봤다가 도로 뺐다:
1049
+ *
1050
+ * x-deployment-id 일반 웹·컨테이너 인프라(배포 해시)가 같은 이름을
1051
+ * 쓴다. 모델과 아무 상관 없이 배포할 때마다 바뀌므로,
1052
+ * 멀쩡한 캐시를 두고 「자리가 바뀌었다」 를 찍는다.
1053
+ * x-ms-deployment-name Azure 의 **논리** 배포 이름이라 물리 자리가 바뀌어도
1054
+ * 그대로다. 읽어 봐야 알아내는 것이 없고, 있는데 없다고
1055
+ * 말하는 쪽이라 더 나쁘다.
1056
+ *
1057
+ * 모르면 아무 말도 안 하는 편이 낫다. 안 뜨는 경고는 사람이 다른 데를 보게
1058
+ * 하지 않지만, 틀린 경고는 진짜 경고까지 안 읽게 만든다.
1059
+ */
1060
+ const 자리머리들 = ['x-litellm-model-id'];
1061
+
1062
+ export function 간자리(머리) {
1063
+ if (!머리?.get) return null;
1064
+ for (const 이름 of 자리머리들) {
1065
+ const v = 머리.get(이름);
1066
+ if (v) return String(v).trim() || null;
1067
+ }
1068
+ return null;
1069
+ }
1070
+
1019
1071
  export async function chat(conn, opts) {
1020
1072
  // 이 회사가 받는 모양으로 도구를 다듬는다 (backend/toolfit.js).
1021
1073
  // 모르는 주소면 아무것도 안 바뀐다 — 지금까지와 똑같이 돈다.
@@ -1038,7 +1090,11 @@ export async function chat(conn, opts) {
1038
1090
  할당량기억(r.headers, 할당량자리(conn));
1039
1091
  // 다듬느라 이름을 고쳤으면 여기서 되돌린다. 밖에서는 그런 일이 있었는지
1040
1092
  // 모른 채로 원래 이름을 받는다.
1041
- if (r.ok) return 이름되돌리기(extractMessage(conn.kind, r.json), 맞춘것.되돌림);
1093
+ if (r.ok) {
1094
+ const 것 = 이름되돌리기(extractMessage(conn.kind, r.json), 맞춘것.되돌림);
1095
+ const 자리 = 간자리(r.headers);
1096
+ return 자리 ? { ...것, 간자리: 자리 } : 것;
1097
+ }
1042
1098
  // 열쇠가 늙어서 막힌 것이면 새로 받고 한 번만 다시. 시도 수는 안 올린다 —
1043
1099
  // 서버가 막은 것이 아니라 우리 열쇠가 낡았던 것이라 물러설 까닭이 없다.
1044
1100
  if (열쇠다시받을까(conn, r.status, 열쇠다시받음)) {
@@ -1267,6 +1323,9 @@ export async function* chatStream(conn, opts) {
1267
1323
  */
1268
1324
  if (!acc.content && acc.거절글?.trim()) acc.content = acc.거절글.trim();
1269
1325
  delete acc.거절글;
1326
+ // 흘려 받는 자리에서도 **어느 자리가 받았는지**를 같이 올린다 (위 간자리).
1327
+ const 자리 = 간자리(r.headers ?? r.res?.headers);
1328
+ if (자리) acc.간자리 = 자리;
1270
1329
  yield { type: 'done', message: 이름되돌리기(acc, 맞춘것.되돌림) };
1271
1330
  }
1272
1331
 
package/src/commands.js CHANGED
@@ -2524,7 +2524,22 @@ function 배움명령(session, ctx, arg = '') {
2524
2524
  say(` ${c.bold('이 폴더에서 돌려 본 명령')}`);
2525
2525
  for (const r of 명령.slice(0, 12)) {
2526
2526
  const 표 = r.no === 0 ? c.green('✓') : r.ok === 0 ? c.red('✗') : c.yellow('~');
2527
- say(` ${표} ${pad(r.이름, 24)} ${c.gray(`됨 ${r.ok} · 안 됨 ${r.no}`)}`);
2527
+ /*
2528
+ * 언제 겪었는지와 **지금 실리는지**를 같이 적는다.
2529
+ *
2530
+ * 셈은 삭는다 (agent/신뢰도.js). 그래서 「됨 3 · 안 됨 0」 두 줄이 화면에
2531
+ * 똑같이 보여도 한 줄은 프롬프트에 실리고 다른 줄은 안 실릴 수 있다.
2532
+ * 특히 「안 된다」 는 스스로를 봉인하던 자리라, 그것이 지금 입을 다물고
2533
+ * 있다는 사실이야말로 사람이 봐야 하는 것이다.
2534
+ *
2535
+ * 판정은 배움이 낸 것을 그대로 받는다 — 여기서 다시 계산하면 언젠가
2536
+ * 한쪽만 고쳐지고, 그때부터 화면이 실제와 다른 말을 한다.
2537
+ */
2538
+ const 셈 = (n) => (Number.isFinite(n) ? String(Math.round(n * 10) / 10) : '?');
2539
+ const 날 = Number.isFinite(r.나이) ? `${Math.floor(r.나이)}일 전` : '언제인지 모름';
2540
+ const 말 = r.판정 === '모름' ? `${날} — 아직 안 싣습니다` : `${날} · 프롬프트에 실림`;
2541
+ say(` ${표} ${pad(r.이름, 24)} ${c.gray(`됨 ${셈(r.ok)} · 안 됨 ${셈(r.no)}`)}`
2542
+ + ` ${c.gray(말)}`);
2528
2543
  }
2529
2544
  say('');
2530
2545
  }
package/src/repl.js CHANGED
@@ -2120,6 +2120,23 @@ export async function chatLoop(opts = {}) {
2120
2120
  break;
2121
2121
  }
2122
2122
 
2123
+ /*
2124
+ * 게이트웨이가 이번 요청을 **다른 자리로** 보냈다.
2125
+ *
2126
+ * 프리픽스 캐시는 자리마다 따로라, 이 줄이 뜬 턴은 우리가 보낸 것이
2127
+ * 한 글자도 안 달라졌어도 앞머리를 통째로 다시 쓴다. 청구서에서
2128
+ * 「왜 이 한 줄만 열 배냐」 로 보이던 것이 이것이다.
2129
+ *
2130
+ * 값은 안 찍는다 — 사내 배포 이름이나 리전 주소일 수 있다. 우리가
2131
+ * 쓸 것은 「바뀌었다」 하나뿐이다 (backend/adapter.js 의 간자리).
2132
+ */
2133
+ case '자리바뀜':
2134
+ say(` ${c.hyellow('⇄')} ${c.gray('게이트웨이가 이번 요청을 다른 자리로 보냈습니다 — 앞머리를 캐시에서 못 읽고 다시 씁니다.')}`);
2135
+ if (session.level !== '쉬움') {
2136
+ say(` ${c.gray('한 대화를 한 자리에 붙여 두는 설정이 게이트웨이 쪽에 있습니다 (세션 고정).')}`);
2137
+ }
2138
+ break;
2139
+
2123
2140
  case 'trimmed':
2124
2141
  say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
2125
2142
  break;
@@ -2148,6 +2165,16 @@ export async function chatLoop(opts = {}) {
2148
2165
  clearThinking();
2149
2166
  say(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 — `)}`
2150
2167
  + `${c.white(ev.아낀토큰.toLocaleString())} ${c.gray('토큰을 비웠습니다. 대화는 그대로입니다.')}`);
2168
+ /*
2169
+ * 접기는 **값이 드는 일**이다 (agent/compact.js 의 손익분기).
2170
+ *
2171
+ * 이력 가운데 글을 바꿔치므로 그 자리 뒤가 전부 새 글이 되고,
2172
+ * 캐시가 도는 연결에서는 싸게 읽히던 것을 비싸게 다시 쓴다.
2173
+ * 여태 화면에는 이득만 나왔다 — 대가는 며칠 뒤 청구서에서 봤다.
2174
+ */
2175
+ if (ev.캐시다시씀) {
2176
+ say(` ${c.gray('이번 턴은 앞머리를 캐시에서 못 읽고 다시 씁니다 — 값과 지연이 한 번 튑니다.')}`);
2177
+ }
2151
2178
  /*
2152
2179
  * 무엇을 접었는지 이름으로 보여 준다.
2153
2180
  *
@@ -2244,7 +2271,7 @@ export async function chatLoop(opts = {}) {
2244
2271
  clearThinking();
2245
2272
  if (streamed) { 답비우기(); say(''); streamed = false; }
2246
2273
  say(` ${mark.warn} ${c.gray(`답이 ${c.white(ev.cap.toLocaleString())} 토큰에서 잘렸습니다`)}`
2247
- + `${ev.정한값 ? c.gray(' (직접 정하신 상한입니다)') : c.gray(' — 올리는 천장입니다')}`);
2274
+ + `${ev.정한값 ? c.gray(' (직접 정하신 상한입니다)') : c.gray(' — 서버가 알려 값이 없어 선 기본 상한입니다')}`);
2248
2275
  say(` ${c.gray('한 번에 더 길게 받으려면')} ${c.cyan('/out 32k')}${c.gray('. 파일을 쓰는 중이었다면 Append 로 나눠 쓰게 하세요.')}`);
2249
2276
  break;
2250
2277
 
@@ -2478,7 +2505,29 @@ export async function chatLoop(opts = {}) {
2478
2505
  if (tools) bits.push(`${옮긴말('scr.tools')} ${옮긴말('unit.calls', { n: tools })}`);
2479
2506
  const dIn = (session.usage.prompt || session.usage.in) - before.in;
2480
2507
  const dOut = session.usage.out - before.out;
2508
+ const d읽음 = (session.usage.cacheRead ?? 0) - before.cacheRead;
2509
+ const d씀 = (session.usage.cacheWrite ?? 0) - before.cacheWrite;
2481
2510
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
2511
+ /*
2512
+ * ── ↑ 안을 가른다 ────────────────────────────────────────────────────
2513
+ *
2514
+ * `↑798,620` 한 줄만 보면 그게 다 정가로 나간 것처럼 읽힌다. 실제로는
2515
+ * 그중 대부분이 **캐시에서 읽힌 것**이고, 우리가 재 본 자리에서 읽기는
2516
+ * 쓰기의 1/12.5 였다. 즉 이 두 숫자는 자릿수가 다른 돈이다.
2517
+ *
2518
+ * 이걸 안 가르면 사람이 할 수 있는 판단이 없다. 가르면 바로 보인다 —
2519
+ * 「새로 쓴 것」 이 크면 캐시가 끊긴 것이고(자리가 바뀌었거나 접었거나),
2520
+ * 「캐시에서」 가 크면 대화가 길 뿐 값은 안 나가고 있는 것이다.
2521
+ *
2522
+ * 잰 값이 없으면 안 적는다. 캐시를 안 세어 주는 서버에서 0 을 적어 두면
2523
+ * 「캐시가 하나도 안 걸렸다」 로 읽히는데, 그건 우리가 모르는 것이다.
2524
+ */
2525
+ if (d읽음 > 0 || d씀 > 0) {
2526
+ const 몫 = [];
2527
+ if (d읽음 > 0) 몫.push(`캐시에서 ${d읽음.toLocaleString()}`);
2528
+ if (d씀 > 0) 몫.push(`새로 씀 ${d씀.toLocaleString()}`);
2529
+ bits.push(`↑ 안: ${몫.join(' · ')}`);
2530
+ }
2482
2531
  /*
2483
2532
  * 이번 턴에 나간 돈.
2484
2533
  *