deel-local-cli 1.8.0 → 1.12.0

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.
Files changed (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
@@ -69,6 +69,30 @@ export function 배울것(message) {
69
69
  }
70
70
 
71
71
  // ── 2) 답 길이 한계 ───────────────────────────────────────────────────
72
+
73
+ /*
74
+ * 「우리가 준 값 > 서버의 한계」 로 말하는 자리부터 본다.
75
+ *
76
+ * Anthropic 은 출력 상한을 이렇게 말한다 —
77
+ *
78
+ * "max_tokens: 100000 > 64000, which is the maximum allowed number of
79
+ * output tokens for claude-x"
80
+ *
81
+ * 숫자가 둘인데 **앞이 우리가 요청한 값**이고 뒤가 한계다. 아래 표는 이름
82
+ * 뒤의 첫 숫자를 집으므로, 그대로 두면 방금 거절당한 바로 그 값을 한계로
83
+ * 배운다. 그러면 같은 값으로 곧장 다시 부르고 또 거절당하는데, 그때는 이미
84
+ * 배운 뒤라 두 번은 못 배우고 턴이 죽는다(loop.js). 게다가 배운 값은
85
+ * 연결저장() 으로 프로필에 적히므로 **다음에 켤 때도 똑같이 죽는다.**
86
+ * 사람이 손으로 설정을 고치기 전에는 안 풀린다.
87
+ *
88
+ * 표보다 먼저 본다. 표에 한 번 걸리고 나면 되돌릴 자리가 없다.
89
+ */
90
+ const 넘김 = /max_(?:completion_)?tokens\s*[:=]?\s*(\d{3,})\s*>\s*(\d{3,})/i.exec(s);
91
+ if (넘김) {
92
+ const limit = 성한수(넘김[2]);
93
+ if (limit) return { kind: 'out', limit, asked: 성한수(넘김[1]), text: 짧게(s) };
94
+ }
95
+
72
96
  const out표 = [
73
97
  // "max_tokens is too large: 200000. This model supports at most 16384 completion tokens"
74
98
  /supports? at most\s+(\d+)\s*(?:completion\s*)?tokens?/i,
@@ -69,6 +69,42 @@ export function 설정읽기(root) {
69
69
  return { 서버들, 자리: p, 있음: true };
70
70
  }
71
71
 
72
+ /*
73
+ * ── 지금 띄워 둔 서버들 ─────────────────────────────────────────────────
74
+ *
75
+ * 여기 왜 명부가 있나. MCP 서버는 붙인 쪽(repl·ACP)이 들고 있을 뿐이라,
76
+ * 프로그램이 어느 길로든 끝나 버리면 **아무도 안 닫는다.** 그러면 사람
77
+ * 컴퓨터에 서버 프로세스가 하나씩 쌓인다 — 작업 관리자를 열기 전에는
78
+ * 모르는 종류의 탈이다.
79
+ *
80
+ * 일감(tools/jobs.js)과 언어 서버(lsp/client.js)에는 이미 이 그물이 있는데
81
+ * 여기만 없었다. 같은 자리, 같은 규칙으로 둔다.
82
+ */
83
+ const 띄운것들 = new Set();
84
+
85
+ /** 검사와 진단이 본다. 지금 살아 있는 서버 수. */
86
+ export function 살아있는수() { return 띄운것들.size; }
87
+
88
+ /**
89
+ * 다 닫는다. 프로그램이 끝날 때와 검사 뒤에 부른다.
90
+ * @returns {number} 닫은 개수
91
+ */
92
+ export function 모두닫기() {
93
+ const 것들 = [...띄운것들];
94
+ 띄운것들.clear();
95
+ let n = 0;
96
+ for (const s of 것들) { try { s.닫기(); n++; } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } }
97
+ return n;
98
+ }
99
+
100
+ /*
101
+ * 어떤 길로 끝나든 남기지 않는다. 붙인 쪽이 이미 닫았어도 무해하다.
102
+ *
103
+ * 이름 있는 함수를 그대로 건다 — 이름 없는 화살표로 걸면 그물이 걸려 있는지
104
+ * 검사가 밖에서 확인할 길이 없다. 걷어내도 아무도 모르는 그물은 없는 것과 같다.
105
+ */
106
+ process.once('exit', 모두닫기);
107
+
72
108
  /**
73
109
  * 서버 하나와의 연결.
74
110
  *
@@ -105,6 +141,9 @@ export class MCP서버 {
105
141
  this.죽음 = `띄우지 못했습니다: ${e.message}`;
106
142
  return false;
107
143
  }
144
+ // 띄운 순간부터 명부에 든다. 악수(initialize)를 못 마쳐도 아이는 이미
145
+ // 떠 있으므로, 여기서 안 적으면 그 아이는 아무도 안 거두는 아이가 된다.
146
+ 띄운것들.add(this);
108
147
 
109
148
  this.kid.on('error', (e) => this.끝냄(`오류: ${e.message}`));
110
149
  this.kid.on('exit', (code, sig) => this.끝냄(`끝났습니다 (코드 ${code ?? sig})`));
@@ -163,25 +202,56 @@ export class MCP서버 {
163
202
  if (!기다림) return;
164
203
  this.기다리는것.delete(j.id);
165
204
  clearTimeout(기다림.타이머);
205
+ // 끝난 자리의 ESC 엿듣기는 떼어 낸다. 안 떼면 한 턴에 도구를 스무 번
206
+ // 부르는 사이 신호 하나에 스무 개가 매달린다 — 노드가 열 개 넘으면
207
+ // 「메모리가 새는 것 같다」 고 경고를 찍는데, 실제로 새는 것이 맞다.
208
+ 기다림.끊기그만?.();
166
209
  if (j.error) 기다림.실패(new Error(j.error.message ?? '알 수 없는 오류'));
167
210
  else 기다림.성공(j.result);
168
211
  }
169
212
 
170
- 보내고기다리기(method, params, timeout = 부르기제한) {
213
+ /**
214
+ * 한 통 보내고 답을 기다린다.
215
+ *
216
+ * signal 은 사람이 누른 ESC 다. 안 받으면 도구 하나 부르는 데 최대 60초
217
+ * (부르기제한)를 기다리는데, 그 60초 동안 ESC 는 아무것도 안 한다 — 화면은
218
+ * 「멈추는 중…」 인데 남의 프로세스의 답을 계속 기다리고 있는 상태다.
219
+ * 그래서 시한과 같은 자리에서 같은 방식으로 푼다: 기다리는 표에서 빼고,
220
+ * 왜 끝났는지를 말로 남기고 끝낸다.
221
+ */
222
+ 보내고기다리기(method, params, timeout = 부르기제한, signal = null) {
171
223
  return new Promise((성공, 실패) => {
172
224
  if (!this.kid || this.kid.exitCode !== null) return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
225
+ // 이미 멈췄으면 보내지도 않는다. 보내 놓고 버리면 남의 서버는 그 일을 끝까지 한다.
226
+ if (signal?.aborted) return 실패(new Error('중단했습니다'));
173
227
  const id = this.다음번호++;
174
228
  const 타이머 = setTimeout(() => {
175
229
  this.기다리는것.delete(id);
230
+ 끊기그만();
176
231
  실패(new Error(`${Math.round(timeout / 1000)}초 안에 답이 없습니다`));
177
232
  }, timeout);
178
233
  if (타이머.unref) 타이머.unref();
179
- this.기다리는것.set(id, { 성공, 실패, 타이머 });
234
+ /*
235
+ * 기다리는 표에서 **반드시** 뺀다.
236
+ *
237
+ * 안 빼면 뒤늦게 온 답이 이미 끝난 약속을 또 푼다. 두 번째 풀기는
238
+ * 조용히 무시되므로 오류는 안 나지만, 표에 죽은 자리가 남아서
239
+ * 끝냄() 이 그것들을 다시 실패시킨다 — 아무도 안 듣는 실패다.
240
+ */
241
+ const 끊겼다 = () => {
242
+ clearTimeout(타이머);
243
+ this.기다리는것.delete(id);
244
+ 실패(new Error('중단했습니다'));
245
+ };
246
+ signal?.addEventListener?.('abort', 끊겼다, { once: true });
247
+ const 끊기그만 = () => signal?.removeEventListener?.('abort', 끊겼다);
248
+ this.기다리는것.set(id, { 성공, 실패, 타이머, 끊기그만 });
180
249
  try {
181
250
  this.kid.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
182
251
  } catch (e) {
183
252
  clearTimeout(타이머);
184
253
  this.기다리는것.delete(id);
254
+ 끊기그만();
185
255
  실패(e);
186
256
  }
187
257
  });
@@ -191,8 +261,8 @@ export class MCP서버 {
191
261
  try { this.kid?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
192
262
  }
193
263
 
194
- async 부르기(도구이름, args, { timeout = 부르기제한 } = {}) {
195
- const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout);
264
+ async 부르기(도구이름, args, { timeout = 부르기제한, signal = null } = {}) {
265
+ const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout, signal);
196
266
  // 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
197
267
  const 조각 = Array.isArray(r?.content) ? r.content : [];
198
268
  const 글 = 조각
@@ -206,6 +276,7 @@ export class MCP서버 {
206
276
  this.죽음 = this.마지막말 ? `${왜} — ${this.마지막말}` : 왜;
207
277
  for (const [, 기다림] of this.기다리는것) {
208
278
  clearTimeout(기다림.타이머);
279
+ 기다림.끊기그만?.();
209
280
  기다림.실패(new Error(this.죽음));
210
281
  }
211
282
  this.기다리는것.clear();
@@ -213,6 +284,7 @@ export class MCP서버 {
213
284
 
214
285
  닫기() {
215
286
  this.끝냄('닫았습니다');
287
+ 띄운것들.delete(this);
216
288
  try {
217
289
  this.kid?.stdin?.end();
218
290
  this.kid?.kill();
@@ -236,6 +308,25 @@ function 깨끗한환경() {
236
308
  return out;
237
309
  }
238
310
 
311
+ /**
312
+ * 게이트웨이 열쇠 **하나만** 뺀 환경. Bash 와 Jobs 가 자식에게 넘길 것.
313
+ *
314
+ * 위 깨끗한환경 은 남의 프로그램(MCP 서버)에 주는 것이라 통째로 씻는다.
315
+ * 여기는 다르다 — Bash 로 도는 것은 **사용자 제 프로젝트**다. PATH·NODE_ENV·
316
+ * DEEL_HOME·사내 프록시 설정이 다 있어야 하고(사용자는 이 프로그램의 검사
317
+ * 자체를 Bash 로 돌린다), 하나라도 빠지면 「내 터미널에서는 되는데」 가 된다.
318
+ *
319
+ * 그래서 딱 하나만 뺀다. 안 빼면 `env` 한 줄로 열쇠가 화면에 찍히고, 그 화면이
320
+ * 대화에 실려 게이트웨이로 나가고 `.deel/sessions/*.jsonl` 로 디스크에도 남는다 —
321
+ * 열쇠를 그 열쇠의 주인에게 보내는 셈이다(guard.js 가 막는 것과 같은 길).
322
+ * 자식이 무엇을 하든 이 값이 필요할 일은 없다. 게이트웨이로 나가는 것은 우리다.
323
+ */
324
+ export function 열쇠뺀환경(env = process.env) {
325
+ const out = { ...env };
326
+ delete out.DEEL_API_KEY;
327
+ return out;
328
+ }
329
+
239
330
  /** 우리 도구 이름과 안 부딪히게 앞에 서버 이름을 붙인다. Claude Code 와 같은 꼴이다. */
240
331
  export const 도구이름 = (서버, 도구) => `mcp__${서버}__${도구}`;
241
332
 
@@ -275,7 +366,7 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
275
366
  const ok = await 서버.붙기({ timeout });
276
367
  if (ok) {
277
368
  붙은것.push(서버);
278
- audit?.note?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
369
+ audit?.write?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
279
370
  } else {
280
371
  못한것.push({ 이름: s.이름, 왜: 서버.죽음 ?? '알 수 없는 이유' });
281
372
  서버.닫기();
@@ -7,7 +7,12 @@
7
7
  import { req, headersFor, serverMessage } from './http.js';
8
8
  import { probeCtx } from './ctxsize.js';
9
9
  import { 눈검사메시지 } from './vision.js';
10
- import { 주소붙이기 } from './adapter.js';
10
+ import {
11
+ 주소붙이기, endpoint, 더할머리, buildBody, extractMessage,
12
+ assistantMessage, toolMessage,
13
+ } from './adapter.js';
14
+ import { 벤더 } from './toolfit.js';
15
+ import { 말 } from '../i18n/index.js';
11
16
 
12
17
  const READ_TOOL = {
13
18
  type: 'function',
@@ -22,32 +27,32 @@ const READ_TOOL = {
22
27
  },
23
28
  };
24
29
 
25
- // 규격별 요청 만들기 — 이 함수 하나가 openai/ollama 차이를 흡수한다.
26
- function build(shape, { model, messages, tools, stream, json, think, maxTokens = 128 }) {
27
- if (shape === 'ollama') {
28
- const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
29
- if (tools) body.tools = tools;
30
- if (json) body.format = json;
31
- if (think !== undefined) body.think = think;
32
- return { path: '/api/chat', body };
33
- }
34
- const body = { model, messages, stream: !!stream, max_tokens: maxTokens };
35
- if (tools) { body.tools = tools; body.tool_choice = 'auto'; }
36
- if (json) {
37
- body.response_format = { type: 'json_schema', json_schema: { name: 'probe', schema: json, strict: true } };
38
- }
39
- if (think !== undefined) body.reasoning_effort = think;
40
- return { path: '/chat/completions', body };
41
- }
30
+ /*
31
+ * ── 규격 차이는 여기서 흡수하지 않는다 ──────────────────────────────────
32
+ *
33
+ * 여기에 build() extract() 따로 있었다. 둘이 아는 규격은 `ollama` 와
34
+ * 「나머지 = OpenAI」 **둘뿐**이었고, `anthropic` 은 「나머지」 로 떨어졌다.
35
+ * 그래서 Claude 를 직접 붙이면 진단이 이렇게 갔다 —
36
+ *
37
+ * · 머리(anthropic-version)를 얹는다 → 그 하나로 400
38
+ * · 문 이름이 /chat/completions (있어야 할 것은 /messages)
39
+ * · 시킴말이 messages 안으로 들어간다 「모르는 역할」
40
+ * · 도구가 {type:'function', function:{…}} 모양으로 간다
41
+ * · 답을 choices[0].message 에서 찾는다 (실제로는 content 블록 배열)
42
+ *
43
+ * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 일곱 칸은 전부 「확인 불가」 로
44
+ * 건너뛴다. 결과 `deel setup` 이 저장하는 프로필에 streaming·tools·json·
45
+ * vision **다 false** 로 적힌다 — Claude 를 붙였는데 도구를 아예 안 쓰는
46
+ * 연결이 만들어진다. 붙기는 붙으니 아무도 고장이라고 생각하지 않는다.
47
+ *
48
+ * adapter.js 첫 줄은 처음부터 "진단(probe)과 에이전트 루프가 같은 함수를
49
+ * 쓴다" 고 적어 두었다. 실제로는 안 썼다. 이제 쓴다 — 규격이 넷째가 되어도
50
+ * 이 파일은 안 고쳐도 된다.
51
+ */
42
52
 
43
- // 응답에서 본문과 도구호출을 꺼낸다.
44
- function extract(shape, json) {
45
- if (shape === 'ollama') {
46
- const m = json?.message ?? {};
47
- return { content: m.content ?? '', toolCalls: m.tool_calls ?? [], thinking: m.thinking ?? '' };
48
- }
49
- const m = json?.choices?.[0]?.message ?? {};
50
- return { content: m.content ?? '', toolCalls: m.tool_calls ?? [], thinking: m.reasoning_content ?? '' };
53
+ /** 회사가 무엇을 받는지까지 봐야 몸통이 맞다 (toolfit.js 의 벤더). */
54
+ function 몸통(conn, opts) {
55
+ return buildBody(conn.kind, { model: conn.model, 회사: 벤더(conn), maxTokens: 128, ...opts });
51
56
  }
52
57
 
53
58
  // 추론 모델일 때 기본 대화 칸에 덧붙일 설명.
@@ -55,6 +60,31 @@ function c_note(retried) {
55
60
  return retried ? ' (추론 모델 — 사고를 끄고 다시 물어 확인)' : ' (추론 모델)';
56
61
  }
57
62
 
63
+ /*
64
+ * ── 이 문으로는 안 나오는 모델 (Codex 계열) ─────────────────────────────
65
+ *
66
+ * `gpt-5-codex` 계열은 OpenAI 가 **Responses API(/responses)로만** 내준다.
67
+ * `/chat/completions` 로 물으면 404 나 400 이 오는데, 그 화면에는 상태 코드
68
+ * 한 줄만 남는다. 그러면 사람은 열쇠를 의심하거나 모델 이름을 잘못 적은 줄
69
+ * 알고, 맞는 이름을 몇 번씩 다시 넣어 본다 — 이름은 처음부터 맞았다.
70
+ *
71
+ * ── 왜 이름을 보나. 이 파일에서만 본다 ─────────────────────────────────
72
+ *
73
+ * 어디로 보낼지는 **주소로만** 정한다(1.9). 그 규칙은 여기서도 안 깬다 —
74
+ * 여기서 이름을 보는 것은 보낼 곳을 고르려는 것이 아니라, **이미 실패한
75
+ * 요청에 까닭을 붙이려는** 것뿐이다. 그래서 미리 막지 않는다. 요청은 그대로
76
+ * 나가고, 실패했을 때만 한 줄이 붙는다. 언젠가 OpenAI 가 이 문으로도 내주면
77
+ * 요청이 그냥 성공하고 이 줄은 저절로 안 나온다.
78
+ *
79
+ * 게이트웨이는 해당 없다. 그 뒤에 무엇이 걸려 있는지 우리는 모르고, 사내
80
+ * 게이트웨이가 제 나름대로 이 계열을 chat 창구로 내주는 일도 있다.
81
+ */
82
+ const CODEX계열 = /^(?:gpt-5-codex|codex-)/i;
83
+
84
+ export function 코덱스인가(conn) {
85
+ return 벤더(conn) === 'openai' && CODEX계열.test(String(conn?.model ?? ''));
86
+ }
87
+
58
88
  const SKIPPED = [
59
89
  ['system', '시스템 메시지'],
60
90
  ['stream', '스트리밍'],
@@ -69,7 +99,14 @@ const SKIPPED = [
69
99
  export async function probe(conn, onStep = () => {}) {
70
100
  const { kind: shape, base, auth, model } = conn;
71
101
  const key = conn.key ?? '';
72
- const H = () => headersFor(auth, key);
102
+ /*
103
+ * 판 머리를 여기서도 얹는다 (adapter.js 의 더할머리).
104
+ *
105
+ * Anthropic 규격은 `anthropic-version` 하나가 없으면 400 이다. 열쇠가
106
+ * 멀쩡해도 그렇다. 진단 화면은 그 400 을 「연결 실패」 로 적고, 사람은
107
+ * 열쇠를 다시 받으러 간다 — 여덟 칸이 전부 그 한 줄 때문에 빨개진다.
108
+ */
109
+ const H = () => headersFor(auth, key, 더할머리(shape));
73
110
  /*
74
111
  * 물음표 뒤를 끝에 남겨야 한다 (adapter.js 의 주소붙이기).
75
112
  *
@@ -85,23 +122,23 @@ export async function probe(conn, onStep = () => {}) {
85
122
  const facts = { shape, base, auth, model };
86
123
 
87
124
  const add = (r) => { results.push(r); onStep(r); return r; };
88
- const call = (opts) => {
89
- const { path, body } = build(shape, { model, ...opts });
90
- return req(url(path), {
91
- method: 'POST',
92
- headers: H(),
93
- body,
94
- timeout: opts.timeout ?? 60000,
95
- stream: opts.stream,
96
- });
97
- };
125
+ const call = (opts) => req(url(endpoint(shape)), {
126
+ method: 'POST',
127
+ headers: H(),
128
+ body: 몸통(conn, opts),
129
+ timeout: opts.timeout ?? 60000,
130
+ stream: opts.stream,
131
+ });
132
+ // 답 읽기도 루프와 같은 함수를 쓴다. 도구 부름은 { id, name, args } 로
133
+ // 고르게 나온다 — 규격마다 다른 자리를 여기서 또 헤아리지 않는다.
134
+ const 읽기 = (r) => extractMessage(shape, r?.json);
98
135
 
99
136
  // 1. 기본 대화 — 이게 안 되면 나머지는 볼 필요가 없다.
100
137
  // 추론 모델은 본문이 전부 thinking 으로 가고 토큰 상한에 잘린다.
101
138
  // 그걸 "안됨"으로 볼 수 없으므로, 사고를 끄고 넉넉히 한 번 더 물어본다.
102
139
  const ASK = { messages: [{ role: 'user', content: '1+1은? 숫자만 답하세요.' }] };
103
140
  let basic = await call({ ...ASK, maxTokens: 256 });
104
- let got = basic.ok ? extract(shape, basic.json) : { content: '', thinking: '' };
141
+ let got = basic.ok ? 읽기(basic) : { content: '', thinking: '' };
105
142
  let thinkingModel = false;
106
143
  let retried = false;
107
144
 
@@ -110,12 +147,12 @@ export async function probe(conn, onStep = () => {}) {
110
147
  retried = true;
111
148
  const second = await call({ ...ASK, maxTokens: 1024, think: false, timeout: 90000 });
112
149
  if (second.ok) {
113
- const e2 = extract(shape, second.json);
150
+ const e2 = 읽기(second);
114
151
  if (e2.content) { basic = second; got = e2; }
115
152
  else {
116
153
  // 사고를 못 끄는 서버 — 상한만 크게 올려 한 번 더.
117
154
  const third = await call({ ...ASK, maxTokens: 2048, timeout: 120000 });
118
- if (third.ok && extract(shape, third.json).content) { basic = third; got = extract(shape, third.json); }
155
+ if (third.ok && 읽기(third).content) { basic = third; got = 읽기(third); }
119
156
  }
120
157
  }
121
158
  }
@@ -136,7 +173,11 @@ export async function probe(conn, onStep = () => {}) {
136
173
  ? got.thinking
137
174
  ? '사고만 나오고 본문이 안 나옵니다 — 토큰 상한을 크게 올려야 합니다'
138
175
  : '응답이 비어 있습니다 — 모델 이름을 확인하세요'
139
- : serverMessage(basic),
176
+ // 못 붙은 까닭을 아는 자리가 하나 있다. 상태 코드만 남기면 사람은
177
+ // 열쇠를 의심하는데, 열쇠도 이름도 처음부터 맞았다 (코덱스인가 머리말).
178
+ : 코덱스인가(conn)
179
+ ? `${serverMessage(basic)}\n${말('probe.codexOnly', { 모델: model })}`
180
+ : serverMessage(basic),
140
181
  ms: basic.ms,
141
182
  });
142
183
  if (!basicOk) {
@@ -154,7 +195,7 @@ export async function probe(conn, onStep = () => {}) {
154
195
  { role: 'user', content: '안녕하세요' },
155
196
  ],
156
197
  });
157
- const sysHit = sys.ok && /DEEL/i.test(extract(shape, sys.json).content);
198
+ const sysHit = sys.ok && /DEEL/i.test(읽기(sys).content);
158
199
  add({
159
200
  id: 'system',
160
201
  label: '시스템 메시지',
@@ -209,15 +250,18 @@ export async function probe(conn, onStep = () => {}) {
209
250
  maxTokens: 512,
210
251
  timeout: 90000,
211
252
  });
212
- const tcalls = tl.ok ? extract(shape, tl.json).toolCalls : [];
253
+ const tcalls = tl.ok ? 읽기(tl).toolCalls : [];
213
254
  const gotCall = tcalls.length > 0;
214
- const argOk = gotCall && JSON.stringify(tcalls[0]?.function?.arguments ?? '').includes('config');
255
+ // 부름은 이미 { id, name, args } 로 고르게 나온다 (extractMessage).
256
+ // 예전에는 여기서 OpenAI 날모양(function.arguments)을 직접 팠다 —
257
+ // 그러면 Anthropic 은 도구를 제대로 불러도 「안 불렀다」 로 읽힌다.
258
+ const argOk = gotCall && JSON.stringify(tcalls[0]?.args ?? '').includes('config');
215
259
  add({
216
260
  id: 'tools',
217
261
  label: '도구 호출',
218
262
  status: gotCall ? (argOk ? 'ok' : 'warn') : 'no',
219
263
  detail: gotCall
220
- ? `${tcalls[0]?.function?.name} 호출됨${argOk ? '' : ' — 인자가 부정확, 편집 신뢰성 작업이 더 필요합니다'}`
264
+ ? `${tcalls[0]?.name} 호출됨${argOk ? '' : ' — 인자가 부정확, 편집 신뢰성 작업이 더 필요합니다'}`
221
265
  : tl.ok ? '도구를 안 부르고 글로만 답합니다' : serverMessage(tl),
222
266
  ms: tl.ms,
223
267
  });
@@ -227,12 +271,15 @@ export async function probe(conn, onStep = () => {}) {
227
271
  if (gotCall) {
228
272
  const tc = tcalls[0];
229
273
  const callId = tc.id ?? 'call_1';
230
- const assistantMsg = shape === 'ollama'
231
- ? { role: 'assistant', content: '', tool_calls: tcalls }
232
- : { role: 'assistant', content: null, tool_calls: [{ id: callId, type: 'function', function: tc.function }] };
233
- const toolMsg = shape === 'ollama'
234
- ? { role: 'tool', tool_name: tc.function?.name, content: '{"port": 7099}' }
235
- : { role: 'tool', tool_call_id: callId, content: '{"port": 7099}' };
274
+ /*
275
+ * 되돌려 넣는 모양도 루프와 같은 함수로 짓는다 (adapter.js).
276
+ *
277
+ * 여기에 규격별 갈래를 손으로 적어 두면 규격이 하나 늘 때마다 이 자리가
278
+ * 조용히 틀린다 Anthropic 에서는 도구 결과가 사람 차례로 가야 하는데
279
+ * `role:'tool'` 보내 「모르는 역할」 받고 있었다.
280
+ */
281
+ const assistantMsg = assistantMessage(shape, { content: '', toolCalls: [tc] });
282
+ const toolMsg = toolMessage(shape, { callId, name: tc.name, content: '{"port": 7099}' });
236
283
  const rt = await call({
237
284
  ...quiet,
238
285
  timeout: 90000,
@@ -245,7 +292,7 @@ export async function probe(conn, onStep = () => {}) {
245
292
  tools: [READ_TOOL],
246
293
  maxTokens: 256,
247
294
  });
248
- const said = rt.ok ? extract(shape, rt.json).content : '';
295
+ const said = rt.ok ? 읽기(rt).content : '';
249
296
  add({
250
297
  id: 'toolresult',
251
298
  label: '도구 결과 되돌리기',
@@ -279,8 +326,20 @@ export async function probe(conn, onStep = () => {}) {
279
326
  timeout: 90000,
280
327
  });
281
328
  if (!js.ok) break;
282
- raw = extract(shape, js.json).content ?? '';
283
- try { parsed = JSON.parse(raw); } catch {}
329
+ raw = 읽기(js).content ?? '';
330
+ /*
331
+ * **객체일 때만** 읽었다고 한다.
332
+ *
333
+ * 답 모양을 강제하는 칸이 없는 규격(Anthropic)에서는 이 물음에 그냥
334
+ * `21` 이라고 답한다. `JSON.parse('21')` 은 오류 없이 숫자 21 을 준다.
335
+ * 그런데 그다음 줄이 `'answer' in parsed` 라, 숫자에 `in` 을 써서
336
+ * **TypeError 로 진단 전체가 통째로 죽었다.** 스키마를 안 지킨 것은
337
+ * 「구조적 출력 안 됨」 이라고 적을 일이지 프로그램이 끝날 일이 아니다.
338
+ */
339
+ try {
340
+ const 읽힌것 = JSON.parse(raw);
341
+ if (읽힌것 && typeof 읽힌것 === 'object' && !Array.isArray(읽힌것)) parsed = 읽힌것;
342
+ } catch { /* 글로만 답하는 서버 — 아래에서 '스키마를 안 지킴' 으로 적는다 */ }
284
343
  }
285
344
  const jsonOk = !!(parsed && 'answer' in parsed);
286
345
  add({
@@ -316,7 +375,7 @@ export async function probe(conn, onStep = () => {}) {
316
375
  maxTokens: 32,
317
376
  timeout: 60000,
318
377
  });
319
- const 눈있음 = !!(눈.ok && extract(shape, 눈.json).content);
378
+ const 눈있음 = !!(눈.ok && 읽기().content);
320
379
  add({
321
380
  id: 'vision',
322
381
  label: '그림 보기',
@@ -330,9 +389,18 @@ export async function probe(conn, onStep = () => {}) {
330
389
  });
331
390
  facts.vision = 눈있음;
332
391
 
333
- // 7. 추론 강도 조절 — 낮음/높음이 실제로 다른 결과를 내느냐.
334
- // 상한에 걸리면 같은 숫자가 나와 비교가 무의미해진다. 넉넉히 준다.
335
- const THINK_CAP = 1500;
392
+ /*
393
+ * 7. 추론 강도 조절 낮음/높음이 실제로 다른 결과를 내느냐.
394
+ *
395
+ * 상한에 걸리면 둘 다 같은 숫자가 나와 비교가 무의미해진다. 넉넉히 준다.
396
+ *
397
+ * Anthropic 규격은 여기서 상한을 더 크게 잡아야 한다. 그쪽은 강도를 말이
398
+ * 아니라 **출력 상한 안에서 나가는 예산**으로 주는데(adapter.js 의
399
+ * 생각예산), 1,500 으로는 답에 남길 자리를 빼고 나면 최소 예산 1,024 를
400
+ * 못 만든다. 그러면 생각이 아예 안 켜진 채로 두 번을 부르고, 이 칸은
401
+ * 「차이 없음」 이라고 적는다 — 켤 수 있는 모델에 대고 못 켠다고 적는 셈이다.
402
+ */
403
+ const THINK_CAP = shape === 'anthropic' ? 8000 : 1500;
336
404
  const seen = [];
337
405
  for (const lv of ['low', 'high']) {
338
406
  const r = await call({
@@ -342,13 +410,15 @@ export async function probe(conn, onStep = () => {}) {
342
410
  timeout: 120000,
343
411
  });
344
412
  if (r.ok) {
345
- const e = extract(shape, r.json);
413
+ const e = 읽기(r);
346
414
  seen.push({
347
415
  lv,
348
416
  ms: r.ms,
349
417
  thought: (e.thinking ?? '').length,
350
- out: r.json?.usage?.completion_tokens ?? r.json?.eval_count ?? 0,
351
- capped: (r.json?.done_reason ?? r.json?.choices?.[0]?.finish_reason) === 'length',
418
+ // 토큰 수와 끝난 까닭도 규격마다 이름이 다르다. 읽는 자리를 하나로 모은다 —
419
+ // 여기서 OpenAI 이름만 보면 Anthropic 은 늘 0 이고 '안 잘림' 이 된다.
420
+ out: e.usage?.out ?? 0,
421
+ capped: e.stopped === 'length' || e.stopped === 'max_tokens',
352
422
  });
353
423
  } else {
354
424
  seen.push({ lv, ms: r.ms, err: serverMessage(r) });