deel-local-cli 1.20.7 → 1.20.9

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.
@@ -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