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.
- package/README.ko.md +10 -25
- package/README.md +43 -51
- package/bin/deel.js +56 -13
- package/package.json +60 -60
- package/src/acp/serve.js +810 -732
- package/src/agent/budget.js +156 -153
- package/src/agent/compact.js +420 -296
- package/src/agent/effort.js +308 -177
- package/src/agent/evidence.js +2 -0
- package/src/agent/evolve.js +263 -213
- package/src/agent/filemem.js +155 -0
- package/src/agent/grade.js +51 -1
- package/src/agent/loop.js +1707 -1349
- package/src/agent/memory.js +156 -152
- package/src/agent/mention.js +210 -210
- package/src/agent/session.js +1049 -832
- package/src/agent/store.js +186 -9
- package/src/agent/threads.js +26 -1
- package/src/backend/adapter.js +1159 -615
- package/src/backend/cachemark.js +150 -0
- package/src/backend/detect.js +327 -288
- package/src/backend/learn.js +24 -0
- package/src/backend/mcp.js +96 -5
- package/src/backend/probe.js +131 -61
- package/src/backend/quota.js +250 -133
- package/src/backend/retry.js +30 -6
- package/src/backend/tokens.js +37 -0
- package/src/backend/toolfit.js +352 -0
- package/src/backend/wire.js +600 -0
- package/src/commands.js +3135 -2918
- package/src/config.js +47 -2
- package/src/i18n/en.js +544 -466
- package/src/i18n/index.js +18 -0
- package/src/i18n/ja.js +497 -417
- package/src/i18n/ko.js +591 -466
- package/src/i18n/zh.js +497 -417
- package/src/lsp/client.js +49 -5
- package/src/oneshot.js +620 -471
- package/src/pack/sbom.js +30 -4
- package/src/pack/selfpack.js +25 -9
- package/src/pack/sheet.en.js +288 -0
- package/src/pack/tar.js +65 -2
- package/src/plugins/manage.js +46 -9
- package/src/providers/bedrock.js +17 -0
- package/src/repl.js +2602 -2286
- package/src/safety/audit.js +92 -8
- package/src/safety/authcmd.js +14 -3
- package/src/safety/guard.js +143 -0
- package/src/safety/keystore.js +62 -39
- package/src/safety/undo.js +24 -6
- package/src/tools/fsutil.js +265 -250
- package/src/tools/index.js +239 -34
- package/src/tools/jobs.js +158 -29
- package/src/tools/verify.js +358 -328
- package/src/tools/webfetch.js +92 -9
- package/src/ui/md.js +201 -5
- package/src/ui/motion.js +0 -1
- package/src/ui/pastechip.js +50 -3
- package/src/ui/pick.js +115 -0
- package/src/ui/screen.js +23 -3
- package/src/ui/status.js +613 -610
package/src/agent/compact.js
CHANGED
|
@@ -1,296 +1,420 @@
|
|
|
1
|
-
// 컨텍스트가 차면 오래된 대화를 '요약해서' 이어 간다.
|
|
2
|
-
//
|
|
3
|
-
// 그냥 잘라내면 방금까지 뭘 하고 있었는지 모델이 잊는다. 파일을 다시 읽고,
|
|
4
|
-
// 이미 고친 곳을 또 고치고, 정해 둔 방침을 어긴다. 그래서 자르는 대신 접는다.
|
|
5
|
-
//
|
|
6
|
-
// 접을 때 지켜야 하는 것 두 가지:
|
|
7
|
-
// 1) 도구 호출과 그 결과는 한 몸이다. 사이를 끊으면 규격 위반이라 서버가 400 을 낸다.
|
|
8
|
-
// 2) 요약도 모델이 만든다. 그 요청이 실패하면 프로그램이 멈추면 안 된다 —
|
|
9
|
-
// 실패하면 옛 방식(그냥 자르기)으로 물러선다.
|
|
10
|
-
import { chat } from '../backend/adapter.js';
|
|
11
|
-
import { estimateTokens, safeCut, safeHead } from './session.js';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export const
|
|
17
|
-
export const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
export const
|
|
39
|
-
export const
|
|
40
|
-
export const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
*
|
|
229
|
-
* @
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1
|
+
// 컨텍스트가 차면 오래된 대화를 '요약해서' 이어 간다.
|
|
2
|
+
//
|
|
3
|
+
// 그냥 잘라내면 방금까지 뭘 하고 있었는지 모델이 잊는다. 파일을 다시 읽고,
|
|
4
|
+
// 이미 고친 곳을 또 고치고, 정해 둔 방침을 어긴다. 그래서 자르는 대신 접는다.
|
|
5
|
+
//
|
|
6
|
+
// 접을 때 지켜야 하는 것 두 가지:
|
|
7
|
+
// 1) 도구 호출과 그 결과는 한 몸이다. 사이를 끊으면 규격 위반이라 서버가 400 을 낸다.
|
|
8
|
+
// 2) 요약도 모델이 만든다. 그 요청이 실패하면 프로그램이 멈추면 안 된다 —
|
|
9
|
+
// 실패하면 옛 방식(그냥 자르기)으로 물러선다.
|
|
10
|
+
import { chat, 부른것들, 결과들, 도구결과인가 } from '../backend/adapter.js';
|
|
11
|
+
import { estimateTokens, safeCut, safeHead, 못박을것 } from './session.js';
|
|
12
|
+
import { wasCut } from './effort.js';
|
|
13
|
+
import { 그림장수, 글만 } from '../backend/vision.js';
|
|
14
|
+
|
|
15
|
+
// 몇 %에서 접기 시작할지. 접고 나서 다시 금방 차면 아무 소용이 없으니 넉넉히 비운다.
|
|
16
|
+
export const COMPACT_AT = 0.8;
|
|
17
|
+
export const KEEP_TAIL_RATIO = 0.3; // 최근 대화 중 남길 비율
|
|
18
|
+
export const KEEP_HEAD = 2; // 맨 처음 요청은 언제나 남긴다 (목표가 거기 있다)
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* ── 그 전에, 도구 결과부터 접는다 ──────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* 요약 압축은 잃는 것이 크다. 사람이 한 말도, 모델이 왜 그렇게 정했는지도
|
|
24
|
+
* 세 줄로 줄어든다. 그런데 자리를 실제로 먹고 있는 것은 대개 그쪽이 아니다.
|
|
25
|
+
*
|
|
26
|
+
* Read 로 600줄짜리 파일을 한 번 열면 32k 창의 15% 가 그 자리에서 사라지고,
|
|
27
|
+
* 그 사본은 대화가 끝날 때까지 이력에 그대로 눌러앉는다. 이미 다 읽고 고친
|
|
28
|
+
* 파일인데도 그렇다. 세 번 읽으면 창의 절반이 옛날 파일 내용이다.
|
|
29
|
+
*
|
|
30
|
+
* 그래서 80% 에서 통째로 요약하기 전에, 먼저 **오래된 도구 결과만** 한 줄로
|
|
31
|
+
* 접는다. 사람 말과 모델의 판단은 한 글자도 안 건드린다. 무엇을 읽었는지는
|
|
32
|
+
* 남기므로 모델은 필요하면 그 파일을 다시 읽으면 된다 — 읽는 값은 싸고,
|
|
33
|
+
* 하던 일을 잊는 값은 비싸다.
|
|
34
|
+
*
|
|
35
|
+
* 최근 것은 안 접는다. 방금 읽은 것을 접으면 그 자리에서 다시 읽게 되고,
|
|
36
|
+
* 그러면 접은 보람이 없다.
|
|
37
|
+
*/
|
|
38
|
+
export const FOLD_AT = 0.55; // 이 아래로는 접을 이유가 없다
|
|
39
|
+
export const KEEP_RECENT = 4; // 최근 도구 결과 이만큼은 원문 그대로 둔다
|
|
40
|
+
export const FOLD_MIN = 300; // 이보다 작으면 접어도 자리가 안 준다 (글자 수)
|
|
41
|
+
export const 접힘표 = '(접힘)';
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
* ── 접기는 **묶음으로** 한다 ────────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* 여태 이 함수는 걸음마다 「최근 넷 빼고 다」 를 접었다. 얼핏 맞는 말인데,
|
|
47
|
+
* 걸음마다 도구 결과가 하나씩 늘어나므로 실제로 하는 일은
|
|
48
|
+
* **걸음마다 딱 하나씩 접는 것**이었다.
|
|
49
|
+
*
|
|
50
|
+
* 접는다는 것은 이력 가운데 있는 글을 **딴 글로 바꿔치는 것**이다. 그러면
|
|
51
|
+
* 그 자리부터 뒤가 전부 새 글이 되고, 프리픽스 캐시는 거기서 끊긴다.
|
|
52
|
+
* 즉 걸음마다 한 줄을 아끼려고 **걸음마다 대화 전체를 다시 보내고 있었다.**
|
|
53
|
+
* 아끼려던 것보다 훨씬 크게 쓴 셈이다.
|
|
54
|
+
*
|
|
55
|
+
* 그래서 문턱을 둔다 — 이만큼 비울 수 있을 때만 접는다. 그러면 접기가
|
|
56
|
+
* 가끔 크게 일어나고, 그 사이에는 앞머리가 가만히 있는다.
|
|
57
|
+
*/
|
|
58
|
+
export const 최소이득 = 2000;
|
|
59
|
+
|
|
60
|
+
/** 인자에서 사람이 알아볼 한 조각만 뽑는다. 경로가 제일 쓸모 있다. */
|
|
61
|
+
function 어디(args) {
|
|
62
|
+
if (!args || typeof args !== 'object') return '';
|
|
63
|
+
for (const k of ['file_path', 'path', 'pattern', 'command', 'query', 'url', '목적']) {
|
|
64
|
+
const v = args[k];
|
|
65
|
+
if (typeof v === 'string' && v.trim()) return v.trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
66
|
+
}
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
* ── 그림은 따로 뺀다 ───────────────────────────────────────────────────
|
|
72
|
+
*
|
|
73
|
+
* 위의 접기는 도구 결과(role:'tool')만 건드린다. 사람 말은 한 글자도 안 건드리는
|
|
74
|
+
* 것이 그 함수의 약속이라 그렇다. 그런데 그림은 **사람 말 자리에** 실린다
|
|
75
|
+
* (backend/vision.js 의 그림메시지 머리말 — 도구 결과에는 못 넣는다).
|
|
76
|
+
*
|
|
77
|
+
* 그림 한 장은 base64 로 몇 MB 다. 화면 사진 몇 장을 이어 보여 주면 대화가
|
|
78
|
+
* 그것만으로 무거워지고, 오래된 사진은 이미 이야기가 끝난 것이다. 그래서
|
|
79
|
+
* 최근 몇 장만 남기고 나머지는 **무엇이었는지만 남기고** 뺀다.
|
|
80
|
+
*
|
|
81
|
+
* 사람이 쓴 말은 그대로 둔다 — 빼는 것은 그림 조각뿐이다.
|
|
82
|
+
*/
|
|
83
|
+
export const KEEP_IMAGES = 2;
|
|
84
|
+
|
|
85
|
+
/** 오래된 그림을 이름만 남기고 뺀다. @returns {{뺀것:number, 뺀것들:Array}} */
|
|
86
|
+
export function foldImages(session, { keep = KEEP_IMAGES } = {}) {
|
|
87
|
+
const ms = session.messages ?? [];
|
|
88
|
+
const 자리 = [];
|
|
89
|
+
ms.forEach((m, i) => { if (그림장수(m)) 자리.push(i); });
|
|
90
|
+
|
|
91
|
+
const 뺄것 = 자리.slice(0, Math.max(0, 자리.length - keep));
|
|
92
|
+
const 뺀것들 = [];
|
|
93
|
+
for (const i of 뺄것) {
|
|
94
|
+
const m = ms[i];
|
|
95
|
+
const 장수 = 그림장수(m);
|
|
96
|
+
const 글 = 글만(m);
|
|
97
|
+
ms[i] = { ...m, content: `${글}
|
|
98
|
+
${접힘표} 그림 ${장수}장은 자리를 비우려고 뺐습니다. 필요하면 다시 여세요.` };
|
|
99
|
+
delete ms[i].images;
|
|
100
|
+
뺀것들.push({ 장수 });
|
|
101
|
+
}
|
|
102
|
+
return { 뺀것: 뺄것.length, 뺀것들 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function shouldFold(session, at = FOLD_AT) {
|
|
106
|
+
const b = session.breakdown();
|
|
107
|
+
return b.total > 0 && b.used / b.total >= at;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 오래된 도구 결과를 한 줄로 접는다.
|
|
112
|
+
*
|
|
113
|
+
* 짝은 안 건드린다 — 메시지를 지우는 게 아니라 **내용만** 바꾸므로 호출과
|
|
114
|
+
* 결과의 짝이 그대로다. 규격이 깨질 자리가 아예 없다.
|
|
115
|
+
*
|
|
116
|
+
* @returns {{접은것: number, 아낀토큰: number}}
|
|
117
|
+
*/
|
|
118
|
+
export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN, 이득문턱 = 최소이득 } = {}) {
|
|
119
|
+
const ms = session.messages ?? [];
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
* 호출 쪽에서 이름과 인자를 가져온다. 결과 메시지에는 그게 안 실려 있다.
|
|
123
|
+
*
|
|
124
|
+
* 부름도 결과도 규격마다 다른 자리에 담긴다(backend/adapter.js). 여기가
|
|
125
|
+
* `tool_calls` 와 `role:'tool'` 만 보던 동안 Anthropic 창구에서는 **접은 것이
|
|
126
|
+
* 늘 0 이었다** — 55% 에서 싸게 자리를 비우는 단계가 통째로 죽어 있었고,
|
|
127
|
+
* 그대로 80% 의 손실 있는 요약으로 직행했다. 캐시가 제일 중요한 창구에서
|
|
128
|
+
* 캐시를 지키려고 만든 단계가 안 돈 것이다.
|
|
129
|
+
*/
|
|
130
|
+
const 이름표 = new Map();
|
|
131
|
+
for (const m of ms) {
|
|
132
|
+
for (const c of 부른것들(m)) if (c.id) 이름표.set(c.id, { name: c.name ?? '도구', args: c.args ?? {} });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const 자리 = [];
|
|
136
|
+
ms.forEach((m, i) => {
|
|
137
|
+
if (!도구결과인가(m)) return;
|
|
138
|
+
const 것들 = 결과들(m);
|
|
139
|
+
// 한 메시지에 결과가 여럿 실리는 규격이 있다. 그때는 이어 붙여 한 자리로 본다.
|
|
140
|
+
const 글 = 것들.map((x) => x.글).join('\n');
|
|
141
|
+
if (글.startsWith(접힘표)) return; // 이미 접은 것
|
|
142
|
+
const 첫 = 것들[0];
|
|
143
|
+
자리.push({ i, 글, 이름: 이름표.get(첫?.id)?.name ?? 첫?.name ?? '' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* ── 가장 최근 할 일 목록은 안 접는다 ─────────────────────────────────
|
|
148
|
+
*
|
|
149
|
+
* 접힘 문구는 「필요하면 다시 읽으세요」다. 파일이면 맞는 말이다 — 읽는
|
|
150
|
+
* 값은 싸고 하던 일을 잊는 값은 비싸니까. 그런데 **할 일 목록은 다시
|
|
151
|
+
* 읽을 파일이 없다.** 접는 순간 남은 항목이 어디에도 없어진다.
|
|
152
|
+
*
|
|
153
|
+
* 그래서 긴 작업일수록 뒤로 갈수록 시킨 것을 빠뜨렸다 — 「대화를 잘
|
|
154
|
+
* 기억 못 한다」로 제보받은 것의 큰 몫이 여기였다.
|
|
155
|
+
*
|
|
156
|
+
* 옛 목록은 그대로 접는다. 새 목록이 나온 순간 옛것은 이미 틀린 것이라
|
|
157
|
+
* 남겨 둘수록 해롭다. 지키는 것은 **마지막 하나**뿐이고, 목록 하나는
|
|
158
|
+
* 몇백 글자라 자리도 거의 안 먹는다.
|
|
159
|
+
*/
|
|
160
|
+
const 지킬할일 = 자리.filter((x) => x.이름 === 'TodoWrite').at(-1)?.i ?? -1;
|
|
161
|
+
|
|
162
|
+
const 접을것 = 자리
|
|
163
|
+
.slice(0, Math.max(0, 자리.length - keep))
|
|
164
|
+
.filter((x) => x.글.length >= min && x.i !== 지킬할일);
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* 비울 수 있는 양이 문턱에 못 미치면 **이번에는 안 접는다.**
|
|
168
|
+
*
|
|
169
|
+
* 한 줄 아끼자고 앞머리를 깨면 그 걸음에 대화가 통째로 다시 나간다.
|
|
170
|
+
* 모아 두었다가 한 번에 접는 편이 눈에 띄게 싸다. 자리가 정말 모자라면
|
|
171
|
+
* 80% 에서 요약 압축이 받아 준다 — 안전망은 그쪽에 있다.
|
|
172
|
+
*/
|
|
173
|
+
const 예상이득 = 접을것.reduce((a, x) => a + estimateTokens(x.글), 0);
|
|
174
|
+
if (예상이득 < 이득문턱) {
|
|
175
|
+
return { 접은것: 0, 아낀토큰: 0, 접은것들: [], 미룸: true, 모인것: 예상이득 };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let 아낀토큰 = 0;
|
|
179
|
+
// 무엇을 버렸는지 이름으로 남긴다.
|
|
180
|
+
//
|
|
181
|
+
// 조용히 버리면 사람이 모른다 — "아까 그 파일 내용 어디 갔지" 를 스스로 답할
|
|
182
|
+
// 길이 없다. 접힌 자리에 표시가 남긴 하지만 그건 모델이 보는 쪽이고,
|
|
183
|
+
// 사람 화면에는 여기서 돌려주는 목록으로 알려 준다.
|
|
184
|
+
const 접은것들 = [];
|
|
185
|
+
|
|
186
|
+
for (const { i, 글 } of 접을것) {
|
|
187
|
+
const m = ms[i];
|
|
188
|
+
const 아는것 = 이름표.get(m.tool_call_id) ?? { name: m.tool_name ?? '도구', args: {} };
|
|
189
|
+
const 곳 = 어디(아는것.args);
|
|
190
|
+
const 줄수 = 글.split('\n').length;
|
|
191
|
+
const 전 = estimateTokens(글);
|
|
192
|
+
ms[i] = {
|
|
193
|
+
...m,
|
|
194
|
+
content: `${접힘표} ${아는것.name}${곳 ? `(${곳})` : ''} — ${줄수}줄. `
|
|
195
|
+
+ '자리를 비우려고 내용을 접었습니다. 필요하면 다시 읽으세요.',
|
|
196
|
+
};
|
|
197
|
+
const 아낀것 = 전 - estimateTokens(ms[i].content);
|
|
198
|
+
아낀토큰 += 아낀것;
|
|
199
|
+
/*
|
|
200
|
+
* 자른 표시용 이름(곳)과 **진짜 경로**를 따로 담는다.
|
|
201
|
+
*
|
|
202
|
+
* 곳 은 화면에 쓰려고 60자에서 자른 것이라, 이걸로 파일 기억을 지우면
|
|
203
|
+
* 긴 경로가 안 맞아서 조용히 안 지워진다 — 그러면 접힌 파일을 다시
|
|
204
|
+
* 읽을 때 「앞에서 읽은 그대로입니다」 만 돌아간다(agent/filemem.js).
|
|
205
|
+
*/
|
|
206
|
+
const 경로 = typeof 아는것.args?.file_path === 'string'
|
|
207
|
+
? 아는것.args.file_path
|
|
208
|
+
: (typeof 아는것.args?.path === 'string' ? 아는것.args.path : null);
|
|
209
|
+
접은것들.push({ 도구: 아는것.name, 곳, 경로, 줄수, 토큰: Math.max(0, 아낀것) });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 접힌 파일을 **파일 기억에서 지울 때 쓸 열쇠** (agent/filemem.js).
|
|
217
|
+
*
|
|
218
|
+
* 접은 자리에 남은 경로는 모델이 적어 보낸 글자 그대로다 — 대개 `src/a.js`
|
|
219
|
+
* 같은 상대 경로다. 그런데 파일 기억은 `ctx.scope.resolve()` 를 거친 **절대
|
|
220
|
+
* 경로**로 묶여 있다(tools/index.js 의 읽은것). 그대로 지우면 Map 이 안 맞아서
|
|
221
|
+
* 조용히 아무것도 안 지워지고, 그러면 접혀 사라진 파일을 다시 읽을 때
|
|
222
|
+
* 「앞에서 읽은 그대로입니다」 가 돌아간다. 모델은 대화 어디에도 없는 글을
|
|
223
|
+
* 가리키는 쪽지를 받고, 결국 또 읽는다 — 접기로 아낀 것을 되읽기로 도로 쓴다.
|
|
224
|
+
*
|
|
225
|
+
* 울타리 밖이라 못 풀면 적힌 그대로 돌려준다. 둘 다 지워 보는 것은 부르는
|
|
226
|
+
* 쪽 몫이다 — 지우는 일은 없는 열쇠에도 안전하다.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} 경로 접은 자리에 적혀 있던 경로 (모델이 준 그대로)
|
|
229
|
+
* @param {object|null} scope safety/guard.js 의 울타리
|
|
230
|
+
* @returns {string} 지울 때 쓸 열쇠
|
|
231
|
+
*/
|
|
232
|
+
export function 접힌파일열쇠(경로, scope = null) {
|
|
233
|
+
const 적힌것 = String(경로 ?? '');
|
|
234
|
+
if (!적힌것) return 적힌것;
|
|
235
|
+
try { return scope?.resolve?.(적힌것) ?? 적힌것; } catch { return 적힌것; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const 요약지시 = `지금까지의 대화를 다음 형식으로 요약하세요. 이어서 일할 사람이 이것만 보고도 계속할 수 있어야 합니다.
|
|
239
|
+
추측하지 말고 실제로 오간 내용만 쓰세요. 한국어로, 각 항목 3줄 이내로 쓰세요.
|
|
240
|
+
|
|
241
|
+
## 목표
|
|
242
|
+
사용자가 무엇을 시켰는가.
|
|
243
|
+
|
|
244
|
+
## 한 일
|
|
245
|
+
어떤 파일을 어떻게 고쳤는가. 파일 경로를 그대로 적으세요.
|
|
246
|
+
|
|
247
|
+
## 알아낸 것
|
|
248
|
+
코드·환경에 대해 확인된 사실. 다시 조사하지 않아도 되도록.
|
|
249
|
+
|
|
250
|
+
## 정한 것
|
|
251
|
+
어떤 방침·이름·구조로 하기로 했는가. 사용자가 거절한 것도 적으세요.
|
|
252
|
+
|
|
253
|
+
## 남은 일
|
|
254
|
+
아직 안 한 것.`;
|
|
255
|
+
|
|
256
|
+
// 자르는 자리를 찾는 두 함수는 session.js 에 있다. 접기와 그냥 줄이기(trim)가
|
|
257
|
+
// **같은 잣대**를 써야 하기 때문이다 — 한쪽만 짝을 맞추면 물러서는 순간 대화가 망가진다.
|
|
258
|
+
// 쓰던 자리가 있으니 이름은 그대로 내보낸다.
|
|
259
|
+
export { safeCut, safeHead } from './session.js';
|
|
260
|
+
|
|
261
|
+
/*
|
|
262
|
+
* 시킨 말과 남은 할 일을 못 박는 것은 session.js 에 있다. 접기와 줄이기가
|
|
263
|
+
* **같은 것**을 박아야 하기 때문이다 — 한쪽만 고치면 물러서는 순간 대화가
|
|
264
|
+
* 어긋난다. safeCut·safeHead 를 그리 둔 것과 같은 까닭이다.
|
|
265
|
+
*/
|
|
266
|
+
export { 못박은요청, 못박은할일, 못박을것 } from './session.js';
|
|
267
|
+
|
|
268
|
+
/** 접을 구간과 남길 구간을 나눈다. */
|
|
269
|
+
export function split(messages, { keepHead = KEEP_HEAD, tailRatio = KEEP_TAIL_RATIO } = {}) {
|
|
270
|
+
const n = messages.length;
|
|
271
|
+
const wantTail = Math.max(4, Math.floor(n * tailRatio));
|
|
272
|
+
const cut = safeCut(messages, n - wantTail);
|
|
273
|
+
const head = safeHead(messages, Math.min(keepHead, cut));
|
|
274
|
+
if (cut <= head) return null; // 접을 것이 없다
|
|
275
|
+
const fold = messages.slice(head, cut);
|
|
276
|
+
if (fold.length < 4) return null;
|
|
277
|
+
return { head: messages.slice(0, head), fold, tail: messages.slice(cut) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 요약에 넣을 글. 도구 결과는 길기만 하고 요약에 도움이 안 되니 앞부분만 준다.
|
|
281
|
+
function transcript(msgs) {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const m of msgs) {
|
|
284
|
+
/*
|
|
285
|
+
* 누가 한 말인지도 규격을 봐야 안다. Anthropic 은 도구 결과를 **사람
|
|
286
|
+
* 차례**에 싣기 때문에, role 만 보면 도구가 뱉은 것이 전부 「사용자」 가
|
|
287
|
+
* 된다. 그러면 아래 400자 자르기도 안 걸려서, 요약을 시키는 요청에 도구
|
|
288
|
+
* 출력이 통째로 실려 나간다 — 요약하려는 대화보다 요약 요청이 더 커진다.
|
|
289
|
+
* 그리고 그렇게 만든 요약이 대화를 **대신하게** 된다.
|
|
290
|
+
*/
|
|
291
|
+
const 결과인가 = 도구결과인가(m);
|
|
292
|
+
const who = 결과인가 ? '도구결과' : (m.role === 'user' ? '사용자' : m.role === 'assistant' ? '나' : '도구결과');
|
|
293
|
+
let body = 결과인가
|
|
294
|
+
? 결과들(m).map((x) => x.글).join('\n')
|
|
295
|
+
: (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''));
|
|
296
|
+
if (결과인가) body = body.slice(0, 400);
|
|
297
|
+
const calls = 부른것들(m).map((t) => `${t.name}(${JSON.stringify(t.args ?? {}).slice(0, 160)})`);
|
|
298
|
+
if (calls.length) out.push(`${who}: [도구] ${calls.join(', ')}`);
|
|
299
|
+
if (body.trim()) out.push(`${who}: ${body.trim()}`);
|
|
300
|
+
}
|
|
301
|
+
return out.join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 실제로 접는다.
|
|
306
|
+
* @returns {{ok:boolean, folded:number, before:number, after:number, summary?:string, why?:string}}
|
|
307
|
+
*/
|
|
308
|
+
export async function compact(session, { auto = false, signal = null, onBackoff = null } = {}) {
|
|
309
|
+
const before = session.breakdown().used;
|
|
310
|
+
const parts = split(session.messages);
|
|
311
|
+
if (!parts) return { ok: false, why: '접을 만큼 쌓이지 않았습니다', folded: 0, before, after: before };
|
|
312
|
+
|
|
313
|
+
const text = transcript(parts.fold);
|
|
314
|
+
let summary = null;
|
|
315
|
+
// 왜 못 받았는지. 「못 받았다」 만으로는 사람이 할 수 있는 일이 없다.
|
|
316
|
+
let 못한까닭 = null;
|
|
317
|
+
// 서버가 잠깐 막아 다시 부른 횟수. 알림은 부르는 쪽(onBackoff)이 화면에 내고, 셈은 여기서 한다.
|
|
318
|
+
let 다시 = 0;
|
|
319
|
+
const 셈하기 = (n) => { session.usage.retries = (session.usage.retries ?? 0) + Math.max(0, n); };
|
|
320
|
+
try {
|
|
321
|
+
const r = await chat(session.conn, {
|
|
322
|
+
messages: [
|
|
323
|
+
{ role: 'system', content: '너는 대화를 요약한다. 요약 외에 다른 말을 하지 않는다.' },
|
|
324
|
+
{ role: 'user', content: `${요약지시}\n\n----- 대화 -----\n${text}` },
|
|
325
|
+
],
|
|
326
|
+
maxTokens: 1200,
|
|
327
|
+
think: session.conn.kind === 'ollama' ? false : 'low', // 요약에 깊은 사고는 필요 없다
|
|
328
|
+
// 사용자가 Ctrl+C 를 누르면 이것도 멈춰야 한다. 안 받으면 답하는 도중
|
|
329
|
+
// 화면이 멈춰 있고 아무 키도 안 먹는 것처럼 보인다.
|
|
330
|
+
signal,
|
|
331
|
+
onBackoff: (알림) => { 다시 += 1; onBackoff?.(알림); },
|
|
332
|
+
// 요약 하나에 5분을 기다릴 이유가 없다. 그만한 서버면 그냥 줄이는 편이 빠르다.
|
|
333
|
+
timeout: 60000,
|
|
334
|
+
});
|
|
335
|
+
셈하기(다시);
|
|
336
|
+
/*
|
|
337
|
+
* 받은 것을 그대로 믿지 않는다. 이 요약은 **대화를 대신하게 될 글**이라,
|
|
338
|
+
* 반쪽이거나 거절이면 대화 전체가 그 반쪽으로 바뀐다.
|
|
339
|
+
*
|
|
340
|
+
* · 상한에 걸려 잘린 요약 — 비어 있지 않으니 그냥 통과했다. 그러면
|
|
341
|
+
* 대화가 문장 중간에서 끊긴 한 토막으로 바뀐다.
|
|
342
|
+
* · 거절 — 이 규격은 거절 글을 `content` 에 담는다(adapter.js). 그러면
|
|
343
|
+
* 대화 전체가 「그건 도와드릴 수 없습니다」 한 줄이 된다.
|
|
344
|
+
*
|
|
345
|
+
* 둘 다 `ok: true` 로 보고됐다. 그냥 줄이는 편이 훨씬 낫다.
|
|
346
|
+
*/
|
|
347
|
+
if (r?.거절) 못한까닭 = '요약을 거절당했습니다';
|
|
348
|
+
else if (wasCut(r)) 못한까닭 = '요약이 상한에서 잘렸습니다';
|
|
349
|
+
else summary = (r.content ?? '').trim();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// 사용자가 끊은 것은 실패가 아니다. 대화를 건드리지 않고 그대로 둔다 —
|
|
352
|
+
// 여기서 물러서기(trim)까지 해 버리면, 끊었는데 대화가 줄어 있게 된다.
|
|
353
|
+
if (err?.name === 'Aborted' || signal?.aborted) {
|
|
354
|
+
셈하기(다시 - 1); // 기다리다 끊긴 마지막 것은 다시 부른 것이 아니다
|
|
355
|
+
return { ok: false, aborted: true, why: '중단했습니다', folded: 0, before, after: before };
|
|
356
|
+
}
|
|
357
|
+
셈하기(다시);
|
|
358
|
+
/*
|
|
359
|
+
* 서버가 한 말을 **버리지 않는다.**
|
|
360
|
+
*
|
|
361
|
+
* 여태 `err` 를 통째로 흘렸다. 열쇠가 만료돼 401 이든, 게이트웨이가
|
|
362
|
+
* 모르는 칸에 400 을 내든, 모델 이름이 틀렸든 화면에는 늘 「요약을 받지
|
|
363
|
+
* 못해 그냥 줄였습니다」 한 줄이었다. 그러면 사람은 압축이 왜 매번
|
|
364
|
+
* 물러서는지 알 길이 없고, 고칠 데는 대개 한 줄이면 되는 것이었다.
|
|
365
|
+
*/
|
|
366
|
+
못한까닭 = err?.serverMessage
|
|
367
|
+
? `요약을 못 받았습니다 — ${String(err.serverMessage).replace(/\s+/g, ' ').trim().slice(0, 120)}`
|
|
368
|
+
: `요약을 못 받았습니다 — ${String(err?.message ?? err).slice(0, 120)}`;
|
|
369
|
+
summary = null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 요약을 못 받았으면 옛 방식으로 물러선다. 멈추지는 않는다.
|
|
373
|
+
if (!summary) {
|
|
374
|
+
const folded = session.trim();
|
|
375
|
+
/*
|
|
376
|
+
* 여기서도 들고 있던 파일 내용을 버린다. 아래 성공한 길과 **같은 까닭**이다.
|
|
377
|
+
*
|
|
378
|
+
* 이 자리를 한동안 빠뜨리고 있었다. 성공한 길에만 넣어 두면, 정작 요약을
|
|
379
|
+
* 못 받아 물러선 자리 — 서버가 흔들릴 때라 제일 자주 지나가는 길 — 에서만
|
|
380
|
+
* 조용히 어긋난다. trim() 은 옛 메시지를 진짜로 지우므로, 지운 뒤에도
|
|
381
|
+
* 「앞에서 읽은 그대로입니다」 를 내밀면 모델은 대화에 없는 글을 가리키는
|
|
382
|
+
* 쪽지만 받는다. 통째로 다시 싣는 것보다 훨씬 나쁘다.
|
|
383
|
+
*/
|
|
384
|
+
if (folded > 0) session.파일기억?.잊기();
|
|
385
|
+
const after = session.breakdown().used;
|
|
386
|
+
return {
|
|
387
|
+
ok: folded > 0, folded, before, after, fallback: true,
|
|
388
|
+
why: 못한까닭 ? `${못한까닭}. 그냥 줄였습니다` : '요약을 받지 못해 그냥 줄였습니다',
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
session.messages = [
|
|
393
|
+
...parts.head,
|
|
394
|
+
{
|
|
395
|
+
role: 'user',
|
|
396
|
+
content: `[앞선 대화 ${parts.fold.length}개를 요약해 접었습니다. 아래가 그 요약입니다.]\n\n${summary}\n\n`
|
|
397
|
+
+ 못박을것(session)
|
|
398
|
+
+ '[요약 끝. 이어서 진행하세요. 파일 내용이 필요하면 다시 읽으세요.]',
|
|
399
|
+
},
|
|
400
|
+
...parts.tail,
|
|
401
|
+
];
|
|
402
|
+
session.filesRead.clear(); // 접힌 뒤에는 읽어 둔 파일도 기억에서 지운다
|
|
403
|
+
/*
|
|
404
|
+
* 들고 있던 파일 내용도 같이 버린다.
|
|
405
|
+
*
|
|
406
|
+
* 「앞에서 읽은 그대로입니다」 는 그 앞엣것이 대화에 살아 있을 때만 참이다.
|
|
407
|
+
* 접어 버린 뒤에도 그 쪽지를 내밀면, 모델은 있지도 않은 글을 가리키는 말만
|
|
408
|
+
* 받는다 — 통째로 다시 싣는 것보다 훨씬 나쁘다.
|
|
409
|
+
*/
|
|
410
|
+
session.파일기억?.잊기();
|
|
411
|
+
|
|
412
|
+
const after = session.breakdown().used;
|
|
413
|
+
return { ok: true, folded: parts.fold.length, before, after, summary, auto };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 지금 접어야 하는가. */
|
|
417
|
+
export function shouldCompact(session, at = COMPACT_AT) {
|
|
418
|
+
const b = session.breakdown();
|
|
419
|
+
return b.total > 0 && b.used / b.total >= at;
|
|
420
|
+
}
|