deel-local-cli 1.8.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 (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
@@ -5,6 +5,7 @@ import { 할당량기억 } from './quota.js';
5
5
  import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
6
6
  import { 말 } from '../i18n/index.js';
7
7
  import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
8
+ import { 도구맞추기, 이름되돌리기, 벤더 } from './toolfit.js';
8
9
 
9
10
  /*
10
11
  * Anthropic 규격의 판 이름.
@@ -59,7 +60,7 @@ export function 요청주소(conn) {
59
60
  return 주소붙이기(conn?.base, endpoint(conn?.kind));
60
61
  }
61
62
 
62
- 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 }) {
63
64
  if (shape === 'ollama') {
64
65
  const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
65
66
  /*
@@ -89,7 +90,13 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
89
90
  body.keep_alive = process.env.DEEL_KEEP_ALIVE || '60m';
90
91
  if (tools?.length) body.tools = tools;
91
92
  if (json) body.format = json;
92
- 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
+ }
93
100
  return body;
94
101
  }
95
102
  if (shape === 'anthropic') return anthropic몸(
@@ -102,14 +109,31 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
102
109
  // max_tokens 만 보내면 상한이 안 걸린 것처럼 제 기본값으로 답하고, 우리가
103
110
  // 셈해 둔 자리와 어긋난다. 사용자 게이트웨이가 바로 그 경우였다.
104
111
  //
105
- // 둘 다 보내도 탈이 없다. 옛 서버는 모르는 이름을 무시하고, 새 서버는
106
- // 제가 보는 이름을 골라 쓴다. 둘 중 무엇을 보는지 우리가 알 필요가 없어진다.
107
- 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;
108
129
  if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; }
109
130
  if (json) {
110
131
  body.response_format = { type: 'json_schema', json_schema: { name: 'out', schema: json, strict: true } };
111
132
  }
112
- if (think !== undefined && think !== false) body.reasoning_effort = think;
133
+ if (think !== undefined && think !== false) {
134
+ const 눈금 = 강도말(think);
135
+ if (눈금) body.reasoning_effort = 눈금;
136
+ }
113
137
  return body;
114
138
  }
115
139
 
@@ -132,6 +156,68 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
132
156
  * · think — 생각 칸의 값 모양을 문서에서 확인하지 못했다.
133
157
  * · json — 이 규격에는 답 모양을 강제하는 칸이 없다. 도구로 하는 방법뿐이다.
134
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
+
135
221
  function anthropic몸({ model, messages, tools, stream, json, think, maxTokens }) {
136
222
  const 머리말 = [];
137
223
  const 나머지 = [];
@@ -141,6 +227,8 @@ function anthropic몸({ model, messages, tools, stream, json, think, maxTokens }
141
227
  }
142
228
  const body = { model, messages: 차례합치기(나머지), stream: !!stream, max_tokens: maxTokens };
143
229
  if (머리말.length) body.system = 머리말.join('\n\n');
230
+ const 예산 = 생각예산(think, maxTokens);
231
+ if (예산) body.thinking = { type: 'enabled', budget_tokens: 예산 };
144
232
  if (tools?.length) {
145
233
  body.tools = tools.map((t) => {
146
234
  const f = t.function ?? t;
@@ -181,15 +269,29 @@ export function extractMessage(shape, json) {
181
269
  // 답이 블록 배열이다. 글·생각·도구 부름이 한 배열에 섞여 온다.
182
270
  let content = '';
183
271
  let thinking = '';
272
+ /*
273
+ * 생각 블록은 **받은 그대로** 따로 챙긴다.
274
+ *
275
+ * 글자만 이어 붙이면 안 된다. 이 규격의 생각 블록에는 서명(signature)이
276
+ * 딸려 있고, 도구를 쓰는 턴에서는 그 블록을 서명째 돌려보내야 서버가
277
+ * 받는다. 서명 없이 지어서 보내면 그 턴이 통째로 거절된다.
278
+ * redacted_thinking 은 속을 우리가 못 읽는 블록인데, 그것도 그대로
279
+ * 돌려보내야 한다 — 읽지 말고 나르라는 뜻이다.
280
+ */
281
+ const 생각블록 = [];
184
282
  const 부름들 = [];
185
283
  for (const b of Array.isArray(json?.content) ? json.content : []) {
186
284
  if (b?.type === 'text') content += b.text ?? '';
187
- 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 });
188
289
  else if (b?.type === 'tool_use') 부름들.push({ id: b.id, name: b.name, args: b.input ?? {} });
189
290
  }
190
291
  return {
191
292
  content,
192
293
  thinking,
294
+ 생각블록,
193
295
  toolCalls: normalizeCalls(부름들),
194
296
  // 이름이 다르다. prompt_tokens 를 찾으면 늘 0 이 나오고, 화면에는
195
297
  // 「토큰을 하나도 안 썼다」 로 뜬다.
@@ -258,20 +360,39 @@ export function normalizeCalls(list) {
258
360
  }
259
361
 
260
362
  // 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
261
- export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [] }) {
363
+ export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [], 생각블록 = null }) {
262
364
  if (shape === 'anthropic') {
365
+ /*
366
+ * ── 생각 블록을 **맨 앞에, 받은 그대로** 돌려보낸다 ──────────────────
367
+ *
368
+ * 여기는 오래 비어 있던 자리다. 예전 주석은 「서명 조각을 제대로 모으는
369
+ * 것까지 확인하지 못했으므로 뺀다」 였다. 이제 모은다(흘려받기의
370
+ * signature_delta). 그래서 실을 수 있다.
371
+ *
372
+ * 왜 실어야 하나 — 생각을 켜고 도구를 쓰면, 서버는 그 도구 부름을 낳은
373
+ * 생각 블록이 **같이 돌아오기를** 요구한다. 안 보내면 그 턴이 거절된다.
374
+ * 즉 생각을 켜는 것과 이 블록을 나르는 것은 한 몸이다.
375
+ *
376
+ * 서명은 우리가 읽거나 고칠 것이 아니다. 받은 문자열 그대로 나른다.
377
+ * 서명이 빈 블록은 아예 안 싣는다 — 지어낸 서명은 거절당하고, 그러면
378
+ * 왜 안 되는지가 화면에서 안 보인다.
379
+ */
263
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
+ }
264
388
  if (content) 블록.push({ type: 'text', text: content });
265
389
  for (const t of toolCalls) 블록.push({ type: 'tool_use', id: t.id, name: t.name, input: t.args ?? {} });
266
390
  /*
267
- * 생각은 **안 돌려보낸다.**
268
- *
269
- * 이 규격의 생각 블록에는 서명(signature)이 딸려 있고, 서명 없이 돌려보내면
270
- * 서버가 거절한다. 흘려받기에서 서명 조각을 제대로 모으는 것까지 확인하지
271
- * 못했으므로 여기서는 뺀다. 화면에는 그대로 흘러가고, 대화 이력에만 안 남는다.
272
- *
273
391
  * 블록이 하나도 없으면 이 규격은 거절한다. 빈 답이 오는 일은 드물지만
274
392
  * 그때 대화 전체가 죽으면 안 되니 자리표시를 하나 넣는다.
393
+ *
394
+ * 생각 블록**만** 있는 경우도 여기 걸리지 않게 한다 — 생각만 하고 아무
395
+ * 말도 안 한 턴은 실제로 있고, 그때 생각 블록은 살아 있어야 한다.
275
396
  */
276
397
  if (!블록.length) 블록.push({ type: 'text', text: '(빈 답)' });
277
398
  return { role: 'assistant', content: 블록 };
@@ -351,7 +472,12 @@ function 열쇠다시받을까(conn, status, 이미) {
351
472
 
352
473
  // 한 번에 받기.
353
474
  export async function chat(conn, opts) {
354
- 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
+ });
355
481
  const 정책 = 정책고르기(conn, opts);
356
482
  let 열쇠다시받음 = false;
357
483
  for (let 시도 = 1; ; 시도++) {
@@ -365,7 +491,9 @@ export async function chat(conn, opts) {
365
491
  // 서버가 남았다고 말해 준 할당량을 적어 둔다 (backend/quota.js).
366
492
  // 429 를 맞고 나서야 아는 것과, 맞기 전에 아는 것은 사람이 할 일이 다르다.
367
493
  할당량기억(r.headers);
368
- if (r.ok) return extractMessage(conn.kind, r.json);
494
+ // 다듬느라 이름을 고쳤으면 여기서 되돌린다. 밖에서는 그런 일이 있었는지
495
+ // 모른 채로 원래 이름을 받는다.
496
+ if (r.ok) return 이름되돌리기(extractMessage(conn.kind, r.json), 맞춘것.되돌림);
369
497
  // 열쇠가 늙어서 막힌 것이면 새로 받고 한 번만 다시. 시도 수는 안 올린다 —
370
498
  // 서버가 막은 것이 아니라 우리 열쇠가 낡았던 것이라 물러설 까닭이 없다.
371
499
  if (열쇠다시받을까(conn, r.status, 열쇠다시받음)) {
@@ -433,7 +561,10 @@ async function 거절읽기(r) {
433
561
 
434
562
  // 흘려 받기. { type:'thinking'|'content', text } 를 내보내고 마지막에 { type:'done', message } 를 준다.
435
563
  export async function* chatStream(conn, opts) {
436
- 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
+ });
437
568
  const 정책 = 정책고르기(conn, opts);
438
569
  let r;
439
570
  let 열쇠다시받음 = false;
@@ -497,9 +628,33 @@ export async function* chatStream(conn, opts) {
497
628
  for (const ev of absorb(conn.kind, obj, acc)) yield ev;
498
629
  }
499
630
  }
500
- 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, 맞춘것.되돌림) };
501
653
  }
502
654
 
655
+ /** 서버가 끝난 까닭을 안 주고 흘려보내기를 멈춘 것. 'stop' 과 구별해야 한다. */
656
+ export const 말없이끝남 = '말없이끝남';
657
+
503
658
  // 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
504
659
  function absorb(shape, obj, acc) {
505
660
  const out = [];
@@ -553,6 +708,19 @@ function anthropic흡수(obj, acc, out) {
553
708
  if (b.type === 'tool_use') {
554
709
  acc._raw ??= [];
555
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 };
556
724
  }
557
725
  return out;
558
726
  }
@@ -563,7 +731,22 @@ function anthropic흡수(obj, acc, out) {
563
731
  out.push({ type: 'content', text: d.text });
564
732
  } else if (d.type === 'thinking_delta' && d.thinking) {
565
733
  acc.thinking += d.thinking;
734
+ // 화면으로 흘려보내는 것과 별개로, 돌려보낼 블록에도 그대로 쌓는다.
735
+ acc.생각블록 ??= [];
736
+ acc.생각블록[번호] ??= { type: 'thinking', thinking: '', signature: '' };
737
+ acc.생각블록[번호].thinking += d.thinking;
566
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;
567
750
  } else if (d.type === 'input_json_delta' && d.partial_json != null) {
568
751
  acc._raw ??= [];
569
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
  서버.닫기();