deel-local-cli 1.8.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
@@ -1,832 +1,1049 @@
1
- // 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
2
- import { readFileSync, existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { 그림장수, 글만, 그림한장토큰 } from '../backend/vision.js';
5
- import { get as workMode, 말 as 모드말, DEFAULT as WORK_DEFAULT } from './modes.js';
6
- import { toolSchemas } from '../tools/index.js';
7
- import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
8
- import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
9
- import { 지문 } from './project.js';
10
- import { 프롬프트토막 as 기억토막 } from './memory.js';
11
- import { 못박기 } from './pins.js';
12
- import { 언어, 지시말, 말 as 옮긴말 } from '../i18n/index.js';
13
- import { 셸안내 } from '../tools/shell.js';
14
-
15
- // 토큰 추정 정확한 토크나이저 없이 대략만 센다.
16
- // 한글은 글자당 1토큰, 영문·코드는 약 4글자당 1토큰으로 본다.
17
- export function estimateTokens(text) {
18
- const s = String(text ?? '');
19
- let cjk = 0;
20
- for (const ch of s) {
21
- const cp = ch.codePointAt(0);
22
- if ((cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x4e00 && cp <= 0x9fff)) cjk++;
23
- }
24
- const rest = s.length - cjk;
25
- return Math.ceil(cjk + rest / 3.6);
26
- }
27
-
28
- const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드를 읽고 고치는 도구다.
29
-
30
- 시킨 일을 **끝까지 해낸다.** 계획만 세우고 멈추지 않는다.
31
-
32
- - 바로 시작한다. 없는 파일·폴더는 만든다. 그게 시킨 일의 일부다.
33
- - 되묻는 것은 **도구로도 못 알아낼 때뿐**이다. 정할 수 있으면 정하고 무엇으로 정했는지 말한다.
34
- - 그래도 물어야 하면 **Ask 도구로 묻는다.** 글로 "알려주세요" 하고 끝내지 마라 — 그러면 그 턴이
35
- 끝나서 여태 읽은 것이 다 버려지고, 사람은 아무것도 안 된 화면을 본다. Ask 는 답이 그 자리로
36
- 돌아와 하던 일이 이어진다. 고를 것을 2~4개 같이 준다.
37
- - **이미 시킨 것을 다시 묻지 마라.** "파일 정리해 줘" 는 이미 답이다. 어떻게 정리할지 정하는
38
- 것이 네 일이지, 그걸 되물으면 사람은 같은 말을 두 번 하게 된다.
39
- - 도구가 자꾸 실패하면 **그것을 말해라.** 못 읽은 파일이 몇 개인지 적고 무엇이 막혔는지 알려라.
40
- 실패를 삼킨 채 "무엇을 도와드릴까요" 로 끝내면, 사람은 왜 안 됐는지 영영 모른다.
41
- - 여러 파일을 만들고 나눠 담아야 하는 일이면 그렇게 한다. 하나만 건드려 놓고 멈추지 마라.
42
- - 다 했으면 확인한다. 돌려 보고 안 되면 고친다. 확인 못 했으면 "확인 못 했다" 고 말한다.
43
-
44
- 지키는 것:
45
- - 추측하지 말고 도구로 확인한다. 있는 파일을 고치기 전에는 반드시 Read 로 읽는다.
46
- - Edit 의 old_string 은 공백과 들여쓰기까지 파일과 정확히 같아야 한다. 짧게 자르지 말고 앞뒤로 넉넉히 포함한다.
47
- - 큰 파일은 한 번에 다 담지 않는다. 앞부분 300줄쯤을 Write 로 만들고, 나머지는 Append 를
48
- 여러 번 불러 끝까지 이어 붙인다. Append 는 Read 없이 바로 쓸 수 있다.
49
- 이어 붙일 때 앞부분을 다시 보내지 않는다 — 그러면 또 같은 자리에서 잘린다.
50
- - 같은 도구를 같은 인자로 다시 부르지 않는다. 결과는 같다. 본 것은 기억하고 다음으로 넘어간다.
51
- - 사용자가 볼 범위를 못 박아 말하면 그 범위를 지킨다. 안 그러면 필요한 만큼 찾아본다.
52
- - 명령 실행이 필요하면 Bash 를 쓴다. 되돌릴 수 없는 명령은 막히니 다른 방법을 찾는다.
53
- - 중간 파일이 필요하면 /tmp 말고 .deel/tmp/ 에 쓴다. 작업 폴더 밖은 막힌다.
54
- - 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.
55
-
56
- 다음에도 쓸 것은 남긴다:
57
- - 사용자가 규칙을 정하거나 하지 말라고 하면 Remember 로 한 줄 남긴다. 그 글은 앞으로
58
- 모든 요청에 실리니 한 문장으로. 이번 일에서만 쓰는 것은 안 남긴다.
59
- - "저번에"·"전에 정한 대로" 처럼 앞선 대화를 가리키면 되묻기 전에 Recall 로 찾는다.
60
- - 또 하게 될 절차를 끝냈으면 .deel/skills/<이름>/SKILL.md 로 적어 둔다.
61
- 앞머리에 name 과 description 을 넣고(--- 로 감싼다) 아래에 순서를 적는다.
62
- 쓰던 스킬에서 틀린 데를 찾으면 그 파일을 고친다.`;
63
-
64
- /*
65
- * 작은 창을 위한 짧은 판.
66
- *
67
- * 같은 규칙이다 — 빠진 것은 없고, 설득하는 문장만 없다. 8k 모델에서 위의 긴
68
- * 판은 창의 13% 를 먹는데, 그 자리는 대화가 써야 하는 자리다.
69
- *
70
- * 짧게 쓰되 **더 못 박아** 쓴다. 작은 모델이 못하는 것이 '긴 글을 끝까지
71
- * 따라가기' 라서, 짧고 단정적인 쪽이 오히려 잘 지켜진다. (grade.js 도 같은
72
- * 생각으로 되어 있다 — 거기는 급, 여기는 창 크기라는 점만 다르다.)
73
- */
74
- const BASE_RULES_짧게 = `너는 deel 다. 사용자의 작업 폴더에서 코드를 읽고 고친다.
75
-
76
- 시킨 일을 끝까지 해낸다. 계획만 내고 멈추지 마라.
77
- - 바로 시작한다. 없는 파일·폴더는 만든다.
78
- - 도구로 알아낼 수 있으면 되묻지 말고 정한다. 무엇으로 정했는지는 말한다.
79
- - 그래도 물어야 하면 Ask 도구로 묻는다. 글로 묻고 끝내면 턴이 끝나 여태 읽은 것이 버려진다.
80
- - 이미 시킨 것을 다시 묻지 마라. 도구가 자꾸 실패하면 몇 개가 막혔는지 말해라.
81
- - 파일이 여럿이면 다 만든다. 하나만 하고 멈추지 마라.
82
- - 끝내기 전에 Verify 로 확인한다. 확인 못 했으면 "확인 못 했다" 고 말한다.
83
-
84
- - 고칠 파일은 먼저 Read 한다.
85
- - Edit 의 old_string 은 공백까지 파일과 똑같아야 한다. 앞뒤를 넉넉히 넣어라.
86
- - 긴 파일은 Write 로 앞부분만, 나머지는 Append 로 잇는다. 앞부분을 다시 보내지 마라.
87
- - 같은 도구를 같은 인자로 또 부르지 마라. 결과는 같다.
88
- - 답은 한국어로 짧게. 코드를 통째로 붙여넣지 마라.
89
- - 사용자가 정한 규칙은 Remember 로 한 줄 남긴다. "저번에" 라고 하면 Recall 로 찾는다.`;
90
-
91
- /*
92
- * ── 영어판 ──────────────────────────────────────────────────────────────
93
- *
94
- * /lang en 일 때 **모델이 읽는 글도** 영어로 간다. 화면 말만 바꾸는 1단계와
95
- * 여기가 다른 점이고, 다르게 한 데는 두 가지 이유가 있다.
96
- *
97
- * 1) 안 바꾸면 답이 한국어로 온다. 위 규칙에 "사용자에게 답할 때는 한국어로"
98
- * 가 박혀 있어서다. 화면 글자만 영어로 갈아 끼워 놓고 모델은 계속 한국어로
99
- * 답하면, 영어권 사람에게는 아무것도 안 고친 것과 같다.
100
- *
101
- * 2) 토큰이 눈에 띄게 싸다. 한글은 글자당 약 1토큰이고 영문·코드는 약 3.6자당
102
- * 1토큰이다(estimateTokens 를 볼 것). 32k 창에서 고정 몫이 15% 를 먹고
103
- * 있었는데, 그 몫이 줄면 그만큼 대화가 쓸 자리가 는다. 작은 창에서는
104
- * 이게 '파일 한 개를 더 읽을 수 있나' 를 가르는 크기다.
105
- *
106
- * 규칙 자체는 한 줄도 안 뺐다. 옮기면서 규칙이 느슨해지면 영어로 켠 사람만
107
- * 다른 프로그램을 쓰는 셈이 된다 — 특히 "확인 못 했으면 확인 못 했다고 말해라"
108
- * 같은 줄은 이 프로그램이 거짓말을 안 하게 하는 자리라 글자 그대로 옮겼다.
109
- */
110
- const BASE_RULES_EN = `You are deel, a tool that reads and edits code inside the user's working folder.
111
-
112
- **Finish the job.** Do not stop at a plan.
113
-
114
- - Start now. Create missing files and folders — that is part of the job.
115
- - Ask back **only when no tool can tell you**. If you can decide, decide, and say what you decided it from.
116
- - When you truly must ask, **ask with the Ask tool.** Do not write "let me know what you want" and stop — that ends
117
- the turn, everything you read is thrown away, and the user sees a screen where nothing happened. An Ask answer
118
- comes straight back to where you are, so the work carries on. Give 2–4 options to pick from.
119
- - **Never ask back what you were already told.** "Tidy up the files" is already the answer. Deciding how to tidy
120
- them is your job; asking it back makes the person say the same thing twice.
121
- - If tools keep failing, **say so.** Report how many files you could not read and what blocked you. Swallowing
122
- the failures and ending with "what can I help you with?" leaves the person with no idea why nothing happened.
123
- - If the job needs several files, make them all. Do not touch one and stop.
124
- - When you are done, check. Run it, and fix it if it fails. If you could not check, say "I could not verify this."
125
-
126
- Rules:
127
- - Do not guess — confirm with a tool. Always Read an existing file before editing it.
128
- - Edit's old_string must match the file exactly, whitespace and indentation included. Do not trim it short; include plenty of surrounding context.
129
- - Do not put a large file in one call. Write the first ~300 lines, then call Append repeatedly until the rest is in place.
130
- Append needs no Read first. Do not resend the earlier part when appending — it will just get cut at the same place again.
131
- - Do not call the same tool with the same arguments twice. The result will be the same. Remember what you saw and move on.
132
- - If the user names the scope to look at, stay inside it. Otherwise look as far as you need.
133
- - Use Bash when you need to run a command. Commands that cannot be undone are blocked, so find another way.
134
- - If you need a scratch file, write it under .deel/tmp/, not /tmp. Outside the working folder is blocked.
135
- - Answer the user in English, briefly. Do not paste whole files back — say what changed.
136
-
137
- Keep what will be needed again:
138
- - When the user sets a rule or tells you not to do something, leave one line with Remember. That line rides on every
139
- later request, so keep it to one sentence. Do not record anything that only applies to this one job.
140
- - When the user points back ("last time", "as we decided"), search with Recall before asking again.
141
- - When you finish a procedure you will do again, write it to .deel/skills/<name>/SKILL.md.
142
- Put name and description in the front matter (fenced with ---) and the steps below. If you find a mistake in a
143
- skill you used, fix that file.`;
144
-
145
- /*
146
- * 작은 창을 위한 짧은 영어판. 위 짧은 판과 같은 생각이다 —
147
- * 빠진 규칙은 없고 설득하는 문장만 없다.
148
- */
149
- const BASE_RULES_짧게_EN = `You are deel. You read and edit code in the user's working folder.
150
-
151
- Finish the job. Do not stop at a plan.
152
- - Start now. Create missing files and folders.
153
- - If a tool can tell you, decide instead of asking. Say what you decided it from.
154
- - If you must ask, use the Ask tool. Asking in prose ends the turn and throws away what you read.
155
- - Never ask back what you were told. If tools keep failing, say how many failed.
156
- - If there are several files, make them all. Do not do one and stop.
157
- - Verify before you finish. If you could not verify, say so.
158
-
159
- - Read a file before you edit it.
160
- - Edit's old_string must match the file exactly, whitespace included. Include plenty of context.
161
- - For a long file, Write the first part and Append the rest. Do not resend the earlier part.
162
- - Do not call the same tool with the same arguments twice. The result will be the same.
163
- - Answer in English, briefly. Do not paste whole files.
164
- - Record rules the user sets with Remember. When they say "last time", search with Recall.`;
165
-
166
- /**
167
- * 이 창 크기에 맞는 기본 규칙.
168
- *
169
- * 24k 를 경계로 삼는다. 그 아래에서는 긴 판이 창의 10% 를 넘어가기 시작한다 —
170
- * 도구 정의(budget.js 의 설명길이)가 줄어드는 자리와 같은 경계다. 두 개가
171
- * 같이 움직여야 '작은 창에서는 고정 몫을 줄인다' 가 한 가지 결정이 된다.
172
- */
173
- function 기본규칙(ctx) {
174
- const 짧게 = Number(ctx) > 0 && Number(ctx) < 24000;
175
- /*
176
- * 시키는 말은 지시말() 이 정한다 — 화면 말과 다른 축이다(i18n/index.js).
177
- *
178
- * 그래서 "영어로 시키고 한국어로 받기" 가 된다. 규칙 글은 영어판을 쓰되,
179
- * **답하는 말**만 다시 못 박는다. 안 박으면 영어 규칙 안의
180
- * "Answer the user in English" 가 그대로 먹어서, 한국 사람이 영어 답을
181
- * 받는다 — 값을 아끼려다 읽을 수 없는 답을 받는 셈이다.
182
- */
183
- const 시키는말 = 지시말();
184
- const 글 = 시키는말 === 'en'
185
- ? (짧게 ? BASE_RULES_짧게_EN : BASE_RULES_EN)
186
- : (짧게 ? BASE_RULES_짧게 : BASE_RULES);
187
- if (시키는말 === 언어()) return 글;
188
- return `${글}\n\n${언어() === 'ko'
189
- ? '**답은 한국어로 해라.** 위 규칙이 영어로 적혀 있어도 사용자에게 하는 말은 한국어다.'
190
- : '**Answer in English.** The rules above are in Korean, but what you say to the user is English.'}`;
191
- }
192
-
193
- export class Session {
194
- constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = null } = {}) {
195
- this.conn = conn;
196
- this.root = root;
197
- this.mode = mode; // 승인 정책 — 얼마나 물어보나 (auto/confirm/strict)
198
- this.work = work ?? WORK_DEFAULT; // 작업 모드 — 무슨 일을 하는 중인가 (modes.js)
199
- // 이번 한마디에만 쓸 모드. 종합 모드일 때 요청을 보고 골라 넣는다 (agent/route.js).
200
- // 기본 모드(this.work)는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
201
- this.routed = null;
202
- // 사용자 수준 — 화면에 무엇을 내놓을지만 정한다. 안전 장치는 안 바꾼다 (ui/level.js)
203
- this.level = normLevel(level) ?? LEVEL_DEFAULT;
204
- this.think = think; // 기준 강도
205
- this.effort = effort; // 그 강도를 단계별로 어떻게 나눌지 (effort.js)
206
- this.web = web; // 웹 읽기 도구를 줄지 (오프라인이면 무조건 안 준다)
207
- /*
208
- * 걸음 수 상한.
209
- *
210
- * 보통은 **작업 모드가 정한다** — 묻기는 8, 코드는 60, 총괄은 100 처럼
211
- * 일의 성격에 맞는 값이 다르기 때문이다. 여기 값은 부를 때 직접 준 경우에만
212
- * 이긴다(loop.js 가 stepsSet 을 본다).
213
- *
214
- * 전에는 stepsSet 을 아무 데서도 안 넣어서, 직접 준 값이 **조용히 무시**됐다.
215
- * 부르는 쪽에서는 4를 줬는데 60을 도는 식이라, 검사에서야 겨우 드러났다.
216
- */
217
- this.stepsSet = maxSteps != null;
218
- this.maxSteps = maxSteps ?? 24;
219
- this.messages = [];
220
- // 추정 × 보정 = 실제. 서버가 알려 주는 진짜 토큰 수로 매 턴 고쳐 나간다.
221
- // 1 은 '아직 안 배웠다' 이고, 그때는 추정을 그대로 쓴다. 배운다() 를 볼 것.
222
- this.보정 = 1;
223
- this.보정잰것 = 0;
224
- // 겪어 본 것 요약 (agent/evolve.js). 켤 때 repl 이 채운다.
225
- this.배움요약 = null;
226
- /*
227
- * 못 박은 것 (agent/pins.js).
228
- *
229
- * 여기에 두는 것이 핵심이다. messages 안에 넣으면 접기와 요약이 언젠가
230
- * 가져간다 — 그래서 아예 그 바깥, 시스템 프롬프트 쪽에 둔다.
231
- */
232
- this.못박은것 = new 못박기();
233
- this.filesRead = new Map(); // 경로 → 추정 토큰
234
- this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
235
- /*
236
- * 상태줄이 보는 두 숫자.
237
- *
238
- * 여기 들고 있는 이유는 **화면을 그릴 때마다 디스크를 읽지 않기 위해서**다.
239
- * 상태줄은 사람이 글자 하나 때마다 다시 그려진다. 거기서 되돌리기
240
- * 이력 파일을 열면 타이핑이 끊긴다 — 화면 꾸미기가 입력을 느리게 만드는
241
- * 것만큼 나쁜 것이 없다. repl 이 턴이 끝날 때 한 번씩 채워 준다.
242
- */
243
- this.되돌릴턴 = 0;
244
- this.검증 = { 돈횟수: 0, 확인: 0, 탈: 0 };
245
- this.skills = []; // 켜질 PC 에서 찾은 것들
246
- /*
247
- * 이 자리에 언어 서버가 있나 (Def·Refs 를 목록에 넣을지).
248
- *
249
- * repl 재서 넣어 준다. 여기서 직접 안 재는 이유는 폴더를
250
- * 훑어야 있어서다세션은 시험에서도 수없이 만들어지는데, 그때마다
251
- * 폴더를 훑으면 시험이 느려지고 그 자리에 뭐가 깔렸는지에 따라 결과가
252
- * 달라진다. 기본은 꺼짐이고, 켜 주는 자리가 딱 하나다.
253
- */
254
- this.lsp = false;
255
- this.commands = [];
256
- this.plugins = [];
257
- this.maxSkillsListed = 40; // 프롬프트에 올릴 최대 개수
258
- this.maxSkillDesc = 140; // 설명 한 줄 최대 길이
259
- this.usage = { in: 0, out: 0, calls: 0, ms: 0, retries: 0 };
260
- /*
261
- * 지금 붙은 모델이 얼마나 하는가 (agent/grade.js).
262
- *
263
- * 창 크기와는 다른 축이다. 창은 '얼마나 담나', 급은 '얼마나 알아서 하나'.
264
- * 128k 짜리 3B 모델과 32k 짜리 좋은 모델을 같은 값으로 다루면 둘 다 손해다.
265
- *
266
- * 처음에는 이름으로 짐작하고, 대화가 돌수록 **실제로 것**으로 고쳐 잡는다.
267
- * 사람이 /grade 로 정하면 그것이 이긴다.
268
- */
269
- this.본것 = new 지켜본것();
270
- this.급정한것 = null;
271
- this.startedAt = Date.now();
272
- this.rules = this.#loadRules();
273
- /*
274
- * 이 폴더가 무슨 프로젝트인가 (agent/project.js).
275
- *
276
- * 규칙(DEEL.md)과 같은 자리에서 읽는다 — 켤 때 한 번이다. 매 턴 다시 읽으면
277
- * 긴 대화에서 수십 번이 되고, 그 사이 사람이 package.json 고쳐 놓으면
278
- * 대화 도중에 프롬프트가 바뀐다. 무엇 때문에 답이 달라졌는지 알 길이 없어진다.
279
- */
280
- this.프로젝트 = 지문(this.root, this.conn?.ctx ?? null);
281
- /*
282
- * 지난 대화에서 정한 것도 여기서 읽는다.
283
- *
284
- * 전에는 대화 화면(repl.js)에서만 넣었다. 그래서 `deel run` 야간 배치로
285
- * 도는 에는 기억이 실렸다. "우리 문서는 CP949 다" 를 사람이 앉아
286
- * 있을 때만 지키고 배치에서는 지키는 셈이라, 그게 제일 나쁜 어긋남이다.
287
- * 규칙과 같은 자리로 옮겨서 길이 같은 것을 들고 시작하게 한다.
288
- */
289
- this.memory = 기억토막(this.root);
290
- }
291
-
292
- #loadRules() {
293
- for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
294
- const p = join(this.root, name);
295
- if (existsSync(p)) {
296
- try { return { name, text: readFileSync(p, 'utf8').slice(0, 20000) }; } catch {}
297
- }
298
- }
299
- return null;
300
- }
301
-
302
- /**
303
- * 지금 순간 실제로 쓰는 작업 모드.
304
- *
305
- * 종합 모드에서는 한마디마다 골라 넣은 것(routed)이 있고, 그때는 그것이 답이다.
306
- * 직접 고른 모드가 있으면 routed 는 비어 있으므로 기본 모드가 그대로 답이 된다.
307
- * 도구·추론·프롬프트가 전부 이 값을 봐야 한다. 하나라도 빠뜨리면 어긋난다.
308
- */
309
- effectiveWork() {
310
- return this.routed ?? this.work;
311
- }
312
-
313
- /** 지금 매겨진 모델 급. 화면과 프롬프트가 같은 것을 봐야 한다. */
314
- 급() { return 매김(this.conn, this.본것, this.급정한것); }
315
-
316
- /** 급에서 쓸 손잡이 값들 (한 번에 만들 파일 수 같은 것). */
317
- 급값() { return 급값(this.급().급); }
318
-
319
- systemPrompt() {
320
- const = 언어() === 'en';
321
- const parts = [기본규칙(this.conn?.ctx)];
322
- // 범위를 못 박는 줄. 이건 모델이 읽는 글이라 화면 말을 따라간다.
323
- parts.push(영
324
- ? `\nWorking folder: ${this.root}\nYou can neither read nor write files outside this folder.`
325
- : `\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
326
- // 어느 셸에서 명령이 도는지 (tools/shell.js). 모델이 ls 칠지 dir 를 칠지가
327
- // 여기서 갈린다 cmd 에서 유닉스 명령을 치면 번에 20~40초짜리 헛걸음이다.
328
- // 세션 안에서는 안 변하는 줄이라 앞머리(캐시되는 자리)에 둔다.
329
- parts.push(셸안내(영));
330
-
331
- /*
332
- * 모델 급에 맞춘 한 문단 (grade.js).
333
- *
334
- * 모델에는 아무것도붙는다 이미 아는 것을 다시 읽느라 자리만 먹는다.
335
- * 작은 모델에만, 짧게, 박아서 붙는다. 급이 못하는 것이 바로
336
- * '긴 글을 끝까지 따라가기' 라서, 길게 쓰면 오히려 나빠진다.
337
- */
338
- const 급글 = 급말(this.급().급);
339
- if (급글) parts.push(`\n${급글}`);
340
-
341
- /*
342
- * 이 폴더가 무슨 프로젝트인가 (agent/project.js).
343
- *
344
- * 규칙보다 **앞에** 둔다. 사용자 규칙은 "이 프로젝트에서는 이렇게 해라"
345
- * 말이라, 무슨 프로젝트인지를 먼저 읽은 뒤에 와야 말이 이어진다.
346
- */
347
- if (this.프로젝트) parts.push(this.프로젝트);
348
-
349
- if (this.rules) {
350
- // 사용자 규칙 파일의 **내용은 안 건드린다.** 사람이 쓴 글이고, 그 사람의
351
- // 말로 모델에게 가야 한다. 여기서 바뀌는 것은 그것을 소개하는 머리말뿐이다.
352
- parts.push(영
353
- ? `\n--- ${this.rules.name} (user rules these win over the principles above) ---\n${this.rules.text}`
354
- : `\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
355
- }
356
-
357
- /*
358
- * 지난 대화에서 정한 것.
359
- *
360
- * 이건 '찾으면 나오는' 것이 아니라 **처음부터 들어가 있어야** 하는 것이다.
361
- * "우리 문서는 CP949 다" 매번 다시 설명하게 하면 번째부터 짜증이 나고
362
- * 세 번째부터는 그냥 안 쓴다.
363
- *
364
- * 켤 때 한 번 읽어 들고 있는다. 매 턴 파일을 읽으면 긴 대화에서 그 횟수가
365
- * 수십 번이 되고, 그 사이 사람이 파일을 고쳐 놓으면 대화 도중에 규칙이
366
- * 바뀌는 셈이 된다 — 무엇 때문에 답이 달라졌는지 알 길이 없어진다.
367
- */
368
- if (this.memory) parts.push(this.memory);
369
- /*
370
- * PC·이 폴더에서 겪어 (agent/evolve.js).
371
- *
372
- * 기억(memory)은 사람이 적어 주는 것이고, 이건 **겪어서 저절로 쌓인 것**이다.
373
- * 여기 한 줄이 헛도는 걸음 서너 개를 없앤다 — 안 되는 명령을 또 부르고,
374
- * 잘릴 걸 알면서 큰 Write 를 또 보내는 걸음들이다. 그래서 자리를 내줄 값이 있다.
375
- * 상한은 evolve.js 못 박는다(220토큰).
376
- */
377
- if (this.배움요약) parts.push(this.배움요약);
378
- const listed = this.listedSkills();
379
- if (listed.length) {
380
- parts.push(
381
- (
382
- ? '\n--- skills available ---\nCall the Skill tool with a name to get its body. If none fits, just carry on.\n'
383
- : '\n--- 쓸 수 있는 스킬 ---\n필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n') +
384
- // 설명이 없는 스킬이 섞일 수 있다 — 남의 폴더에서 오는 파일이라
385
- // 앞머리(frontmatter)가 빠지곤 한다. 여기서 터지면 시스템 프롬프트를
386
- // 못 만들어 **매 턴** 죽는다. 목록 명령 하나가 아니라 대화 전체가 막힌다.
387
- listed.map((s) => `- ${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n')
388
- );
389
- const rest = this.skills.filter((s) => s.enabled).length - listed.length;
390
- if (rest > 0) {
391
- parts.push(영
392
- ? `(${rest} more exist but did not fit.)`
393
- : `(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
394
- }
395
- }
396
- /*
397
- * 지금 무슨 일을 하는 중인지 — **일부러 끝쪽에** 둔다.
398
- *
399
- * 이 절은 이 프롬프트에서 유일하게 **매 턴 바뀔 수 있는** 자리다. 말을
400
- * 던질 때마다 알맞은 모드로 저절로 옮겨 가기 때문이다(route.js). 그런데
401
- * Ollama·llama.cpp 프리픽스 캐시는 앞부분이 지난 요청과 같을 때만
402
- * 계산을 재쓴다 — 이 절이 앞쪽에 있으면 모드가 바뀌는 순간 그 뒤 전부,
403
- * 시스템 프롬프트 나머지에 대화 전체까지 다시 계산된다. 대화일수록
404
- * 매 턴 몇천 토큰이고, 로컬에서는 그게 그대로 몇 초다.
405
- *
406
- * 그래서 변하지 않는 것들(규칙·폴더·급말·지문·사용자 규칙·기억·스킬)
407
- * 앞에 굳히고 절을 뒤로 보냈다. 읽기 쪽으로도 손해가 아니다 끝자리는
408
- * 가운데보다 오히려 읽힌다(lost in the middle 반대편이다).
409
- * 차례는 test/cache.test.js 지킨다.
410
- *
411
- * 도구 목록도 이 모드에 맞춰 이미 걸러져 있다. 창이 좁으면 짧은 판을
412
- * 쓴다 (modes.js 의 말()) — 규칙은 같고 설득하는 문장만 빠진다.
413
- */
414
- const w = workMode(this.effectiveWork());
415
- parts.push(영
416
- ? `\n--- current mode: ${w.en} ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`
417
- : `\n--- 지금 모드: ${w.name} (${w.en}) ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`);
418
- /*
419
- * 박은 것은 **맨 끝**에 붙인다 (agent/pins.js).
420
- *
421
- * 긴 글의 가운데는 흘려 읽힌다 — 'lost in the middle' 이라 부르는 것이고,
422
- * 어느 모델에서나 잰다. 사람이 직접 못 박은 말은 그 가운데에 묻히면 안 되므로
423
- * 가장 마지막, 대화 바로 앞에 둔다. 모드 절보다도 뒤인 것도 그래서다.
424
- */
425
- const 못박은글 = this.못박은것?.요약();
426
- if (못박은글) parts.push(못박은글);
427
- return parts.join('\n');
428
- }
429
-
430
- // 프롬프트에 실제로 올릴 스킬: 가까운 자리(프로젝트 > 사용자 > 플러그인) 순으로 상한까지.
431
- listedSkills() {
432
- const rank = { project: 0, user: 1, plugin: 2 };
433
- return this.skills
434
- .filter((s) => s.enabled)
435
- .slice()
436
- .sort((a, b) => (rank[a.source] ?? 3) - (rank[b.source] ?? 3))
437
- .slice(0, this.maxSkillsListed);
438
- }
439
-
440
- /*
441
- * ── 턴이 어디서 시작했는지 ────────────────────────────────────────────
442
- *
443
- * 되돌리기(/undo)는 파일만 되돌렸다. 대화에는 "src/runner.js 를 고쳤습니다" 가
444
- * 그대로 남아 있어서, 되돌린 다음 턴에 모델은 **이미 고쳐 놓은 줄 알고** 그
445
- * 위에 이어 일했다 없는 코드를 고치려 들고, 없는 함수를 부른다. 사람 눈에는
446
- * 모델이 헛소리하는 것으로 보이지만, 사실은 우리가 모델에게 거짓말을 남겨
447
- * 것이다. 그러니 파일을 되감을 말도 같이 걷어내야 한다.
448
- *
449
- * 자리를 **숫자로 적어 두지 않는다.** 접기(compact) 줄이기(trim)가 messages
450
- * 를 통째로 갈아 끼우기 때문에, 적어 둔 3번은 다음 순간 엉뚱한 말을 가리킨다.
451
- * 잘못된 자리에서 자르는 되돌리기는 하느니만 못하다. 그래서 **메시지 객체
452
- * 자체**를 들고 있다가 그때그때 indexOf 로 찾는다. 접혀서 사라졌으면 못 찾고,
453
- * 찾으면 턴은 되감을 없다고 정직하게 말한다.
454
- */
455
- #턴표 = [];
456
- #다음턴 = null;
457
- static #표최대 = 200;
458
-
459
- /** 턴을 연다. 바로 다음에 push 되는 말이 이 턴의 첫 말이 된다. */
460
- 턴시작() {
461
- if (턴 == null) return this;
462
- // 접혀 없어진 표는 여기서 턴다 — 안 그러면 긴 대화에서 끝없이 쌓인다.
463
- this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
464
- if (this.#턴표.length > Session.#표최대) this.#턴표 = this.#턴표.slice(-Session.#표최대);
465
- this.#다음턴 = 턴;
466
- return this;
467
- }
468
-
469
- /** 살아 있는 턴 표시들. 접혀 사라진 것은 빠진다. @returns {{턴:number, 자리:number}[]} */
470
- 턴자리() {
471
- const out = [];
472
- for (const x of this.#턴표) {
473
- const i = this.messages.indexOf(x.표);
474
- if (i >= 0) out.push({ 턴: x.턴, 자리: i });
475
- }
476
- return out.sort((a, b) => a.자리 - b.자리);
477
- }
478
-
479
- /**
480
- * 주어진 턴들의 말을 걷어낸다.
481
- *
482
- * 여러 턴이면 그중 **제일 이른** 자리까지 간다 그 뒤는 어차피 되돌린 파일
483
- * 위에서 나눈 이야기라 남겨 이유가 없다. 사람이 쳤던 말은 돌려준다,
484
- * 다시 치기 쉽게.
485
- *
486
- * 자르고 나서 repairToolPairs 를 반드시 한 번 돌린다. 도구를 부른 assistant
487
- * 남고 결과가 없으면 그 뒤 모든 요청이 400 으로 튕긴다 — 되돌리기가
488
- * 대화를 아예 쓰게 만드는 셈이다. 경계는 보통 깨끗하지만, 여기서만은
489
- * '보통' 기대지 않는다.
490
- */
491
- 되감기(턴들) {
492
- const 찾을것 = new Set((Array.isArray(턴들) ? 턴들 : []).filter((t) => t != null));
493
- const 빈것 = { 걷은것: 0, 고친것: 0, 사람말: null, 턴: [] };
494
- if (!찾을것.size) return 빈것;
495
-
496
- const 표들 = this.턴자리().filter((x) => 찾을것.has(x.턴));
497
- if (!표들.length) return 빈것;
498
-
499
- const 자리 = 표들[0].자리;
500
- const 첫말 = this.messages[자리];
501
- const 사람말 = 첫말?.role === 'user' && typeof 첫말.content === 'string' ? 첫말.content : null;
502
-
503
- const = this.messages.length;
504
- const 고침 = repairToolPairs(this.messages.slice(0, 자리));
505
- this.messages = 고침.messages;
506
- this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
507
- this.#다음턴 = null;
508
- return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
509
- }
510
-
511
- push(msg) {
512
- if (this.#다음턴 != null) { this.#턴표.push({ 턴: this.#다음턴, 표: msg }); this.#다음턴 = null; }
513
- this.messages.push(msg);
514
- return this;
515
- }
516
-
517
- clear() {
518
- this.messages = [];
519
- this.filesRead.clear();
520
- this.#턴표 = [];
521
- this.#다음턴 = null;
522
- return this;
523
- }
524
-
525
- noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
526
-
527
- /**
528
- * 이번 대화에서 어느 파일이 얼마나 바뀌었는지 적어 둔다. /diff 가 이걸 본다.
529
- *
530
- * 견준 결과를 통째로 들고 있지는 않는다 파일 내용 두 벌이 딸려 온다.
531
- * 스무 고치면 그것만으로 수십 MB 다. 여기서는 숫자만 센다.
532
- */
533
- noteChange(path, d) {
534
- if (!path || !d) return;
535
- const 앞 = this.changes.get(path) ?? { added: 0, removed: 0, times: 0 };
536
- 앞.added += d.added ?? 0;
537
- 앞.removed += d.removed ?? 0;
538
- 앞.times += 1;
539
- this.changes.set(path, 앞);
540
- }
541
-
542
- // 모델에 실제로 보낼 배열.
543
- wire() {
544
- return [{ role: 'system', content: this.systemPrompt() }, ...this.messages];
545
- }
546
-
547
- /**
548
- * /context 그릴 내역.
549
- *
550
- * 여기서 나온 used 는 화면 숫자로만 쓰이는 게 아니다. effort.js 가 이 값으로
551
- * '남은 자리' 셈해 출력 상한을 정한다. 그래서 여기서 덜 세면 **매 요청마다
552
- * 그만큼 몰래 나간다** — 다 찼는데 안 찼다고 알고 있는 상태가 된다.
553
- *
554
- * 전에는 두 가지를 안 셌다.
555
- * · 도구 스키마 JSON — 도구 11종의 설명과 인자 정의. 약 1,800토큰이다.
556
- * 매 요청에 통째로 들어가는데 어느 칸에도 잡혔다.
557
- * · 지금 모드 문구 — modes.js 의 say. 모드마다 수백 토큰이다.
558
- */
559
- /**
560
- * 추정을 실제에 맞춰 간다.
561
- *
562
- * estimateTokens 는 추정이다 — 토크나이저를 안 싣기 때문이다(의존성 0개).
563
- * 그런데 서버는 응답에 **진짜 값**을 실어 준다(usage.prompt_tokens ·
564
- * prompt_eval_count). 여태 그 값은 /cost 에만 쓰고 버렸다.
565
- *
566
- * 안 맞으면 두 가지가 조용히 나빠진다.
567
- * · 적게 잡으면 남은 자리를 넉넉히 보고 답 상한을 크게 잡는다. 답이 잘린다.
568
- * · 많이 잡으면 — 아직 자리가 있는데 접기 시작한다. 창을 놀린다.
569
- * 화면에는 아무 말도 뜬다. 그래서 스스로 재게 한다.
570
- *
571
- * 조심한 것:
572
- * · 실제값을 안 주는 서버가 있다. 0 이면 아무것도 안 배운다.
573
- * · 표본이 작으면 비율이 튄다. 몇백 토큰짜리 대화는 건너뛴다.
574
- * · 0.5~2배 밖은 믿는다. 서버가 것을 세고 있을 있다.
575
- * · 되먹임이 겹치지 않게, 비교는 **보정 먹인 추정**으로 한다.
576
- *
577
- * @returns {number|null} 새 보정 배수. 못 배웠으면 null
578
- */
579
- 배운다(실제) {
580
- const n = Number(실제);
581
- if (!Number.isFinite(n) || n <= 0) return null;
582
- const 추정 = this.#원추정().used;
583
- if (추정 < 200) return null;
584
- const 비율 = n / 추정;
585
- if (비율 < 0.5 || 비율 > 2) return null;
586
- // 번은 그대로 받고, 그 뒤로는 천천히 따라간다. 한 번 튄 값에 안 휘둘린다.
587
- this.보정 = this.보정잰것 ? this.보정 + (비율 - this.보정) * 0.3 : 비율;
588
- this.보정잰것++;
589
- return this.보정;
590
- }
591
-
592
- /** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
593
- #원추정() {
594
- // 폴더 지문도 매 요청에 통째로 나간다. 시스템 프롬프트 쪽에 같이 센다 —
595
- // 안 세면 '남은 자리' 가 그만큼 뻥튀기되고, effort.js 가 그 값으로 출력
596
- // 상한을 잡으므로 답이 조용히 잘리기 시작한다.
597
- const sys = estimateTokens(기본규칙(this.conn?.ctx)) + estimateTokens(`작업 폴더: ${this.root}`)
598
- + estimateTokens(모드말(this.effectiveWork(), this.conn?.ctx) ?? '')
599
- + estimateTokens(this.프로젝트 ?? '');
600
- const rules = this.rules ? estimateTokens(this.rules.text) : 0;
601
- const listed = this.listedSkills();
602
- const skills = listed.length
603
- ? estimateTokens(listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
604
- : 0;
605
-
606
- let history = 0;
607
- let files = 0;
608
- for (const m of this.messages) {
609
- /*
610
- * 그림은 글자 수로 세지 않는다.
611
- *
612
- * 그림은 base64 실려 있어서 글로 세면 4MB 짜리 장이 150만 토큰으로
613
- * 잡힌다. 그러면 창이 다 찬 줄 알고 대화를 통째로 접는다 — 화면 사진 한 장
614
- * 보여 준 값으로 하던 일을 잃는 셈이다. 한 장에 얼마인지는 서버가 정하는
615
- * 것이라 우리는 모르므로, 정해 둔 값으로 세고 서버가 알려주는 실제 값으로
616
- * 고쳐 나간다 (backend/vision.js 의 그림한장토큰 머리말).
617
- */
618
- const 장수 = 그림장수(m);
619
- const t = estimateTokens(장수 ? 글만(m) : (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '')))
620
- + 장수 * 그림한장토큰
621
- + estimateTokens(JSON.stringify(m.tool_calls ?? ''));
622
- if (m.role === 'tool') files += t; else history += t;
623
- }
624
-
625
- // 도구 정의도 요청에 실려 나간다. 세는 값이라기보다 '이미 나간 값' 이다.
626
- const 도구 = this.#도구토큰();
627
-
628
- // 기억도 매 요청에 통째로 나간다. 안 세면 '남은 자리' 가 그만큼 뻥튀기되고,
629
- // effort.js 가 그 값으로 출력 상한을 잡으므로 답이 조용히 잘리기 시작한다.
630
- const 기억 = this.memory ? estimateTokens(this.memory) : 0;
631
- const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
632
-
633
- /*
634
- * 이름은 화면에 그대로 나간다(`/context`). 그래서 여기서 말 표를 거친다.
635
- * 여기만 한국어로 두면 영어로 켠 사람의 컨텍스트 표가 통째로 한국어가
636
- * 되는데, `/lang` 사이에도 100% 라고 답한다 — 표에 없는 글은
637
- * 세지지 못하기 때문이다. test/langleak.test.js 가 이 자리를 지킨다.
638
- */
639
- const rows = [
640
- { label: 옮긴말('ctx.system'), n: sys },
641
- { label: this.rules ? 옮긴말('ctx.rules', { 이름: this.rules.name }) : 옮긴말('ctx.rulesNone'), n: rules },
642
- { label: 옮긴말('ctx.memory', { n: 기억줄 }), n: 기억 },
643
- { label: 옮긴말('ctx.learned'), n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
644
- { label: 옮긴말('ctx.skills', { 실림: listed.length, 전체: this.skills.length }), n: skills },
645
- { label: 옮긴말('ctx.tools'), n: 도구 },
646
- { label: 옮긴말('ctx.history'), n: history },
647
- { label: 옮긴말('ctx.toolResults', { n: this.filesRead.size }), n: files },
648
- ];
649
- const used = rows.reduce((a, r) => a + r.n, 0);
650
- const total = this.conn.ctx ?? 32768;
651
- return { rows, used, total, left: Math.max(0, total - used) };
652
- }
653
-
654
- /**
655
- * 화면과 예산이 보는 값. 배운 보정을 먹여서 내놓는다.
656
- *
657
- * 줄마다 보정을 먹인다 — 합계만 고치면 표의 줄을 더한 값과 합계가 안 맞아서,
658
- * 보는 사람이 어느 쪽을 믿어야 할지 모르게 된다.
659
- */
660
- breakdown() {
661
- const 날것 = this.#원추정();
662
- if (!(this.보정잰것 > 0) || this.보정 === 1) return 날것;
663
- const rows = 날것.rows.map((r) => ({ ...r, n: Math.round(r.n * this.보정) }));
664
- const used = rows.reduce((a, r) => a + r.n, 0);
665
- return {
666
- rows,
667
- used,
668
- total: 날것.total,
669
- left: Math.max(0, 날것.total - used),
670
- 보정: this.보정,
671
- 보정잰것: this.보정잰것,
672
- };
673
- }
674
-
675
- /**
676
- * 도구 정의가 토큰인가.
677
- *
678
- * 모드가 바뀌면 도구 목록도 바뀌므로 모드별로 한 번씩만 재고 넣어 둔다.
679
- * JSON.stringify 하면 자체가 아깝다 값은 안 바뀌는데.
680
- */
681
- #도구잰것 = new Map();
682
- #도구토큰() {
683
- // 밖에서 붙인 도구 수까지 열쇠에 넣는다. 서버가 붙고 떨어지면 값이 달라진다.
684
- const mcp수 = (this.mcp ?? []).reduce((n, s) => n + (s.도구?.length ?? 0), 0);
685
- // 크기도 열쇠에 넣는다. 설명을 창에 맞춰 줄여 싣기 때문에(budget.js),
686
- // /ctx 창을 다시 잡으면 값도 달라져야 한다. 안 넣으면 옛 값이 남는다.
687
- const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
688
- if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
689
- let n = 0;
690
- try {
691
- const list = toolSchemas(null, {
692
- hasSkills: (this.skills?.length ?? 0) > 0,
693
- web: this.web !== false,
694
- work: this.effectiveWork(),
695
- mcp: this.mcp ?? null,
696
- lsp: this.lsp === true,
697
- // 실제로 나가는 것과 **같은 것**을 재야 한다. 넘기면 줄인 것을
698
- // 재게 되고, 그러면 /context 가 실제보다 크게 말한다 — 그 값으로
699
- // effort.js 출력 상한을 잡으므로 답이 이유 없이 짧아진다.
700
- ctx: this.conn?.ctx ?? null,
701
- vision: this.conn?.vision === true,
702
- });
703
- n = estimateTokens(JSON.stringify(list));
704
- } catch { n = 0; }
705
- this.#도구잰것.set(열쇠, n);
706
- return n;
707
- }
708
-
709
- /**
710
- * 오래된 대화를 잘라낸다. 앞의 2턴과 최근 절반만 남긴다.
711
- *
712
- * 자르는 자리를 **아무 데나 잡으면 안 된다.** 도구 호출과 그 결과는 한 몸이라,
713
- * 사이를 끊으면 규격 위반이 되어 그 뒤 모든 요청이 400 으로 거절당한다.
714
- *
715
- * 이게 실제로 있었던 길이다 — 접기(compact) 요약을 못 받으면 여기로
716
- * 물러섰는데, 여기가 짝을 맞췄다. 화면에는 노란 안내 줄이 뜨고,
717
- * 뒤로 무엇을 해도 400 났다. 원인은 화면 전이라 이어 붙일 수 없고,
718
- * /clear 말고는 길이 없었다. 물러설 자리가 더 큰 사고를 만든 셈이다.
719
- */
720
- trim() {
721
- if (this.messages.length < 12) return 0;
722
- const keepHead = safeHead(this.messages, 2);
723
- const keepTail = safeCut(this.messages, this.messages.length - Math.floor(this.messages.length / 2));
724
- const dropped = keepTail - keepHead;
725
- if (dropped <= 0) return 0;
726
- this.messages = [
727
- ...this.messages.slice(0, keepHead),
728
- { role: 'user', content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)` },
729
- ...this.messages.slice(keepTail),
730
- ];
731
- return dropped;
732
- }
733
- }
734
-
735
- /**
736
- * 도구 호출과 결과가 갈라지지 않는 자리를 찾는다.
737
- * i 번째부터 남긴다고 때, 안전한 i 옮겨 준다.
738
- *
739
- * 접기(compact.js)와 그냥 줄이기(trim)가 **같은 함수**를 쓴다. 전에는 접기에만
740
- * 있었고 줄이기는 제멋대로 잘랐다. 그래서 접기가 실패해 줄이기로 물러선 순간
741
- * 대화가 망가졌다 안전망이 사고를 만드는 자리는 이런 모양이다.
742
- */
743
- export function safeCut(messages, i) {
744
- let k = Math.max(0, Math.min(i, messages.length));
745
- // tool 결과로 시작하면 앞의 assistant(tool_calls)없어 규격이 깨진다. 앞으로 당긴다.
746
- while (k > 0 && messages[k]?.role === 'tool') k--;
747
- return k;
748
- }
749
-
750
- /**
751
- * 머리 쪽 자르는 자리.
752
- * 머리가 '결과를 기다리는 도구 호출' 로 끝나면 그 결과가 접혀 없어져 짝이 깨진다.
753
- * 그런 assistant 머리에서 뺀다 접히는 쪽에 같이 넘긴다.
754
- */
755
- export function safeHead(messages, k) {
756
- let h = Math.max(0, Math.min(k, messages.length));
757
- while (h > 0 && messages[h - 1]?.tool_calls?.length) h--;
758
- return h;
759
- }
760
-
761
- /**
762
- * 도중에 죽은 대화를 다시 있게 손본다.
763
- *
764
- * 필요한가:
765
- * store 는 메시지가 오갈 때마다 즉시 적는다(jsonl). 그래서 도구를 **돌리는
766
- * 도중에** 죽으면 `assistant(tool_calls)` 적히고 결과가 없다. Bash
767
- * 돌리는 중, 전원이 나가는 중, 창을 닫는 — 흔한 자리다.
768
- *
769
- * 그 이력을 그대로 이어받아 보내면 OpenAI 규격 서버는 400 낸다. 호출 뒤에는
770
- * 결과가 와야 한다는 규격이다. compact.js safeCut 으로 막고 있는 바로 그
771
- * 사고인데, **이어받기 길에는 안전망이 없었다.** 이어받자마자 첫 마디에서
772
- * 죽으니, 사람 눈에는 '이어하기가 고장 났다' 보인다.
773
- *
774
- * 무엇을 하나:
775
- * 결과가 없는 호출을 **지운다.** 가짜 결과를 채우지 않는다 — 안 돌아간 도구를
776
- * 돌았다고 적으면 모델이 거짓말 위에서 계속한다. 파일을 고쳤는데 고친
777
- * 줄 알고 다음 단계로 넘어가는 것이 제일 나쁘다.
778
- * 호출이 전부 빠졌는데 할 말도 없으면 그 메시지째 지운다.
779
- * 짝 없는 결과(앞에 호출이 없는 tool)도 지운다 — 머리가 잘려 나간 이력이다.
780
- *
781
- * 규격이 둘이라 짝짓는 법도 둘이다 (adapter.js 의 toolMessage):
782
- * OpenAI { role:'tool', tool_call_id } → id 짝짓는다
783
- * Ollama { role:'tool', tool_name } → id 없다. 순서로 짝짓는다
784
- *
785
- * @returns {{messages: object[], 고친것: number}} 고친것 = 걷어낸 호출·결과 수
786
- */
787
- export function repairToolPairs(messages) {
788
- const out = [];
789
- let 고친것 = 0;
790
-
791
- for (let i = 0; i < (messages?.length ?? 0); i++) {
792
- const m = messages[i];
793
-
794
- // 적다 줄. 도중에 죽으면 JSONL 한 줄이 반만 적히고, 그 자리가 빈 값이나
795
- // role 없는 조각으로 읽힌다. 그대로 보내면 서버가 거절한다 — 걷어내는 것이
796
- // 이 함수의 일이므로 여기서 같이 턴다.
797
- if (!m || typeof m !== 'object' || typeof m.role !== 'string') { 고친것++; continue; }
798
-
799
- // 호출 없이 굴러다니는 결과. 앞이 잘려 나간 이력이다.
800
- if (m.role === 'tool') { 고친것++; continue; }
801
-
802
- if (m?.role !== 'assistant' || !m.tool_calls?.length) { out.push(m); continue; }
803
-
804
- // 호출에 딸린 결과 묶음 바로 뒤에 붙어 있는 tool 들이 전부다.
805
- const 결과 = [];
806
- let j = i + 1;
807
- while (j < messages.length && messages[j]?.role === 'tool') 결과.push(messages[j++]);
808
- i = j - 1; // 결과는 여기서 같이 처리한다
809
-
810
- const 있는id = new Set(결과.map((r) => r?.tool_call_id).filter(Boolean));
811
- const 남길호출 = 있는id.size
812
- ? m.tool_calls.filter((c) => 있는id.has(c?.id))
813
- : m.tool_calls.slice(0, 결과.length); // id 가 없는 규격 — 순서로 본다
814
- const 남길id = new Set(남길호출.map((c) => c?.id).filter(Boolean));
815
- const 남길결과 = 있는id.size
816
- ? 결과.filter((r) => 남길id.has(r.tool_call_id))
817
- : 결과.slice(0, 남길호출.length);
818
-
819
- 고친것 += (m.tool_calls.length - 남길호출.length) + (결과.length - 남길결과.length);
820
-
821
- if (남길호출.length) {
822
- out.push(남길호출.length === m.tool_calls.length ? m : { ...m, tool_calls: 남길호출 });
823
- out.push(...남길결과);
824
- continue;
825
- }
826
- // 남은 호출이 없다. 할 말이라도 있으면 그건 살린다.
827
- const 글 = typeof m.content === 'string' ? m.content.trim() : '';
828
- if (글) { const { tool_calls: _버림, ...나머지 } = m; out.push(나머지); }
829
- }
830
-
831
- return { messages: out, 고친것 };
832
- }
1
+ // 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { 그림장수, 글만, 그림한장토큰 } from '../backend/vision.js';
5
+ import { 부른것들, 결과들, 도구결과인가 } from '../backend/adapter.js';
6
+ import { 조각표 } from '../backend/cachemark.js';
7
+ import { get as workMode, 말 as 모드말, DEFAULT as WORK_DEFAULT } from './modes.js';
8
+ import { toolSchemas } from '../tools/index.js';
9
+ import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
10
+ import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
11
+ import { 지문 } from './project.js';
12
+ import { 프롬프트토막 as 기억토막 } from './memory.js';
13
+ import { 못박기 } from './pins.js';
14
+ import { 파일기억 } from './filemem.js';
15
+ import { 언어, 지시말, as 옮긴말 } from '../i18n/index.js';
16
+ import { 셸안내 } from '../tools/shell.js';
17
+ import { estimateTokens } from '../backend/tokens.js';
18
+
19
+ /*
20
+ * 토큰 추정 자는 backend/tokens.js 에 한 벌만 둔다.
21
+ *
22
+ * 여기서 다시 내보낸다. 부르던 자리(compact·evolve·mention·pins) 그대로
23
+ * 두면서, 잎 모듈인 cachemark 도 같은 자를 쓸 수 있게 하려는 것이다.
24
+ * 자가 둘이면 언젠가 둘이 어긋나고, 어긋난 자는 화면에서 안 보인다.
25
+ */
26
+ export { estimateTokens } from '../backend/tokens.js';
27
+
28
+ const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드를 읽고 고치는 도구다.
29
+
30
+ 시킨 일을 **끝까지 해낸다.** 계획만 세우고 멈추지 않는다.
31
+
32
+ - 바로 시작한다. 없는 파일·폴더는 만든다. 그게 시킨 일의 일부다.
33
+ - 되묻는 것은 **도구로도 못 알아낼 때뿐**이다. 정할 수 있으면 정하고 무엇으로 정했는지 말한다.
34
+ - 그래도 물어야 하면 **Ask 도구로 묻는다.** 글로 "알려주세요" 하고 끝내지 마라 — 그러면 그 턴이
35
+ 끝나서 여태 읽은 것이 다 버려지고, 사람은 아무것도 안 된 화면을 본다. Ask 는 답이 그 자리로
36
+ 돌아와 하던 일이 이어진다. 고를 것을 2~4개 같이 준다.
37
+ - **이미 시킨 것을 다시 묻지 마라.** "파일 정리해 줘" 는 이미 답이다. 어떻게 정리할지 정하는
38
+ 것이 네 일이지, 그걸 되물으면 사람은 같은 말을 두 번 하게 된다.
39
+ - 도구가 자꾸 실패하면 **그것을 말해라.** 못 읽은 파일이 몇 개인지 적고 무엇이 막혔는지 알려라.
40
+ 실패를 삼킨 채 "무엇을 도와드릴까요" 로 끝내면, 사람은 왜 안 됐는지 영영 모른다.
41
+ - 여러 파일을 만들고 나눠 담아야 하는 일이면 그렇게 한다. 하나만 건드려 놓고 멈추지 마라.
42
+ - 다 했으면 확인한다. 돌려 보고 안 되면 고친다. 확인 못 했으면 "확인 못 했다" 고 말한다.
43
+
44
+ 지키는 것:
45
+ - 추측하지 말고 도구로 확인한다. 있는 파일을 고치기 전에는 반드시 Read 로 읽는다.
46
+ - Edit 의 old_string 은 공백과 들여쓰기까지 파일과 정확히 같아야 한다. 짧게 자르지 말고 앞뒤로 넉넉히 포함한다.
47
+ - 큰 파일은 한 번에 다 담지 않는다. 앞부분 300줄쯤을 Write 로 만들고, 나머지는 Append 를
48
+ 여러 번 불러 끝까지 이어 붙인다. Append 는 Read 없이 바로 쓸 수 있다.
49
+ 이어 붙일 때 앞부분을 다시 보내지 않는다 — 그러면 또 같은 자리에서 잘린다.
50
+ - 같은 도구를 같은 인자로 다시 부르지 않는다. 결과는 같다. 본 것은 기억하고 다음으로 넘어간다.
51
+ - 사용자가 볼 범위를 못 박아 말하면 그 범위를 지킨다. 안 그러면 필요한 만큼 찾아본다.
52
+ - 명령 실행이 필요하면 Bash 를 쓴다. 되돌릴 수 없는 명령은 막히니 다른 방법을 찾는다.
53
+ - 중간 파일이 필요하면 /tmp 말고 .deel/tmp/ 에 쓴다. 작업 폴더 밖은 막힌다.
54
+ - 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.
55
+
56
+ 다음에도 쓸 것은 남긴다:
57
+ - 사용자가 규칙을 정하거나 하지 말라고 하면 Remember 로 한 줄 남긴다. 그 글은 앞으로
58
+ 모든 요청에 실리니 한 문장으로. 이번 일에서만 쓰는 것은 안 남긴다.
59
+ - "저번에"·"전에 정한 대로" 처럼 앞선 대화를 가리키면 되묻기 전에 Recall 로 찾는다.
60
+ - 또 하게 될 절차를 끝냈으면 .deel/skills/<이름>/SKILL.md 로 적어 둔다.
61
+ 앞머리에 name 과 description 을 넣고(--- 로 감싼다) 아래에 순서를 적는다.
62
+ 쓰던 스킬에서 틀린 데를 찾으면 그 파일을 고친다.`;
63
+
64
+ /*
65
+ * 작은 창을 위한 짧은 판.
66
+ *
67
+ * 같은 규칙이다 — 빠진 것은 없고, 설득하는 문장만 없다. 8k 모델에서 위의 긴
68
+ * 판은 창의 13% 를 먹는데, 그 자리는 대화가 써야 하는 자리다.
69
+ *
70
+ * 짧게 쓰되 **더 못 박아** 쓴다. 작은 모델이 못하는 것이 '긴 글을 끝까지
71
+ * 따라가기' 라서, 짧고 단정적인 쪽이 오히려 잘 지켜진다. (grade.js 도 같은
72
+ * 생각으로 되어 있다 — 거기는 급, 여기는 창 크기라는 점만 다르다.)
73
+ */
74
+ const BASE_RULES_짧게 = `너는 deel 다. 사용자의 작업 폴더에서 코드를 읽고 고친다.
75
+
76
+ 시킨 일을 끝까지 해낸다. 계획만 내고 멈추지 마라.
77
+ - 바로 시작한다. 없는 파일·폴더는 만든다.
78
+ - 도구로 알아낼 수 있으면 되묻지 말고 정한다. 무엇으로 정했는지는 말한다.
79
+ - 그래도 물어야 하면 Ask 도구로 묻는다. 글로 묻고 끝내면 턴이 끝나 여태 읽은 것이 버려진다.
80
+ - 이미 시킨 것을 다시 묻지 마라. 도구가 자꾸 실패하면 몇 개가 막혔는지 말해라.
81
+ - 파일이 여럿이면 다 만든다. 하나만 하고 멈추지 마라.
82
+ - 끝내기 전에 Verify 로 확인한다. 확인 못 했으면 "확인 못 했다" 고 말한다.
83
+
84
+ - 고칠 파일은 먼저 Read 한다.
85
+ - Edit 의 old_string 은 공백까지 파일과 똑같아야 한다. 앞뒤를 넉넉히 넣어라.
86
+ - 긴 파일은 Write 로 앞부분만, 나머지는 Append 로 잇는다. 앞부분을 다시 보내지 마라.
87
+ - 같은 도구를 같은 인자로 또 부르지 마라. 결과는 같다.
88
+ - 답은 한국어로 짧게. 코드를 통째로 붙여넣지 마라.
89
+ - 사용자가 정한 규칙은 Remember 로 한 줄 남긴다. "저번에" 라고 하면 Recall 로 찾는다.`;
90
+
91
+ /*
92
+ * ── 영어판 ──────────────────────────────────────────────────────────────
93
+ *
94
+ * /lang en 일 때 **모델이 읽는 글도** 영어로 간다. 화면 말만 바꾸는 1단계와
95
+ * 여기가 다른 점이고, 다르게 한 데는 두 가지 이유가 있다.
96
+ *
97
+ * 1) 안 바꾸면 답이 한국어로 온다. 위 규칙에 "사용자에게 답할 때는 한국어로"
98
+ * 가 박혀 있어서다. 화면 글자만 영어로 갈아 끼워 놓고 모델은 계속 한국어로
99
+ * 답하면, 영어권 사람에게는 아무것도 안 고친 것과 같다.
100
+ *
101
+ * 2) 토큰이 눈에 띄게 싸다. 한글은 글자당 약 1토큰이고 영문·코드는 약 3.6자당
102
+ * 1토큰이다(estimateTokens 를 볼 것). 32k 창에서 고정 몫이 15% 를 먹고
103
+ * 있었는데, 그 몫이 줄면 그만큼 대화가 쓸 자리가 는다. 작은 창에서는
104
+ * 이게 '파일 한 개를 더 읽을 수 있나' 를 가르는 크기다.
105
+ *
106
+ * 규칙 자체는 한 줄도 안 뺐다. 옮기면서 규칙이 느슨해지면 영어로 켠 사람만
107
+ * 다른 프로그램을 쓰는 셈이 된다 — 특히 "확인 못 했으면 확인 못 했다고 말해라"
108
+ * 같은 줄은 이 프로그램이 거짓말을 안 하게 하는 자리라 글자 그대로 옮겼다.
109
+ */
110
+ const BASE_RULES_EN = `You are deel, a tool that reads and edits code inside the user's working folder.
111
+
112
+ **Finish the job.** Do not stop at a plan.
113
+
114
+ - Start now. Create missing files and folders — that is part of the job.
115
+ - Ask back **only when no tool can tell you**. If you can decide, decide, and say what you decided it from.
116
+ - When you truly must ask, **ask with the Ask tool.** Do not write "let me know what you want" and stop — that ends
117
+ the turn, everything you read is thrown away, and the user sees a screen where nothing happened. An Ask answer
118
+ comes straight back to where you are, so the work carries on. Give 2–4 options to pick from.
119
+ - **Never ask back what you were already told.** "Tidy up the files" is already the answer. Deciding how to tidy
120
+ them is your job; asking it back makes the person say the same thing twice.
121
+ - If tools keep failing, **say so.** Report how many files you could not read and what blocked you. Swallowing
122
+ the failures and ending with "what can I help you with?" leaves the person with no idea why nothing happened.
123
+ - If the job needs several files, make them all. Do not touch one and stop.
124
+ - When you are done, check. Run it, and fix it if it fails. If you could not check, say "I could not verify this."
125
+
126
+ Rules:
127
+ - Do not guess — confirm with a tool. Always Read an existing file before editing it.
128
+ - Edit's old_string must match the file exactly, whitespace and indentation included. Do not trim it short; include plenty of surrounding context.
129
+ - Do not put a large file in one call. Write the first ~300 lines, then call Append repeatedly until the rest is in place.
130
+ Append needs no Read first. Do not resend the earlier part when appending — it will just get cut at the same place again.
131
+ - Do not call the same tool with the same arguments twice. The result will be the same. Remember what you saw and move on.
132
+ - If the user names the scope to look at, stay inside it. Otherwise look as far as you need.
133
+ - Use Bash when you need to run a command. Commands that cannot be undone are blocked, so find another way.
134
+ - If you need a scratch file, write it under .deel/tmp/, not /tmp. Outside the working folder is blocked.
135
+ - Answer the user in English, briefly. Do not paste whole files back — say what changed.
136
+
137
+ Keep what will be needed again:
138
+ - When the user sets a rule or tells you not to do something, leave one line with Remember. That line rides on every
139
+ later request, so keep it to one sentence. Do not record anything that only applies to this one job.
140
+ - When the user points back ("last time", "as we decided"), search with Recall before asking again.
141
+ - When you finish a procedure you will do again, write it to .deel/skills/<name>/SKILL.md.
142
+ Put name and description in the front matter (fenced with ---) and the steps below. If you find a mistake in a
143
+ skill you used, fix that file.`;
144
+
145
+ /*
146
+ * 작은 창을 위한 짧은 영어판. 위 짧은 판과 같은 생각이다 —
147
+ * 빠진 규칙은 없고 설득하는 문장만 없다.
148
+ */
149
+ const BASE_RULES_짧게_EN = `You are deel. You read and edit code in the user's working folder.
150
+
151
+ Finish the job. Do not stop at a plan.
152
+ - Start now. Create missing files and folders.
153
+ - If a tool can tell you, decide instead of asking. Say what you decided it from.
154
+ - If you must ask, use the Ask tool. Asking in prose ends the turn and throws away what you read.
155
+ - Never ask back what you were told. If tools keep failing, say how many failed.
156
+ - If there are several files, make them all. Do not do one and stop.
157
+ - Verify before you finish. If you could not verify, say so.
158
+
159
+ - Read a file before you edit it.
160
+ - Edit's old_string must match the file exactly, whitespace included. Include plenty of context.
161
+ - For a long file, Write the first part and Append the rest. Do not resend the earlier part.
162
+ - Do not call the same tool with the same arguments twice. The result will be the same.
163
+ - Answer in English, briefly. Do not paste whole files.
164
+ - Record rules the user sets with Remember. When they say "last time", search with Recall.`;
165
+
166
+ /**
167
+ * 이 창 크기에 맞는 기본 규칙.
168
+ *
169
+ * 24k 를 경계로 삼는다. 그 아래에서는 긴 판이 창의 10% 를 넘어가기 시작한다 —
170
+ * 도구 정의(budget.js 의 설명길이)가 줄어드는 자리와 같은 경계다. 두 개가
171
+ * 같이 움직여야 '작은 창에서는 고정 몫을 줄인다' 가 한 가지 결정이 된다.
172
+ */
173
+ function 기본규칙(ctx) {
174
+ const 짧게 = Number(ctx) > 0 && Number(ctx) < 24000;
175
+ /*
176
+ * 시키는 말은 지시말() 이 정한다 — 화면 말과 다른 축이다(i18n/index.js).
177
+ *
178
+ * 그래서 "영어로 시키고 한국어로 받기" 가 된다. 규칙 글은 영어판을 쓰되,
179
+ * **답하는 말**만 다시 못 박는다. 안 박으면 영어 규칙 안의
180
+ * "Answer the user in English" 가 그대로 먹어서, 한국 사람이 영어 답을
181
+ * 받는다 — 값을 아끼려다 읽을 수 없는 답을 받는 셈이다.
182
+ */
183
+ const 시키는말 = 지시말();
184
+ const 글 = 시키는말 === 'en'
185
+ ? (짧게 ? BASE_RULES_짧게_EN : BASE_RULES_EN)
186
+ : (짧게 ? BASE_RULES_짧게 : BASE_RULES);
187
+ if (시키는말 === 언어()) return 글;
188
+ return `${글}\n\n${언어() === 'ko'
189
+ ? '**답은 한국어로 해라.** 위 규칙이 영어로 적혀 있어도 사용자에게 하는 말은 한국어다.'
190
+ : '**Answer in English.** The rules above are in Korean, but what you say to the user is English.'}`;
191
+ }
192
+
193
+ export class Session {
194
+ constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = null } = {}) {
195
+ this.conn = conn;
196
+ this.root = root;
197
+ this.mode = mode; // 승인 정책 — 얼마나 물어보나 (auto/confirm/strict)
198
+ this.work = work ?? WORK_DEFAULT; // 작업 모드 — 무슨 일을 하는 중인가 (modes.js)
199
+ // 이번 한마디에만 쓸 모드. 종합 모드일 때 요청을 보고 골라 넣는다 (agent/route.js).
200
+ // 기본 모드(this.work)는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
201
+ this.routed = null;
202
+ // 사용자 수준 — 화면에 무엇을 내놓을지만 정한다. 안전 장치는 안 바꾼다 (ui/level.js)
203
+ this.level = normLevel(level) ?? LEVEL_DEFAULT;
204
+ this.think = think; // 기준 강도
205
+ this.effort = effort; // 그 강도를 단계별로 어떻게 나눌지 (effort.js)
206
+ this.web = web; // 웹 읽기 도구를 줄지 (오프라인이면 무조건 안 준다)
207
+ /*
208
+ * 걸음 수 상한.
209
+ *
210
+ * 보통은 **작업 모드가 정한다** — 묻기는 8, 코드는 60, 총괄은 100 처럼
211
+ * 일의 성격에 맞는 값이 다르기 때문이다. 여기 값은 부를 때 직접 준 경우에만
212
+ * 이긴다(loop.js 가 stepsSet 을 본다).
213
+ *
214
+ * 전에는 stepsSet 을 아무 데서도 안 넣어서, 직접 준 값이 **조용히 무시**됐다.
215
+ * 부르는 쪽에서는 4를 줬는데 60을 도는 식이라, 검사에서야 겨우 드러났다.
216
+ */
217
+ this.stepsSet = maxSteps != null;
218
+ this.maxSteps = maxSteps ?? 24;
219
+ this.messages = [];
220
+ // 추정 × 보정 = 실제. 서버가 알려 주는 진짜 토큰 수로 매 턴 고쳐 나간다.
221
+ // 1 은 '아직 안 배웠다' 이고, 그때는 추정을 그대로 쓴다. 배운다() 를 볼 것.
222
+ this.보정 = 1;
223
+ this.보정잰것 = 0;
224
+ // 겪어 본 것 요약 (agent/evolve.js). 켤 때 repl 이 채운다.
225
+ this.배움요약 = null;
226
+ /*
227
+ * 못 박은 것 (agent/pins.js).
228
+ *
229
+ * 여기에 두는 것이 핵심이다. messages 안에 넣으면 접기와 요약이 언젠가
230
+ * 가져간다 — 그래서 아예 그 바깥, 시스템 프롬프트 쪽에 둔다.
231
+ */
232
+ this.못박은것 = new 못박기();
233
+ this.filesRead = new Map(); // 경로 → 추정 토큰
234
+ /*
235
+ * 읽은 파일을 들고 있다가 **바뀐 만큼만** 다시 싣는다 (agent/filemem.js).
236
+ *
237
+ * filesRead 와 둘로 나눠 둔 이유: 저쪽은 `/context` 가 「몇 개 읽었나」 를
238
+ * 세는 자리라 값이 토큰 수뿐이다. 여기는 자체를 들고 있어야 한다.
239
+ * 맵에 가지를 담으면 화면 셈이 글자까지 들고 다니게 된다.
240
+ */
241
+ this.파일기억 = new 파일기억();
242
+ /*
243
+ * 접거나 줄여도 잃으면 안 되는 두 가지 (아래 못박을것).
244
+ *
245
+ * 이번요청 이번 턴에 사람이 시킨 원문. loop.js 가 턴마다 채운다.
246
+ * 할일 — 마지막 TodoWrite 목록. loop.js 가 그 결과에서 채운다.
247
+ *
248
+ * 요약은 요약이라 「네 가지를 고쳐 달라고 했다」로 뭉개지고, 할 일 목록은
249
+ * 도구 결과 자리에만 살아서 접히면 통째로 사라진다. 접는 자리에서
250
+ * 다시 박으려면 세션이 들고 있어야 한다 ctx 에만 두면 우리 코드만 보고
251
+ * 모델은 본다.
252
+ */
253
+ this.이번요청 = '';
254
+ this.할일 = [];
255
+ this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
256
+ /*
257
+ * 상태줄이 보는 숫자.
258
+ *
259
+ * 여기 들고 있는 이유는 **화면을 그릴 때마다 디스크를 읽지 않기 위해서**다.
260
+ * 상태줄은 사람이 글자 하나 칠 때마다 다시 그려진다. 거기서 되돌리기
261
+ * 이력 파일을 열면 타이핑이 끊긴다 — 화면 꾸미기가 입력을 느리게 만드는
262
+ * 것만큼 나쁜 것이 없다. repl 이 턴이 끝날 때 한 번씩 채워 준다.
263
+ */
264
+ this.되돌릴턴 = 0;
265
+ this.검증 = { 돈횟수: 0, 확인: 0, 탈: 0 };
266
+ this.skills = []; // 켜질 PC 에서 찾은 것들
267
+ /*
268
+ * 이 자리에 언어 서버가 있나 (Def·Refs 를 목록에 넣을지).
269
+ *
270
+ * 때 repl 이 한 번 재서 넣어 준다. 여기서 직접 안 재는 이유는 폴더를
271
+ * 훑어야 알 수 있어서다 — 세션은 시험에서도 수없이 만들어지는데, 그때마다
272
+ * 폴더를 훑으면 시험이 느려지고 그 자리에 뭐가 깔렸는지에 따라 결과가
273
+ * 달라진다. 기본은 꺼짐이고, 켜 주는 자리가 딱 하나다.
274
+ */
275
+ this.lsp = false;
276
+ this.commands = [];
277
+ this.plugins = [];
278
+ this.maxSkillsListed = 40; // 프롬프트에 올릴 최대 개수
279
+ this.maxSkillDesc = 140; // 설명 한 줄 최대 길이
280
+ /*
281
+ * 캐시 읽기·쓰기도 센다.
282
+ *
283
+ * 여태 이 셈에는 캐시 칸이 아예 없었다. 그래서 캐시가 통째로 안 걸리고
284
+ * 있어도 화면에는 아무 표시가 없었고, 고쳐도 나아졌는지 스스로 확인할
285
+ * 방법이 없었다. 읽기와 쓰기를 **따로** 센다 「매번 쓰기만 하고
286
+ * 번도 읽는」 것과 「잘 읽고 있는」 것은 완전히 다른 상태인데,
287
+ * 하나로 뭉치면 둘이 같아 보인다.
288
+ */
289
+ /*
290
+ * `in` 과 `prompt` 는 다른 값이다.
291
+ *
292
+ * in 서버가 「새로 읽었다」 고 센 만큼. 규격마다 세는 범위가 다르다.
293
+ * prompt 이번에 **실제로 보낸** 프롬프트 전체 (캐시에 맞은 몫까지).
294
+ *
295
+ * 캐시가 걸리기 전에는 둘이 늘 같았다. 걸리기 시작하면 갈라지고, 그때
296
+ * 「들어간 토큰」 자리에 in 적으면 캐시가 맞을수록 숫자가 줄어서
297
+ * 일이 줄어든 것처럼 보인다 — 까닭은 backend/adapter.js 의 보낸토큰.
298
+ */
299
+ this.usage = { in: 0, out: 0, prompt: 0, calls: 0, ms: 0, retries: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
300
+ /*
301
+ * 이 대화의 이름. 게이트웨이에 「같은 대화다」 라고 알려 줄 때 쓴다.
302
+ *
303
+ * repl·oneshot·acp 채운다. 여기에는 경로도 주소도 열쇠도 안
304
+ * 들어간다 — 밖으로 나가는 값이라, 남에게 알려도 되는 것만 담는다.
305
+ */
306
+ this.세션이름 = null;
307
+ /*
308
+ * 지금 붙은 모델이 얼마나 하는가 (agent/grade.js).
309
+ *
310
+ * 크기와는 다른 축이다. 창은 '얼마나 담나', 급은 '얼마나 알아서 하나'.
311
+ * 128k 짜리 3B 모델과 32k 짜리 좋은 모델을 같은 값으로 다루면 둘 다 손해다.
312
+ *
313
+ * 처음에는 이름으로 짐작하고, 대화가 돌수록 **실제로 것**으로 고쳐 잡는다.
314
+ * 사람이 /grade 정하면 그것이 이긴다.
315
+ */
316
+ this.본것 = new 지켜본것();
317
+ this.급정한것 = null;
318
+ this.startedAt = Date.now();
319
+ /** 규칙 파일이 있는데 못 읽었나. `{이름, 까닭}` — /status 가 이걸 말한다. */
320
+ this.규칙못읽음 = null;
321
+ this.rules = this.#loadRules();
322
+ /*
323
+ * 이 폴더가 무슨 프로젝트인가 (agent/project.js).
324
+ *
325
+ * 규칙(DEEL.md)과 같은 자리에서 읽는다 번이다. 매 턴 다시 읽으면
326
+ * 대화에서 수십 번이 되고, 사이 사람이 package.json 고쳐 놓으면
327
+ * 대화 도중에 프롬프트가 바뀐다. 무엇 때문에 답이 달라졌는지 길이 없어진다.
328
+ */
329
+ this.프로젝트 = 지문(this.root, this.conn?.ctx ?? null);
330
+ /*
331
+ * 지난 대화에서 정한 것도 여기서 읽는다.
332
+ *
333
+ * 전에는 대화 화면(repl.js)에서만 넣었다. 그래서 `deel run` — 야간 배치로
334
+ * 도는 에는 기억이 실렸다. "우리 문서는 CP949 다" 사람이 앉아
335
+ * 있을 때만 지키고 배치에서는 지키는 셈이라, 그게 제일 나쁜 어긋남이다.
336
+ * 규칙과 같은 자리로 옮겨서 길이 같은 것을 들고 시작하게 한다.
337
+ */
338
+ this.memory = 기억토막(this.root);
339
+ }
340
+
341
+ /**
342
+ * 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md 중 먼저 읽히는 하나.
343
+ *
344
+ * 없는 것은 그냥 없는 것이라 아무 말도 한다. 그런데 **있는데 못 읽는**
345
+ * 것까지 같이 삼키면 된다 권한이 막혔거나 같은 이름의 폴더가 있으면
346
+ * 그렇게 된다. 사람은 규칙을 적어 뒀으니 걸려 있다고 믿는데 실제로는 하나도
347
+ * 걸린 채로 일이 돈다. 「운영 DB 는 건드리지 마라」 를 적어 놓고 그게
348
+ * 안 걸린 것이 여기서 나올 수 있는 제일 나쁜 모양이다.
349
+ * 읽은 것은 적어 두고 /status 가 '없음' 대신 그 까닭을 말한다.
350
+ */
351
+ #loadRules() {
352
+ for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
353
+ try { return { name, text: readFileSync(join(this.root, name), 'utf8').slice(0, 20000) }; }
354
+ catch (err) {
355
+ // 없으면 그냥 없는 것이다 — 말할 일이 아니다. existsSync 로 먼저 보지
356
+ // 않는 이유도 여기 있다: 보고 나서 읽는 사이에 지워지면 그 ENOENT 를
357
+ // 「있는데 못 읽었다」 로 적게 된다.
358
+ if (err?.code === 'ENOENT') continue;
359
+ // 첫 번째 것만 적어 둔다. 뒤엣것이 읽히면 그게 규칙이 되지만, 사람이
360
+ // 적어 자리를 읽었다는 사실은 그래도 남아야 한다.
361
+ if (!this.규칙못읽음) this.규칙못읽음 = { 이름: name, 까닭: err?.code ?? err?.message ?? String(err) };
362
+ }
363
+ }
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * 지금 이 순간 실제로 쓰는 작업 모드.
369
+ *
370
+ * 종합 모드에서는 한마디마다 골라 넣은 것(routed)이 있고, 그때는 그것이 답이다.
371
+ * 직접 고른 모드가 있으면 routed 는 비어 있으므로 기본 모드가 그대로 답이 된다.
372
+ * 도구·추론·프롬프트가 전부 값을 봐야 한다. 하나라도 빠뜨리면 어긋난다.
373
+ */
374
+ effectiveWork() {
375
+ return this.routed ?? this.work;
376
+ }
377
+
378
+ /** 지금 매겨진 모델 급. 화면과 프롬프트가 같은 것을 봐야 한다. */
379
+ 급() { return 매김(this.conn, this.본것, this.급정한것); }
380
+
381
+ /** 이 급에서 쓸 손잡이 값들 (한 번에 만들 파일 수 같은 것). */
382
+ 급값() { return 급값(this.급().급); }
383
+
384
+ /**
385
+ * 시스템 글을 **굳은 부분**과 **매 바뀌는 부분**으로 나눠 돌려준다.
386
+ *
387
+ * ── 나누나 ────────────────────────────────────────────────────────
388
+ *
389
+ * 캐시는 앞머리가 글자도 바뀐 만큼만 걸린다. 이 글에서 바뀌는 것은
390
+ * 끝의 둘뿐이다 — 지금 모드(말을 던질 때마다 옮겨 간다) 못 박은 것.
391
+ * 그래서 그 앞까지를 한 덩어리로 묶어 두면, 모드가 바뀌어도 **그 앞은
392
+ * 그대로 읽힌다.**
393
+ *
394
+ * 이어 붙이면 예전 글과 **한 글자도 다르지 않다.** 그게 이 함수의 약속이고,
395
+ * test/cache.test.js 가 그것을 지킨다 — 나누느라 글이 달라지면 로컬
396
+ * 프리픽스 캐시가 통째로 한 번 더 깨진다.
397
+ *
398
+ * @returns {[string, string]} [굳은 부분, 바뀌는 부분]
399
+ */
400
+ 시스템조각() {
401
+ const = 언어() === 'en';
402
+ const parts = [기본규칙(this.conn?.ctx)];
403
+ // 범위를 박는 줄. 이건 모델이 읽는 글이라 화면 말을 따라간다.
404
+ parts.push(영
405
+ ? `\nWorking folder: ${this.root}\nYou can neither read nor write files outside this folder.`
406
+ : `\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
407
+ // 어느 셸에서 명령이 도는지 (tools/shell.js). 모델이 ls 칠지 dir 를 칠지가
408
+ // 여기서 갈린다 cmd 에서 유닉스 명령을 치면 한 번에 20~40초짜리 헛걸음이다.
409
+ // 세션 안에서는 변하는 줄이라 앞머리(캐시되는 자리)에 둔다.
410
+ parts.push(셸안내(영));
411
+
412
+ /*
413
+ * 모델 급에 맞춘 한 문단 (grade.js).
414
+ *
415
+ * 큰 모델에는 아무것도 안 붙는다 — 이미 아는 것을 다시 읽느라 자리만 먹는다.
416
+ * 작은 모델에만, 짧게, 박아서 붙는다. 그 급이 못하는 것이 바로
417
+ * '긴 글을 끝까지 따라가기' 라서, 길게 쓰면 오히려 나빠진다.
418
+ */
419
+ const 급글 = 급말(this.급().급);
420
+ if (급글) parts.push(`\n${급글}`);
421
+
422
+ /*
423
+ * 폴더가 무슨 프로젝트인가 (agent/project.js).
424
+ *
425
+ * 규칙보다 **앞에** 둔다. 사용자 규칙은 "이 프로젝트에서는 이렇게 해라" 는
426
+ * 말이라, 무슨 프로젝트인지를 먼저 읽은 뒤에 와야 말이 이어진다.
427
+ */
428
+ if (this.프로젝트) parts.push(this.프로젝트);
429
+
430
+ if (this.rules) {
431
+ // 사용자 규칙 파일의 **내용은 안 건드린다.** 사람이 쓴 글이고, 그 사람의
432
+ // 말로 모델에게 가야 한다. 여기서 바뀌는 것은 그것을 소개하는 머리말뿐이다.
433
+ parts.push(영
434
+ ? `\n--- ${this.rules.name} (user rules — these win over the principles above) ---\n${this.rules.text}`
435
+ : `\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
436
+ }
437
+
438
+ /*
439
+ * 지난 대화에서 정한 것.
440
+ *
441
+ * 이건 '찾으면 나오는' 것이 아니라 **처음부터 들어가 있어야** 하는 것이다.
442
+ * "우리 문서는 CP949 다" 를 매번 다시 설명하게 하면 두 번째부터 짜증이 나고
443
+ * 번째부터는 그냥 쓴다.
444
+ *
445
+ * 읽어 들고 있는다. 파일을 읽으면 대화에서 그 횟수가
446
+ * 수십 번이 되고, 사이 사람이 파일을 고쳐 놓으면 대화 도중에 규칙이
447
+ * 바뀌는 셈이 된다 무엇 때문에 답이 달라졌는지 알 길이 없어진다.
448
+ */
449
+ if (this.memory) parts.push(this.memory);
450
+ /*
451
+ * PC·이 폴더에서 겪어 (agent/evolve.js).
452
+ *
453
+ * 기억(memory)은 사람이 적어 주는 것이고, 이건 **겪어서 저절로 쌓인 것**이다.
454
+ * 여기 한 줄이 헛도는 걸음 서너 개를 없앤다 — 안 되는 명령을 또 부르고,
455
+ * 잘릴 걸 알면서 큰 Write 를 또 보내는 걸음들이다. 그래서 자리를 내줄 값이 있다.
456
+ * 상한은 evolve.js 가 못 박는다(220토큰).
457
+ */
458
+ if (this.배움요약) parts.push(this.배움요약);
459
+ const listed = this.listedSkills();
460
+ if (listed.length) {
461
+ parts.push(
462
+ (영
463
+ ? '\n--- skills available ---\nCall the Skill tool with a name to get its body. If none fits, just carry on.\n'
464
+ : '\n--- 있는 스킬 ---\n필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n') +
465
+ // 설명이 없는 스킬이 섞일 수 있다 — 남의 폴더에서 오는 파일이라
466
+ // 앞머리(frontmatter)가 빠지곤 한다. 여기서 터지면 시스템 프롬프트를
467
+ // 못 만들어 **매 턴** 죽는다. 목록 명령 하나가 아니라 대화 전체가 막힌다.
468
+ listed.map((s) => `- ${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n')
469
+ );
470
+ const rest = this.skills.filter((s) => s.enabled).length - listed.length;
471
+ if (rest > 0) {
472
+ parts.push(
473
+ ? `(${rest} more exist but did not fit.)`
474
+ : `( 밖에 ${rest}개가 있으나 자리가 모자라 안 실었다.)`);
475
+ }
476
+ }
477
+ /*
478
+ * 지금 무슨 일을 하는 중인지 — **일부러 끝쪽에** 둔다.
479
+ *
480
+ * 절은 이 프롬프트에서 유일하게 **매 턴 바뀔 수 있는** 자리다. 말을
481
+ * 던질 때마다 알맞은 모드로 저절로 옮겨 가기 때문이다(route.js). 그런데
482
+ * Ollama·llama.cpp 프리픽스 캐시는 앞부분이 지난 요청과 같을 때만
483
+ * 계산을 재쓴다 절이 앞쪽에 있으면 모드가 바뀌는 순간 그 뒤 전부,
484
+ * 시스템 프롬프트 나머지에 대화 전체까지 다시 계산된다. 긴 대화일수록
485
+ * 매 턴 몇천 토큰이고, 로컬에서는 그게 그대로 몇 초다.
486
+ *
487
+ * 그래서 변하지 않는 것들(규칙·폴더·급말·지문·사용자 규칙·기억·스킬)을
488
+ * 앞에 굳히고 절을 뒤로 보냈다. 읽기 쪽으로도 손해가 아니다 — 끝자리는
489
+ * 가운데보다 오히려 읽힌다(lost in the middle 의 반대편이다).
490
+ * 이 차례는 test/cache.test.js 가 지킨다.
491
+ *
492
+ * 도구 목록도 모드에 맞춰 이미 걸러져 있다. 창이 좁으면 짧은 판을
493
+ * 쓴다 (modes.js 말()) 규칙은 같고 설득하는 문장만 빠진다.
494
+ */
495
+ // 여기까지가 굳은 부분이다. 아래는 말을 던질 때마다 바뀔 수 있다.
496
+ const 굳은 = parts.join('\n');
497
+ const 변함 = [];
498
+ const w = workMode(this.effectiveWork());
499
+ 변함.push(영
500
+ ? `\n--- current mode: ${w.en} ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`
501
+ : `\n--- 지금 모드: ${w.name} (${w.en}) ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`);
502
+ /*
503
+ * 박은 것은 **맨 끝**에 붙인다 (agent/pins.js).
504
+ *
505
+ * 글의 가운데는 흘려 읽힌다 — 'lost in the middle' 이라 부르는 것이고,
506
+ * 어느 모델에서나 잰다. 사람이 직접 못 박은 말은 그 가운데에 묻히면 안 되므로
507
+ * 가장 마지막, 대화 바로 앞에 둔다. 모드 절보다도 뒤인 것도 그래서다.
508
+ */
509
+ const 못박은글 = this.못박은것?.요약();
510
+ if (못박은글) 변함.push(못박은글);
511
+ return [굳은, 변함.length ? `\n${변함.join('\n')}` : ''];
512
+ }
513
+
514
+ /** 모델이 읽는 시스템 글 전체. 조각을 그대로 이어 붙인 것이다. */
515
+ systemPrompt() { return this.시스템조각().join(''); }
516
+
517
+ // 프롬프트에 실제로 올릴 스킬: 가까운 자리(프로젝트 > 사용자 > 플러그인) 순으로 상한까지.
518
+ listedSkills() {
519
+ const rank = { project: 0, user: 1, plugin: 2 };
520
+ return this.skills
521
+ .filter((s) => s.enabled)
522
+ .slice()
523
+ .sort((a, b) => (rank[a.source] ?? 3) - (rank[b.source] ?? 3))
524
+ .slice(0, this.maxSkillsListed);
525
+ }
526
+
527
+ /*
528
+ * ── 턴이 어디서 시작했는지 ────────────────────────────────────────────
529
+ *
530
+ * 되돌리기(/undo)는 파일만 되돌렸다. 대화에는 "src/runner.js 고쳤습니다"
531
+ * 그대로 남아 있어서, 되돌린 다음 턴에 모델은 **이미 고쳐 놓은 줄 알고** 그
532
+ * 위에 이어 일했다 — 없는 코드를 고치려 들고, 없는 함수를 부른다. 사람 눈에는
533
+ * 모델이 헛소리하는 것으로 보이지만, 사실은 우리가 모델에게 거짓말을 남겨 둔
534
+ * 것이다. 그러니 파일을 되감을 때 말도 같이 걷어내야 한다.
535
+ *
536
+ * 자리를 **숫자로 적어 두지 않는다.** 접기(compact)와 줄이기(trim)가 messages
537
+ * 통째로 갈아 끼우기 때문에, 적어 둔 3번은 다음 순간 엉뚱한 말을 가리킨다.
538
+ * 잘못된 자리에서 자르는 되돌리기는 안 하느니만 못하다. 그래서 **메시지 객체
539
+ * 자체**를 들고 있다가 그때그때 indexOf 로 찾는다. 접혀서 사라졌으면 못 찾고,
540
+ * 못 찾으면 그 턴은 되감을 수 없다고 정직하게 말한다.
541
+ */
542
+ #턴표 = [];
543
+ #다음턴 = null;
544
+ static #표최대 = 200;
545
+
546
+ /** 새 턴을 연다. 바로 다음에 push 되는 말이 이 턴의 첫 말이 된다. */
547
+ 턴시작(턴) {
548
+ if (턴 == null) return this;
549
+ // 접혀 없어진 표는 여기서 턴다 — 안 그러면 긴 대화에서 끝없이 쌓인다.
550
+ this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
551
+ if (this.#턴표.length > Session.#표최대) this.#턴표 = this.#턴표.slice(-Session.#표최대);
552
+ this.#다음턴 = 턴;
553
+ return this;
554
+ }
555
+
556
+ /** 살아 있는 표시들. 접혀 사라진 것은 빠진다. @returns {{턴:number, 자리:number}[]} */
557
+ 턴자리() {
558
+ const out = [];
559
+ for (const x of this.#턴표) {
560
+ const i = this.messages.indexOf(x.표);
561
+ if (i >= 0) out.push({ 턴: x.턴, 자리: i });
562
+ }
563
+ return out.sort((a, b) => a.자리 - b.자리);
564
+ }
565
+
566
+ /**
567
+ * 주어진 턴들의 말을 걷어낸다.
568
+ *
569
+ * 여러 턴이면 그중 **제일 이른** 자리까지 간다 뒤는 어차피 되돌린 파일
570
+ * 위에서 나눈 이야기라 남겨 둘 이유가 없다. 사람이 쳤던 말은 돌려준다,
571
+ * 다시 치기 쉽게.
572
+ *
573
+ * 자르고 나서 repairToolPairs 반드시 돌린다. 도구를 부른 assistant
574
+ * 남고 결과가 없으면 모든 요청이 400 으로 튕긴다 — 되돌리기가
575
+ * 대화를 아예 쓰게 만드는 셈이다. 경계는 보통 깨끗하지만, 여기서만은
576
+ * '보통' 에 기대지 않는다.
577
+ */
578
+ 되감기(턴들) {
579
+ const 찾을것 = new Set((Array.isArray(턴들) ? 턴들 : []).filter((t) => t != null));
580
+ const 빈것 = { 걷은것: 0, 고친것: 0, 사람말: null, 턴: [] };
581
+ if (!찾을것.size) return 빈것;
582
+
583
+ const 표들 = this.턴자리().filter((x) => 찾을것.has(x.턴));
584
+ if (!표들.length) return 빈것;
585
+
586
+ const 자리 = 표들[0].자리;
587
+ const 첫말 = this.messages[자리];
588
+ const 사람말 = 첫말?.role === 'user' && typeof 첫말.content === 'string' ? 첫말.content : null;
589
+
590
+ const 전 = this.messages.length;
591
+ const 고침 = repairToolPairs(this.messages.slice(0, 자리));
592
+ this.messages = 고침.messages;
593
+ this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
594
+ this.#다음턴 = null;
595
+ /*
596
+ * 들고 있던 파일 내용도 같이 버린다. clear() 가 하는 것과 같은 까닭이다.
597
+ *
598
+ * 되감기는 턴의 Read 결과까지 대화에서 **진짜로 지운다.** 그런데 파일
599
+ * 기억은 그대로 남아서, 같은 파일을 다시 읽으면 「앞에서 읽은 그대로입니다」
600
+ * 「나머지는 앞에 실린 그대로입니다」 내민다 — 그 「앞엣것」 은 방금
601
+ * 줄이 지웠다.
602
+ *
603
+ * /undo 특히 그렇다. 파일을 고치고(기억에 담김) 되돌리면 디스크는 옛
604
+ * 모습으로 돌아가는데 기억은 고친 모습이라, 다시 읽을 때 있지도 않은 차이를
605
+ * 적어 보내게 된다. 여기서 한 줄 지우면 그냥 통째로 다시 읽는다.
606
+ */
607
+ this.파일기억?.잊기();
608
+ return { 걷은것: - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
609
+ }
610
+
611
+ push(msg) {
612
+ if (this.#다음턴 != null) { this.#턴표.push({ 턴: this.#다음턴, 표: msg }); this.#다음턴 = null; }
613
+ this.messages.push(msg);
614
+ return this;
615
+ }
616
+
617
+ clear() {
618
+ this.messages = [];
619
+ this.filesRead.clear();
620
+ this.파일기억.잊기();
621
+ /*
622
+ * 목록과 시킨 말도 같이 버린다.
623
+ *
624
+ * 이 둘은 접거나 줄일 때 다시 박히는 것들이다(못박을것). 대화를 지운 뒤에도
625
+ * 들고 있으면, 새로 시작한 일이 처음 접히는 순간 **지운 대화의 일**이
626
+ * 되살아나 붙는다. 모델은 그걸 지금 시킨 것으로 알고 하러 간다 —
627
+ * 안 지운 것만 못하다.
628
+ */
629
+ this.할일 = [];
630
+ this.이번요청 = '';
631
+ this.#턴표 = [];
632
+ this.#다음턴 = null;
633
+ return this;
634
+ }
635
+
636
+ noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
637
+
638
+ /**
639
+ * 이번 대화에서 어느 파일이 얼마나 바뀌었는지 적어 둔다. /diff 가 이걸 본다.
640
+ *
641
+ * 견준 결과를 통째로 들고 있지는 않는다 파일 내용 벌이 딸려 온다.
642
+ * 스무 고치면 그것만으로 수십 MB 다. 여기서는 숫자만 센다.
643
+ */
644
+ noteChange(path, d) {
645
+ if (!path || !d) return;
646
+ const 앞 = this.changes.get(path) ?? { added: 0, removed: 0, times: 0 };
647
+ 앞.added += d.added ?? 0;
648
+ 앞.removed += d.removed ?? 0;
649
+ 앞.times += 1;
650
+ this.changes.set(path, 앞);
651
+ }
652
+
653
+ // 모델에 실제로 보낼 배열.
654
+ wire() {
655
+ const 조각 = this.시스템조각();
656
+ const 머리 = { role: 'system', content: 조각.join('') };
657
+ /*
658
+ * 나눈 자리를 같이 알려 준다 캐시 표식을 박는 규격에서만 쓴다
659
+ * (backend/adapter.js 의 anthropic몸).
660
+ *
661
+ * Symbol 다는 것이 중요하다. 보통 이름으로 달면 openai 규격에서는
662
+ * 메시지가 그대로 몸통에 실려 나가고, 모르는 하나가 그 게이트웨이
663
+ * 에서 400 만든다. JSON.stringify Symbol 열쇠를 아예 본다.
664
+ */
665
+ if (조각[1]) 머리[조각표] = 조각;
666
+ return [머리, ...this.messages];
667
+ }
668
+
669
+ /**
670
+ * /context 가 그릴 내역.
671
+ *
672
+ * 여기서 나온 used 는 화면 숫자로만 쓰이는 게 아니다. effort.js 가 이 값으로
673
+ * '남은 자리' 를 셈해 출력 상한을 정한다. 그래서 여기서 덜 세면 **매 요청마다
674
+ * 그만큼 몰래 나간다** — 다 찼는데 안 찼다고 알고 있는 상태가 된다.
675
+ *
676
+ * 전에는 가지를 안 셌다.
677
+ * · 도구 스키마 JSON — 도구 스무 종의 설명과 인자 정의. 매 요청에
678
+ * 통째로 들어가는데 어느 칸에도 잡히고 있었다.
679
+ * · 지금 모드 문구 modes.js say. 모드마다 수백 토큰이다.
680
+ */
681
+ /**
682
+ * 추정을 실제에 맞춰 간다.
683
+ *
684
+ * estimateTokens 추정이다 토크나이저를 싣기 때문이다(의존성 0).
685
+ * 그런데 서버는 응답에 **진짜 값**을 실어 준다(usage.prompt_tokens ·
686
+ * prompt_eval_count). 여태 값은 /cost 에만 쓰고 버렸다.
687
+ *
688
+ * 맞으면 두 가지가 조용히 나빠진다.
689
+ * · 적게 잡으면 — 남은 자리를 넉넉히 보고 답 상한을 크게 잡는다. 답이 잘린다.
690
+ * · 많이 잡으면 — 아직 자리가 있는데 접기 시작한다. 창을 놀린다.
691
+ * 화면에는 아무 말도 안 뜬다. 그래서 스스로 재게 한다.
692
+ *
693
+ * 조심한 것:
694
+ * · 실제값을 안 주는 서버가 있다. 0 이면 아무것도 안 배운다.
695
+ * · 표본이 작으면 비율이 튄다. 첫 몇백 토큰짜리 대화는 건너뛴다.
696
+ * · 0.5~2배 밖은 안 믿는다. 서버가 딴 것을 세고 있을 수 있다.
697
+ * · 되먹임이 겹치지 않게, 비교는 **보정먹인 추정**으로 한다.
698
+ *
699
+ * @returns {number|null} 보정 배수. 배웠으면 null
700
+ */
701
+ 배운다(실제) {
702
+ const n = Number(실제);
703
+ if (!Number.isFinite(n) || n <= 0) return null;
704
+ const 추정 = this.#원추정().used;
705
+ if (추정 < 200) return null;
706
+ const 비율 = n / 추정;
707
+ if (비율 < 0.5 || 비율 > 2) return null;
708
+ // 첫 번은 그대로 받고, 그 뒤로는 천천히 따라간다. 한 번 튄 값에 안 휘둘린다.
709
+ this.보정 = this.보정잰것 ? this.보정 + (비율 - this.보정) * 0.3 : 비율;
710
+ this.보정잰것++;
711
+ return this.보정;
712
+ }
713
+
714
+ /** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
715
+ #원추정() {
716
+ // 폴더 지문도 요청에 통째로 나간다. 시스템 프롬프트 쪽에 같이 센다 —
717
+ // 세면 '남은 자리' 그만큼 뻥튀기되고, effort.js 값으로 출력
718
+ // 상한을 잡으므로 답이 조용히 잘리기 시작한다.
719
+ const sys = estimateTokens(기본규칙(this.conn?.ctx)) + estimateTokens(`작업 폴더: ${this.root}`)
720
+ + estimateTokens(모드말(this.effectiveWork(), this.conn?.ctx) ?? '')
721
+ + estimateTokens(this.프로젝트 ?? '');
722
+ const rules = this.rules ? estimateTokens(this.rules.text) : 0;
723
+ const listed = this.listedSkills();
724
+ const skills = listed.length
725
+ ? estimateTokens(listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
726
+ : 0;
727
+
728
+ let history = 0;
729
+ let files = 0;
730
+ for (const m of this.messages) {
731
+ /*
732
+ * 그림은 글자 수로 세지 않는다.
733
+ *
734
+ * 그림은 base64 로 실려 있어서 글로 세면 4MB 짜리 한 장이 150만 토큰으로
735
+ * 잡힌다. 그러면 창이 다 찬 줄 알고 대화를 통째로 접는다 — 화면 사진 한 장
736
+ * 보여 값으로 하던 일을 잃는 셈이다. 한 장에 얼마인지는 서버가 정하는
737
+ * 것이라 우리는 모르므로, 정해 값으로 세고 서버가 알려주는 실제 값으로
738
+ * 고쳐 나간다 (backend/vision.js 의 그림한장토큰 머리말).
739
+ */
740
+ const 장수 = 그림장수(m);
741
+ const t = estimateTokens(장수 ? 글만(m) : (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '')))
742
+ + 장수 * 그림한장토큰
743
+ + estimateTokens(JSON.stringify(m.tool_calls ?? ''));
744
+ // 도구 결과는 규격마다 다른 자리에 온다. `role` 만 보면 Anthropic 에서는
745
+ // 도구 결과가 통째로 '대화' 세어져서, /context 「도구 결과 0 토큰」
746
+ // 이라고 적는다 무엇을 접어야 할지 보라고 만든 표가 거꾸로 가리킨다.
747
+ if (도구결과인가(m)) files += t; else history += t;
748
+ }
749
+
750
+ // 도구 정의도 매 요청에 실려 나간다. 세는 값이라기보다 '이미 나간 값' 이다.
751
+ const 도구 = this.#도구토큰();
752
+
753
+ // 기억도 요청에 통째로 나간다. 세면 '남은 자리' 그만큼 뻥튀기되고,
754
+ // effort.js 가 그 값으로 출력 상한을 잡으므로 답이 조용히 잘리기 시작한다.
755
+ const 기억 = this.memory ? estimateTokens(this.memory) : 0;
756
+ const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
757
+
758
+ /*
759
+ * 이름은 화면에 그대로 나간다(`/context`). 그래서 여기서 말 표를 거친다.
760
+ * 여기만 한국어로 두면 영어로 켠 사람의 컨텍스트 표가 통째로 한국어가
761
+ * 되는데, `/lang` 은 그 사이에도 100% 라고 답한다 — 표에 없는 글은
762
+ * 세지지 못하기 때문이다. test/langleak.test.js 자리를 지킨다.
763
+ */
764
+ const rows = [
765
+ { label: 옮긴말('ctx.system'), n: sys },
766
+ { label: this.rules ? 옮긴말('ctx.rules', { 이름: this.rules.name }) : 옮긴말('ctx.rulesNone'), n: rules },
767
+ { label: 옮긴말('ctx.memory', { n: 기억줄 }), n: 기억 },
768
+ { label: 옮긴말('ctx.learned'), n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
769
+ { label: 옮긴말('ctx.skills', { 실림: listed.length, 전체: this.skills.length }), n: skills },
770
+ { label: 옮긴말('ctx.tools'), n: 도구 },
771
+ { label: 옮긴말('ctx.history'), n: history },
772
+ { label: 옮긴말('ctx.toolResults', { n: this.filesRead.size }), n: files },
773
+ ];
774
+ const used = rows.reduce((a, r) => a + r.n, 0);
775
+ const total = this.conn.ctx ?? 32768;
776
+ return { rows, used, total, left: Math.max(0, total - used) };
777
+ }
778
+
779
+ /**
780
+ * 화면과 예산이 보는 값. 배운 보정을 먹여서 내놓는다.
781
+ *
782
+ * 줄마다 보정을 먹인다 합계만 고치면 표의 줄을 더한 값과 합계가 안 맞아서,
783
+ * 보는 사람이 어느 쪽을 믿어야 할지 모르게 된다.
784
+ */
785
+ breakdown() {
786
+ const 날것 = this.#원추정();
787
+ if (!(this.보정잰것 > 0) || this.보정 === 1) return 날것;
788
+ const rows = 날것.rows.map((r) => ({ ...r, n: Math.round(r.n * this.보정) }));
789
+ const used = rows.reduce((a, r) => a + r.n, 0);
790
+ return {
791
+ rows,
792
+ used,
793
+ total: 날것.total,
794
+ left: Math.max(0, 날것.total - used),
795
+ 보정: this.보정,
796
+ 보정잰것: this.보정잰것,
797
+ };
798
+ }
799
+
800
+ /**
801
+ * 도구 정의가 몇 토큰인가.
802
+ *
803
+ * 모드가 바뀌면 도구 목록도 바뀌므로 모드별로 한 번씩만 재고 넣어 둔다.
804
+ * JSON.stringify 하면 자체가 아깝다 값은 바뀌는데.
805
+ */
806
+ #도구잰것 = new Map();
807
+ #도구토큰() {
808
+ // 밖에서 붙인 도구 수까지 열쇠에 넣는다. 서버가 붙고 떨어지면 값이 달라진다.
809
+ const mcp수 = (this.mcp ?? []).reduce((n, s) => n + (s.도구?.length ?? 0), 0);
810
+ // 크기도 열쇠에 넣는다. 설명을 창에 맞춰 줄여 싣기 때문에(budget.js),
811
+ // /ctx 창을 다시 잡으면 이 값도 달라져야 한다. 안 넣으면 옛 값이 남는다.
812
+ const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
813
+ if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
814
+ let n = 0;
815
+ try {
816
+ const list = toolSchemas(null, {
817
+ hasSkills: (this.skills?.length ?? 0) > 0,
818
+ web: this.web !== false,
819
+ work: this.effectiveWork(),
820
+ mcp: this.mcp ?? null,
821
+ lsp: this.lsp === true,
822
+ // 실제로 나가는 것과 **같은 것**을 재야 한다. 넘기면 안 줄인 것을
823
+ // 재게 되고, 그러면 /context 가 실제보다 크게 말한다 — 그 값으로
824
+ // effort.js 가 출력 상한을 잡으므로 답이 이유 없이 짧아진다.
825
+ ctx: this.conn?.ctx ?? null,
826
+ vision: this.conn?.vision === true,
827
+ });
828
+ n = estimateTokens(JSON.stringify(list));
829
+ } catch { n = 0; }
830
+ this.#도구잰것.set(열쇠, n);
831
+ return n;
832
+ }
833
+
834
+ /**
835
+ * 오래된 대화를 잘라낸다. 앞의 2턴과 최근 절반만 남긴다.
836
+ *
837
+ * 자르는 자리를 **아무 데나 잡으면 안 된다.** 도구 호출과 그 결과는 한 몸이라,
838
+ * 사이를 끊으면 규격 위반이 되어 그 뒤 모든 요청이 400 으로 거절당한다.
839
+ *
840
+ * 이게 실제로 있었던 길이다 — 접기(compact)가 요약을 못 받으면 여기로
841
+ * 물러섰는데, 여기가 짝을 안 맞췄다. 화면에는 노란 안내 한 줄이 뜨고,
842
+ * 그 뒤로 무엇을 해도 400 이 났다. 원인은 두 화면 전이라 이어 붙일 수 없고,
843
+ * /clear 말고는 길이 없었다. 물러설 자리가 더 큰 사고를 만든 셈이다.
844
+ */
845
+ trim() {
846
+ if (this.messages.length < 12) return 0;
847
+ const keepHead = safeHead(this.messages, 2);
848
+ const keepTail = safeCut(this.messages, this.messages.length - Math.floor(this.messages.length / 2));
849
+ const dropped = keepTail - keepHead;
850
+ if (dropped <= 0) return 0;
851
+ this.messages = [
852
+ ...this.messages.slice(0, keepHead),
853
+ {
854
+ role: 'user',
855
+ /*
856
+ * 줄일 때도 시킨 말과 남은 할 일을 다시 박는다.
857
+ *
858
+ * 이 길은 **요약을 못 받아 물러선** 길이다. 서버가 흔들릴 때 지나가는
859
+ * 길이라 제일 자주 밟히는데, 여태 여기에는 못 박는 것이 없었다.
860
+ * 머리 2개만 남기므로, 사용자가 세 번째 턴에서 네 가지를 시켰다면
861
+ * 그 네 가지가 그냥 사라진다. 접기(compact) 쪽에만 못 박아 두면
862
+ * 정작 제일 자주 지나가는 길에서만 조용히 어긋난다.
863
+ */
864
+ content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)\n\n` + 못박을것(this),
865
+ },
866
+ ...this.messages.slice(keepTail),
867
+ ];
868
+ return dropped;
869
+ }
870
+ }
871
+
872
+ /*
873
+ * ── 접거나 줄여도 이것만은 글자 그대로 남긴다 ──────────────────────────
874
+ *
875
+ * 요약은 요약이다. 네 가지를 적어 준 요청이 「네 가지를 고쳐 달라고 했다」
876
+ * 한 줄로 뭉개지고, **그 네 가지가 무엇이었는지는 사라진다.** 접힌 뒤로는
877
+ * 원문이 어디에도 없으니 남은 것을 이어 하려 해도 무엇이 남았는지 모른다.
878
+ *
879
+ * 할 일 목록도 같다. 목록이 사는 자리가 도구 결과 하나뿐이라, 55%에서 한 줄로
880
+ * 접히고 80%에서 요약에 뭉개진다. 접힘 문구는 「필요하면 다시 읽으세요」인데
881
+ * 할 일 목록은 **다시 읽을 파일이 없다.**
882
+ *
883
+ * 그래서 둘 다 접는 자리·줄이는 자리에서 다시 박는다. 끝난 항목은 안 싣는다 —
884
+ * 자리를 먹기만 하고, 이 쪽지가 답할 질문은 「무엇이 남았나」 하나다.
885
+ *
886
+ * compact.js 가 이 두 함수를 그대로 내보낸다. safeCut·safeHead 와 같은 까닭이다 —
887
+ * 접기와 줄이기가 **같은 것**을 박아야 하고, 한쪽만 고치면 물러서는 순간
888
+ * 대화가 어긋난다.
889
+ */
890
+ const 못박을길이 = 1200;
891
+
892
+ export function 못박은요청(session) {
893
+ const 원문 = String(session?.이번요청 ?? '').trim();
894
+ if (!원문) return '';
895
+ const 실을것 = 원문.length > 못박을길이
896
+ ? `${원문.slice(0, 못박을길이)}\n…(뒷부분 줄임)`
897
+ : 원문;
898
+ return `[이번에 시킨 말 — 요약이 아니라 원문 그대로입니다. 여기 적힌 것을 빠짐없이 하세요.]\n${실을것}\n\n`;
899
+ }
900
+
901
+ export function 못박은할일(session) {
902
+ const 남은 = (session?.할일 ?? []).filter((x) => x?.state !== 'done');
903
+ if (!남은.length) return '';
904
+ const 줄 = 남은.map((x) => `${x.state === 'doing' ? '▶ (하는 중)' : '☐'} ${String(x.text ?? '').trim()}`);
905
+ return `[아직 안 끝난 할 일 — 접히기 전 목록 그대로입니다. 이걸 이어서 하세요.]\n${줄.join('\n')}\n\n`;
906
+ }
907
+
908
+ export function 못박을것(session) {
909
+ return 못박은요청(session) + 못박은할일(session);
910
+ }
911
+
912
+ /**
913
+ * 도구 호출과 결과가 갈라지지 않는 자리를 찾는다.
914
+ * i 번째부터 남긴다고 할 때, 안전한 i 로 옮겨 준다.
915
+ *
916
+ * 접기(compact.js)와 그냥 줄이기(trim)가 **같은 함수**를 쓴다. 전에는 접기에만
917
+ * 있었고 줄이기는 제멋대로 잘랐다. 그래서 접기가 실패해 줄이기로 물러선 순간
918
+ * 대화가 망가졌다 — 안전망이 사고를 만드는 자리는 늘 이런 모양이다.
919
+ */
920
+ export function safeCut(messages, i) {
921
+ let k = Math.max(0, Math.min(i, messages.length));
922
+ /*
923
+ * 도구 결과로 시작하면 그 앞의 부름이 없어 규격이 깨진다. 앞으로 당긴다.
924
+ *
925
+ * 「도구 결과인가」 는 규격마다 다른 자리를 본다(backend/adapter.js). 여기가
926
+ * `role === 'tool'` 만 보던 동안 Anthropic 창구에서는 이 울타리가 **한 번도
927
+ * 안 걸렸다** — 결과는 그쪽에서 사람 차례에 실려 오기 때문이다. 그래서
928
+ * 자동 접기가 짝을 반으로 갈랐고, 그 뒤 요청이 통째로 400 을 받았다.
929
+ */
930
+ while (k > 0 && 도구결과인가(messages[k])) k--;
931
+ return k;
932
+ }
933
+
934
+ /**
935
+ * 머리 쪽 자르는 자리.
936
+ * 머리가 '결과를 기다리는 도구 호출' 로 끝나면 그 결과가 접혀 없어져 짝이 깨진다.
937
+ * 그런 assistant 는 머리에서 뺀다 — 접히는 쪽에 같이 넘긴다.
938
+ */
939
+ export function safeHead(messages, k) {
940
+ let h = Math.max(0, Math.min(k, messages.length));
941
+ // 부름이 담긴 자리도 규격마다 다르다 — safeCut 과 같은 까닭이다.
942
+ while (h > 0 && 부른것들(messages[h - 1]).length) h--;
943
+ return h;
944
+ }
945
+
946
+ /**
947
+ * 도중에 죽은 대화를 다시 쓸 수 있게 손본다.
948
+ *
949
+ * 왜 필요한가:
950
+ * store 는 메시지가 오갈 때마다 즉시 적는다(jsonl). 그래서 도구를 **돌리는
951
+ * 도중에** 죽으면 `assistant(tool_calls)` 만 적히고 그 결과가 없다. 긴 Bash 를
952
+ * 돌리는 중, 전원이 나가는 중, 창을 닫는 중 — 흔한 자리다.
953
+ *
954
+ * 그 이력을 그대로 이어받아 보내면 OpenAI 규격 서버는 400 을 낸다. 호출 뒤에는
955
+ * 결과가 와야 한다는 규격이다. compact.js 가 safeCut 으로 막고 있는 바로 그
956
+ * 사고인데, **이어받기 길에는 그 안전망이 없었다.** 이어받자마자 첫 마디에서
957
+ * 죽으니, 사람 눈에는 '이어하기가 고장 났다' 로 보인다.
958
+ *
959
+ * 무엇을 하나:
960
+ * 결과가 없는 호출을 **지운다.** 가짜 결과를 채우지 않는다 — 안 돌아간 도구를
961
+ * 돌았다고 적으면 모델이 그 거짓말 위에서 계속한다. 파일을 안 고쳤는데 고친
962
+ * 줄 알고 다음 단계로 넘어가는 것이 제일 나쁘다.
963
+ * 호출이 전부 빠졌는데 할 말도 없으면 그 메시지째 지운다.
964
+ * 짝 없는 결과(앞에 호출이 없는 tool)도 지운다 — 머리가 잘려 나간 이력이다.
965
+ *
966
+ * 규격이 둘이라 짝짓는 법도 둘이다 (adapter.js 의 toolMessage):
967
+ * OpenAI { role:'tool', tool_call_id } → id 로 짝짓는다
968
+ * Ollama { role:'tool', tool_name } → id 가 없다. 순서로 짝짓는다
969
+ *
970
+ * @returns {{messages: object[], 고친것: number}} 고친것 = 걷어낸 호출·결과 수
971
+ */
972
+ /** 남길 부름만 남긴 새 메시지. 규격마다 부름이 담긴 자리가 다르다. */
973
+ function 부름줄이기(m, 남길id) {
974
+ if (Array.isArray(m.content)) {
975
+ return { ...m, content: m.content.filter((b) => b?.type !== 'tool_use' || 남길id.has(b.id)) };
976
+ }
977
+ return { ...m, tool_calls: (m.tool_calls ?? []).filter((c) => 남길id.has(c?.id)) };
978
+ }
979
+
980
+ /** 이 메시지가 사람에게 한 말만. 부름 블록은 뺀다. */
981
+ function 글자만(m) {
982
+ if (typeof m.content === 'string') return m.content.trim();
983
+ if (!Array.isArray(m.content)) return '';
984
+ return m.content.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('').trim();
985
+ }
986
+
987
+ /** 부름은 빼고 한 말만 남긴 메시지. */
988
+ function 부름빼기(m, 글) {
989
+ if (Array.isArray(m.content)) return { ...m, content: [{ type: 'text', text: 글 }] };
990
+ const { tool_calls: _버림, ...나머지 } = m;
991
+ return { ...나머지, content: 글 };
992
+ }
993
+
994
+ export function repairToolPairs(messages) {
995
+ const out = [];
996
+ let 고친것 = 0;
997
+
998
+ for (let i = 0; i < (messages?.length ?? 0); i++) {
999
+ const m = messages[i];
1000
+
1001
+ // 적다 만 줄. 도중에 죽으면 JSONL 한 줄이 반만 적히고, 그 자리가 빈 값이나
1002
+ // role 없는 조각으로 읽힌다. 그대로 보내면 서버가 거절한다 — 걷어내는 것이
1003
+ // 이 함수의 일이므로 여기서 같이 턴다.
1004
+ if (!m || typeof m !== 'object' || typeof m.role !== 'string') { 고친것++; continue; }
1005
+
1006
+ // 호출 없이 굴러다니는 결과. 앞이 잘려 나간 이력이다.
1007
+ if (도구결과인가(m)) { 고친것++; continue; }
1008
+
1009
+ /*
1010
+ * 부름이 담긴 자리는 규격마다 다르다(backend/adapter.js 의 부른것들).
1011
+ *
1012
+ * 여기가 `m.tool_calls` 만 보던 동안 Anthropic 이력은 **손도 안 대고 그대로
1013
+ * 지나갔다.** 도중에 죽어 부름만 남은 대화를 이어 열면 「고친 것 0」 이라
1014
+ * 조용히 넘어가고, 첫 요청이 400 이었다. 이 함수가 없애겠다고 적어 둔 바로
1015
+ * 그 고장이다.
1016
+ */
1017
+ const 부름 = 부른것들(m);
1018
+ if (m?.role !== 'assistant' || !부름.length) { out.push(m); continue; }
1019
+
1020
+ // 이 호출에 딸린 결과 묶음 — 바로 뒤에 붙어 있는 결과들이 전부다.
1021
+ const 결과 = [];
1022
+ let j = i + 1;
1023
+ while (j < messages.length && 도구결과인가(messages[j])) 결과.push(messages[j++]);
1024
+ i = j - 1; // 결과는 여기서 같이 처리한다
1025
+
1026
+ const 결과id = (r) => 결과들(r).map((x) => x.id).filter(Boolean);
1027
+ const 있는id = new Set(결과.flatMap(결과id));
1028
+ const 남길부름 = 있는id.size
1029
+ ? 부름.filter((c) => 있는id.has(c?.id))
1030
+ : 부름.slice(0, 결과.length); // id 가 없는 규격 — 순서로 본다
1031
+ const 남길id = new Set(남길부름.map((c) => c?.id).filter(Boolean));
1032
+ const 남길결과 = 있는id.size
1033
+ ? 결과.filter((r) => 결과id(r).some((x) => 남길id.has(x)))
1034
+ : 결과.slice(0, 남길부름.length);
1035
+
1036
+ 고친것 += (부름.length - 남길부름.length) + (결과.length - 남길결과.length);
1037
+
1038
+ if (남길부름.length) {
1039
+ out.push(남길부름.length === 부름.length ? m : 부름줄이기(m, 남길id));
1040
+ out.push(...남길결과);
1041
+ continue;
1042
+ }
1043
+ // 남은 호출이 없다. 할 말이라도 있으면 그건 살린다.
1044
+ const 글 = 글자만(m);
1045
+ if (글) out.push(부름빼기(m, 글));
1046
+ }
1047
+
1048
+ return { messages: out, 고친것 };
1049
+ }