deel-local-cli 1.20.6 → 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.
@@ -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
  //