deel-local-cli 1.20.13 → 2.0.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 (155) hide show
  1. package/README.ko.md +96 -78
  2. package/README.md +119 -81
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +23 -3
  7. package/src/acp/serve.js +333 -25
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +253 -26
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +21 -4
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -9,7 +9,7 @@ import {
9
9
  import { 할당량기억, 미리기다릴까, 왜띄우나, 마지막할당량, 할당량자리 } from './quota.js';
10
10
  import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
11
11
  import { 말 } from '../i18n/index.js';
12
- import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
12
+ import { 다시부를지, 기다리기, 정책고르기, 못부른말 } from './retry.js';
13
13
  import { 도구맞추기, 이름되돌리기, 벤더 } from './toolfit.js';
14
14
  import { 눈금맞추기 } from './wire.js';
15
15
  import { 시스템블록, 메시지표식, 잡힐만한가, 조각표 } from './cachemark.js';
@@ -321,8 +321,12 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
321
321
  * 안 보내는 것도 적어 둔다. 확인 못 한 것은 안 보낸다는 뜻이지, 없다는 뜻이
322
322
  * 아니다 — 짐작으로 보낸 칸 하나가 400 을 만들면 열쇠가 틀린 줄 알게 된다.
323
323
  *
324
- * · think — 생각 칸의 값 모양을 문서에서 확인하지 못했다.
325
324
  * · json — 이 규격에는 답 모양을 강제하는 칸이 없다. 도구로 하는 방법뿐이다.
325
+ *
326
+ * think 는 **보낸다.** 한동안 이 자리에 「확인 못 했다」 고 적혀 있었는데, 그 사이
327
+ * 값 모양(`thinking:{type:'enabled',budget_tokens:n}`)을 확인하고 실어 보내게 됐다
328
+ * (바로 아래 「이 규격의 추론 강도」). 목록만 옛말로 남아 있었다 — 안 보낸다고 적힌
329
+ * 칸이 실제로는 나가고 있으면, 이 목록을 믿고 읽는 사람이 제일 크게 헛짚는다.
326
330
  */
327
331
  /*
328
332
  * ── 이 규격의 추론 강도 ────────────────────────────────────────────────
@@ -607,6 +611,114 @@ export function 보낸토큰(shape, u) {
607
611
  return Math.max(들어온것, 캐시몫);
608
612
  }
609
613
 
614
+ /*
615
+ * ── 생각 글은 창구마다 **다른 칸**에 실린다 ─────────────────────────────
616
+ *
617
+ * reasoning_content DeepSeek · vLLM 옛 판 · 여러 호환 서버
618
+ * reasoning OpenRouter · Ollama 의 /v1 · vLLM 새 판
619
+ *
620
+ * 여태 앞엣것만 읽었다. 뒤엣것으로 오면 생각이 화면에 안 나오는 데서 끝나지
621
+ * 않았다 — 흘려받는 동안 acc 가 한 글자도 안 자라니, 소식 시계(무소식)가
622
+ * **멀쩡히 생각하는 중인 흐름**을 「내용이 안 온다」 로 보고 끊었다. 오래
623
+ * 생각하는 모델일수록 확실히 끊겼다.
624
+ *
625
+ * 두 칸에 **같은 글을 같이** 싣는 판도 있다. 그래서 둘을 더하지 않고 하나만
626
+ * 고른다 — 더하면 생각이 두 벌이 된다. 글이 아닌 값(객체로 된 요약 칸 등)은
627
+ * 생각 글이 아니므로 안 읽는다.
628
+ */
629
+ function 생각칸(d) {
630
+ if (typeof d?.reasoning_content === 'string' && d.reasoning_content) return d.reasoning_content;
631
+ if (typeof d?.reasoning === 'string' && d.reasoning) return d.reasoning;
632
+ return '';
633
+ }
634
+
635
+ /*
636
+ * ── 200 을 받아 놓고 **몸 안에서** 온 실패 ───────────────────────────────
637
+ *
638
+ * 머리말이 200 으로 나간 뒤에 위층이 막히면 서버는 상태 코드로 말할 길이
639
+ * 없다. 그래서 몸 안에 적는다. 모양이 창구마다 다르다.
640
+ *
641
+ * Anthropic event: error / {"type":"error","error":{"type":"overloaded_error",…}}
642
+ * OpenAI 호환 {"error":{"message":…,"code":"429"},"choices":[{"finish_reason":"error"}]}
643
+ * Ollama {"error":"model runner has unexpectedly stopped…"}
644
+ * 한 번에 받는 길 200 몸이 통째로 {"error":{"message":"Budget exceeded"}}
645
+ *
646
+ * 여태 넷 다 조용히 버려졌다. choices 도 content 도 없으니 **빈 답**이 되고,
647
+ * 루프는 그 빈 답을 「스트리밍이 안 맞는 서버」 로 읽어 세션 내내 흘려받기를
648
+ * 껐다(agent/loop.js 의 빈답 갈래). 글 뒤에 왔으면 「말없이끝남」 이 되어 까닭이
649
+ * 사라졌다. 아래 거절읽기 머리말이 말하는 그대로다 — 사용자를 구할 수 있었던
650
+ * 문장이 그 자리에서 사라졌다.
651
+ *
652
+ * 그래서 이것을 **HTTP 거절과 같은 모양**으로 바꾼다. 그러면 거절오류·다시부를지
653
+ * 가 그대로 받는다 — 과부하는 529 처럼 기다렸다 다시 부르고, 모르는 것은 서버
654
+ * 말 그대로 오류가 된다. 루프에 새 갈래를 만들지 않는다.
655
+ *
656
+ * 상태는 **서버가 적어 준 것**에서만 읽는다. 숫자(status·code)가 있으면 그것,
657
+ * 없으면 문서에 적힌 오류 이름. 둘 다 없으면 0 이다 — 모르는 것을 429 로
658
+ * 짐작하면 다시 불러 봐야 같은 것을 세 번 더 두드린다.
659
+ */
660
+ const 오류이름상태 = {
661
+ // Anthropic 문서의 오류 이름 (platform.claude.com/docs/en/api/errors)
662
+ invalid_request_error: 400, authentication_error: 401, billing_error: 402, permission_error: 403,
663
+ not_found_error: 404, request_too_large: 413, rate_limit_error: 429, api_error: 500,
664
+ timeout_error: 504, overloaded_error: 529,
665
+ // OpenAI 호환 창구·게이트웨이가 쓰는 이름
666
+ rate_limit_exceeded: 429, server_error: 500,
667
+ };
668
+
669
+ /**
670
+ * @param {object} obj 받은 JSON 한 덩이
671
+ * @param {string|null} 사건이름 SSE 의 event: 이름 (없으면 null)
672
+ * @returns {object|null} 거절읽기 와 같은 모양. 실패가 아니면 null
673
+ */
674
+ function 몸속오류(obj, 사건이름 = null) {
675
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
676
+ // `"error": null` · `"error": {}` 를 싣는 창구가 있다. 뜻이 담긴 것만 실패로 친다.
677
+ // 이 목록은 **아래에서 읽는 이름과 같아야 한다.** `detail` 이 빠져 있어서, 까닭을
678
+ // 거기에 싣는 창구(FastAPI 로 앞을 세운 게이트웨이의 기본 모양)의 실패가 통째로
679
+ // 「뜻 없는 error」 로 버려졌다 — 답이 비고, 서버가 적어 준 한 줄은 어디에도 안 남는다.
680
+ const 뜻있나 = (e) => !!e && typeof e === 'object' && !Array.isArray(e)
681
+ && ['message', 'detail', 'code', 'type', 'status'].some((k) => e[k] != null && e[k] !== '');
682
+ let 것 = null;
683
+ if (typeof obj.error === 'string' && obj.error.trim()) 것 = { message: obj.error };
684
+ else if (뜻있나(obj.error)) 것 = obj.error;
685
+ else if (obj.type === 'error' || 사건이름 === 'error') 것 = obj;
686
+ else if (obj.choices?.[0]?.finish_reason === 'error') 것 = {};
687
+ if (!것) return null;
688
+
689
+ const 글 = [것.message, 것.error?.message, 것.detail].find((v) => typeof v === 'string' && v.trim());
690
+ const 숫자 = [것.status, 것.status_code, 것.code].map(Number)
691
+ .find((n) => Number.isInteger(n) && n >= 400 && n <= 599);
692
+ const 이름 = [것.type, 것.code].find((v) => typeof v === 'string' && Object.hasOwn(오류이름상태, v));
693
+ const status = 숫자 ?? (이름 ? 오류이름상태[이름] : 0);
694
+ // 문장을 비워 보내는 창구가 있다. 빈 말은 화면에 빈 줄로 남으니 무엇이 왔는지라도 적는다.
695
+ const 무엇 = [것.type, 것.code].filter((v) => v != null && v !== '').join(' · ');
696
+ const 말 = 글 ? 글.trim() : `서버가 답하던 중에 오류를 보냈습니다${무엇 ? ` (${무엇})` : ''}`;
697
+ return { ok: false, status, error: null, code: null, json: { error: { message: 말 } }, text: 말, headers: null, ms: 0 };
698
+ }
699
+
700
+ /*
701
+ * ── usage 숫자를 **숫자로** 읽는다 (사냥5 B5-07) ─────────────────────────
702
+ *
703
+ * 규격은 숫자라고 적지만 `"prompt_tokens": "120"` 처럼 글자로 싣는 게이트웨이가
704
+ * 있다. 그 값을 `?? 0` 으로만 받으면 루프의 `session.usage.in += …` 가 **글자 잇기**가
705
+ * 되어 deel run --json 의 usage.in 이 "0120" 으로 나갔다 — 그 JSON 을 받아 더하는
706
+ * 스크립트는 조용히 틀린 합을 낸다.
707
+ *
708
+ * 못 읽는 값(NaN · 음수 · 객체)은 null 이다. 0 으로 메우는 것은 부르는 쪽이 하고,
709
+ * 들어온 것도 나간 것도 못 읽었으면 「안 쟀다」(잰것 false)로 적는다 — 아래 갈래의
710
+ * 「안 왔다는 0 이 아니다」 규칙과 같은 자리다. 서버가 준 진짜 0 은 읽힌 값이다.
711
+ */
712
+ function 토큰수(v) {
713
+ const n = typeof v === 'string' && v.trim() ? Number(v) : v;
714
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : null;
715
+ }
716
+ const 잰사용량 = (들어옴, 나감) => ({
717
+ 잰것: 토큰수(들어옴) != null || 토큰수(나감) != null,
718
+ in: 토큰수(들어옴) ?? 0,
719
+ out: 토큰수(나감) ?? 0,
720
+ });
721
+
610
722
  export function extractMessage(shape, json) {
611
723
  if (shape === 'anthropic') {
612
724
  // 답이 블록 배열이다. 글·생각·도구 부름이 한 배열에 섞여 온다.
@@ -653,9 +765,7 @@ export function extractMessage(shape, json) {
653
765
  * 그래서 값은 그대로 0 으로 두되(셈하는 쪽이 다 고치지 않아도 되게),
654
766
  * **재긴 쟀나**를 따로 남긴다. 화면과 JSON 이 그것으로 가른다.
655
767
  */
656
- 잰것: json?.usage != null,
657
- in: json?.usage?.input_tokens ?? 0,
658
- out: json?.usage?.output_tokens ?? 0,
768
+ ...잰사용량(json?.usage?.input_tokens, json?.usage?.output_tokens),
659
769
  ...(() => { const c = 캐시읽기(json?.usage); return { cacheRead: c.읽음, cacheWrite: c.씀 }; })(),
660
770
  },
661
771
  stopped: json?.stop_reason ?? null,
@@ -669,11 +779,7 @@ export function extractMessage(shape, json) {
669
779
  content: m.content ?? '',
670
780
  thinking: m.thinking ?? '',
671
781
  toolCalls: normalizeCalls(m.tool_calls ?? []),
672
- usage: {
673
- 잰것: json?.prompt_eval_count != null || json?.eval_count != null,
674
- in: json?.prompt_eval_count ?? 0,
675
- out: json?.eval_count ?? 0,
676
- },
782
+ usage: 잰사용량(json?.prompt_eval_count, json?.eval_count),
677
783
  stopped: json?.done_reason ?? null,
678
784
  };
679
785
  }
@@ -681,8 +787,8 @@ export function extractMessage(shape, json) {
681
787
  // 생각에 쓴 토큰. 이것도 출력 예산에서 나간다 — 상한이 8,000인데 생각에 6,000을
682
788
  // 쓰면 실제로 쓸 수 있는 답은 2,000뿐이다. 잘리는 이유가 여기 있을 때가 많은데,
683
789
  // 전에는 이 숫자를 읽지도 않아서 화면에도 셈에도 안 나타났다.
684
- const 생각 = json?.usage?.completion_tokens_details?.reasoning_tokens
685
- ?? json?.usage?.reasoning_tokens ?? 0;
790
+ const 생각 = 토큰수(json?.usage?.completion_tokens_details?.reasoning_tokens
791
+ ?? json?.usage?.reasoning_tokens) ?? 0;
686
792
  /*
687
793
  * ── 안 하겠다고 한 것인가 ────────────────────────────────────────────
688
794
  *
@@ -708,13 +814,11 @@ export function extractMessage(shape, json) {
708
814
  // 오히려 흔해서, 그 경우 거절 글이 통째로 사라졌다 — 흘려받기 쪽은
709
815
  // `if (!acc.content …)` 로 이미 빈 글자를 챙기고 있었다. 두 길을 맞춘다.
710
816
  content: (typeof m.content === 'string' && m.content) ? m.content : (거절글 ?? m.content ?? ''),
711
- thinking: m.reasoning_content ?? '',
817
+ thinking: 생각칸(m),
712
818
  toolCalls: normalizeCalls(m.tool_calls ?? []),
713
819
  usage: {
714
- // 「안 왔다」 와 「0」 을 가른다 (위 anthropic 갈래의 머리말).
715
- 잰것: json?.usage != null,
716
- in: json?.usage?.prompt_tokens ?? 0,
717
- out: json?.usage?.completion_tokens ?? 0,
820
+ // 「안 왔다」 와 「0」 을 가른다 (위 anthropic 갈래의 머리말). 글자 숫자는 토큰수 머리말.
821
+ ...잰사용량(json?.usage?.prompt_tokens, json?.usage?.completion_tokens),
718
822
  reasoning: 생각,
719
823
  ...(() => { const c = 캐시읽기(json?.usage); return { cacheRead: c.읽음, cacheWrite: c.씀 }; })(),
720
824
  },
@@ -737,6 +841,56 @@ export function extractMessage(shape, json) {
737
841
  * 도구를 열세 번 부르고, 컨텍스트가 다 차서 대화를 접었고, 파일은 안 생겼다.
738
842
  * 조용히 삼킨 값 하나가 그 전부를 만들었다.
739
843
  */
844
+ /*
845
+ * ── 부름의 이름은 **늘 글자**로 넘긴다 (사냥5 L5-1) ─────────────────────────
846
+ *
847
+ * 한 번에 받는 길에서 `function.name` 이 빠졌거나 `42` 로 오면 그 값이 그대로 넘어가,
848
+ * 루프의 「모르는 도구」 관문이 `call.name.startsWith` 에서 TypeError 로 run() 밖으로
849
+ * 튀었다. 그 전에 부름이 든 답은 이미 대화에 실려 있어서, 다음 요청은 결과 없는
850
+ * tool_calls 를 싣고 나가 까다로운 서버에서 400 이 됐다. 흘려받는 길은 이름 칸을 ''
851
+ * 로 열어 두고 이어 붙이므로 멀쩡했다 — 두 길이 같은 모양을 내게 맞춘다. 글자가 된
852
+ * 이름은 루프에서 모르는 도구로 걸러지고 결과도 짝지어 실린다.
853
+ */
854
+ const 이름글 = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
855
+
856
+ /*
857
+ * ── 못 읽은 인자가 **잘린** 것인가, **모양이 틀린** 것인가 (사냥5 L5-5) ──────
858
+ *
859
+ * 둘 다 JSON.parse 가 실패한다는 겉모습은 같은데 할 일이 정반대다. 잘렸으면 상한을
860
+ * 올려 다시 부르거나 나눠 보내라고 해야 하고, 모양이 틀렸으면(홑따옴표 · 끝 쉼표 ·
861
+ * 글 속 날줄바꿈) 크기와는 상관이 없다. 여태는 못 읽으면 늘 잘린 것으로 쳐서, 걸음마다
862
+ * 16,384 로 다시 부르고 /out 을 고치라 하고 모델에게는 「300줄씩 나눠 Append 하라」 고
863
+ * 했다 — 따옴표 하나 고치면 될 일을. 날줄바꿈 Write 는 파일을 이미 다 써 놓고도
864
+ * 「받은 데까지만 썼다」 고 했다.
865
+ *
866
+ * 잘린 JSON 은 **끝까지 쓴 JSON 의 앞토막**이다. 그래서 큰따옴표 안에서 끝났거나
867
+ * 괄호가 덜 닫혔으면 잘린 것이고, 괄호가 다 닫혔는데도 못 읽으면 모양이 틀린 것이다.
868
+ * 게이트웨이가 잘라 놓고 stop 이라 말하는 판(agent/effort.js 의 wasCut 머리말)은
869
+ * 앞토막이라 여전히 잘린 것으로 잡힌다.
870
+ *
871
+ * 홑따옴표도 글을 연다 (2.0.0 6회차 · Gemini 고리5). 모양이 틀린 JSON 의 첫째가 홑따옴표인데
872
+ * 큰따옴표만 글로 쳐서, `{'desc': 'say "hi'}` 는 글 속에서 끝났다고, `{'code': 'if (x) {'}` 는
873
+ * 괄호가 덜 닫혔다고 봤다 — 모양만 틀린 것을 도로 잘린 것으로. 글을 연 따옴표와 같은 것이
874
+ * 와야 닫힌다. 그래서 큰따옴표 글 속의 `don't` 는 여전히 글자일 뿐이다.
875
+ */
876
+ export function 잘린모양인가(raw) {
877
+ const s = String(raw ?? '').trim();
878
+ let 깊이 = 0;
879
+ let 연따옴표 = null;
880
+ for (let i = 0; i < s.length; i++) {
881
+ const ch = s[i];
882
+ if (연따옴표) {
883
+ if (ch === '\\') { i++; continue; }
884
+ if (ch === 연따옴표) 연따옴표 = null;
885
+ continue;
886
+ }
887
+ if (ch === '"' || ch === "'") 연따옴표 = ch;
888
+ else if (ch === '{' || ch === '[') 깊이++;
889
+ else if (ch === '}' || ch === ']') 깊이--;
890
+ }
891
+ return 연따옴표 !== null || 깊이 > 0;
892
+ }
893
+
740
894
  export function normalizeCalls(list) {
741
895
  return list.map((tc, i) => {
742
896
  const fn = tc.function ?? tc;
@@ -752,8 +906,9 @@ export function normalizeCalls(list) {
752
906
  catch { 깨짐 = true; 원문 = args; args = {}; }
753
907
  }
754
908
  }
755
- const call = { id: tc.id ?? `call_${i + 1}`, name: fn.name, args };
756
- if (깨짐) { call.argsBroken = true; call.rawArgs = 원문; }
909
+ const call = { id: tc.id ?? `call_${i + 1}`, name: 이름글(fn.name), args };
910
+ // argsCut: 잘린 앞토막인가(위 잘린모양인가). false 면 끝까지 온 틀린 JSON 이다.
911
+ if (깨짐) { call.argsBroken = true; call.rawArgs = 원문; call.argsCut = 잘린모양인가(원문); }
757
912
  return call;
758
913
  });
759
914
  }
@@ -950,10 +1105,14 @@ export function 결과바꾸기(m, 새글) {
950
1105
  if (Array.isArray(m.content)) {
951
1106
  let 바꿨나 = false;
952
1107
  const 새것 = m.content.map((b) => {
953
- if (b?.type !== 'tool_result' || 바꿨나) return b;
954
- 바꿨나 = true; // 여럿이면 첫 자리에만 적고 나머지는 비운다
955
- return { ...b, content: String(새글) };
956
- }).map((b) => (b?.type === 'tool_result' && b.content !== String(새글) ? { ...b, content: '' } : b));
1108
+ if (b?.type !== 'tool_result') return b;
1109
+ if (!바꿨나) { 바꿨나 = true; return { ...b, content: String(새글) }; }
1110
+ /*
1111
+ * 여럿이면 첫 자리에만 적는다. 나머지를 빈 글로 두면 모델은 그 부름이 **아무것도
1112
+ * 안 돌려준 것**으로 읽고 같은 것을 다시 부른다. 함께 접혔다는 짧은 표를 남긴다.
1113
+ */
1114
+ return { ...b, content: '(앞 결과와 함께 접었습니다)' };
1115
+ });
957
1116
  return 바꿨나 ? { ...m, content: 새것 } : m;
958
1117
  }
959
1118
  if (m.role !== 'tool') return m;
@@ -1157,16 +1316,22 @@ export async function chat(conn, opts) {
1157
1316
  // 429 를 맞고 나서야 아는 것과, 맞기 전에 아는 것은 사람이 할 일이 다르다.
1158
1317
  // 막힌 것이면 그 사실도 적는다 — 다음 부름이 이걸 보고 띄운다 (backend/quota.js).
1159
1318
  할당량기억(r.headers, 할당량자리(conn), { 막힘: r.status === 429 });
1319
+ /*
1320
+ * 200 인데 몸에 오류만 실려 온 것은 **거절로 친다** (위 몸속오류).
1321
+ * extractMessage 로 넘기면 빈 답이 되고, 예산이 떨어졌다는 한 줄은 사라진다.
1322
+ */
1323
+ const 몸오류 = r.ok ? 몸속오류(r.json) : null;
1160
1324
  // 다듬느라 이름을 고쳤으면 여기서 되돌린다. 밖에서는 그런 일이 있었는지
1161
1325
  // 모른 채로 원래 이름을 받는다.
1162
- if (r.ok) {
1326
+ if (r.ok && !몸오류) {
1163
1327
  const 것 = 이름되돌리기(extractMessage(conn.kind, r.json), 맞춘것.되돌림);
1164
1328
  const 자리 = 간자리(r.headers);
1165
1329
  return 자리 ? { ...것, 간자리: 자리 } : 것;
1166
1330
  }
1331
+ const 거절 = 몸오류 ? { ...몸오류, headers: r.headers ?? null, ms: r.ms } : r;
1167
1332
  // 열쇠가 늙어서 막힌 것이면 새로 받고 한 번만 다시. 시도 수는 안 올린다 —
1168
1333
  // 서버가 막은 것이 아니라 우리 열쇠가 낡았던 것이라 물러설 까닭이 없다.
1169
- if (열쇠다시받을까(conn, r.status, 열쇠다시받음)) {
1334
+ if (열쇠다시받을까(conn, 거절.status, 열쇠다시받음)) {
1170
1335
  열쇠다시받음 = true;
1171
1336
  await 머리말짓기(conn, opts, { 다시: true });
1172
1337
  시도 -= 1;
@@ -1175,8 +1340,9 @@ export async function chat(conn, opts) {
1175
1340
  // 잠깐 막힌 것이면 기다렸다 다시 부른다 (backend/retry.js 머리말).
1176
1341
  // 한 번에 받는 길은 제너레이터가 아니라 화면에 말을 못 걸어서, 부르는 쪽이
1177
1342
  // 준 onBackoff 로 알린다. 안 줬으면 조용히 기다린다.
1178
- const 다시 = 다시부를지(r, 시도, 정책, 쌓인대기);
1179
- if (!다시) throw 거절오류(r, 시도);
1343
+ const 못부른것 = {};
1344
+ const 다시 = 다시부를지(거절, 시도, 정책, 쌓인대기, 못부른것);
1345
+ if (!다시) throw 거절오류(거절, 시도, 못부른것);
1180
1346
  opts.onBackoff?.(다시);
1181
1347
  쌓인대기 += 다시.wait;
1182
1348
  await 기다리기(다시.wait, opts.signal ?? null);
@@ -1190,10 +1356,20 @@ export async function chat(conn, opts) {
1190
1356
  * 한 번 막힌 것인지 계속 막히는 것인지 알 수 없고, 그 둘은 사람이 할 일이 다르다 —
1191
1357
  * 앞은 그냥 다시 시키면 되고, 뒤는 할당량을 봐야 한다.
1192
1358
  */
1193
- function 거절오류(r, 시도) {
1359
+ function 거절오류(r, 시도, 못부른것 = null) {
1194
1360
  const 원문 = serverMessage(r);
1195
1361
  let 말 = 원문;
1196
- if (시도 > 1) {
1362
+ /*
1363
+ * 안 부른 까닭이 둘인데 여태 한 가지로 말했다 (retry.js 의 못부른까닭).
1364
+ *
1365
+ * `retry.막힘최대` 를 8 로 올려 둔 사람이 「3번 불렀지만 계속 막혔습니다」 를 보면,
1366
+ * 자기가 올린 값이 아무 일도 안 하는 것으로 읽힌다. 실제로는 여덟 번을 채우기 전에
1367
+ * **우리 쪽 시간 울타리**(retry.총상한)가 먼저 닫힌 것이다. 앞은 서버를 봐야 하는
1368
+ * 일이고 뒤는 우리 설정을 올리면 되는 일이라, 둘에 같은 말을 하면 엉뚱한 데를 판다.
1369
+ */
1370
+ const 우리가그만둠 = 못부른말(못부른것);
1371
+ if (우리가그만둠) 말 += `\n ${시도}번 부르고 멈췄습니다. ${우리가그만둠}`;
1372
+ else if (시도 > 1) {
1197
1373
  const 무엇 = r.status ? `HTTP ${r.status}` : (r.code ?? '연결 끊김');
1198
1374
  말 += `\n ${시도}번 불렀지만 계속 막혔습니다 (${무엇}) — 잠시 뒤 다시 시키세요`;
1199
1375
  }
@@ -1236,7 +1412,6 @@ export async function* chatStream(conn, opts) {
1236
1412
  const body = 몸만들기(conn, opts, 맞춘것, { stream: true });
1237
1413
  몸덤프(body);
1238
1414
  const 정책 = 정책고르기(conn, opts);
1239
- let r;
1240
1415
  let 열쇠다시받음 = false;
1241
1416
  let 쌓인대기 = 0;
1242
1417
  {
@@ -1249,7 +1424,7 @@ export async function* chatStream(conn, opts) {
1249
1424
  }
1250
1425
  }
1251
1426
  for (let 시도 = 1; ; 시도++) {
1252
- r = await req(요청주소(conn), {
1427
+ const r = await req(요청주소(conn), {
1253
1428
  method: 'POST',
1254
1429
  headers: await 머리말짓기(conn, opts),
1255
1430
  body,
@@ -1266,26 +1441,58 @@ export async function* chatStream(conn, opts) {
1266
1441
  signal: opts.signal ?? null,
1267
1442
  });
1268
1443
  할당량기억(r.headers ?? r.res?.headers, 할당량자리(conn), { 막힘: r.status === 429 });
1269
- if (r.ok && r.res?.body) break;
1270
- const 거절 = await 거절읽기(r);
1271
- // 위 chat() 과 같은 규칙. 몸을 먼저 읽고(거절읽기) 나서 다시 부른다 —
1272
- // 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
1444
+ let 거절;
1445
+ if (r.ok && r.res?.body) {
1446
+ const 읽은것 = yield* 흘려읽기(conn, opts, r);
1447
+ if (!읽은것.오류) {
1448
+ yield { type: 'done', message: 이름되돌리기(읽은것.acc, 맞춘것.되돌림) };
1449
+ return;
1450
+ }
1451
+ 거절 = 읽은것.오류;
1452
+ /*
1453
+ * 몸 안에서 온 실패(몸속오류)는 **글이 흘러가기 전이면** 머리말에서 막힌
1454
+ * 것과 같다 — 화면에 나간 것이 없으니 다시 불러도 두 벌이 안 된다. 과부하
1455
+ * 사건 하나로 빈 답이 되고 흘려받기가 세션 내내 꺼지던 자리가 여기서 낫는다.
1456
+ *
1457
+ * 글이나 생각이 한 글자라도 흘러갔으면 다시 안 부른다. 반쯤 온 답을 두 벌
1458
+ * 만들면 안 된다(backend/retry.js 머리말). 대신 **서버가 한 말로 던진다** —
1459
+ * 루프는 흘러간 글을 대화에 남기고 그 말을 오류로 적는다. 여태처럼
1460
+ * 「말없이끝남」 으로 넘기면 까닭이 사라진다.
1461
+ */
1462
+ if (읽은것.흘렸나) throw 거절오류(거절, 시도);
1463
+ } else {
1464
+ // 위 chat() 과 같은 규칙. 몸을 먼저 읽고(거절읽기) 나서 다시 부른다 —
1465
+ // 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
1466
+ 거절 = await 거절읽기(r);
1467
+ }
1273
1468
  if (열쇠다시받을까(conn, 거절.status, 열쇠다시받음)) {
1274
1469
  열쇠다시받음 = true;
1275
1470
  await 머리말짓기(conn, opts, { 다시: true });
1276
1471
  시도 -= 1;
1277
1472
  continue;
1278
1473
  }
1279
- // 잠깐 막힌 것이면 알리고, 기다렸다, 다시 부른다. 머리말도 못 받은 자리라
1280
- // 화면에 흘러간 글이 없다 — 그래서 여기서만 다시 부르고, 아래 읽기 도중에
1281
- // 끊긴 것은 다시 안 부른다 (backend/retry.js 머리말).
1282
- const 다시 = 다시부를지(거절, 시도, 정책, 쌓인대기);
1283
- if (!다시) throw 거절오류(거절, 시도);
1474
+ // 잠깐 막힌 것이면 알리고, 기다렸다, 다시 부른다. 화면에 흘러간 글이 없는
1475
+ // 자리만 여기 온다 — 읽는 도중에 멎거나 끊긴 것(STALL·소켓)은 흘려읽기가
1476
+ // 받은 것을 살려 주거나 던지고, 다시 안 부른다 (backend/retry.js 머리말).
1477
+ const 못부른것 = {};
1478
+ const 다시 = 다시부를지(거절, 시도, 정책, 쌓인대기, 못부른것);
1479
+ if (!다시) throw 거절오류(거절, 시도, 못부른것);
1284
1480
  yield 다시;
1285
1481
  쌓인대기 += 다시.wait;
1286
1482
  await 기다리기(다시.wait, opts.signal ?? null);
1287
1483
  }
1484
+ }
1288
1485
 
1486
+ /**
1487
+ * 머리말을 받은 흐름 하나를 끝까지 읽는다.
1488
+ *
1489
+ * chatStream 에서 떼어 낸 까닭은 하나다 — 몸 안에서 온 실패(몸속오류)를 두고
1490
+ * **다시 부르는 자리로 돌아가야** 해서다. 읽기가 부르기 고리 밖에 있으면
1491
+ * 돌아갈 길이 없었고, 그래서 그 실패는 빈 답으로 흘러 나갔다.
1492
+ *
1493
+ * @returns {{acc: object} | {오류: object, 흘렸나: boolean}}
1494
+ */
1495
+ async function* 흘려읽기(conn, opts, r) {
1289
1496
  const acc = {
1290
1497
  content: '', thinking: '', toolCalls: [],
1291
1498
  usage: { 잰것: false, in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1309,6 +1516,9 @@ export async function* chatStream(conn, opts) {
1309
1516
  const 자란만큼 = () =>
1310
1517
  acc.content.length
1311
1518
  + acc.thinking.length
1519
+ // Ollama 는 도구 부름을 줄마다 통째로 준다 — _raw 가 아니라 여기 바로 쌓인다.
1520
+ // 이것을 안 세면 부름이 줄줄이 오는 멀쩡한 흐름을 「내용이 안 온다」 로 보고 잘랐다.
1521
+ + acc.toolCalls.length
1312
1522
  + (acc.거절글?.length ?? 0)
1313
1523
  + (acc._raw?.reduce(
1314
1524
  (n, c) => n + 1 + (c?.name?.length ?? 0) + (c?.args?.length ?? 0), 0) ?? 0)
@@ -1334,15 +1544,110 @@ export async function* chatStream(conn, opts) {
1334
1544
  * TextDecoder 도 마지막에 flush 해야 한다. 여러 바이트짜리 글자가 마지막
1335
1545
  * 조각에 걸쳐 있으면 그 글자가 사라진다 — 한국어에서 특히 잘 난다.
1336
1546
  */
1337
- const 한줄 = function* (line) {
1338
- if (!line) return;
1339
- const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
1340
- if (payload === '[DONE]') return;
1341
- let obj;
1342
- try { obj = JSON.parse(payload); } catch { return; }
1547
+ /*
1548
+ * 살려서 줄 것이 있나 — 멎었을 때(STALL·NONEWS) 던질지 줄지를 가른다.
1549
+ *
1550
+ * 도구 인자는 **끝에서 한 번** 묶는다(mergeDeltaCalls 머리말). 그러니 흐름
1551
+ * 가운데에는 묶인 부름(acc.toolCalls)이 아직 비어 있다. 그것만 보면 인자를
1552
+ * 다 받아 놓고 핑만 오는 자리에서 받은 것을 버리고 던진다. 모으는 중인
1553
+ * 조각(acc._raw)도 받은 것으로 센다 — 묶는 일은 꼬리의 도구마무리가 한다.
1554
+ *
1555
+ * 거절 글(acc.거절글)도 받은 것이다. 이 규격은 거절을 content 가 아니라 refusal
1556
+ * 로 흘려보내므로 앞의 넷에 안 담긴다 — 거절만 받고 멎으면 그 글을 통째로 버리고
1557
+ * 「내용이 안 왔습니다」 로 던졌다. 사람은 **왜** 거절당했는지 한 글자도 못 보고
1558
+ * 같은 말을 또 친다. 판정은 같으니 또 거절이고 값만 두 배가 된다. 잠잠한지 세는
1559
+ * 자(위 자란만큼)는 진작 이것을 세고 있었다 — 둘이 같은 것을 봐야 한다.
1560
+ */
1561
+ const 받은것있나 = () => !!(acc.content || acc.thinking || acc.toolCalls.length
1562
+ || acc.거절글?.trim() || acc._raw?.some(Boolean));
1563
+
1564
+ /*
1565
+ * ── SSE 를 **규격대로** 읽는다 ──────────────────────────────────────────
1566
+ *
1567
+ * 여태 「\n 으로 자르고 data: 를 떼서 JSON 한 줄로 읽기」 였다. 흔한 서버에는
1568
+ * 그걸로 됐는데, 규격(WHATWG SSE)이 허락하는 두 모양에서 답이 통째로 비었다.
1569
+ *
1570
+ * · 줄 끝이 \r 하나뿐인 서버. \n 을 영영 못 만나 한 줄도 못 읽는다.
1571
+ * · 한 사건의 data 를 **여러 줄**에 나눠 싣는 서버. 규격은 그 줄들을 \n 으로
1572
+ * 이어 한 덩이로 읽으라고 한다. 반쪽씩 읽으면 둘 다 JSON 이 아니다.
1573
+ *
1574
+ * 빈 답은 이 파일에서 제일 비싼 고장이다 — 루프가 흘려받기를 세션 내내 끈다.
1575
+ *
1576
+ * 다만 규격만 따르면 **여태 읽던 것을 못 읽는다.** 빈 줄 없이 data 줄마다 JSON
1577
+ * 을 하나씩 싣는 서버가 있고, Ollama 는 data: 도 없는 줄바꿈 JSON 이다. 그래서
1578
+ * data 줄이 올 때마다 **여태 모인 것이 JSON 으로 다 찼나** 를 보고, 찼으면 빈
1579
+ * 줄을 안 기다리고 읽는다. 반쪽은 JSON 으로 안 읽히니 다음 줄을 기다린다.
1580
+ * 이름 없는 줄(맨 JSON)은 여태처럼 그 줄 하나를 읽는다.
1581
+ *
1582
+ * 사건 이름(event:)은 기억해 둔다. `event: error` 로 오는 실패를 알아보려면
1583
+ * 있어야 한다 (위 몸속오류).
1584
+ */
1585
+ let 사건이름 = null;
1586
+ let 자료 = null;
1587
+ let 받은오류 = null;
1588
+
1589
+ const 읽어보기 = (글) => {
1590
+ try { return JSON.parse(글); } catch { return undefined; }
1591
+ };
1592
+ /*
1593
+ * data 줄 여럿을 한 덩이로. 규격대로 \n 으로 먼저 잇는다.
1594
+ *
1595
+ * 그게 안 읽히면 **사이 없이** 한 번 더 이어 본다. 긴 줄을 글자 수로 끊어
1596
+ * data 줄 여럿에 싣는 중계기가 있는데, 그 끊는 자리가 문자열 가운데면 \n 이
1597
+ * 문자열 안에 들어가 JSON 이 아니게 된다. 두 잇기의 차이는 끊은 자리의 \n
1598
+ * 하나뿐이라, 사이 없이 읽혔다면 그 뜻은 하나밖에 없다.
1599
+ */
1600
+ const 이어읽기 = (줄들) => {
1601
+ const 규격대로 = 읽어보기(줄들.join('\n').trim());
1602
+ if (규격대로 !== undefined || 줄들.length < 2) return 규격대로;
1603
+ return 읽어보기(줄들.join('').trim());
1604
+ };
1605
+ // JSON 한 덩이를 받았다. 실패면 적어 두고 멈추고, 아니면 acc 에 쌓는다.
1606
+ const 소화 = function* (obj) {
1607
+ if (!obj || typeof obj !== 'object') return;
1608
+ const 오류 = 몸속오류(obj, 사건이름);
1609
+ if (오류) { 받은오류 = 오류; return; }
1343
1610
  for (const ev of absorb(conn.kind, obj, acc)) yield ev;
1344
1611
  };
1345
1612
 
1613
+ const 한줄 = function* (원래줄) {
1614
+ const line = 원래줄.trim();
1615
+ if (!line) {
1616
+ // 빈 줄 = 사건 하나가 끝났다. 모아 둔 data 가 남아 있으면 마저 읽는다.
1617
+ if (자료 !== null) {
1618
+ const 모인것 = 자료.join('\n').trim();
1619
+ const obj = 이어읽기(자료);
1620
+ 자료 = null;
1621
+ if (obj !== undefined) yield* 소화(obj);
1622
+ // 이름이 error 인 사건은 몸이 JSON 이 아니어도 실패다. 그 글을 그대로 말로 쓴다.
1623
+ else if (사건이름 === 'error' && 모인것 && 모인것 !== '[DONE]') 받은오류 = 몸속오류({ error: 모인것 });
1624
+ }
1625
+ 사건이름 = null;
1626
+ return;
1627
+ }
1628
+ if (line.startsWith(':')) return; // 주석 줄 — keep-alive
1629
+ const 칸 = /^(data|event|id|retry)(?::|$)/.exec(line);
1630
+ if (!칸) {
1631
+ // 이름 없는 줄 — 줄바꿈 JSON(Ollama)이나 data: 를 안 붙이는 서버. 여태 읽던 그대로.
1632
+ const obj = 읽어보기(line);
1633
+ if (obj !== undefined) yield* 소화(obj);
1634
+ return;
1635
+ }
1636
+ const 값 = line.slice(칸[0].length).replace(/^ /, '');
1637
+ if (칸[1] === 'event') { 사건이름 = 값.trim(); return; }
1638
+ if (칸[1] !== 'data') return;
1639
+ (자료 ??= []).push(값);
1640
+ if (자료.join('\n').trim() === '[DONE]') { 자료 = null; return; }
1641
+ let obj = 이어읽기(자료);
1642
+ // 앞에 모인 것이 JSON 이 아닌 찌꺼기면(`data: keep-alive` 따위) 이 줄 하나만 본다.
1643
+ // 반쪽 JSON 의 뒷반쪽은 혼자서는 절대 JSON 이 안 되므로 이어 읽기를 해치지 않는다.
1644
+ if (obj === undefined && 자료.length > 1) obj = 읽어보기(값.trim());
1645
+ if (obj === undefined) return; // 아직 덜 찼다 — 다음 data 줄이나 빈 줄을 기다린다
1646
+ 자료 = null;
1647
+ yield* 소화(obj);
1648
+ };
1649
+ const 줄끝 = /\r\n|\r|\n/g;
1650
+
1346
1651
  while (true) {
1347
1652
  // 사용자가 끊었으면 흘러오는 것을 더 받지 않는다. 읽던 연결도 닫는다.
1348
1653
  if (opts.signal?.aborted) {
@@ -1365,7 +1670,7 @@ export async function* chatStream(conn, opts) {
1365
1670
  * 다만 한 글자도 못 받았으면 그건 그냥 실패다. 빈 답을 '답' 이라고
1366
1671
  * 부르는 것이 이 파일에서 제일 하면 안 되는 일이다.
1367
1672
  */
1368
- if (err?.code === 'STALL' && (acc.content || acc.thinking || acc.toolCalls.length)) {
1673
+ if (err?.code === 'STALL' && 받은것있나()) {
1369
1674
  acc.stopped = 흐름멎음;
1370
1675
  acc.멎은초 = 초로(err.잠잠 ?? 잠잠기본);
1371
1676
  break;
@@ -1375,21 +1680,35 @@ export async function* chatStream(conn, opts) {
1375
1680
  // 끝났으면 decoder 에 걸쳐 있던 마지막 글자까지 뱉게 한다(flush).
1376
1681
  buf += done ? dec.decode() : dec.decode(value, { stream: true });
1377
1682
 
1378
- // OpenAI 는 SSE(data: ...), Ollama 는 줄바꿈 JSON. 둘 다 줄 단위로 처리된다.
1379
- let nl;
1380
- while ((nl = buf.indexOf('\n')) >= 0) {
1381
- const line = buf.slice(0, nl).trim();
1382
- buf = buf.slice(nl + 1);
1683
+ /*
1684
+ * OpenAI·Anthropic 은 SSE, Ollama 는 줄바꿈 JSON. 둘 다 줄 단위로 처리된다.
1685
+ * 줄 끝은 규격대로 \r\n · \n · \r 셋을 다 받는다(위 한줄 머리말).
1686
+ *
1687
+ * 조각 맨 끝의 \r 은 아직 줄 끝으로 안 친다 — 다음 조각이 \n 으로 시작하면
1688
+ * 둘이 한 줄 끝이다. 먼저 끊으면 없는 빈 줄이 하나 생겨 사건이 반으로 갈린다.
1689
+ * 읽은 자리는 한 번에 떼어 낸다. 줄마다 buf 를 새로 만들면 큰 조각에서 느리다.
1690
+ */
1691
+ let 읽은자리 = 0;
1692
+ for (;;) {
1693
+ 줄끝.lastIndex = 읽은자리;
1694
+ const 맞은것 = 줄끝.exec(buf);
1695
+ if (!맞은것) break;
1696
+ if (맞은것[0] === '\r' && 맞은것.index === buf.length - 1 && !done) break;
1697
+ const line = buf.slice(읽은자리, 맞은것.index);
1698
+ 읽은자리 = 맞은것.index + 맞은것[0].length;
1383
1699
  yield* 한줄(line);
1700
+ if (받은오류) break;
1384
1701
  }
1702
+ buf = buf.slice(읽은자리);
1385
1703
 
1386
- if (done) {
1387
- // 개행 없이 끝난 마지막 한 줄. 있으면 같은 길로 읽고 끝낸다.
1388
- const 꼬리 = buf.trim();
1704
+ if (done && !받은오류) {
1705
+ // 개행 없이 끝난 마지막 한 줄, 그리고 빈 줄 없이 끝난 마지막 사건. 같은 길로 읽고 끝낸다.
1706
+ const 꼬리 = buf;
1389
1707
  buf = '';
1390
1708
  yield* 한줄(꼬리);
1391
- break;
1709
+ if (!받은오류) yield* 한줄('');
1392
1710
  }
1711
+ if (done || 받은오류) break;
1393
1712
 
1394
1713
  /*
1395
1714
  * 이번 조각으로 acc 가 자랐나. 자랐으면 시계를 되감고, 안 자랐으면 얼마나
@@ -1405,6 +1724,19 @@ export async function* chatStream(conn, opts) {
1405
1724
  알렸나 = false;
1406
1725
  } else {
1407
1726
  const 흐른 = Date.now() - 마지막소식;
1727
+ /*
1728
+ * 알림이 **먼저**다. 위 머리말이 그렇게 약속해 놓고 코드는 끊기부터 봤다.
1729
+ *
1730
+ * 두 시계 사이에 조각이 하나도 안 들어오면(1초 상한에 1.6초 만에 온 핑 하나)
1731
+ * 한 조각이 두 시계를 한꺼번에 넘긴다. 끊기를 먼저 보면 그대로 잘리고 알림은
1732
+ * **한 번도 못 나온다** — 「원래 이런 게이트웨이」 를 우리가 죽이면서 그 사실을
1733
+ * 아무도 못 보는 자리가 이것이다. 핑이 촘촘한 흐름에서는 알림 시계만 먼저
1734
+ * 넘으니 차례가 어긋나도 티가 안 난다.
1735
+ */
1736
+ if (!알렸나 && 흐른 >= 무소식알림) {
1737
+ 알렸나 = true;
1738
+ yield { type: '소식없음', 초: 초로(흐른), 상한초: 무소식 > 0 ? 초로(무소식) : null };
1739
+ }
1408
1740
  if (무소식 > 0 && 흐른 >= 무소식) {
1409
1741
  /*
1410
1742
  * 끊을 때는 **소켓도 같이 닫는다.**
@@ -1423,19 +1755,24 @@ export async function* chatStream(conn, opts) {
1423
1755
  *
1424
1756
  * 한 글자도 못 받았으면 그건 그냥 실패다. 빈 답을 답이라고 부르지 않는다.
1425
1757
  */
1426
- if (acc.content || acc.thinking || acc.toolCalls.length) {
1758
+ if (받은것있나()) {
1427
1759
  acc.stopped = 흐름멎음;
1428
1760
  acc.멎은초 = 초로(흐른);
1429
1761
  break;
1430
1762
  }
1431
1763
  throw 소식없음오류(무소식);
1432
1764
  }
1433
- if (!알렸나 && 흐른 >= 무소식알림) {
1434
- 알렸나 = true;
1435
- yield { type: '소식없음', 초: 초로(흐른), 상한초: 무소식 > 0 ? 초로(무소식) : null };
1436
- }
1437
1765
  }
1438
1766
  }
1767
+ if (받은오류) {
1768
+ // 몸 안에서 실패가 왔다. 남은 흐름은 안 읽는다 — 연결도 닫는다(안 닫으면 남는다).
1769
+ try { await reader.cancel(); } catch { /* 이미 닫혔으면 그만 */ }
1770
+ return {
1771
+ 오류: { ...받은오류, headers: r.headers ?? r.res?.headers ?? null, ms: r.ms },
1772
+ // 화면에 한 글자라도 나갔나. 나갔으면 다시 부르지 않는다 (chatStream).
1773
+ 흘렸나: !!(acc.content || acc.thinking),
1774
+ };
1775
+ }
1439
1776
  /*
1440
1777
  * ── 끝을 안 알려 주고 끊긴 것은 '끝난 것' 이 아니다 ──────────────────────
1441
1778
  *
@@ -1454,22 +1791,33 @@ export async function* chatStream(conn, opts) {
1454
1791
  * 사실대로 말할 수 있게 한다. 아무것도 안 온 경우는 여기서 안 다룬다 —
1455
1792
  * 그건 '빈 답' 쪽이 받는다.
1456
1793
  */
1457
- if (acc.stopped == null && (acc.content || acc.thinking || acc.toolCalls.length)) {
1458
- acc.stopped = 말없이끝남;
1459
- }
1794
+ /*
1795
+ * 도구 인자는 **끝났다는 사건에서만** 묶는다(absorb 의 content_block_stop·message_stop).
1796
+ * 그 둘이 안 오고 끊기면 모아 둔 조각이 부름으로 안 묶여, 모델이 부른 도구가 통째로
1797
+ * 사라졌다 — 글만 남고 턴이 「다 했다」 로 끝난다. 끊겼어도 묶는다. 인자가 반쪽이면
1798
+ * 도구마무리가 깨졌다고 표시하고, 루프가 그 까닭을 모델에게 준다.
1799
+ */
1800
+ if (acc._raw) 도구마무리(acc);
1460
1801
  /*
1461
1802
  * 거절 글밖에 안 온 경우, 그 글을 답 자리에 놓는다.
1462
1803
  *
1463
1804
  * 이 규격은 거절을 `content` 가 아니라 `refusal` 로 흘려보낸다. 그대로 두면
1464
1805
  * 화면에는 **아무 글도 안 나오고**, 사람은 답이 비었다고 읽고 같은 말을 또
1465
1806
  * 친다. 판정은 같으니 또 거절이고, 값만 두 배가 된다.
1807
+ *
1808
+ * 이 구제가 바로 아래 「왜 끝났나」 보다 **먼저**여야 한다. 뒤에 두면 그 자리에서는
1809
+ * 답이 아직 비어 있어서, 끝 사건 없이 잘린 거절 답이 `stopped=null`(= 멀쩡히 끝남)로
1810
+ * 나갔다 — 중계 프록시가 몸통을 자른 것과 모델이 할 말을 다 한 것이 화면에서 같아진다.
1466
1811
  */
1467
1812
  if (!acc.content && acc.거절글?.trim()) acc.content = acc.거절글.trim();
1468
1813
  delete acc.거절글;
1814
+ if (acc.stopped == null && (acc.content || acc.thinking || acc.toolCalls.length)) {
1815
+ acc.stopped = 말없이끝남;
1816
+ }
1469
1817
  // 흘려 받는 자리에서도 **어느 자리가 받았는지**를 같이 올린다 (위 간자리).
1470
1818
  const 자리 = 간자리(r.headers ?? r.res?.headers);
1471
1819
  if (자리) acc.간자리 = 자리;
1472
- yield { type: 'done', message: 이름되돌리기(acc, 맞춘것.되돌림) };
1820
+ return { acc };
1473
1821
  }
1474
1822
 
1475
1823
  /** 서버가 끝난 까닭을 안 주고 흘려보내기를 멈춘 것. 'stop' 과 구별해야 한다. */
@@ -1494,15 +1842,27 @@ function absorb(shape, obj, acc) {
1494
1842
  const m = obj.message ?? {};
1495
1843
  if (m.thinking) { acc.thinking += m.thinking; out.push({ type: 'thinking', text: m.thinking }); }
1496
1844
  if (m.content) { acc.content += m.content; out.push({ type: 'content', text: m.content }); }
1497
- if (m.tool_calls?.length) acc.toolCalls.push(...normalizeCalls(m.tool_calls));
1845
+ if (m.tool_calls?.length) {
1846
+ /*
1847
+ * 이 규격은 부름에 id 를 안 준다. normalizeCalls 는 **받은 묶음 안에서** 번호를
1848
+ * 매기니, 줄마다 하나씩 온 부름이 모두 call_1 이 됐다 — id 로 짝을 맞추는
1849
+ * 자리에서 결과가 엉뚱한 부름에 붙는다. 여태 모인 수에서 이어 센다.
1850
+ * 서버가 id 를 줬으면 그대로 둔다.
1851
+ */
1852
+ const 앞 = acc.toolCalls.length;
1853
+ acc.toolCalls.push(...normalizeCalls(m.tool_calls)
1854
+ .map((c, i) => (m.tool_calls[i]?.id ? c : { ...c, id: `call_${앞 + i + 1}` })));
1855
+ }
1498
1856
  if (obj.done) {
1499
- acc.usage = { 잰것: true, in: obj.prompt_eval_count ?? 0, out: obj.eval_count ?? 0 };
1857
+ acc.usage = 잰사용량(obj.prompt_eval_count, obj.eval_count);
1500
1858
  acc.stopped = obj.done_reason ?? 'stop';
1501
1859
  }
1502
1860
  return out;
1503
1861
  }
1504
1862
  const d = obj.choices?.[0]?.delta ?? {};
1505
- if (d.reasoning_content) { acc.thinking += d.reasoning_content; out.push({ type: 'thinking', text: d.reasoning_content }); }
1863
+ // 생각 글 칸 이름이 창구마다 다르다 (위 생각칸). 하나만 골라 붙인다.
1864
+ const 생각 = 생각칸(d);
1865
+ if (생각) { acc.thinking += 생각; out.push({ type: 'thinking', text: 생각 }); }
1506
1866
  if (d.content) { acc.content += d.content; out.push({ type: 'content', text: d.content }); }
1507
1867
  if (d.tool_calls?.length) mergeDeltaCalls(acc, d.tool_calls);
1508
1868
  /*
@@ -1513,13 +1873,12 @@ function absorb(shape, obj, acc) {
1513
1873
  if (obj.usage) {
1514
1874
  const c = 캐시읽기(obj.usage);
1515
1875
  acc.usage = {
1516
- 잰것: true,
1517
- in: obj.usage.prompt_tokens ?? 0,
1518
- out: obj.usage.completion_tokens ?? 0,
1876
+ // 글자로 온 숫자도 숫자로 읽는다 — 까닭은 토큰수 머리말.
1877
+ ...잰사용량(obj.usage.prompt_tokens, obj.usage.completion_tokens),
1519
1878
  // 한 번에 받는 길과 같은 자리를 본다. 여기만 빠지면 흘려받을 때
1520
1879
  // 생각 토큰이 늘 0 으로 보이는데, 그건 「생각을 안 했다」 로 읽힌다.
1521
- reasoning: obj.usage.completion_tokens_details?.reasoning_tokens
1522
- ?? obj.usage.reasoning_tokens ?? 0,
1880
+ reasoning: 토큰수(obj.usage.completion_tokens_details?.reasoning_tokens
1881
+ ?? obj.usage.reasoning_tokens) ?? 0,
1523
1882
  cacheRead: c.읽음,
1524
1883
  cacheWrite: c.씀,
1525
1884
  };
@@ -1529,6 +1888,10 @@ function absorb(shape, obj, acc) {
1529
1888
  // 흘려받는 길도 거절을 알아본다. 여기가 빠지면 흘려받기를 켠 사람에게만
1530
1889
  // 예전 그대로 되밀기가 남는다 — 그게 기본값이라 사실상 아무도 안 고쳐진다.
1531
1890
  if (acc.거절글?.trim()) acc.거절 = { type: 'refusal', message: acc.거절글.trim() };
1891
+ // 글 없이 사유만 주는 창구가 있다. Anthropic 갈래는 stop_reason 하나로 거절을 세우는데
1892
+ // (anthropic흡수) 이쪽만 글이 있을 때 세워서, 그런 창구에서는 거절이 「빈 답」 이 됐다 —
1893
+ // 루프는 되밀고, 같은 판정에 같은 요금이 한 번 더 나간다.
1894
+ else if (fin === 'refusal') acc.거절 = { type: 'refusal' };
1532
1895
  else if (fin === 'content_filter') acc.거절 = { type: 'content_filter' };
1533
1896
  return out;
1534
1897
  }
@@ -1564,7 +1927,7 @@ function anthropic흡수(obj, acc, out) {
1564
1927
  * 그걸 「살아 있다」 의 한 표로 세는데, Anthropic 흘려받기에서는 usage 가
1565
1928
  * 제일 먼저 오는 소식이라 하필 그 한 표를 못 셌다.
1566
1929
  */
1567
- acc.usage = { 잰것: true, in: u.input_tokens ?? 0, out: u.output_tokens ?? 0, cacheRead: c.읽음, cacheWrite: c.씀 };
1930
+ acc.usage = { 잰것: true, in: 토큰수(u.input_tokens) ?? 0, out: 토큰수(u.output_tokens) ?? 0, cacheRead: c.읽음, cacheWrite: c.씀 };
1568
1931
  }
1569
1932
  return out;
1570
1933
  }
@@ -1573,6 +1936,16 @@ function anthropic흡수(obj, acc, out) {
1573
1936
  if (b.type === 'tool_use') {
1574
1937
  acc._raw ??= [];
1575
1938
  acc._raw[번호] = { id: b.id, name: b.name ?? '', args: '' };
1939
+ /*
1940
+ * 규격대로면 여기 input 은 늘 `{}` 이고 알맹이는 input_json_delta 로 온다.
1941
+ * 그런데 한 번에 받은 답을 흘려받기 꼴로 옮겨 주는 게이트웨이는 인자를 여기
1942
+ * **통째로** 싣고 delta 를 안 보낸다. 버리면 도구가 빈 인자로 불리고 「경로가
1943
+ * 비었습니다」 가 뜬다 — 원인과 상관없는 말이다. delta 가 한 조각도 안 왔을
1944
+ * 때만 쓴다 (도구마무리).
1945
+ */
1946
+ if (b.input && typeof b.input === 'object' && !Array.isArray(b.input) && Object.keys(b.input).length) {
1947
+ acc._raw[번호].처음인자 = b.input;
1948
+ }
1576
1949
  } else if (b.type === 'thinking') {
1577
1950
  /*
1578
1951
  * 생각 블록이 열렸다. 여기서는 속이 비어 있고(`thinking:''`,
@@ -1626,8 +1999,9 @@ function anthropic흡수(obj, acc, out) {
1626
1999
  * 왔을 때만 덮는다 — 없는 것을 0 으로 덮으면 message_start 에서 받아 둔
1627
2000
  * 진짜 값이 지워진다.
1628
2001
  */
1629
- if (obj.usage?.output_tokens != null) acc.usage.out = obj.usage.output_tokens;
1630
- if (obj.usage?.input_tokens != null) acc.usage.in = obj.usage.input_tokens;
2002
+ // 못 읽는 값(글자가 아닌 것 · NaN)은 받아 둔 값을 덮지 않는다 — 토큰수 머리말.
2003
+ if (토큰수(obj.usage?.output_tokens) != null) acc.usage.out = 토큰수(obj.usage.output_tokens);
2004
+ if (토큰수(obj.usage?.input_tokens) != null) acc.usage.in = 토큰수(obj.usage.input_tokens);
1631
2005
  // 캐시 수치는 대개 message_start 에 실리지만, 여기 싣는 판도 있다.
1632
2006
  const c = 캐시읽기(obj.usage);
1633
2007
  if (c.읽음) acc.usage.cacheRead = c.읽음;
@@ -1640,17 +2014,64 @@ function anthropic흡수(obj, acc, out) {
1640
2014
  return out;
1641
2015
  }
1642
2016
 
1643
- // OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 인덱스별로 이어 붙인다.
2017
+ /*
2018
+ * OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 번호별로 이어 붙인다.
2019
+ *
2020
+ * ── 여기서는 **모으기만** 한다 ───────────────────────────────────────────
2021
+ *
2022
+ * 여태 조각이 올 때마다 도구마무리를 불렀다. 그 자는 **여태 모인 인자 전체**를
2023
+ * 다듬고 JSON.parse 한다 — 조각이 n 개면 n² 만큼 읽는 셈이다. 80KB 인자를 네
2024
+ * 글자씩 받으면 1.6초, 200KB 면 8초 동안 이벤트 루프가 막혔다. 그동안 Ctrl+C 도
2025
+ * 화면도 멈춘다. Anthropic 갈래는 처음부터 끝 사건에서만 묶었다. 이제 둘이 같다 —
2026
+ * 흐름이 끝나면 흘려읽기 꼬리의 `if (acc._raw) 도구마무리(acc)` 가 한 번 묶는다.
2027
+ * 끝 사건 없이 끊긴 것도 그 줄이 받는다.
2028
+ *
2029
+ * ── 창구마다 조각 버릇이 다르다 ─────────────────────────────────────────
2030
+ *
2031
+ * index 가 없는 창구 (Gemini 의 OpenAI 호환 꼴)
2032
+ * 여태 `index ?? 0` 이라 부름 둘이 한 칸에 섞였다 — 이름은 `ReadGrep`, 인자는
2033
+ * `{"p":"a"}{"q":"b"}` 로 깨졌다. index 가 없으면 **id** 로 가른다. 처음 보는
2034
+ * id 면 새 칸이고, id 도 없으면 방금 것의 이음 조각이다.
2035
+ * 이름을 조각마다 되풀이하는 창구
2036
+ * `Read` 가 조각 수만큼 붙어 `ReadRead` 가 됐다. 여태 모인 이름과 **똑같은**
2037
+ * 조각은 되풀이로 본다. 진짜로 쪼개 보내는 창구(`Re` · `ad`)는 조각이 모인
2038
+ * 이름과 같을 일이 없어서 그대로 이어진다.
2039
+ * 인자를 글이 아니라 객체로 싣는 창구
2040
+ * 글에 객체를 더하면 `[object Object]` 가 되어 깨진 부름이 됐다. 한 번에 받는
2041
+ * 길(normalizeCalls)은 객체를 그대로 받는다. 두 길이 같은 것을 같게 읽는다.
2042
+ */
1644
2043
  function mergeDeltaCalls(acc, deltas) {
1645
2044
  acc._raw ??= [];
1646
2045
  for (const d of deltas) {
1647
- const i = d.index ?? 0;
2046
+ const i = 도구칸번호(acc._raw, d);
1648
2047
  acc._raw[i] ??= { id: d.id, name: '', args: '' };
1649
- if (d.id) acc._raw[i].id = d.id;
1650
- if (d.function?.name) acc._raw[i].name += d.function.name;
1651
- if (d.function?.arguments) acc._raw[i].args += d.function.arguments;
2048
+ const 칸 = acc._raw[i];
2049
+ if (d.id) 칸.id = d.id;
2050
+ const 이름 = d.function?.name;
2051
+ const 인자 = d.function?.arguments;
2052
+ /*
2053
+ * 되풀이하는 창구는 이름을 **인자 조각에 얹어** 보낸다. 그러니 되풀이로 보려면
2054
+ * 그 조각에 인자도 있어야 한다. 「모인 이름과 같으면 되풀이」 로만 가르면
2055
+ * `s`·`s`·`h` 의 둘째 `s` 가 버려져 `ssh` 가 `sh` 가 된다 — 없는 도구
2056
+ * 이름이라 그 부름은 통째로 죽고, 화면에는 모델이 엉뚱한 도구를 불렀다고 나온다.
2057
+ */
2058
+ if (이름 && !(인자 !== undefined && 칸.name === 이름)) 칸.name += 이름;
2059
+ if (typeof 인자 === 'string') {
2060
+ if (인자) 칸.args = typeof 칸.args === 'string' ? 칸.args + 인자 : 인자;
2061
+ } else if (인자 && typeof 인자 === 'object') {
2062
+ 칸.args = 인자;
2063
+ }
2064
+ }
2065
+ }
2066
+
2067
+ /** 이 조각이 몇 번째 부름의 것인가 — index 가 없으면 id 로, id 도 없으면 방금 것. */
2068
+ function 도구칸번호(모음, d) {
2069
+ if (d.index != null) return d.index;
2070
+ if (d.id) {
2071
+ const 있던 = 모음.findIndex((c) => c?.id === d.id);
2072
+ return 있던 >= 0 ? 있던 : 모음.length;
1652
2073
  }
1653
- 도구마무리(acc);
2074
+ return Math.max(0, 모음.length - 1);
1654
2075
  }
1655
2076
 
1656
2077
  // 글자로 쪼개져 온 도구 인자를 하나로 읽는다. 두 규격이 같이 쓴다.
@@ -1659,7 +2080,7 @@ function 도구마무리(acc) {
1659
2080
  // 인자가 안 읽히면 읽혔다고 치지 않는다 — normalizeCalls 머리말 참고.
1660
2081
  // 스트리밍은 마지막 조각이 안 오면 여기서 늘 깨진 채로 끝난다.
1661
2082
  acc.toolCalls = acc._raw.filter(Boolean).map((c, i) => {
1662
- const call = { id: c.id ?? `call_${i + 1}`, name: c.name, args: {} };
2083
+ const call = { id: c.id ?? `call_${i + 1}`, name: 이름글(c.name), args: {} };
1663
2084
  /*
1664
2085
  * 빈 것은 깨진 것이 아니다 — 인자가 아예 없는 도구가 있다.
1665
2086
  *
@@ -1670,9 +2091,11 @@ function 도구마무리(acc) {
1670
2091
  * 빈 것으로 봐야 한다.
1671
2092
  */
1672
2093
  const 원문 = typeof c.args === 'string' ? c.args.trim() : c.args;
1673
- if (!원문) return call;
2094
+ if (!원문) return c.처음인자 ? { ...call, args: c.처음인자 } : call;
2095
+ // 객체로 온 인자는 이미 읽힌 것이다 (mergeDeltaCalls 머리말). 글로 바꿔 다시 읽으면 깨진다.
2096
+ if (typeof 원문 !== 'string') { call.args = 원문; return call; }
1674
2097
  try { call.args = JSON.parse(원문); }
1675
- catch { call.argsBroken = true; call.rawArgs = c.args; }
2098
+ catch { call.argsBroken = true; call.rawArgs = c.args; call.argsCut = 잘린모양인가(원문); }
1676
2099
  return call;
1677
2100
  });
1678
2101
  }