deel-local-cli 1.7.0 → 1.10.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 +1166 -0
  2. package/README.md +1223 -1107
  3. package/bin/deel.js +78 -12
  4. package/package.json +4 -4
  5. package/src/acp/map.js +1 -1
  6. package/src/acp/serve.js +40 -3
  7. package/src/agent/compact.js +314 -296
  8. package/src/agent/effort.js +27 -7
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/filemem.js +141 -0
  11. package/src/agent/grade.js +51 -1
  12. package/src/agent/loop.js +218 -40
  13. package/src/agent/session.js +136 -14
  14. package/src/agent/store.js +186 -9
  15. package/src/agent/threads.js +26 -1
  16. package/src/backend/adapter.js +272 -23
  17. package/src/backend/learn.js +24 -0
  18. package/src/backend/mcp.js +96 -5
  19. package/src/backend/probe.js +131 -61
  20. package/src/backend/quota.js +29 -5
  21. package/src/backend/retry.js +3 -0
  22. package/src/backend/scanui.js +1 -1
  23. package/src/backend/toolfit.js +325 -0
  24. package/src/commands.js +170 -61
  25. package/src/completion.js +20 -2
  26. package/src/config.js +47 -2
  27. package/src/i18n/en.js +210 -3
  28. package/src/i18n/index.js +40 -0
  29. package/src/i18n/ja.js +204 -3
  30. package/src/i18n/ko.js +270 -3
  31. package/src/i18n/zh.js +204 -3
  32. package/src/lsp/client.js +49 -5
  33. package/src/oneshot.js +92 -5
  34. package/src/pack/sbom.js +30 -4
  35. package/src/pack/selfpack.js +26 -10
  36. package/src/pack/sheet.en.js +288 -0
  37. package/src/pack/tar.js +65 -2
  38. package/src/plugins/manage.js +46 -9
  39. package/src/repl.js +344 -61
  40. package/src/reset.js +397 -0
  41. package/src/safety/audit.js +95 -8
  42. package/src/safety/authcmd.js +316 -0
  43. package/src/safety/guard.js +143 -0
  44. package/src/safety/keystore.js +114 -38
  45. package/src/safety/undo.js +25 -6
  46. package/src/tools/desc.en.js +19 -14
  47. package/src/tools/index.js +349 -75
  48. package/src/tools/jobs.js +175 -42
  49. package/src/tools/lsp.js +5 -4
  50. package/src/tools/outline.js +7 -3
  51. package/src/tools/task.js +10 -6
  52. package/src/tools/todo.js +9 -2
  53. package/src/tools/verify.js +42 -7
  54. package/src/tools/webfetch.js +94 -10
  55. package/src/ui/export.js +1 -1
  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/README.en.md +0 -1117
@@ -2,7 +2,10 @@
2
2
  // 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
3
3
  import { req, headersFor, serverMessage, Aborted } from './http.js';
4
4
  import { 할당량기억 } from './quota.js';
5
+ import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
6
+ import { 말 } from '../i18n/index.js';
5
7
  import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
8
+ import { 도구맞추기, 이름되돌리기, 벤더 } from './toolfit.js';
6
9
 
7
10
  /*
8
11
  * Anthropic 규격의 판 이름.
@@ -33,9 +36,9 @@ export function 더할머리(shape) {
33
36
  * 지장이 없어서 아무도 안 고치고, 그 화면을 믿고 남에게 설명하게 된다.
34
37
  */
35
38
  export function 규격이름(shape) {
36
- if (shape === 'ollama') return 'Ollama 자체 규격';
37
- if (shape === 'anthropic') return 'Anthropic 규격';
38
- return 'OpenAI 호환';
39
+ if (shape === 'ollama') return 말('head.spec.ollama');
40
+ if (shape === 'anthropic') return 말('head.spec.anthropic');
41
+ return 말('head.spec.openai');
39
42
  }
40
43
 
41
44
  /**
@@ -57,7 +60,7 @@ export function 요청주소(conn) {
57
60
  return 주소붙이기(conn?.base, endpoint(conn?.kind));
58
61
  }
59
62
 
60
- export function buildBody(shape, { model, messages, tools, stream, json, think, maxTokens = 4096, ctx = null }) {
63
+ export function buildBody(shape, { model, messages, tools, stream, json, think, maxTokens = 4096, ctx = null, 회사 = null }) {
61
64
  if (shape === 'ollama') {
62
65
  const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
63
66
  /*
@@ -87,7 +90,13 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
87
90
  body.keep_alive = process.env.DEEL_KEEP_ALIVE || '60m';
88
91
  if (tools?.length) body.tools = tools;
89
92
  if (json) body.format = json;
90
- if (think !== undefined) body.think = think;
93
+ // 참·거짓은 그대로(생각을 켜고 끄는 말이다). 단계말은 이 규격이 아는
94
+ // 말로 옮긴다 — 아래 강도말() 머리말 참고.
95
+ if (typeof think === 'boolean') body.think = think;
96
+ else if (think !== undefined) {
97
+ const 눈금 = 강도말(think);
98
+ if (눈금) body.think = 눈금;
99
+ }
91
100
  return body;
92
101
  }
93
102
  if (shape === 'anthropic') return anthropic몸(
@@ -100,14 +109,31 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
100
109
  // max_tokens 만 보내면 상한이 안 걸린 것처럼 제 기본값으로 답하고, 우리가
101
110
  // 셈해 둔 자리와 어긋난다. 사용자 게이트웨이가 바로 그 경우였다.
102
111
  //
103
- // 둘 다 보내도 탈이 없다. 옛 서버는 모르는 이름을 무시하고, 새 서버는
104
- // 제가 보는 이름을 골라 쓴다. 둘 중 무엇을 보는지 우리가 알 필요가 없어진다.
105
- const body = { model, messages, stream: !!stream, max_tokens: maxTokens, max_completion_tokens: maxTokens };
112
+ // 둘 다 보내도 탈이 없다 — **한 곳만 빼고.** 옛 서버는 모르는 이름을 무시하고,
113
+ // 새 서버는 제가 보는 이름을 골라 쓴다. 둘 중 무엇을 보는지 우리가 알 필요가
114
+ // 없어진다.
115
+ //
116
+ // ── 그 한 곳: OpenAI 직통 ──────────────────────────────────────────────
117
+ //
118
+ // 여기 추론 모델(o 계열·GPT-5 계열)은 옛 이름을 **무시하지 않고 튕긴다** —
119
+ // "Unsupported parameter: 'max_tokens' is not supported with this model."
120
+ // 그러면 첫 요청부터 400 이고, 화면에서는 열쇠가 틀린 것과 구별이 안 된다.
121
+ // 모델 이름으로 가르지 않는다(게이트웨이 뒤에 무엇이 있는지 우리는 모른다).
122
+ // **주소로** 가른다 — 그 규칙은 toolfit.js 의 벤더() 한 곳에서만 정한다.
123
+ //
124
+ // Azure 는 여기 안 넣는다. 옛 판(api-version)이 아직 많고 그쪽은 옛 이름만
125
+ // 본다 — 같이 묶으면 멀쩡히 쓰던 사내 Azure 연결이 이 줄 하나로 끊긴다.
126
+ const 옛이름도 = 회사 !== 'openai';
127
+ const body = { model, messages, stream: !!stream, max_completion_tokens: maxTokens };
128
+ if (옛이름도) body.max_tokens = maxTokens;
106
129
  if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; }
107
130
  if (json) {
108
131
  body.response_format = { type: 'json_schema', json_schema: { name: 'out', schema: json, strict: true } };
109
132
  }
110
- if (think !== undefined && think !== false) body.reasoning_effort = think;
133
+ if (think !== undefined && think !== false) {
134
+ const 눈금 = 강도말(think);
135
+ if (눈금) body.reasoning_effort = 눈금;
136
+ }
111
137
  return body;
112
138
  }
113
139
 
@@ -130,6 +156,68 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
130
156
  * · think — 생각 칸의 값 모양을 문서에서 확인하지 못했다.
131
157
  * · json — 이 규격에는 답 모양을 강제하는 칸이 없다. 도구로 하는 방법뿐이다.
132
158
  */
159
+ /*
160
+ * ── 이 규격의 추론 강도 ────────────────────────────────────────────────
161
+ *
162
+ * OpenAI 호환 쪽은 `reasoning_effort: 'high'` 처럼 **말**로 준다. 이쪽은
163
+ * **토큰 수**로 준다 — `thinking: { type:'enabled', budget_tokens: 12000 }`.
164
+ * 그래서 우리 단계말(low·medium·high·max)을 숫자로 옮겨야 한다.
165
+ *
166
+ * 여태 이 자리가 비어 있었다. anthropic몸() 이 think 를 인자로 받아 놓고 한
167
+ * 번도 안 썼다. 그래서 Claude 를 직접 붙이면 상태줄에는 `◇ medium` 이 뜨는데
168
+ * 요청에는 아무것도 안 실렸다 — **화면과 전선이 다른 말을 하고 있었다.**
169
+ * 아무 일도 안 하는 것보다 나쁘다. 사람은 조절했다고 믿기 때문이다.
170
+ *
171
+ * 지키는 선 둘(둘 다 서버가 거절하는 자리다):
172
+ * · 최소 1,024. 그보다 작게 주면 요청이 통째로 튕긴다.
173
+ * · max_tokens 보다 작아야 한다. 생각도 그 예산에서 나가기 때문이다.
174
+ * 그래서 답이 설 자리를 남겨 두고 깎는다. 그러고도 1,024 가 안 되면
175
+ * 생각을 아예 안 켠다 — 켤 수 없는 자리에서 켜면 그 턴이 죽는다.
176
+ */
177
+ const 생각최소 = 1024;
178
+ const 답에남길것 = 1024;
179
+ const 강도별예산 = { low: 2048, medium: 6144, high: 16384, max: 32768 };
180
+
181
+ /*
182
+ * ── 우리 눈금은 다섯, 전선 위의 눈금은 넷 ──────────────────────────────
183
+ *
184
+ * agent/effort.js 의 눈금은 off·low·medium·high·**max** 다. 그런데 `max` 를
185
+ * 받는 창구는 하나도 없다 — OpenAI 는 minimal·low·medium·high, Gemini 의
186
+ * OpenAI 호환 창구는 none·low·medium·high, Ollama 는 참·거짓이거나
187
+ * low·medium·high 다. 그 말을 그대로 실어 보내면 400 이고, 그 400 은 화면에서
188
+ * 열쇠가 틀린 것과 구별이 안 된다.
189
+ *
190
+ * 여태 그대로 흘려보내고 있었다. 그리고 이 자리는 `/think max` 를 친
191
+ * 사람만 밟는 것이 아니다 — `깊게` 배분은 첫 판단과 막혔을 때를 한 칸씩
192
+ * 올리므로, `/think high` 만 해도 그 두 자리가 `max` 가 된다. 즉 **가장
193
+ * 세게 생각하라고 시킨 턴만 골라서 죽는다.**
194
+ *
195
+ * 그래서 여기서 전선이 아는 말로 옮긴다. `max` 는 그 규격이 낼 수 있는 제일
196
+ * 센 말(high)이 된다 — 없는 칸을 지어내지 않고, 있는 칸 중 가장 위에 선다.
197
+ *
198
+ * Anthropic 규격은 여기 안 온다. 거기는 말이 아니라 **숫자 예산**으로 주므로
199
+ * (아래 생각예산) `max` 가 32,768 이라는 진짜 값이 된다. 눈금이 모자라지 않는다.
200
+ *
201
+ * 모르는 말은 **안 보낸다.** 짐작으로 실은 칸 하나가 그 턴을 죽인다.
202
+ *
203
+ * 받은 값을 담는 자리를 `말` 이라고 부르지 않는다. 이 파일에서 `말` 은
204
+ * i18n 의 그 함수라, 같은 이름으로 가리면 그 블록 안에서는 화면에 말을 걸
205
+ * 수가 없어진다 — 나중에 한 줄 더 적으려는 사람이 거기서 넘어진다.
206
+ */
207
+ const 전선눈금 = { low: 'low', medium: 'medium', high: 'high', max: 'high' };
208
+
209
+ export function 강도말(강도) {
210
+ return 전선눈금[String(강도)] ?? null;
211
+ }
212
+
213
+ export function 생각예산(강도, maxTokens) {
214
+ const 바라는것 = 강도별예산[String(강도)];
215
+ if (!바라는것) return 0;
216
+ const 쓸수있는 = Math.floor(Number(maxTokens) || 0) - 답에남길것;
217
+ const 예산 = Math.min(바라는것, 쓸수있는);
218
+ return 예산 >= 생각최소 ? 예산 : 0;
219
+ }
220
+
133
221
  function anthropic몸({ model, messages, tools, stream, json, think, maxTokens }) {
134
222
  const 머리말 = [];
135
223
  const 나머지 = [];
@@ -139,6 +227,8 @@ function anthropic몸({ model, messages, tools, stream, json, think, maxTokens }
139
227
  }
140
228
  const body = { model, messages: 차례합치기(나머지), stream: !!stream, max_tokens: maxTokens };
141
229
  if (머리말.length) body.system = 머리말.join('\n\n');
230
+ const 예산 = 생각예산(think, maxTokens);
231
+ if (예산) body.thinking = { type: 'enabled', budget_tokens: 예산 };
142
232
  if (tools?.length) {
143
233
  body.tools = tools.map((t) => {
144
234
  const f = t.function ?? t;
@@ -179,15 +269,29 @@ export function extractMessage(shape, json) {
179
269
  // 답이 블록 배열이다. 글·생각·도구 부름이 한 배열에 섞여 온다.
180
270
  let content = '';
181
271
  let thinking = '';
272
+ /*
273
+ * 생각 블록은 **받은 그대로** 따로 챙긴다.
274
+ *
275
+ * 글자만 이어 붙이면 안 된다. 이 규격의 생각 블록에는 서명(signature)이
276
+ * 딸려 있고, 도구를 쓰는 턴에서는 그 블록을 서명째 돌려보내야 서버가
277
+ * 받는다. 서명 없이 지어서 보내면 그 턴이 통째로 거절된다.
278
+ * redacted_thinking 은 속을 우리가 못 읽는 블록인데, 그것도 그대로
279
+ * 돌려보내야 한다 — 읽지 말고 나르라는 뜻이다.
280
+ */
281
+ const 생각블록 = [];
182
282
  const 부름들 = [];
183
283
  for (const b of Array.isArray(json?.content) ? json.content : []) {
184
284
  if (b?.type === 'text') content += b.text ?? '';
185
- else if (b?.type === 'thinking') thinking += b.thinking ?? '';
285
+ else if (b?.type === 'thinking') {
286
+ thinking += b.thinking ?? '';
287
+ 생각블록.push({ type: 'thinking', thinking: b.thinking ?? '', signature: b.signature ?? '' });
288
+ } else if (b?.type === 'redacted_thinking') 생각블록.push({ type: 'redacted_thinking', data: b.data });
186
289
  else if (b?.type === 'tool_use') 부름들.push({ id: b.id, name: b.name, args: b.input ?? {} });
187
290
  }
188
291
  return {
189
292
  content,
190
293
  thinking,
294
+ 생각블록,
191
295
  toolCalls: normalizeCalls(부름들),
192
296
  // 이름이 다르다. prompt_tokens 를 찾으면 늘 0 이 나오고, 화면에는
193
297
  // 「토큰을 하나도 안 썼다」 로 뜬다.
@@ -256,20 +360,39 @@ export function normalizeCalls(list) {
256
360
  }
257
361
 
258
362
  // 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
259
- export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [] }) {
363
+ export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [], 생각블록 = null }) {
260
364
  if (shape === 'anthropic') {
365
+ /*
366
+ * ── 생각 블록을 **맨 앞에, 받은 그대로** 돌려보낸다 ──────────────────
367
+ *
368
+ * 여기는 오래 비어 있던 자리다. 예전 주석은 「서명 조각을 제대로 모으는
369
+ * 것까지 확인하지 못했으므로 뺀다」 였다. 이제 모은다(흘려받기의
370
+ * signature_delta). 그래서 실을 수 있다.
371
+ *
372
+ * 왜 실어야 하나 — 생각을 켜고 도구를 쓰면, 서버는 그 도구 부름을 낳은
373
+ * 생각 블록이 **같이 돌아오기를** 요구한다. 안 보내면 그 턴이 거절된다.
374
+ * 즉 생각을 켜는 것과 이 블록을 나르는 것은 한 몸이다.
375
+ *
376
+ * 서명은 우리가 읽거나 고칠 것이 아니다. 받은 문자열 그대로 나른다.
377
+ * 서명이 빈 블록은 아예 안 싣는다 — 지어낸 서명은 거절당하고, 그러면
378
+ * 왜 안 되는지가 화면에서 안 보인다.
379
+ */
261
380
  const 블록 = [];
381
+ for (const b of 생각블록 ?? []) {
382
+ if (b?.type === 'thinking' && b.signature) {
383
+ 블록.push({ type: 'thinking', thinking: b.thinking ?? '', signature: b.signature });
384
+ } else if (b?.type === 'redacted_thinking' && b.data) {
385
+ 블록.push({ type: 'redacted_thinking', data: b.data });
386
+ }
387
+ }
262
388
  if (content) 블록.push({ type: 'text', text: content });
263
389
  for (const t of toolCalls) 블록.push({ type: 'tool_use', id: t.id, name: t.name, input: t.args ?? {} });
264
390
  /*
265
- * 생각은 **안 돌려보낸다.**
266
- *
267
- * 이 규격의 생각 블록에는 서명(signature)이 딸려 있고, 서명 없이 돌려보내면
268
- * 서버가 거절한다. 흘려받기에서 서명 조각을 제대로 모으는 것까지 확인하지
269
- * 못했으므로 여기서는 뺀다. 화면에는 그대로 흘러가고, 대화 이력에만 안 남는다.
270
- *
271
391
  * 블록이 하나도 없으면 이 규격은 거절한다. 빈 답이 오는 일은 드물지만
272
392
  * 그때 대화 전체가 죽으면 안 되니 자리표시를 하나 넣는다.
393
+ *
394
+ * 생각 블록**만** 있는 경우도 여기 걸리지 않게 한다 — 생각만 하고 아무
395
+ * 말도 안 한 턴은 실제로 있고, 그때 생각 블록은 살아 있어야 한다.
273
396
  */
274
397
  if (!블록.length) 블록.push({ type: 'text', text: '(빈 답)' });
275
398
  return { role: 'assistant', content: 블록 };
@@ -301,14 +424,66 @@ export function toolMessage(shape, { callId, name, content }) {
301
424
  : { role: 'tool', tool_call_id: callId, content: String(content) };
302
425
  }
303
426
 
427
+ /*
428
+ * 이번 요청에 실을 머리말.
429
+ *
430
+ * 열쇠받기가 걸려 있으면 여기서 받아 온다. 요청 **직전**에 받는 것이
431
+ * 중요하다 — 판을 켤 때 한 번 받아 두면 세 시간짜리 대화의 두 시간째에
432
+ * 죽어 있고, 그 401 은 「열쇠가 틀렸다」 와 화면에서 구별이 안 된다.
433
+ *
434
+ * 못 받으면 **던지지 않는다.** 원래 열쇠(있으면)로 그냥 간다. 여기서
435
+ * 막아 버리면 열쇠받기 설정 한 줄이 잘못된 것으로 멀쩡히 붙던 연결까지
436
+ * 안 붙는다. 못 받았다는 것은 부르는 쪽이 onAuth 로 듣고 화면에 적는다.
437
+ */
438
+ async function 머리말짓기(conn, opts, { 다시 = false } = {}) {
439
+ const 설정 = conn.열쇠받기 ?? null;
440
+ const 판단 = 쓸수있나(설정, { auth: conn.auth });
441
+ if (!설정 || !판단.된다) {
442
+ if (설정 && 판단.왜) opts.onAuth?.({ ok: false, 왜: 판단.왜, 안부름: true });
443
+ return headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind));
444
+ }
445
+ const r = await 열쇠받아오기(설정, {
446
+ 다시, signal: opts.signal ?? null,
447
+ 물어보기: opts.열쇠물어보기 ?? null,
448
+ 알림: opts.onAuth ? (것) => opts.onAuth(것) : null,
449
+ });
450
+ if (!r.ok) {
451
+ opts.onAuth?.({ ...r, ok: false });
452
+ return headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind));
453
+ }
454
+ if (!r.그대로) opts.onAuth?.({ ok: true, 만료: r.만료, ms: r.ms });
455
+ return headersFor(conn.auth, r.token, { ...더할머리(conn.kind), ...r.headers });
456
+ }
457
+
458
+ /*
459
+ * 401 을 맞았을 때 열쇠를 새로 받고 한 번만 다시 부를까.
460
+ *
461
+ * retry.js 는 401 을 안 다시 부른다 — 열쇠가 틀린 것은 백 번 불러도
462
+ * 같기 때문이다. 그 말은 지금도 맞다. 다른 것은 **열쇠를 바꿀 수 있을
463
+ * 때**뿐이다. 그때는 같은 열쇠로 다시 부르는 것이 아니라 새 열쇠로
464
+ * 부르는 것이라, 「불러 봐야 같다」 에 해당하지 않는다.
465
+ *
466
+ * 한 번만이다. 두 번째 401 은 진짜로 권한이 없는 것이고, 그때 더 부르면
467
+ * 로그인 명령만 되풀이해서 띄우게 된다.
468
+ */
469
+ function 열쇠다시받을까(conn, status, 이미) {
470
+ return !이미 && Number(status) === 401 && !!conn.열쇠받기;
471
+ }
472
+
304
473
  // 한 번에 받기.
305
474
  export async function chat(conn, opts) {
306
- const body = buildBody(conn.kind, { model: conn.model, ctx: conn.ctx ?? null, ...opts });
475
+ // 회사가 받는 모양으로 도구를 다듬는다 (backend/toolfit.js).
476
+ // 모르는 주소면 아무것도 안 바뀐다 — 지금까지와 똑같이 돈다.
477
+ const 맞춘것 = 도구맞추기(opts.tools, conn);
478
+ const body = buildBody(conn.kind, {
479
+ model: conn.model, ctx: conn.ctx ?? null, ...opts, tools: 맞춘것.tools, 회사: 벤더(conn),
480
+ });
307
481
  const 정책 = 정책고르기(conn, opts);
482
+ let 열쇠다시받음 = false;
308
483
  for (let 시도 = 1; ; 시도++) {
309
484
  const r = await req(요청주소(conn), {
310
485
  method: 'POST',
311
- headers: headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind)),
486
+ headers: await 머리말짓기(conn, opts),
312
487
  body,
313
488
  timeout: opts.timeout ?? 300000,
314
489
  signal: opts.signal ?? null,
@@ -316,7 +491,17 @@ export async function chat(conn, opts) {
316
491
  // 서버가 남았다고 말해 준 할당량을 적어 둔다 (backend/quota.js).
317
492
  // 429 를 맞고 나서야 아는 것과, 맞기 전에 아는 것은 사람이 할 일이 다르다.
318
493
  할당량기억(r.headers);
319
- if (r.ok) return extractMessage(conn.kind, r.json);
494
+ // 다듬느라 이름을 고쳤으면 여기서 되돌린다. 밖에서는 그런 일이 있었는지
495
+ // 모른 채로 원래 이름을 받는다.
496
+ if (r.ok) return 이름되돌리기(extractMessage(conn.kind, r.json), 맞춘것.되돌림);
497
+ // 열쇠가 늙어서 막힌 것이면 새로 받고 한 번만 다시. 시도 수는 안 올린다 —
498
+ // 서버가 막은 것이 아니라 우리 열쇠가 낡았던 것이라 물러설 까닭이 없다.
499
+ if (열쇠다시받을까(conn, r.status, 열쇠다시받음)) {
500
+ 열쇠다시받음 = true;
501
+ await 머리말짓기(conn, opts, { 다시: true });
502
+ 시도 -= 1;
503
+ continue;
504
+ }
320
505
  // 잠깐 막힌 것이면 기다렸다 다시 부른다 (backend/retry.js 머리말).
321
506
  // 한 번에 받는 길은 제너레이터가 아니라 화면에 말을 못 걸어서, 부르는 쪽이
322
507
  // 준 onBackoff 로 알린다. 안 줬으면 조용히 기다린다.
@@ -376,13 +561,17 @@ async function 거절읽기(r) {
376
561
 
377
562
  // 흘려 받기. { type:'thinking'|'content', text } 를 내보내고 마지막에 { type:'done', message } 를 준다.
378
563
  export async function* chatStream(conn, opts) {
379
- const body = buildBody(conn.kind, { model: conn.model, ctx: conn.ctx ?? null, ...opts, stream: true });
564
+ const 맞춘것 = 도구맞추기(opts.tools, conn);
565
+ const body = buildBody(conn.kind, {
566
+ model: conn.model, ctx: conn.ctx ?? null, ...opts, tools: 맞춘것.tools, stream: true, 회사: 벤더(conn),
567
+ });
380
568
  const 정책 = 정책고르기(conn, opts);
381
569
  let r;
570
+ let 열쇠다시받음 = false;
382
571
  for (let 시도 = 1; ; 시도++) {
383
572
  r = await req(요청주소(conn), {
384
573
  method: 'POST',
385
- headers: headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind)),
574
+ headers: await 머리말짓기(conn, opts),
386
575
  body,
387
576
  timeout: opts.timeout ?? 300000,
388
577
  stream: true,
@@ -391,6 +580,14 @@ export async function* chatStream(conn, opts) {
391
580
  할당량기억(r.headers ?? r.res?.headers);
392
581
  if (r.ok && r.res?.body) break;
393
582
  const 거절 = await 거절읽기(r);
583
+ // 위 chat() 과 같은 규칙. 몸을 먼저 읽고(거절읽기) 나서 다시 부른다 —
584
+ // 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
585
+ if (열쇠다시받을까(conn, 거절.status, 열쇠다시받음)) {
586
+ 열쇠다시받음 = true;
587
+ await 머리말짓기(conn, opts, { 다시: true });
588
+ 시도 -= 1;
589
+ continue;
590
+ }
394
591
  // 잠깐 막힌 것이면 알리고, 기다렸다, 다시 부른다. 머리말도 못 받은 자리라
395
592
  // 화면에 흘러간 글이 없다 — 그래서 여기서만 다시 부르고, 아래 읽기 도중에
396
593
  // 끊긴 것은 다시 안 부른다 (backend/retry.js 머리말).
@@ -431,9 +628,33 @@ export async function* chatStream(conn, opts) {
431
628
  for (const ev of absorb(conn.kind, obj, acc)) yield ev;
432
629
  }
433
630
  }
434
- yield { type: 'done', message: acc };
631
+ /*
632
+ * ── 끝을 안 알려 주고 끊긴 것은 '끝난 것' 이 아니다 ──────────────────────
633
+ *
634
+ * 규격대로면 끝을 알리는 조각이 반드시 하나 온다 —
635
+ * OpenAI 는 finish_reason, Anthropic 은 message_delta.stop_reason,
636
+ * Ollama 는 done:true. 그게 하나도 안 왔는데 흘러오던 것이 그냥 멎었으면
637
+ * **왜 끝났는지 모르는 것**이다. 중계 프록시가 몸통을 자르고 연결을 곱게
638
+ * 닫으면 이 모양이 된다(끊긴 티가 안 나서 read 가 던지지도 않는다).
639
+ *
640
+ * 여태 stopped 를 null 로 뒀는데, 위에서는 null 을 '정상 종료' 와 구별하지
641
+ * 못했다. 그래서 중간에서 잘린 답이 온전한 답으로 지나갔다 — 사람 눈에는
642
+ * 모델이 말을 하다 만 것으로 보이니 같은 것을 다시 시킨다.
643
+ *
644
+ * 다만 '상한에서 잘림(length)' 과 같이 취급하지는 않는다. 까닭이 다르므로
645
+ * 상한을 올려 다시 부르는 것은 답이 아니다. 이름만 따로 붙여서, 화면이
646
+ * 사실대로 말할 수 있게 한다. 아무것도 안 온 경우는 여기서 안 다룬다 —
647
+ * 그건 '빈 답' 쪽이 받는다.
648
+ */
649
+ if (acc.stopped == null && (acc.content || acc.thinking || acc.toolCalls.length)) {
650
+ acc.stopped = 말없이끝남;
651
+ }
652
+ yield { type: 'done', message: 이름되돌리기(acc, 맞춘것.되돌림) };
435
653
  }
436
654
 
655
+ /** 서버가 끝난 까닭을 안 주고 흘려보내기를 멈춘 것. 'stop' 과 구별해야 한다. */
656
+ export const 말없이끝남 = '말없이끝남';
657
+
437
658
  // 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
438
659
  function absorb(shape, obj, acc) {
439
660
  const out = [];
@@ -487,6 +708,19 @@ function anthropic흡수(obj, acc, out) {
487
708
  if (b.type === 'tool_use') {
488
709
  acc._raw ??= [];
489
710
  acc._raw[번호] = { id: b.id, name: b.name ?? '', args: '' };
711
+ } else if (b.type === 'thinking') {
712
+ /*
713
+ * 생각 블록이 열렸다. 여기서는 속이 비어 있고(`thinking:''`,
714
+ * `signature:''`), 글은 thinking_delta 로, 서명은 **블록이 닫히기
715
+ * 직전** signature_delta 로 따로 온다. 그래서 자리를 먼저 잡아 두고
716
+ * 번호로 찾아 채운다 — 한 답에 생각 블록이 여럿일 수 있다.
717
+ */
718
+ acc.생각블록 ??= [];
719
+ acc.생각블록[번호] = { type: 'thinking', thinking: b.thinking ?? '', signature: b.signature ?? '' };
720
+ } else if (b.type === 'redacted_thinking') {
721
+ // 속을 우리가 못 읽는 블록. 읽지 말고 그대로 나르라는 뜻이다.
722
+ acc.생각블록 ??= [];
723
+ acc.생각블록[번호] = { type: 'redacted_thinking', data: b.data };
490
724
  }
491
725
  return out;
492
726
  }
@@ -497,7 +731,22 @@ function anthropic흡수(obj, acc, out) {
497
731
  out.push({ type: 'content', text: d.text });
498
732
  } else if (d.type === 'thinking_delta' && d.thinking) {
499
733
  acc.thinking += d.thinking;
734
+ // 화면으로 흘려보내는 것과 별개로, 돌려보낼 블록에도 그대로 쌓는다.
735
+ acc.생각블록 ??= [];
736
+ acc.생각블록[번호] ??= { type: 'thinking', thinking: '', signature: '' };
737
+ acc.생각블록[번호].thinking += d.thinking;
500
738
  out.push({ type: 'thinking', text: d.thinking });
739
+ } else if (d.type === 'signature_delta' && d.signature) {
740
+ /*
741
+ * 서명. 이것 하나가 없으면 그 생각 블록은 못 돌려보낸다 — 서버가
742
+ * 서명 없는 생각 블록을 거절하기 때문이다. 여기를 빠뜨리면 생각을
743
+ * 켠 채 도구를 쓰는 순간 그 턴이 죽고, 화면에는 왜인지 안 나온다.
744
+ *
745
+ * 조각으로 나뉘어 올 수 있으므로 이어 붙인다.
746
+ */
747
+ acc.생각블록 ??= [];
748
+ acc.생각블록[번호] ??= { type: 'thinking', thinking: '', signature: '' };
749
+ acc.생각블록[번호].signature += d.signature;
501
750
  } else if (d.type === 'input_json_delta' && d.partial_json != null) {
502
751
  acc._raw ??= [];
503
752
  acc._raw[번호] ??= { id: null, name: '', args: '' };
@@ -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
  서버.닫기();