deel-local-cli 1.20.12 → 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 -77
  2. package/README.md +119 -80
  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 +62 -13
  7. package/src/acp/serve.js +338 -30
  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 +506 -32
  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 +40 -9
  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
@@ -151,7 +151,8 @@ export function 살린쓰기(raw) {
151
151
  if (typeof path !== 'string' || !path.trim()) return null;
152
152
  if (typeof body !== 'string' || !body) return null;
153
153
  // 경로 자체가 잘렸으면 엉뚱한 자리에 쓴다. 그건 안 건진다.
154
- if (r.truncatedKey === 'file_path' || r.truncatedKey === 'path') return null;
154
+ // 위에서 경로로 받는 이름 셋을 **다** 본다 — filePath 를 빠뜨려 「src/inde」 에 쓸 뻔했다 (6회차 L1).
155
+ if (r.truncatedKey === 'file_path' || r.truncatedKey === 'path' || r.truncatedKey === 'filePath') return null;
155
156
 
156
157
  // 내용이 온전히 다 왔는데도 JSON 이 깨진 것이라면(뒤쪽 다른 인자에서 잘림)
157
158
  // 마지막 줄을 버릴 이유가 없다.
@@ -1,11 +1,12 @@
1
1
  // 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { 그림장수, 글만, 그림한장토큰 } from '../backend/vision.js';
5
- import { 부른것들, 결과들, 도구결과인가 } from '../backend/adapter.js';
4
+ import { 그림장수, 글만, 그림한장토큰, 그림메시지 } from '../backend/vision.js';
5
+ import { 부른것들, 결과들, 도구결과인가, assistantMessage, toolMessage, 본문글 } from '../backend/adapter.js';
6
6
  import { 조각표 } from '../backend/cachemark.js';
7
7
  import { get as workMode, 말 as 모드말, DEFAULT as WORK_DEFAULT } from './modes.js';
8
8
  import { toolSchemas } from '../tools/index.js';
9
+ import { isOffline } from '../safety/network.js';
9
10
  import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
10
11
  import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
11
12
  import { 지문 } from './project.js';
@@ -613,6 +614,27 @@ export class Session {
613
614
  변함.push(영
614
615
  ? `\n--- current mode: ${w.en} ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`
615
616
  : `\n--- 지금 모드: ${w.name} (${w.en}) ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`);
617
+ /*
618
+ * 기본 규칙(BASE_RULES)은 **모든 모드에서** 「Remember 로 남긴다」 · 「끝내기 전에 Verify」
619
+ * 를 시킨다. 그 도구가 없는 모드(묻기·계획 …)에서는 없는 도구를 부르라는 말이 된다 —
620
+ * 부르면 loop.js 가 모드에 없다고 거절하고 한 걸음이 헛간다. 굳은 부분은 모드와 상관없이
621
+ * 캐시되는 자리라 거기서 빼지 않고, 여기 모드 절에서 바로잡는다.
622
+ */
623
+ // 하위 작업은 모드 말고 에이전트 정의가 도구를 줄인다(도구제한). 그쪽도 같이 본다(2.0.0 2차 리뷰).
624
+ const 도구제한 = this.도구제한 ? [...this.도구제한] : null;
625
+ const 모드가뺐나 = (n) => Array.isArray(w.tools) && !w.tools.includes(n);
626
+ const 없는도구 = ['Remember', 'Verify'].filter((n) => 모드가뺐나(n) || (도구제한 && !도구제한.includes(n)));
627
+ if (없는도구.length) {
628
+ const 영남김 = 없는도구.includes('Remember') ? ' Tell the user in your answer what is worth keeping.' : '';
629
+ const 영확인 = 없는도구.includes('Verify') ? ' Say plainly what you could not check.' : '';
630
+ const 남김 = 없는도구.includes('Remember') ? ' 남길 만한 규칙은 답에 적어 사람에게 알린다.' : '';
631
+ const 확인 = 없는도구.includes('Verify') ? ' 확인 못 한 것은 확인 못 했다고 말한다.' : '';
632
+ 변함.push(영
633
+ // 모드 탓인지 작업 탓인지는 안 가른다. 둘이 섞이면(모드에 Verify 없음 · 작업에 Remember 없음)
634
+ // 「이 모드에는 Remember 가 없다」 는 거짓말이 된다 — 모델이 알아야 할 것은 「지금 없다」 뿐이다.
635
+ ? `No ${없는도구.join(' or ')} tool is available here. Where the rules above say to call it, do not.${영남김}${영확인}`
636
+ : `지금은 ${없는도구.join('·')} 도구가 없다. 위 규칙이 부르라고 해도 부르지 마라.${남김}${확인}`);
637
+ }
616
638
  /*
617
639
  * 못 박은 것은 **맨 끝**에 붙인다 (agent/pins.js).
618
640
  *
@@ -740,9 +762,29 @@ export class Session {
740
762
  this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
741
763
  if (this.#턴표.length > Session.#표최대) this.#턴표 = this.#턴표.slice(-Session.#표최대);
742
764
  this.#다음턴 = 턴;
765
+ this.#지금턴 = 턴;
743
766
  return this;
744
767
  }
745
768
 
769
+ /*
770
+ * ── 줄이기·접기가 박은 쪽지는 **어느 턴에서** 박혔나 ──────────────────────
771
+ *
772
+ * 줄이기(trim)·접기(compact)·비우기(loop.js)는 앞선 대화 자리에 시킨 말 원문과
773
+ * 남은 할 일을 「빠짐없이 하세요」 와 함께 다시 박는다(아래 못박을것). 그 쪽지는
774
+ * 그 턴의 자리표보다 **앞**에 놓인다. 그래서 그 턴을 /undo 로 되감으면 자리표부터
775
+ * 뒤만 걷히고 쪽지는 남았다 — 파일은 되돌아갔는데 다음 턴이 되돌린 일을 다시
776
+ * 시켰다. 아래 되감기() 가 이 표를 보고, 되감은 턴에서 박힌 쪽지만 뺀다.
777
+ *
778
+ * 메시지 객체를 열쇠로 든다(턴표와 같은 까닭 — 접기가 자리 번호를 바꾼다).
779
+ * WeakMap 이라 접혀 없어진 쪽지는 저절로 빠진다.
780
+ */
781
+ #지금턴 = null;
782
+ #박은쪽지 = new WeakMap();
783
+ 박은쪽지표시(메시지, 쪽지) {
784
+ if (!메시지 || typeof 메시지 !== 'object' || !쪽지 || this.#지금턴 == null) return;
785
+ this.#박은쪽지.set(메시지, { 턴: this.#지금턴, 쪽지 });
786
+ }
787
+
746
788
  /** 살아 있는 턴 표시들. 접혀 사라진 것은 빠진다. @returns {{턴:number, 자리:number}[]} */
747
789
  턴자리() {
748
790
  const out = [];
@@ -771,14 +813,41 @@ export class Session {
771
813
  if (!찾을것.size) return 빈것;
772
814
 
773
815
  const 표들 = this.턴자리().filter((x) => 찾을것.has(x.턴));
774
- if (!표들.length) return 빈것;
816
+ if (!표들.length) {
817
+ /*
818
+ * 자리표가 하나도 없어 대화는 못 걷는다. 그래도 **그 턴이 박은 쪽지**는 뺀다.
819
+ *
820
+ * 자리가 차서 턴 안에서 비우면(loop.js 의 비우기) 그 턴의 사람 말까지 비워서
821
+ * 자리표가 없다. 여기서 그냥 돌아가면 비운 자리에 박힌 「이번에 시킨 말 —
822
+ * 빠짐없이 하세요」 가 남아, 파일은 되돌아갔는데 다음 턴이 되돌린 일을 다시
823
+ * 시켰다. 쪽지는 턴 표를 들고 있으니 자리표 없이도 가려 뺄 수 있다.
824
+ * 메시지는 제자리에서 바꾼다 — 배열째 갈면 적는 자리가 세던 수가 어긋날 수 있다.
825
+ */
826
+ let 뺀쪽지 = 0;
827
+ for (let i = 0; i < this.messages.length; i++) {
828
+ const 새것 = this.#쪽지빼기(this.messages[i], 찾을것);
829
+ if (새것 !== this.messages[i]) { this.messages[i] = 새것; 뺀쪽지++; }
830
+ }
831
+ return { ...빈것, 뺀쪽지 };
832
+ }
775
833
 
776
834
  const 자리 = 표들[0].자리;
777
835
  const 첫말 = this.messages[자리];
778
836
  const 사람말 = 첫말?.role === 'user' && typeof 첫말.content === 'string' ? 첫말.content : null;
837
+ /*
838
+ * **못 걷은 턴.** 되돌리라고 받았는데 자리표가 접혀 없는 턴 가운데, 가장 이른
839
+ * 자리표보다 **앞선** 것들이다. 그 턴의 말은 요약 속에 남는다. 가장 이른 자리표보다
840
+ * 뒤에서 접힌 턴은 그 요약째 아래에서 걷히므로 여기 안 든다 — 턴 번호는 늘기만
841
+ * 한다(safety/undo.js 의 nextTurn). 부르는 쪽(commands.js)이 「반만 걷었다」 를
842
+ * 말하는 데 쓴다.
843
+ */
844
+ const 찾은턴 = new Set(표들.map((x) => x.턴));
845
+ const 못걷은턴 = [...찾을것].filter((t) => !찾은턴.has(t) && t < 표들[0].턴);
779
846
 
780
847
  const 전 = this.messages.length;
781
- const 고침 = repairToolPairs(this.messages.slice(0, 자리));
848
+ // 걷히는 턴들 — 받은 것과, 가장 이른 자리표 뒤에서 시작해 같이 걷히는 턴들.
849
+ const 걷는턴 = new Set([...찾을것, ...this.턴자리().filter((x) => x.자리 >= 자리).map((x) => x.턴)]);
850
+ const 고침 = repairToolPairs(this.messages.slice(0, 자리).map((m) => this.#쪽지빼기(m, 걷는턴)));
782
851
  this.messages = 고침.messages;
783
852
  this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
784
853
  this.#다음턴 = null;
@@ -820,7 +889,31 @@ export class Session {
820
889
  * 대화에서 읽은 파일이 계속 세어진다.
821
890
  */
822
891
  this.filesRead?.clear?.();
823
- return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
892
+ return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴), 못걷은턴 };
893
+ }
894
+
895
+ /*
896
+ * 걷는 턴에서 박힌 쪽지를 이 메시지에서 뺀다. 아니면 **같은 객체를 그대로** 준다.
897
+ *
898
+ * 새 객체를 지으면 턴표·박은쪽지 표가 그 메시지를 못 알아본다. 뺄 것이 있을 때만
899
+ * 새로 짓고, 그 메시지는 이제 쪽지가 없으니 표에서도 뺀다.
900
+ */
901
+ #쪽지빼기(m, 걷는턴) {
902
+ const 표 = m && typeof m === 'object' ? this.#박은쪽지.get(m) : null;
903
+ if (!표 || !걷는턴.has(표.턴) || typeof m.content !== 'string' || !m.content.includes(표.쪽지)) return m;
904
+ const 새것 = { ...m, content: m.content.replace(표.쪽지, '') };
905
+ /*
906
+ * 새 객체를 지었으면 **턴표도 그 객체를 가리키게 옮긴다** (2.0.0 8회차 판정).
907
+ *
908
+ * 바로 위 머리말이 「새 객체를 지으면 턴표·박은쪽지 표가 그 메시지를 못
909
+ * 알아본다」 고 적어 두고, 박은쪽지 쪽만 챙겼다. 턴표는 안 옮겼다.
910
+ * 그래서 부르는 쪽의 `#턴표.filter((x) => this.messages.includes(x.표))` 가
911
+ * 그 턴을 통째로 떨어뜨렸고, **다음 `/undo` 가 아무것도 못 걷었다** —
912
+ * 메시지는 멀쩡히 남아 있는데 걷을 자리를 못 찾는다.
913
+ */
914
+ this.#박은쪽지.delete(m);
915
+ for (const x of this.#턴표) if (x.표 === m) x.표 = 새것;
916
+ return 새것;
824
917
  }
825
918
 
826
919
  /**
@@ -896,6 +989,17 @@ export class Session {
896
989
  앞.added += d?.added ?? 0;
897
990
  앞.removed += d?.removed ?? 0;
898
991
  앞.times += 1;
992
+ /*
993
+ * **파일마다** 마지막으로 고친 때를 남긴다 (2.0.0 8회차 판정).
994
+ *
995
+ * 증거 모으기(agent/evidence.js)는 「고친 뒤에 돌린 확인만 증거로 친다」 고
996
+ * 적어 두고, 그 기준 시각을 부르는 쪽이 넘겨 주기를 기다렸다. 부르는 자리
997
+ * 셋(commit·work·export)이 하나도 안 넘겼다 — 아무도 안 넘기는 인자는 없는
998
+ * 것과 같아서, 고치기 **전**에 돌린 검사가 그대로 증거가 됐다. 게다가 그
999
+ * 기준이 하나뿐이라 파일 A 뒤에 돈 검사가 파일 B 의 증명으로 복제됐다.
1000
+ * 여기서 파일별로 남겨 두면 부르는 쪽이 아무것도 안 넘겨도 순서를 가린다.
1001
+ */
1002
+ 앞.at = Date.now();
899
1003
  this.changes.set(path, 앞);
900
1004
  }
901
1005
 
@@ -912,7 +1016,19 @@ export class Session {
912
1016
  * 에서 400 을 만든다. JSON.stringify 는 Symbol 열쇠를 아예 안 본다.
913
1017
  */
914
1018
  if (조각[1]) 머리[조각표] = 조각;
915
- return [머리, ...this.messages];
1019
+ /*
1020
+ * 보낼 **사본만** 지금 규격으로 맞춘다 (아래 규격맞추기).
1021
+ *
1022
+ * 규격을 옮기는 문은 /model(commands/model.js)과 이어받기(threads.js · acp/serve.js)다.
1023
+ * 그런데 연결을 짓는 자리가 넷이라, 그 문을 안 지나고 규격이 바뀌는 길이 하나라도
1024
+ * 생기면 옛 모양이 그대로 나가 400 이다. 여기가 마지막 울타리다.
1025
+ *
1026
+ * 이력(this.messages)은 안 건드린다. 턴마다 파일에 적는 자리가 **메시지 수**로
1027
+ * 어디까지 적었는지 세는데(repl 의 saved · ACP 의 적은데까지), 옮기면 수가 바뀔
1028
+ * 수 있다. 이미 맞는 대화면 받은 배열이 그대로 돌아오고, 맞는 메시지는 같은 객체라
1029
+ * 캐시 앞머리도 그대로다.
1030
+ */
1031
+ return [머리, ...규격맞추기(this.messages, this.conn?.kind).messages];
916
1032
  }
917
1033
 
918
1034
  /**
@@ -1076,8 +1192,14 @@ export class Session {
1076
1192
  */
1077
1193
  // 이 자리만 `?.` 가 빠져 있었다 — 다른 다섯 자리는 전부 this.conn?.ctx 다.
1078
1194
  // conn 없이 만든 세션에서 breakdown() 이 TypeError 로 죽는다.
1079
- const 잰것 = this.conn?.ctx != null;
1080
- const total = this.conn?.ctx ?? 32768;
1195
+ /*
1196
+ * 0·음수·숫자 아님은 「안 잰 것」 이다 (사냥5 L5-6). 연결을 짓는 자리(models.js 의
1197
+ * 양수크기)가 거르지만 conn 은 그 밖에서도 지어진다. 창 0 을 그대로 믿으면 표가
1198
+ * 「0 토큰 중 22만」 이 되고, 접기·요약은 몇 퍼센트인지 못 재어 영영 안 돈다.
1199
+ */
1200
+ const 창 = Number(this.conn?.ctx);
1201
+ const 잰것 = this.conn?.ctx != null && Number.isFinite(창) && 창 > 0;
1202
+ const total = 잰것 ? 창 : 32768;
1081
1203
  return { rows, used, total, 총잰것: 잰것, left: Math.max(0, total - used) };
1082
1204
  }
1083
1205
 
@@ -1125,13 +1247,39 @@ export class Session {
1125
1247
  // /ctx 로 창을 다시 잡으면 이 값도 달라져야 한다. 안 넣으면 옛 값이 남는다.
1126
1248
  // 눈 유무도 열쇠에 넣는다. 아래에서 vision 을 넘겨 쓰기 때문에 그 값이
1127
1249
  // 바뀌면 스키마 크기도 바뀐다 — 안 넣으면 모델을 갈아 끼워도 옛 값이 남는다.
1128
- const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}|v${this.conn?.vision === true ? 1 : 0}`;
1250
+ /*
1251
+ * 실제로 나가는 목록(loop.js 의 toolSchemas)과 **같은 것**을 잰다. 여기는 도구제한(하위
1252
+ * 작업)·오프라인(웹 도구가 빠진다)·이름 붙인 에이전트 목록을 안 넘겨서, /context 가
1253
+ * 나가지도 않는 도구까지 세어 보였다. 셋이 달라지면 값도 달라지게 열쇠에도 넣는다.
1254
+ */
1255
+ const 웹 = this.web !== false && !isOffline();
1256
+ const 에이전트들 = this.실린에이전트들 ?? null;
1257
+ /*
1258
+ * 그 셋은 **무엇이 다른지까지** 열쇠에 넣는다 (2.0.0 8회차 판정).
1259
+ *
1260
+ * 이 값은 한 번 재면 열쇠에 굳고 다시 안 잰다. 그래서 열쇠가 못 가르는 차이는
1261
+ * 「값이 조금 틀리다」 가 아니라 **남의 값을 그대로 집어 오는 것**이다. 아래 두 자리가
1262
+ * 그랬다.
1263
+ *
1264
+ * · 도구제한 — `null`(제한 없음)과 `[]`(0개)이 둘 다 빈 글로 합쳐졌다. 도구를 다 잃은
1265
+ * 하위 작업이 부모의 전체 도구 값을 그대로 받는다. 없는 것과 0개는 다른 목록이라
1266
+ * 제한 없음은 `*` 로 따로 적는다 — 도구 이름에 `*` 는 안 들어간다.
1267
+ * · 에이전트 목록 — **개수**로만 갈랐다. 스키마에 실리는 것은 개수가 아니라 이름과
1268
+ * 설명 전문이다(agents.js 의 고를말). 개수가 같으면 설명이 아무리 달라도 앞 값이 돌아왔다.
1269
+ *
1270
+ * 남는 열쇠 조각이 길어지지만, 여기 굳는 것은 세션당 몇 개뿐이고 틀린 값 하나가
1271
+ * effort.js 의 출력 상한까지 끌고 간다.
1272
+ */
1273
+ const 제한열쇠 = this.도구제한 ? [...this.도구제한].join(',') : '*';
1274
+ const 에이전트열쇠 = (에이전트들 ?? []).map((a) => `${a?.이름}(${a?.설명})`).join('·');
1275
+ const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${웹 ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}|v${this.conn?.vision === true ? 1 : 0}|t${제한열쇠}|a${에이전트열쇠}`;
1129
1276
  if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
1130
1277
  let n = 0;
1131
1278
  try {
1132
- const list = toolSchemas(null, {
1279
+ const list = toolSchemas(this.도구제한 ?? null, {
1133
1280
  hasSkills: (this.skills?.length ?? 0) > 0,
1134
- web: this.web !== false,
1281
+ web: 웹,
1282
+ 에이전트들,
1135
1283
  work: this.effectiveWork(),
1136
1284
  mcp: this.mcp ?? null,
1137
1285
  lsp: this.lsp === true,
@@ -1187,9 +1335,8 @@ export class Session {
1187
1335
  const keepTail = safeCut(this.messages, this.messages.length - Math.floor(this.messages.length / 2));
1188
1336
  const dropped = keepTail - keepHead;
1189
1337
  if (dropped <= 0) return 0;
1190
- this.messages = [
1191
- ...this.messages.slice(0, keepHead),
1192
- {
1338
+ const 쪽지 = 못박을것(this);
1339
+ const 줄인말 = {
1193
1340
  role: 'user',
1194
1341
  /*
1195
1342
  * 줄일 때도 시킨 말과 남은 할 일을 다시 박는다.
@@ -1200,10 +1347,11 @@ export class Session {
1200
1347
  * 그 네 가지가 그냥 사라진다. 접기(compact) 쪽에만 못 박아 두면
1201
1348
  * 정작 제일 자주 지나가는 길에서만 조용히 어긋난다.
1202
1349
  */
1203
- content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)\n\n` + 못박을것(this),
1204
- },
1205
- ...this.messages.slice(keepTail),
1206
- ];
1350
+ content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)\n\n` + 쪽지,
1351
+ };
1352
+ // 어느 턴의 시킨 말을 박았는지 적어 둔다 — 그 턴을 되감으면 이 쪽지도 빠져야 한다(박은쪽지표시).
1353
+ this.박은쪽지표시(줄인말, 쪽지);
1354
+ this.messages = [...this.messages.slice(0, keepHead), 줄인말, ...this.messages.slice(keepTail)];
1207
1355
  return dropped;
1208
1356
  }
1209
1357
  }
@@ -1226,19 +1374,43 @@ export class Session {
1226
1374
  * 접기와 줄이기가 **같은 것**을 박아야 하고, 한쪽만 고치면 물러서는 순간
1227
1375
  * 대화가 어긋난다.
1228
1376
  */
1229
- const 못박을길이 = 1200;
1377
+ export const 못박을길이 = 1200;
1378
+
1379
+ /** 박아 넣으면서 시킨 말 뒤가 잘리나. oneshot.js 가 「잘린 채로 끝까지 했다」 를 막는 데 쓴다. */
1380
+ export function 요청잘리나(session) {
1381
+ return String(session?.이번요청 ?? '').trim().length > 못박을길이;
1382
+ }
1230
1383
 
1231
1384
  export function 못박은요청(session) {
1232
1385
  const 원문 = String(session?.이번요청 ?? '').trim();
1233
1386
  if (!원문) return '';
1234
- const 실을것 = 원문.length > 못박을길이
1235
- ? `${원문.slice(0, 못박을길이)}\n…(뒷부분 줄임)`
1236
- : 원문;
1237
- return `[이번에 시킨 말 — 요약이 아니라 원문 그대로입니다. 여기 적힌 것을 빠짐없이 하세요.]\n${실을것}\n\n`;
1387
+ /*
1388
+ * 잘랐으면 머리말부터 **잘랐다고** 말한다 (사냥5 B5-01).
1389
+ *
1390
+ * 여기는 앞 1,200자만 싣고도 「요약이 아니라 원문 그대로입니다. 여기 적힌 것을
1391
+ * 빠짐없이 하세요」 라고 적었다. 모델은 그 말대로 앞부분을 시킨 일 **전부**로 믿고
1392
+ * 끝까지 했고, `deel run --json` 은 ok:true 로 끝났다 — 뒷부분은 한 번도 못 본 채로.
1393
+ * 잘린 줄 모르는 모델은 모자란 줄도 모른다. 모자라다고 알려야 뒤를 지어내지 않는다.
1394
+ */
1395
+ if (원문.length > 못박을길이) {
1396
+ return `[이번에 시킨 말 — 자리가 모자라 앞 ${못박을길이.toLocaleString('en-US')}자만 옮겼고 뒤는 잘렸습니다.`
1397
+ + ' 잘린 뒤쪽을 짐작해 채우지 말고, 모자라서 못 한 것은 못 했다고 말하세요.]\n'
1398
+ + `${원문.slice(0, 못박을길이)}\n…(뒷부분 줄임)\n\n`;
1399
+ }
1400
+ return `[이번에 시킨 말 — 요약이 아니라 원문 그대로입니다. 여기 적힌 것을 빠짐없이 하세요.]\n${원문}\n\n`;
1238
1401
  }
1239
1402
 
1240
1403
  export function 못박은할일(session) {
1241
- const 남은 = (session?.할일 ?? []).filter((x) => x?.state !== 'done');
1404
+ /*
1405
+ * 빈 칸은 **거르개에서** 걷는다 (2.0.0 8회차 판정).
1406
+ *
1407
+ * 여기는 `x?.state` 로 null 을 조심해 놓고, 바로 아래 map 은 `x.state` 였다. 그래서
1408
+ * 목록에 낀 `null` 하나가 거르개를 「안 끝난 것」 으로 통과한 뒤 map 에서 던졌다 —
1409
+ * 던지는 자리가 접기·줄이기라, 자리가 차는 순간 그 대화는 더 안 이어진다.
1410
+ * 목록은 대화 파일에서 그대로 돌아오므로(store.js 의 `{"t":"todo"}`) 반쯤 적힌 줄이나
1411
+ * 손으로 고친 파일이면 빈 칸이 낀다. 빈 칸은 할 일이 아니다 — 빈 줄로 박지도 않는다.
1412
+ */
1413
+ const 남은 = (session?.할일 ?? []).filter((x) => x && typeof x === 'object' && x.state !== 'done');
1242
1414
  if (!남은.length) return '';
1243
1415
  const 줄 = 남은.map((x) => `${x.state === 'doing' ? '▶ (하는 중)' : '☐'} ${String(x.text ?? '').trim()}`);
1244
1416
  return `[아직 안 끝난 할 일 — 접히기 전 목록 그대로입니다. 이걸 이어서 하세요.]\n${줄.join('\n')}\n\n`;
@@ -1361,6 +1533,21 @@ function 부름줄이기(m, 남길id, 남길수) {
1361
1533
  return { ...m, tool_calls: 고르기(m.tool_calls ?? [], () => true) };
1362
1534
  }
1363
1535
 
1536
+ /**
1537
+ * 결과 메시지에서 **결과 블록을 걷고 남는 사람 차례**. 남는 것이 없으면 null.
1538
+ *
1539
+ * Anthropic 꼴에는 도구 차례가 없어서 결과가 사람 차례에 실리고, 도구가 도는 사이
1540
+ * 사람이 끼어든 말도 같은 메시지에 text 블록으로 붙는다(adapter.js 의 결과들).
1541
+ * repairToolPairs 가 메시지째 걷던 동안 결과가 짝을 잃으면 그 옆의 사람 말도 같이
1542
+ * 없어졌다 — 고치려던 것은 짝이지 사람 말이 아니다. OpenAI·Ollama 의 결과는 글
1543
+ * 하나(role:'tool')라 사람 말이 섞일 자리가 없어서 늘 null 이다.
1544
+ */
1545
+ function 사람몫(m) {
1546
+ if (!Array.isArray(m?.content)) return null;
1547
+ const 남은 = m.content.filter((b) => b?.type !== 'tool_result');
1548
+ return 남은.length ? { ...m, role: 'user', content: 남은 } : null;
1549
+ }
1550
+
1364
1551
  /** 이 메시지가 사람에게 한 말만. 부름 블록은 뺀다. */
1365
1552
  function 글자만(m) {
1366
1553
  if (typeof m.content === 'string') return m.content.trim();
@@ -1388,7 +1575,13 @@ export function repairToolPairs(messages) {
1388
1575
  if (!m || typeof m !== 'object' || typeof m.role !== 'string') { 고친것++; continue; }
1389
1576
 
1390
1577
  // 호출 없이 굴러다니는 결과. 앞이 잘려 나간 이력이다.
1391
- if (도구결과인가(m)) { 고친것++; continue; }
1578
+ // 걷는 것은 **결과 블록뿐**이다 — 같은 메시지에 사람이 한 말이 붙어 있으면 그건 남긴다(아래 사람몫).
1579
+ if (도구결과인가(m)) {
1580
+ 고친것++;
1581
+ const 사람 = 사람몫(m);
1582
+ if (사람) out.push(사람);
1583
+ continue;
1584
+ }
1392
1585
 
1393
1586
  /*
1394
1587
  * 부름이 담긴 자리는 규격마다 다르다(backend/adapter.js 의 부른것들).
@@ -1413,21 +1606,254 @@ export function repairToolPairs(messages) {
1413
1606
  ? 부름.filter((c) => 있는id.has(c?.id))
1414
1607
  : 부름.slice(0, 결과.length); // id 가 없는 규격 — 순서로 본다
1415
1608
  const 남길id = new Set(남길부름.map((c) => c?.id).filter(Boolean));
1416
- const 남길결과 = 있는id.size
1417
- ? 결과.filter((r) => 결과id(r).some((x) => 남길id.has(x)))
1418
- : 결과.slice(0, 남길부름.length);
1609
+ /*
1610
+ * 결과도 **블록 단위로** 가른다 (위 사람몫).
1611
+ *
1612
+ * · 짝이 하나도 없는 결과 메시지 — 결과 블록은 걷고, 사람이 한 말은 결과들 **뒤에**
1613
+ * 사람 차례로 남긴다. 결과 블록이 사람 글보다 앞이어야 하는 규격이 있다.
1614
+ * · 짝 있는 결과와 짝 없는 결과가 한 메시지에 섞였다 — 짝 없는 블록만 걷는다.
1615
+ * 메시지째 남기면 그 블록 하나로 400 이고, 메시지째 걷으면 짝 있는 결과가 없어진다.
1616
+ */
1617
+ const 남길결과 = [];
1618
+ const 남길사람말 = [];
1619
+ /*
1620
+ * 같은 id 의 결과는 **먼저 온 하나만.** 반쯤 적힌 JSONL 을 이어 열면 같은 결과가 두 번
1621
+ * 실려 있을 수 있는데, 여기가 짝 있는지만 보던 동안 둘 다 남고 「고친 것 0」 이었다 —
1622
+ * Anthropic 은 보내기 직전 차례합치기 로 한 사람 차례에 같은 tool_use_id 둘이 실려 거절한다.
1623
+ * (6회차 Gemini 대화고침6a S1)
1624
+ */
1625
+ const 본id = new Set();
1626
+ 결과.forEach((r, k) => {
1627
+ const 짝있나 = 있는id.size ? 결과id(r).some((x) => 남길id.has(x) && !본id.has(x)) : k < 남길부름.length;
1628
+ if (!짝있나) {
1629
+ 고친것++;
1630
+ const 사람 = 사람몫(r);
1631
+ if (사람) 남길사람말.push(사람);
1632
+ return;
1633
+ }
1634
+ if (있는id.size && Array.isArray(r.content)) {
1635
+ let 뺀블록 = 0;
1636
+ const 남길블록 = r.content.filter((b) => {
1637
+ if (b?.type !== 'tool_result') return true;
1638
+ if (!남길id.has(b.tool_use_id) || 본id.has(b.tool_use_id)) { 뺀블록++; return false; }
1639
+ 본id.add(b.tool_use_id);
1640
+ return true;
1641
+ });
1642
+ if (뺀블록) {
1643
+ 고친것 += 뺀블록;
1644
+ 남길결과.push({ ...r, content: 남길블록 });
1645
+ return;
1646
+ }
1647
+ } else if (있는id.size) {
1648
+ for (const x of 결과id(r)) 본id.add(x);
1649
+ }
1650
+ 남길결과.push(r);
1651
+ });
1419
1652
 
1420
- 고친것 += (부름.length - 남길부름.length) + (결과.length - 남길결과.length);
1653
+ 고친것 += 부름.length - 남길부름.length;
1421
1654
 
1422
1655
  if (남길부름.length) {
1423
1656
  out.push(남길부름.length === 부름.length ? m : 부름줄이기(m, 남길id, 남길부름.length));
1424
- out.push(...남길결과);
1657
+ out.push(...남길결과, ...남길사람말);
1425
1658
  continue;
1426
1659
  }
1427
- // 남은 호출이 없다. 할 말이라도 있으면 그건 살린다.
1660
+ // 남은 호출이 없다. 할 말이라도 있으면 그건 살린다 — 모델이 한 말도, 사람이 한 말도.
1428
1661
  const 글 = 글자만(m);
1429
1662
  if (글) out.push(부름빼기(m, 글));
1663
+ out.push(...남길사람말);
1430
1664
  }
1431
1665
 
1432
1666
  return { messages: out, 고친것 };
1433
1667
  }
1668
+
1669
+ /*
1670
+ * ── 다른 규격으로 적힌 대화를 **지금 규격으로** 옮겨 적는다 ────────────────
1671
+ *
1672
+ * 대화 이력은 쓰던 규격의 모양 그대로 쌓인다(backend/adapter.js 의
1673
+ * assistantMessage·toolMessage). 그런데 규격이 도중에 바뀌는 길이 둘 있다.
1674
+ *
1675
+ * `/model` Anthropic 프로필에서 OpenAI 호환 프로필로 갈아탄다
1676
+ * 이어받기 어제 Anthropic 으로 한 대화를 오늘 로컬 Ollama 로 --resume 한다
1677
+ * (저장 파일 머리글에는 규격이 없다)
1678
+ *
1679
+ * 둘 다 옛 모양을 그대로 보냈다. 서버는 모르는 모양에 400 을 준다 —
1680
+ *
1681
+ * OpenAI 에 tool_use · tool_result 블록
1682
+ * Anthropic 에 `role:'tool'` · `content:null` · tool_calls
1683
+ * OpenAI 에 Ollama 의 id 없는 부름 · 객체 인자
1684
+ *
1685
+ * 읽는 것은 adapter.js 의 규격을 안 가리는 자(부른것들·결과들·본문글)로, 쓰는
1686
+ * 것은 같은 파일의 규격별 짓는 자로 한다. 규칙이 여기 새로 생기지 않는다.
1687
+ *
1688
+ * **이미 맞는 메시지는 손대지 않는다 — 같은 객체를 그대로 둔다.** 턴 자리표
1689
+ * (되감기)와 박은 쪽지 표가 메시지 객체를 열쇠로 들고 있어서, 새로 지으면
1690
+ * 옮기는 순간 /undo 가 대화를 못 걷는다. 사람 말은 어느 규격에서나 글 하나라
1691
+ * 거의 늘 그대로 남는다.
1692
+ *
1693
+ * id 가 없는 부름(Ollama)은 **차례로** 짝을 짓고 id 를 지어 붙인다 — 그 규격이
1694
+ * 원래 차례로 짝짓는다. 옮길 수 없는 것(주소만 있는 그림, 서명 붙은 생각 블록)은
1695
+ * 빼고 셈에 올린다. 부르는 쪽이 무엇을 뺐는지 화면에 말한다.
1696
+ *
1697
+ * @returns {{messages:object[], 바꾼것:number, 뺀것:{그림:number, 생각:number}}}
1698
+ * 바꾼 것이 없으면 받은 배열을 그대로 돌려준다.
1699
+ */
1700
+ export function 규격갈래(kind) {
1701
+ return kind === 'anthropic' || kind === 'ollama' ? kind : 'openai';
1702
+ }
1703
+
1704
+ const 앤블록 = new Set(['text', 'image', 'tool_use', 'tool_result', 'thinking', 'redacted_thinking', 'document']);
1705
+
1706
+ /** 이 메시지가 이 규격에 **그대로 보내도 되는** 모양인가. */
1707
+ function 규격에맞나(m, 규격) {
1708
+ const 블록 = Array.isArray(m.content) ? m.content : null;
1709
+ if (규격 === 'anthropic') {
1710
+ /*
1711
+ * `thinking` 칸도 본다 (2.0.0 8회차 판정).
1712
+ *
1713
+ * 최상위 `thinking` 은 Ollama 가 적는 자리다(adapter.js 의 assistantMessage). 이 규격은
1714
+ * 생각을 **블록**으로 나르지 최상위 칸으로 안 나른다 — 모르는 칸 하나에 400 을 준다.
1715
+ * 아래 openai 갈래는 같은 줄에서 이 칸을 이미 보고 있었고, 여기만 빠져 있었다.
1716
+ * 그래서 Ollama 로 하던 대화를 이 규격으로 이어받으면(--resume · /model) 그 답이
1717
+ * 「맞는 모양」 으로 통과해 그대로 나갔다.
1718
+ */
1719
+ if (m.role === 'tool' || 'tool_calls' in m || 'images' in m || 'thinking' in m || m.content == null) return false;
1720
+ /*
1721
+ * 이 규격은 빈 차례를 거절한다 — 답이든 사람 말이든(adapter.js 의 assistantMessage 가
1722
+ * 자리표시를 넣고, 아래 사람말옮기기 가 `(빈 말)` 을 넣는 까닭). 여기가 빈 글 **답**만 보던
1723
+ * 동안 이 규격 모양의 빈 사람 말 `''` 과 빈 블록 `[]`(`every` 가 빈 배열에 참)은 그대로
1724
+ * 지나갔다. (6회차 Gemini 대화고침6b G1·G2)
1725
+ */
1726
+ if (블록) return 블록.length > 0 && 블록.every((b) => 앤블록.has(b?.type));
1727
+ return String(m.content).trim() !== '';
1728
+ }
1729
+ if (규격 === 'ollama') {
1730
+ if (블록 || m.content == null) return false;
1731
+ if (m.role === 'tool') return typeof m.tool_name === 'string';
1732
+ return (m.tool_calls ?? []).every((t) => t?.function && typeof t.function.arguments === 'object' && t.function.arguments !== null);
1733
+ }
1734
+ if (m.role === 'tool') return typeof m.tool_call_id === 'string' && m.tool_call_id !== '' && typeof m.content === 'string';
1735
+ if ('images' in m || 'thinking' in m) return false;
1736
+ if (m.role === 'assistant') {
1737
+ if (블록) return false;
1738
+ if (m.content == null && !m.tool_calls?.length) return false;
1739
+ return (m.tool_calls ?? []).every((t) => t?.id && typeof t?.function?.arguments === 'string');
1740
+ }
1741
+ /*
1742
+ * 사람·시스템 차례도 `content == null` 을 본다 (2.0.0 8회차 판정).
1743
+ *
1744
+ * 바로 위 답 차례는 보고 있었고 여기만 빠져 있었다. 블록이 아니면 `!블록` 이 참이라
1745
+ * `content: null` 인 사람 말이 「맞는 모양」 으로 통과해 그대로 나갔다 — 이 규격에서
1746
+ * 사람 차례의 content 는 없어도 되는 칸이 아니라 400 이다. 반쯤 적힌 JSONL 을 이어
1747
+ * 열거나(store.js) 다른 규격 이력을 이어받으면 나온다. 아래 사람말옮기기 가 빈 글로 채운다.
1748
+ */
1749
+ if (m.content == null) return false;
1750
+ return !블록 || 블록.every((b) => b?.type === 'text' || b?.type === 'image_url');
1751
+ }
1752
+
1753
+ /**
1754
+ * 옮기면서 **생각을 버리게 되나**. 부르는 쪽이 무엇을 뺐는지 화면에 말한다.
1755
+ *
1756
+ * 생각이 실리는 자리가 규격마다 다르다 — Anthropic 은 서명 붙은 블록, Ollama 는 최상위
1757
+ * `thinking` 칸, OpenAI 는 아예 자리가 없다. 그래서 어느 쪽으로 옮기든 옮겨 갈 자리가
1758
+ * 없으면 그냥 사라진다. 블록 쪽만 세던 동안 최상위 칸은 소리 없이 없어졌다.
1759
+ */
1760
+ function 생각버리나(m, 규격) {
1761
+ // 서명이 붙은 블록은 그 규격 밖으로 못 나른다(adapter.js 의 assistantMessage).
1762
+ if (규격 !== 'anthropic' && Array.isArray(m.content)
1763
+ && m.content.some((b) => b?.type === 'thinking' || b?.type === 'redacted_thinking')) return true;
1764
+ // 최상위 칸은 Ollama 로 갈 때만 그대로 실린다.
1765
+ return 규격 !== 'ollama' && typeof m.thinking === 'string' && m.thinking.trim() !== '';
1766
+ }
1767
+
1768
+ /** base64 머리로 그림 종류를 알아낸다. Ollama 는 종류를 안 적어 둔다. */
1769
+ function 그림종류(b64) {
1770
+ const s = String(b64 ?? '');
1771
+ if (s.startsWith('/9j/')) return 'image/jpeg';
1772
+ if (s.startsWith('iVBOR')) return 'image/png';
1773
+ if (s.startsWith('R0lGOD')) return 'image/gif';
1774
+ if (s.startsWith('UklGR')) return 'image/webp';
1775
+ return null;
1776
+ }
1777
+
1778
+ /** 사람 차례(도구 결과가 아닌 것)를 이 규격 모양으로. 그림은 옮길 수 있는 만큼 옮긴다. */
1779
+ function 사람말옮기기(m, 규격, 뺀것) {
1780
+ const 그림들 = [];
1781
+ for (const b64 of Array.isArray(m.images) ? m.images : []) {
1782
+ const mime = 그림종류(b64);
1783
+ if (mime) 그림들.push({ mime, b64 }); else 뺀것.그림++;
1784
+ }
1785
+ for (const b of Array.isArray(m.content) ? m.content : []) {
1786
+ if (b?.type === 'image') {
1787
+ if (b.source?.type === 'base64' && b.source.data) 그림들.push({ mime: b.source.media_type, b64: b.source.data });
1788
+ else 뺀것.그림++;
1789
+ } else if (b?.type === 'image_url') {
1790
+ const 맞음 = /^data:([^;,]+);base64,(.+)$/s.exec(String(b.image_url?.url ?? b.image_url ?? ''));
1791
+ if (맞음) 그림들.push({ mime: 맞음[1], b64: 맞음[2] }); else 뺀것.그림++;
1792
+ }
1793
+ }
1794
+ const 글 = 본문글(m);
1795
+ if (그림들.length) return { ...그림메시지(규격, { 글, 그림들 }), role: m.role };
1796
+ // 그림이 하나도 없는데 글도 비면 이 규격은 빈 사람 차례를 거절한다. 칸만 채운다.
1797
+ if (!글.trim() && 규격 === 'anthropic') return { role: m.role, content: '(빈 말)' };
1798
+ return { role: m.role, content: 글 };
1799
+ }
1800
+
1801
+ export function 규격맞추기(messages, kind) {
1802
+ const 규격 = 규격갈래(kind);
1803
+ const 뺀것 = { 그림: 0, 생각: 0 };
1804
+ if (!Array.isArray(messages)) return { messages: [], 바꾼것: 0, 뺀것 };
1805
+ const out = [];
1806
+ let 바꾼것 = 0;
1807
+ let 지은수 = 0;
1808
+ // 바로 앞 부름들. 결과를 짝지을 때 쓴다 — id 가 없는 규격은 차례로 짝짓는다.
1809
+ let 대기 = [];
1810
+ let 대기자리 = 0;
1811
+
1812
+ for (const m of messages) {
1813
+ if (!m || typeof m !== 'object' || typeof m.role !== 'string') { out.push(m); continue; }
1814
+
1815
+ const 부름 = 부른것들(m);
1816
+ if (부름.length) {
1817
+ 대기 = 부름.map((x) => ({ id: x.id || `call_deel_${++지은수}`, name: x.name, args: x.args ?? {} }));
1818
+ 대기자리 = 0;
1819
+ if (규격에맞나(m, 규격)) { out.push(m); continue; }
1820
+ if (생각버리나(m, 규격)) 뺀것.생각++;
1821
+ out.push(assistantMessage(규격, {
1822
+ content: 본문글(m),
1823
+ thinking: 규격 === 'ollama' && typeof m.thinking === 'string' ? m.thinking : '',
1824
+ toolCalls: 대기,
1825
+ }));
1826
+ 바꾼것++;
1827
+ continue;
1828
+ }
1829
+
1830
+ if (도구결과인가(m)) {
1831
+ const 것들 = 결과들(m).map((r) => {
1832
+ let 짝 = r.id ? 대기.find((x) => x.id === r.id) : 대기[대기자리];
1833
+ if (짝) 대기자리 = 대기.indexOf(짝) + 1;
1834
+ return { id: r.id ?? 짝?.id ?? `call_deel_${++지은수}`, name: r.name ?? 짝?.name ?? '?', 글: r.글 };
1835
+ });
1836
+ if (규격에맞나(m, 규격)) { out.push(m); continue; }
1837
+ for (const r of 것들) out.push(toolMessage(규격, { callId: r.id, name: r.name, content: r.글 }));
1838
+ // 결과 블록 옆에 사람 글·그림이 같이 실려 있었으면 그건 결과들 **뒤에** 사람 차례로 붙인다.
1839
+ const 나머지 = Array.isArray(m.content) ? m.content.filter((b) => b?.type !== 'tool_result') : [];
1840
+ if (나머지.length) out.push(사람말옮기기({ role: 'user', content: 나머지 }, 규격, 뺀것));
1841
+ 바꾼것++;
1842
+ continue;
1843
+ }
1844
+
1845
+ if (규격에맞나(m, 규격)) { out.push(m); continue; }
1846
+ if (m.role === 'assistant') {
1847
+ if (생각버리나(m, 규격)) 뺀것.생각++;
1848
+ out.push(assistantMessage(규격, {
1849
+ content: 본문글(m),
1850
+ thinking: 규격 === 'ollama' && typeof m.thinking === 'string' ? m.thinking : '',
1851
+ }));
1852
+ } else {
1853
+ out.push(사람말옮기기(m, 규격, 뺀것));
1854
+ }
1855
+ 바꾼것++;
1856
+ }
1857
+
1858
+ return 바꾼것 ? { messages: out, 바꾼것, 뺀것 } : { messages, 바꾼것: 0, 뺀것 };
1859
+ }