deel-local-cli 2.1.1 → 2.1.3

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.
package/src/agent/loop.js CHANGED
@@ -8,6 +8,8 @@ import { toolSchemas, runTool, TOOLS, 파일현황, 바뀔내용 } from '../tool
8
8
  import { isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
9
9
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel, 자동강도, 천장고르기, 인사인가 as 인사말인가 } from './effort.js';
10
10
  import { 배울전선, 카드고치기, 카드저장꼴, 전선붙이기 } from '../backend/wire.js';
11
+ import { 읽기만하나 } from '../backend/mcp.js';
12
+ import { 글속부름 } from '../backend/textcalls.js';
11
13
  import { 살린쓰기 } from './salvage.js';
12
14
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
13
15
  import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것, 접힌파일열쇠 } from './compact.js';
@@ -1083,6 +1085,11 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1083
1085
  });
1084
1086
 
1085
1087
  let msg;
1088
+ /*
1089
+ * 생성 속도를 잰다 (2.1.3 · session.js 의 생성속도). 흘려받는 부름만 — 첫 글자(생각 포함)부터 끝까지.
1090
+ * 요청을 보낸 때부터 재면 앞머리 읽기(prefill)가 섞여, 대화가 길수록 모델이 느려진 것처럼 보인다.
1091
+ */
1092
+ let 생성잰것 = null;
1086
1093
  // 흘러온 글을 우리도 모아 둔다. 중간에 끊기면 이것만이 남는 전부다 —
1087
1094
  // chatStream 은 끝까지 가야 message 를 주므로, 끊긴 순간에는 msg 가 비어 있다.
1088
1095
  let 흘린것 = '';
@@ -1095,12 +1102,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1095
1102
  const 셈하기 = () => { session.usage.retries = (session.usage.retries ?? 0) + 미룬셈; 미룬셈 = 0; };
1096
1103
  const 끝셈 = (err) => { if (err?.name === 'Aborted') 미룬셈 = 0; else 셈하기(); };
1097
1104
  const askModel = async function* (maxTokens, think, { 한번에 = false } = {}) {
1105
+ 생성잰것 = null;
1098
1106
  if (conn.streaming && !한번에) {
1107
+ let 첫글때 = 0;
1099
1108
  try {
1100
1109
  for await (const ev of chatStream(conn, ask(maxTokens, think))) {
1101
1110
  셈하기(); // 무슨 소식이든 왔다는 것은 앞의 다시 부름이 실제로 있었다는 뜻
1102
- if (ev.type === 'done') msg = ev.message;
1103
- else {
1111
+ if (ev.type === 'done') {
1112
+ msg = ev.message;
1113
+ if (첫글때) 생성잰것 = { ms: Date.now() - 첫글때 };
1114
+ } else {
1115
+ if (!첫글때 && (ev.type === 'content' || ev.type === 'thinking') && ev.text) 첫글때 = Date.now();
1104
1116
  if (ev.type === 'content') 흘린것 += ev.text ?? '';
1105
1117
  // 맞기 전에 비켜 준 것은 **다시 부른 것이 아니다.** 요청은 한 번만
1106
1118
  // 나갔다. 이걸 같이 세면 `/cost` 의 「다시 부른 횟수」 와 `--json` 의
@@ -1565,6 +1577,11 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1565
1577
  if (msg.usage?.잰것 === false) session.usage.못잰것 = (session.usage.못잰것 ?? 0) + 1;
1566
1578
  session.usage.in += msg.usage?.in ?? 0;
1567
1579
  session.usage.out += msg.usage?.out ?? 0;
1580
+ // 한 번에 받았거나 글이 하나도 안 흘렀으면 안 센다 — 느린 값을 지어내지 않는다 (위 생성잰것).
1581
+ if (생성잰것?.ms > 0 && (msg.usage?.out ?? 0) > 0) {
1582
+ session.usage.genMs = (session.usage.genMs ?? 0) + 생성잰것.ms;
1583
+ session.usage.genOut = (session.usage.genOut ?? 0) + msg.usage.out;
1584
+ }
1568
1585
  // 캐시가 얼마나 맞았나. 읽기와 쓰기를 따로 센다 — 「매번 쓰기만 하고 한
1569
1586
  // 번도 못 읽는」 것과 「잘 읽고 있는」 것은 완전히 다른 상태다.
1570
1587
  session.usage.cacheRead = (session.usage.cacheRead ?? 0) + (msg.usage?.cacheRead ?? 0);
@@ -1595,6 +1612,24 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1595
1612
  // 배운 배수를 디스크에도 남긴다. 다음에 켤 때 이 값으로 시작한다.
1596
1613
  if (새보정) ctx.배움?.보정본것(conn.model, 새보정);
1597
1614
 
1615
+ /*
1616
+ * ── 글로 적은 도구 부름을 건진다 (2.1.3 · backend/textcalls.js) ──────────
1617
+ *
1618
+ * 서버가 모델의 부름 꼴을 모르면 `tool_calls` 는 비어 오고 답 글에 `<tool_call>{…}` 이 실린다.
1619
+ * 여태는 「말로만 답했다」 로 읽고 턴을 끝냈다. 이번 걸음에 **내준 도구 이름**으로 적힌 것만
1620
+ * 건져 진짜 부름으로 바꾼다 — 이력에도 제 꼴(tool_calls · tool 결과)로 실려, 다음 요청은
1621
+ * 네이티브 부름과 똑같이 나간다. 건진 부름도 아래 관문(모드 · 승인 · 정책 바닥)을 그대로 지난다.
1622
+ * 거절 답은 안 건진다 — 안 하겠다는 말 속 예시를 부르면 안 된다.
1623
+ */
1624
+ if (!msg.toolCalls?.length && !msg.거절 && typeof msg.content === 'string' && tools.length) {
1625
+ const 건짐 = 글속부름(msg.content, tools.map((t) => t.function.name));
1626
+ if (건짐) {
1627
+ msg.toolCalls = 건짐.부름.map((x, i) => ({ id: `text_${steps}_${i + 1}`, name: x.name, args: x.args }));
1628
+ msg.content = 건짐.남은글;
1629
+ yield { type: 'textcalls', names: msg.toolCalls.map((x) => x.name), count: msg.toolCalls.length };
1630
+ }
1631
+ }
1632
+
1598
1633
  session.push(assistantMessage(conn.kind, msg));
1599
1634
 
1600
1635
  /*
@@ -2052,12 +2087,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
2052
2087
  // 친다 — 읽을 때마다 물으면 사람은 y 만 치다가 정작 물어야 할 것도 그렇게 넘긴다.
2053
2088
  // 끄는가는 **도구가 읽는 자**(일감인자)로 가른다. `stop` 칸만 보면 작은 모델이 보내는
2054
2089
  // `kill` · `끝내기` 가 안 묻고 서버를 끄고, `stop:"false"` 는 `!!` 로 참이 되어 헛물음이 난다.
2090
+ const 남의도구 = call.name.startsWith('mcp__');
2055
2091
  const 바꾸는것 = (call.name === 'Jobs' ? 일감인자(call.args).끝내기 === true : 바꾸는도구.includes(call.name))
2056
- || call.name.startsWith('mcp__');
2092
+ || 남의도구;
2093
+ // confirm 은 「되돌릴 수 없는 것만」 묻는다. 남의 프로그램이 한 일은 /undo 가 못 되돌리므로
2094
+ // MCP 도구도 묻는다 — 서버가 읽기만 한다고 적은 것만 빼고 (backend/mcp.js 의 읽기만하나).
2057
2095
  const needsOk = (판정.답 === 'allow' && !정책이묻게함) ? false : session.mode === 'strict'
2058
2096
  ? 바꾸는것
2059
2097
  : session.mode === 'confirm'
2060
- ? (call.name === 'Bash' && isMutating(call.args?.command))
2098
+ ? (call.name === 'Bash' && isMutating(call.args?.command)) || (남의도구 && !읽기만하나(ctx.mcp, call.name))
2061
2099
  : false;
2062
2100
  if (needsOk) {
2063
2101
  /*
@@ -2312,6 +2350,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
2312
2350
  // 하위가 쓴 토큰·시간도 이번 턴의 셈에 들어가야 한다. 안 그러면 /context 가 거짓말을 한다.
2313
2351
  session.usage.in += 자식.usage.in;
2314
2352
  session.usage.out += 자식.usage.out;
2353
+ session.usage.genMs = (session.usage.genMs ?? 0) + (자식.usage.genMs ?? 0);
2354
+ session.usage.genOut = (session.usage.genOut ?? 0) + (자식.usage.genOut ?? 0);
2315
2355
  /*
2316
2356
  * 새로 생긴 네 칸도 같이 더한다.
2317
2357
  *
@@ -707,6 +707,72 @@ export const 표 = {
707
707
  ],
708
708
  };
709
709
 
710
+ /*
711
+ * ── 일본어 · 중국어 (2.1.3) ─────────────────────────────────────────────
712
+ *
713
+ * 화면 말은 네 가지(ko · en · ja · zh)인데 여기는 한국어 · 영어만 알았다. 일본어 · 중국어로 시키면
714
+ * 무슨 말이든 종합에 남았고, 고치라는 말(손대라했나)도 몰라서 「説明して、直して」 같은 말에서
715
+ * 읽기 전용 모드를 빼 주지 못했다.
716
+ *
717
+ * 한국어 규칙처럼 촘촘하지는 않다 — 신호가 뚜렷한 말만 넣고, 문턱은 그대로 둔다. 애매하면 안 옮긴다는
718
+ * 원칙이 먼저다. 같은 뜻을 두 말에 따로 적으면 한 문장이 두 번 세어지므로(한자 「全部」 는 둘 다에
719
+ * 있다) 겹치는 것은 한 규칙에 모은다.
720
+ */
721
+ // 고치라는 말. 진행형(〜している)·부정(〜しないで)은 아니다. 「計画を作って」 는 계획을 달라는 말이다.
722
+ const 일본어시킴말 = /(?:(?<!(?:計画|プラン|方針|段取り)を?)作(?:って|成して)|実装して|追加して|書いて|直して|修正して|変更して|削除して|消して|置き換えて|書き換えて|リファクタ(?:リング)?して|移動して|リネームして)(?!い[るまた]|な[いく])/;
723
+ /*
724
+ * 중국어는 끝바꿈이 없어서 「고쳐라」 와 「고쳤다 · 어떻게 고쳤나」 가 같은 글자다. 그래서 시키는 표시
725
+ * (请 · 帮我 · 给我 · 麻烦 · 帮忙) 뒤이거나 문장 첫머리일 때만 시킴말로 본다. 하지 말라는 말(不要 · 别 …)은
726
+ * 아니고, 「写一个计划」 처럼 계획을 달라는 말도 아니다.
727
+ */
728
+ const 중국어시킴말 = /(?<!不要|别|不用|无需|不需要|不必|请勿|勿)(?:(?:请|帮我|给我|麻烦你?|帮忙)\s*(?:把[^。!?!?\n]{0,30}?)?(?:实现|添加|增加|新增|编写|写|创建|修复|修改|改|删除|删掉|去掉|重构|重命名|替换|更新)|(?:^|[。!?!?\n;;,,]\s*)(?:实现|添加|增加|新增|编写|创建|修复|修改|删除|删掉|重构|重命名)(?![的了吗呢]))(?!(?:一个|一份|个|份)?(?:计划|方案|规划))/;
729
+
730
+ const 다른말규칙 = {
731
+ debug: [
732
+ [/エラー|例外|バグ|不具合/, 4],
733
+ [/動か(?:ない|なく|ず)|うまく(?:いか|動か)ない|失敗(?:する|した|します|して)|落ちる|クラッシュ/, 4],
734
+ [/なぜ[^。??\n]{0,15}(?:ない|ません)/, 3],
735
+ [/报错|错误|异常|崩溃|闪退/, 4],
736
+ [/(?:不能|无法|没法)(?:运行|启动|工作|使用|编译|连接|登录)|(?:跑|启动|运行)不(?:起来|了)|不起作用|失败/, 4],
737
+ [/为什么[^。??\n]{0,15}(?:不|没)/, 3],
738
+ ],
739
+ plan: [
740
+ [/(?:計画|プラン|段取り)を?(?:立て|作っ|考え|練っ)/, 5],
741
+ [/(?:制定|做|写|列|出)(?:一个|一份|个|份)?(?:计划|方案|规划)/, 5],
742
+ [/先[^。!?\n]{0,6}(?:规划|计划)/, 5],
743
+ ],
744
+ architect: [
745
+ [/設計|アーキテクチャ/, 5],
746
+ [/(?:構成|構造)を?どう|どう分け/, 5],
747
+ [/架构/, 5],
748
+ [/(?:怎么|如何)(?:拆分|划分|分层|组织)/, 5],
749
+ [/设计(?:一下|方案|模块|接口|结构)/, 5],
750
+ ],
751
+ inspect: [
752
+ [/レビュー|点検|監査|脆弱性/, 5],
753
+ [/チェックして|見直して/, 4],
754
+ [/审查|审核|评审|审计|漏洞/, 5],
755
+ [/检查(?:一下|下)/, 4],
756
+ ],
757
+ ask: [
758
+ [/とは(?:何|なに)|って何|何を(?:する|している)(?:もの|の)|何(?:ですか|なの)/, 5],
759
+ [/説明して|教えて/, 5],
760
+ [/どう(?:やって|すれば)[^。??\n]{0,12}(?:使|動|設定|実行|起動)/, 5],
761
+ [/意味/, 3],
762
+ [/是什么|什么意思|啥意思/, 5],
763
+ [/解释|讲解|说明一下|介绍一下/, 5],
764
+ [/(?:怎么|如何)(?:用|使用|运行|配置|安装|启动|调用)/, 5],
765
+ ],
766
+ orchestrator: [
767
+ [/全部|すべて|全て|一つずつ|最後まで|所有|逐个|一个一个|从头到尾/, 3],
768
+ ],
769
+ code: [
770
+ [일본어시킴말, 5],
771
+ [중국어시킴말, 5],
772
+ ],
773
+ };
774
+ for (const [mode, 규칙들] of Object.entries(다른말규칙)) 표[mode].push(...규칙들);
775
+
710
776
  // 이만큼은 되어야 바꾼다. 못 미치면 종합 모드 그대로 둔다.
711
777
  //
712
778
  // 읽기 전용 모드는 문턱을 높인다. 잘못 보내면 파일을 못 고쳐서 사용자가 막힌다.
@@ -1207,7 +1273,7 @@ const 영어손대라 = 영어시킴말;
1207
1273
  /** 파일을 손대라는 말이 들었나. */
1208
1274
  export function 손대라했나(text) {
1209
1275
  const s = String(text ?? '');
1210
- return 손대라는말.test(s) || 반말시킴.test(s) || 영어손대라.test(s);
1276
+ return 손대라는말.test(s) || 반말시킴.test(s) || 영어손대라.test(s) || 일본어시킴말.test(s) || 중국어시킴말.test(s);
1211
1277
  }
1212
1278
 
1213
1279
  /*
@@ -348,7 +348,7 @@ export class Session {
348
348
  * 유료로 불렀고, 쓴 것을 우리가 모를 뿐이다. 「0 원 썼다」 와 「얼마
349
349
  * 썼는지 모른다」 는 사람이 하는 판단이 다르다 — 그래서 세어 둔다.
350
350
  */
351
- this.usage = { in: 0, out: 0, prompt: 0, calls: 0, ms: 0, retries: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, 못잰것: 0 };
351
+ this.usage = { in: 0, out: 0, prompt: 0, calls: 0, ms: 0, retries: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, 못잰것: 0, genMs: 0, genOut: 0 };
352
352
  /*
353
353
  * 이 대화의 이름. 게이트웨이에 「같은 대화다」 라고 알려 줄 때 쓴다.
354
354
  *
@@ -1111,16 +1111,33 @@ export class Session {
1111
1111
  return this.보정;
1112
1112
  }
1113
1113
 
1114
+ /*
1115
+ * 글이 같으면 센 값을 쓴다 (2.1.2).
1116
+ *
1117
+ * 상태줄은 일하는 동안 90ms 마다 그리고 한 번 그릴 때 여기를 여러 번 부른다. 대화는 2.1.1 에서
1118
+ * 메시지마다 한 번만 세게 했는데, 시스템 글(규칙 파일·기억·스킬 목록까지 수만 자)은 그때마다
1119
+ * 새로 짓고 **글자 하나씩** 다시 셌다 — 그것이 한 번 그리는 값의 대부분이었다. 칸마다 지난 글과
1120
+ * 센 값을 들고 있다가 글이 같으면(같은 글인지 보는 것은 글자 훑기보다 훨씬 싸다) 그 값을 쓴다.
1121
+ */
1122
+ #셈기억 = new Map();
1123
+ #셈(칸, 글) {
1124
+ const 있던 = this.#셈기억.get(칸);
1125
+ if (있던 && 있던.글 === 글) return 있던.t;
1126
+ const t = estimateTokens(글);
1127
+ this.#셈기억.set(칸, { 글, t });
1128
+ return t;
1129
+ }
1130
+
1114
1131
  /** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
1115
1132
  #원추정() {
1116
- const rules = this.rules ? estimateTokens(this.rules.text) : 0;
1133
+ const rules = this.rules ? this.#셈('규칙', String(this.rules.text ?? '')) : 0;
1117
1134
  const listed = this.listedSkills();
1118
1135
  const skills = listed.length
1119
- ? estimateTokens(listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
1136
+ ? this.#셈('스킬', listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
1120
1137
  : 0;
1121
- const 배움 = this.배움요약 ? estimateTokens(this.배움요약) : 0;
1138
+ const 배움 = this.배움요약 ? this.#셈('배움', String(this.배움요약)) : 0;
1122
1139
  // 기억도 매 요청에 통째로 나간다 — 아래 시스템 칸에서 빼려면 여기서 먼저 잰다.
1123
- const 기억 = this.memory ? estimateTokens(this.memory) : 0;
1140
+ const 기억 = this.memory ? this.#셈('기억', String(this.memory)) : 0;
1124
1141
  const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
1125
1142
 
1126
1143
  /*
@@ -1139,7 +1156,7 @@ export class Session {
1139
1156
  * 따로 줄을 갖는 것들(규칙·기억·배움·스킬)을 빼면 남는 것이 시스템
1140
1157
  * 칸이다. 앞으로 무엇이 더 붙어도 저절로 세어진다.
1141
1158
  */
1142
- const sys = Math.max(0, estimateTokens(this.systemPrompt()) - (rules + 기억 + 배움 + skills));
1159
+ const sys = Math.max(0, this.#셈('시스템', this.systemPrompt()) - (rules + 기억 + 배움 + skills));
1143
1160
 
1144
1161
  let history = 0;
1145
1162
  let files = 0;
@@ -1406,6 +1423,18 @@ export class Session {
1406
1423
  * 접기와 줄이기가 **같은 것**을 박아야 하고, 한쪽만 고치면 물러서는 순간
1407
1424
  * 대화가 어긋난다.
1408
1425
  */
1426
+ /**
1427
+ * 생성 속도(토큰/초, 소수 한 자리). 흘려받은 부름의 첫 글자부터 끝까지만 잰 값이다 (agent/loop.js 의
1428
+ * 생성잰것). 잰 것이 없으면 null — 한 번에 받은 부름은 앞머리 읽기와 생성을 못 갈라 안 잰다. 0 을 적으면
1429
+ * 「멈춰 있었다」 로 읽힌다.
1430
+ */
1431
+ export function 생성속도(usage) {
1432
+ const ms = Number(usage?.genMs ?? 0);
1433
+ const 토큰 = Number(usage?.genOut ?? 0);
1434
+ if (!(ms > 0) || !(토큰 > 0)) return null;
1435
+ return Math.round((토큰 / ms) * 10000) / 10;
1436
+ }
1437
+
1409
1438
  export const 못박을길이 = 1200;
1410
1439
 
1411
1440
  /** 박아 넣으면서 시킨 말 뒤가 잘리나. oneshot.js 가 「잘린 채로 끝까지 했다」 를 막는 데 쓴다. */
@@ -13,6 +13,7 @@ import { 다시부를지, 기다리기, 정책고르기, 못부른말 } from './
13
13
  import { 도구맞추기, 이름되돌리기, 벤더 } from './toolfit.js';
14
14
  import { 눈금맞추기 } from './wire.js';
15
15
  import { 시스템블록, 메시지표식, 잡힐만한가, 조각표 } from './cachemark.js';
16
+ import { 느슨한JSON } from './loosejson.js';
16
17
 
17
18
  /*
18
19
  * Anthropic 규격의 판 이름.
@@ -891,22 +892,42 @@ export function 잘린모양인가(raw) {
891
892
  return 연따옴표 !== null || 깊이 > 0;
892
893
  }
893
894
 
895
+ /*
896
+ * 인자 글을 읽는다. 곧이곧대로 못 읽으면, **잘린 앞토막이 아닐 때만** 느슨하게 읽는다 (2.1.3).
897
+ *
898
+ * 홑따옴표 · 끝 쉼표 · 따옴표 없는 열쇠 · 글 속 날 줄바꿈은 끝까지 온 틀린 JSON 이다. 여태는 깨진
899
+ * 부름으로 돌려보내 「고쳐 다시 보내라」 했고, 작은 모델은 같은 꼴을 되풀이하며 걸음을 썼다. 뜻이
900
+ * 하나로 읽히면 여기서 읽고 `argsRepaired` 를 단다. 잘린 것은 닫아 읽지 않는다 — 반쪽 내용이
901
+ * 온전한 척 도구로 간다(backend/loosejson.js 머리말). 객체가 아닌 것도 안 받는다.
902
+ */
903
+ function 인자읽기(원문) {
904
+ try { return { args: JSON.parse(원문) }; } catch { /* 아래 */ }
905
+ if (잘린모양인가(원문)) return null;
906
+ try {
907
+ const v = 느슨한JSON(원문);
908
+ if (v && typeof v === 'object' && !Array.isArray(v)) return { args: v, 고침: true };
909
+ } catch { /* 못 읽는다 */ }
910
+ return null;
911
+ }
912
+
894
913
  export function normalizeCalls(list) {
895
914
  return list.map((tc, i) => {
896
915
  const fn = tc.function ?? tc;
897
916
  let args = fn.arguments ?? fn.args ?? {};
898
917
  let 깨짐 = false;
918
+ let 고침 = false;
899
919
  let 원문 = null;
900
920
  if (typeof args === 'string') {
901
921
  const s = args.trim();
902
922
  // 인자가 아예 없는 도구도 있다. 빈 것은 깨진 것이 아니다.
903
923
  if (!s) args = {};
904
924
  else {
905
- try { args = JSON.parse(s); }
906
- catch { 깨짐 = true; 원문 = args; args = {}; }
925
+ const 읽음 = 인자읽기(s);
926
+ if (읽음) { args = 읽음.args; 고침 = !!읽음.고침; } else { 깨짐 = true; 원문 = args; args = {}; }
907
927
  }
908
928
  }
909
929
  const call = { id: tc.id ?? `call_${i + 1}`, name: 이름글(fn.name), args };
930
+ if (고침) call.argsRepaired = true;
910
931
  // argsCut: 잘린 앞토막인가(위 잘린모양인가). false 면 끝까지 온 틀린 JSON 이다.
911
932
  if (깨짐) { call.argsBroken = true; call.rawArgs = 원문; call.argsCut = 잘린모양인가(원문); }
912
933
  return call;
@@ -2094,8 +2115,9 @@ function 도구마무리(acc) {
2094
2115
  if (!원문) return c.처음인자 ? { ...call, args: c.처음인자 } : call;
2095
2116
  // 객체로 온 인자는 이미 읽힌 것이다 (mergeDeltaCalls 머리말). 글로 바꿔 다시 읽으면 깨진다.
2096
2117
  if (typeof 원문 !== 'string') { call.args = 원문; return call; }
2097
- try { call.args = JSON.parse(원문); }
2098
- catch { call.argsBroken = true; call.rawArgs = c.args; call.argsCut = 잘린모양인가(원문); }
2118
+ const 읽음 = 인자읽기(원문);
2119
+ if (읽음) { call.args = 읽음.args; if (읽음.고침) call.argsRepaired = true; }
2120
+ else { call.argsBroken = true; call.rawArgs = c.args; call.argsCut = 잘린모양인가(원문); }
2099
2121
  return call;
2100
2122
  });
2101
2123
  }
@@ -36,29 +36,8 @@ export const AUTH_STYLES = [
36
36
  { id: 'none', label: '인증 없음', apply: () => {} },
37
37
  ];
38
38
 
39
- /**
40
- * 열어 둔 연결을 닫는다. 프로그램을 끝내기 직전에 부른다.
41
- *
42
- * fetch 는 연결을 재사용하려고 소켓을 살려 둔다(keep-alive). 그래서 할 일이
43
- * 끝나도 프로세스가 저절로 안 끝난다. 예전에는 그걸 process.exit() 으로
44
- * 잘라 냈는데, 윈도우에서 닫는 중인 핸들을 두고 끊으면 libuv 가 abort 한다.
45
- *
46
- * Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c
47
- *
48
- * 실제로 `deel scan` 이 결과를 다 찍고 나서 이렇게 죽었다. 화면에는 정상으로
49
- * 보이는데 종료코드는 3221226505(0xC0000409) 였다.
50
- *
51
- * 그래서 잘라 내는 대신 닫는다. 이 자리는 Node 내부 이름이라 없을 수도 있으므로,
52
- * 없으면 조용히 넘어간다 — 그때는 부르는 쪽의 시간제한이 받아 준다.
53
- * 프록시 길은 요청마다 소켓을 새로 열고 끝나면 닫으므로(Connection: close) 여기 안 온다.
54
- */
55
- export function closeConnections() {
56
- try {
57
- const d = globalThis[Symbol.for('undici.globalDispatcher.1')];
58
- if (d && typeof d.close === 'function') return d.close().catch(() => {});
59
- } catch { /* 없으면 그만 */ }
60
- return Promise.resolve();
61
- }
39
+ // 살려 둔 연결 닫기는 src/exit.js 에 있다 — 진입점이 이 파일 없이 끝맺게 (2.1.2).
40
+ export { closeConnections } from '../exit.js';
62
41
 
63
42
  export function headersFor(authStyle, key, extra = {}) {
64
43
  const h = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra };
@@ -0,0 +1,109 @@
1
+ /*
2
+ * 모양이 조금 틀린 JSON 을 읽는다 (2.1.3).
3
+ *
4
+ * 작은 모델이 도구 인자에 적는 JSON 은 자주 **끝까지 왔는데 모양만 틀렸다** — 홑따옴표 · 따옴표
5
+ * 없는 열쇠 · 끝 쉼표 · 글 속 날 줄바꿈 · 파이썬 꼴 True/False/None · // 설명. 여태는 이것을
6
+ * 깨진 부름으로 돌려보내 「JSON 을 고쳐 다시 보내라」 했고, 모델은 같은 꼴을 되풀이하다 걸음을
7
+ * 다 썼다. 뜻이 하나로 읽히는 것은 여기서 읽는다.
8
+ *
9
+ * **잘린 것은 읽지 않는다.** 잘린 앞토막(backend/adapter.js 의 잘린모양인가)을 닫아 읽으면 반쪽
10
+ * 내용이 온전한 것처럼 도구로 간다 — 그건 부르는 쪽에서 먼저 가른다. 여기는 괄호가 다 닫혔는데
11
+ * JSON.parse 만 못 읽는 것의 자리다.
12
+ */
13
+
14
+ /** 느슨하게 읽는다 — 홑따옴표 · 따옴표 없는 열쇠 · 끝 쉼표 · 글 속 날 줄바꿈 · True/False/None. 못 읽으면 던진다. */
15
+ export function 느슨한JSON(글) {
16
+ const s = String(글 ?? '');
17
+ let i = 0;
18
+ const 틀림 = (왜) => { throw new SyntaxError(`${왜} (${i}번째 글자)`); };
19
+ const 빈칸 = () => {
20
+ for (;;) {
21
+ while (i < s.length && /\s/.test(s[i])) i++;
22
+ // 주석 — 작은 모델이 JSON 에 // 설명을 붙이는 일이 있다.
23
+ if (s.startsWith('//', i)) { while (i < s.length && s[i] !== '\n') i++; continue; }
24
+ if (s.startsWith('/*', i)) { const 끝 = s.indexOf('*/', i + 2); i = 끝 < 0 ? s.length : 끝 + 2; continue; }
25
+ return;
26
+ }
27
+ };
28
+ const 글읽기 = () => {
29
+ const 따옴표 = s[i++];
30
+ let out = '';
31
+ while (i < s.length) {
32
+ const ch = s[i++];
33
+ if (ch === 따옴표) return out;
34
+ if (ch !== '\\') { out += ch; continue; }
35
+ const 다음 = s[i++];
36
+ if (다음 === undefined) break;
37
+ if (다음 === 'n') out += '\n';
38
+ else if (다음 === 't') out += '\t';
39
+ else if (다음 === 'r') out += '\r';
40
+ else if (다음 === 'b') out += '\x08'; // 백스페이스 — 역슬래시 b 로 적으면 정규식 실수처럼 읽힌다(자동모드 검사)
41
+ else if (다음 === 'f') out += '\f';
42
+ else if (다음 === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i, i + 4))) { out += String.fromCharCode(parseInt(s.slice(i, i + 4), 16)); i += 4; }
43
+ // 모르는 되받이(\' · \d)는 글자 그대로 — 모델이 정규식을 JSON 에 옮겨 적으면 이렇게 온다.
44
+ else out += 다음 === "'" || 다음 === '"' || 다음 === '\\' || 다음 === '/' ? 다음 : `\\${다음}`;
45
+ }
46
+ return 틀림('글이 안 닫혔습니다');
47
+ };
48
+ const 낱말 = () => {
49
+ const m = /^[A-Za-z_$\p{L}][\w$\p{L}\p{N}-]*/u.exec(s.slice(i));
50
+ if (!m) return null;
51
+ i += m[0].length;
52
+ return m[0];
53
+ };
54
+ const 값 = () => {
55
+ 빈칸();
56
+ const ch = s[i];
57
+ if (ch === '{') {
58
+ i++;
59
+ const o = {};
60
+ for (;;) {
61
+ 빈칸();
62
+ if (s[i] === '}') { i++; return o; }
63
+ let 열쇠;
64
+ if (s[i] === '"' || s[i] === "'") 열쇠 = 글읽기();
65
+ else { 열쇠 = 낱말(); if (열쇠 === null) 틀림('열쇠가 아닙니다'); }
66
+ 빈칸();
67
+ if (s[i] !== ':') 틀림('":" 가 없습니다');
68
+ i++;
69
+ o[열쇠] = 값();
70
+ 빈칸();
71
+ if (s[i] === ',') { i++; continue; }
72
+ if (s[i] === '}') { i++; return o; }
73
+ 틀림('"," 나 "}" 가 없습니다');
74
+ }
75
+ }
76
+ if (ch === '[') {
77
+ i++;
78
+ const a = [];
79
+ for (;;) {
80
+ 빈칸();
81
+ if (s[i] === ']') { i++; return a; }
82
+ a.push(값());
83
+ 빈칸();
84
+ if (s[i] === ',') { i++; continue; }
85
+ if (s[i] === ']') { i++; return a; }
86
+ 틀림('"," 나 "]" 가 없습니다');
87
+ }
88
+ }
89
+ if (ch === '"' || ch === "'") return 글읽기();
90
+ const 수 = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(s.slice(i));
91
+ if (수) { i += 수[0].length; return Number(수[0]); }
92
+ const 말 = 낱말();
93
+ if (말 === 'true' || 말 === 'True') return true;
94
+ if (말 === 'false' || 말 === 'False') return false;
95
+ if (말 === 'null' || 말 === 'None') return null;
96
+ return 틀림('값이 아닙니다');
97
+ };
98
+ const 결과 = 값();
99
+ 빈칸();
100
+ if (i < s.length) 틀림('뒤에 남은 것이 있습니다');
101
+ return 결과;
102
+ }
103
+
104
+ /** 글을 JSON 으로 — 먼저 곧이곧대로, 안 되면 느슨하게. 못 읽으면 undefined. */
105
+ export function 읽어보기(글) {
106
+ try { return JSON.parse(글); } catch { /* 느슨하게 */ }
107
+ try { return 느슨한JSON(글); } catch { return undefined; }
108
+ }
109
+