deel-local-cli 1.12.0 → 1.13.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 +5 -5
- package/README.md +5 -5
- package/package.json +60 -60
- package/src/acp/map.js +367 -362
- package/src/agent/commit.js +534 -511
- package/src/agent/compact.js +36 -15
- package/src/agent/loop.js +25 -4
- package/src/agent/recall.js +222 -209
- package/src/agent/session.js +10 -3
- package/src/backend/adapter.js +219 -23
- package/src/backend/cachemark.js +82 -9
- package/src/backend/http.js +460 -458
- package/src/backend/mcp.js +407 -395
- package/src/backend/price.js +260 -197
- package/src/backend/probe.js +487 -473
- package/src/backend/quota.js +35 -11
- package/src/backend/tokens.js +59 -37
- package/src/backend/wire.js +121 -6
- package/src/commands.js +56 -7
- package/src/config.js +283 -244
- package/src/i18n/en.js +1 -0
- package/src/i18n/ja.js +1 -0
- package/src/i18n/ko.js +1 -0
- package/src/i18n/zh.js +1 -0
- package/src/oneshot.js +6 -1
- package/src/pack/tar.js +154 -132
- package/src/pack/zip.js +235 -217
- package/src/plugins/manage.js +416 -336
- package/src/repl.js +17 -3
- package/src/safety/audit.js +148 -129
- package/src/safety/guard.js +641 -526
- package/src/safety/network.js +200 -157
- package/src/tools/fastgrep.js +229 -214
- package/src/tools/fsutil.js +28 -2
- package/src/tools/index.js +2560 -2523
- package/src/tools/jobs.js +876 -795
- package/src/tools/label.js +52 -0
- package/src/tools/spawn.js +213 -99
- package/src/tools/webfetch.js +418 -393
- package/src/ui/export.js +233 -217
- package/src/ui/status.js +5 -1
package/src/agent/compact.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 1) 도구 호출과 그 결과는 한 몸이다. 사이를 끊으면 규격 위반이라 서버가 400 을 낸다.
|
|
8
8
|
// 2) 요약도 모델이 만든다. 그 요청이 실패하면 프로그램이 멈추면 안 된다 —
|
|
9
9
|
// 실패하면 옛 방식(그냥 자르기)으로 물러선다.
|
|
10
|
-
import { chat, 부른것들, 결과들,
|
|
10
|
+
import { chat, 부른것들, 결과들, 도구결과인가, 결과바꾸기 } from '../backend/adapter.js';
|
|
11
11
|
import { estimateTokens, safeCut, safeHead, 못박을것 } from './session.js';
|
|
12
12
|
import { wasCut } from './effort.js';
|
|
13
13
|
import { 그림장수, 글만 } from '../backend/vision.js';
|
|
@@ -140,7 +140,22 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
|
|
|
140
140
|
const 글 = 것들.map((x) => x.글).join('\n');
|
|
141
141
|
if (글.startsWith(접힘표)) return; // 이미 접은 것
|
|
142
142
|
const 첫 = 것들[0];
|
|
143
|
-
|
|
143
|
+
/*
|
|
144
|
+
* 이름과 인자를 **여기서** 뽑아 둔다.
|
|
145
|
+
*
|
|
146
|
+
* 아래 접는 자리가 이걸 안 쓰고 `이름표.get(m.tool_call_id)` 로 다시
|
|
147
|
+
* 뽑고 있었다. `tool_call_id` 는 OpenAI 꼴 짐작이라 Anthropic·Ollama
|
|
148
|
+
* 에서는 늘 undefined 였고, 그러면 이름이 「도구」로, 인자가 `{}` 로
|
|
149
|
+
* 떨어진다. 인자가 비면 경로도 못 뽑아서 파일 기억이 안 지워지고,
|
|
150
|
+
* 접힌 파일을 다시 읽을 때 「앞에서 읽은 그대로입니다」 만 돌아온다.
|
|
151
|
+
* 같은 것을 두 번 뽑을 까닭이 없다 — 한 번 뽑아서 같이 들고 간다.
|
|
152
|
+
*/
|
|
153
|
+
const 아는것 = 이름표.get(첫?.id) ?? null;
|
|
154
|
+
자리.push({
|
|
155
|
+
i, 글,
|
|
156
|
+
이름: 아는것?.name ?? 첫?.name ?? '',
|
|
157
|
+
args: 아는것?.args ?? {},
|
|
158
|
+
});
|
|
144
159
|
});
|
|
145
160
|
|
|
146
161
|
/*
|
|
@@ -183,18 +198,24 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
|
|
|
183
198
|
// 사람 화면에는 여기서 돌려주는 목록으로 알려 준다.
|
|
184
199
|
const 접은것들 = [];
|
|
185
200
|
|
|
186
|
-
for (const { i,
|
|
201
|
+
for (const { i, 글, 이름, args } of 접을것) {
|
|
187
202
|
const m = ms[i];
|
|
188
|
-
const
|
|
189
|
-
const 곳 = 어디(
|
|
203
|
+
const 도구이름 = 이름 || '도구';
|
|
204
|
+
const 곳 = 어디(args);
|
|
190
205
|
const 줄수 = 글.split('\n').length;
|
|
191
206
|
const 전 = estimateTokens(글);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
207
|
+
const 접은글 = `${접힘표} ${도구이름}${곳 ? `(${곳})` : ''} — ${줄수}줄. `
|
|
208
|
+
+ '자리를 비우려고 내용을 접었습니다. 필요하면 다시 읽으세요.';
|
|
209
|
+
/*
|
|
210
|
+
* 글만 갈아 끼운다 — 메시지를 통째로 덮으면 짝이 깨진다.
|
|
211
|
+
*
|
|
212
|
+
* Anthropic 꼴 결과는 `tool_result` 블록 안에 `tool_use_id` 를 들고 있다.
|
|
213
|
+
* `{ ...m, content: 접은글 }` 로 덮으면 그 id 가 없어지고, 앞 차례의
|
|
214
|
+
* `tool_use` 가 짝을 잃어 다음 요청이 400 이다. 규격별로 어디를 바꿔야
|
|
215
|
+
* 하는지는 backend/adapter.js 가 안다 (결과바꾸기).
|
|
216
|
+
*/
|
|
217
|
+
ms[i] = 결과바꾸기(m, 접은글);
|
|
218
|
+
const 아낀것 = 전 - estimateTokens(접은글);
|
|
198
219
|
아낀토큰 += 아낀것;
|
|
199
220
|
/*
|
|
200
221
|
* 자른 표시용 이름(곳)과 **진짜 경로**를 따로 담는다.
|
|
@@ -203,10 +224,10 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
|
|
|
203
224
|
* 긴 경로가 안 맞아서 조용히 안 지워진다 — 그러면 접힌 파일을 다시
|
|
204
225
|
* 읽을 때 「앞에서 읽은 그대로입니다」 만 돌아간다(agent/filemem.js).
|
|
205
226
|
*/
|
|
206
|
-
const 경로 = typeof
|
|
207
|
-
?
|
|
208
|
-
: (typeof
|
|
209
|
-
접은것들.push({ 도구:
|
|
227
|
+
const 경로 = typeof args?.file_path === 'string'
|
|
228
|
+
? args.file_path
|
|
229
|
+
: (typeof args?.path === 'string' ? args.path : null);
|
|
230
|
+
접은것들.push({ 도구: 도구이름, 곳, 경로, 줄수, 토큰: Math.max(0, 아낀것) });
|
|
210
231
|
}
|
|
211
232
|
|
|
212
233
|
return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들 };
|
package/src/agent/loop.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
|
|
2
2
|
// 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
|
|
3
|
-
import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남, 보낸토큰,
|
|
3
|
+
import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남, 보낸토큰, 부른것들, 도구결과인가 } from '../backend/adapter.js';
|
|
4
4
|
import { 그림메시지 } from '../backend/vision.js';
|
|
5
5
|
import { 어떻게할까 } from '../safety/policy.js';
|
|
6
6
|
import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
|
|
@@ -20,6 +20,7 @@ import { 바깥인가 } from '../safety/runmode.js';
|
|
|
20
20
|
import { get as workMode } from './modes.js';
|
|
21
21
|
import { 지시말 } from '../i18n/index.js';
|
|
22
22
|
import { 빠진것, 빠졌다는말 } from './asks.js';
|
|
23
|
+
import { 환경속열쇠들 } from '../config.js';
|
|
23
24
|
|
|
24
25
|
/*
|
|
25
26
|
* 콜백으로만 소식을 주는 부름을, 제너레이터가 중간에 내보낼 수 있는 모양으로 바꾼다.
|
|
@@ -311,7 +312,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
311
312
|
// 부른 자리는 규격마다 다르다 — 읽는 규칙은 backend/adapter.js 의 부른것들 하나뿐이다.
|
|
312
313
|
const 부른것 = 부른것들(last);
|
|
313
314
|
if (!부른것.length) return;
|
|
314
|
-
|
|
315
|
+
// 결과가 어느 role 에 실리는지도 규격마다 다르다. `role === 'tool'` 로
|
|
316
|
+
// 세면 Anthropic 꼴에서 언제나 0 이라, 지어낸 번호가 전부 겹친다.
|
|
317
|
+
const 이미 = session.messages.filter(도구결과인가).length;
|
|
315
318
|
for (const t of 부른것) {
|
|
316
319
|
session.push(toolMessage(conn.kind, {
|
|
317
320
|
callId: t.id ?? `call_${이미 + 1}`,
|
|
@@ -895,7 +898,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
895
898
|
* 걸음은 안 센다. 아직 일은 시작도 안 했고, 고친 값으로 다시 부르는
|
|
896
899
|
* 것이 이 자리의 전부다 (위 '배우기' 와 같은 셈법).
|
|
897
900
|
*/
|
|
898
|
-
const 전선고침 = 배울전선(err.serverMessage ?? err.message);
|
|
901
|
+
const 전선고침 = 배울전선(err.serverMessage ?? err.message, conn.kind);
|
|
899
902
|
if (전선고침 && !전선배운적) {
|
|
900
903
|
전선배운적 = true;
|
|
901
904
|
conn.전선 = 카드고치기(conn.전선, 전선고침);
|
|
@@ -990,6 +993,14 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
990
993
|
return;
|
|
991
994
|
}
|
|
992
995
|
|
|
996
|
+
/*
|
|
997
|
+
* usage 를 **안 준 응답**을 따로 센다.
|
|
998
|
+
*
|
|
999
|
+
* 0 을 더하면 합계는 그대로라 화면에 표가 안 난다. 그런데 그 부름은
|
|
1000
|
+
* 유료였고, 쓴 것을 우리가 모를 뿐이다. 「0 원 썼다」 로 적으면 그건
|
|
1001
|
+
* 지어낸 값이다 (backend/adapter.js 의 잰것).
|
|
1002
|
+
*/
|
|
1003
|
+
if (msg.usage?.잰것 === false) session.usage.못잰것 = (session.usage.못잰것 ?? 0) + 1;
|
|
993
1004
|
session.usage.in += msg.usage?.in ?? 0;
|
|
994
1005
|
session.usage.out += msg.usage?.out ?? 0;
|
|
995
1006
|
// 캐시가 얼마나 맞았나. 읽기와 쓰기를 따로 센다 — 「매번 쓰기만 하고 한
|
|
@@ -1433,6 +1444,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1433
1444
|
session.usage.calls += 자식.usage.calls;
|
|
1434
1445
|
session.usage.ms += 자식.usage.ms;
|
|
1435
1446
|
session.usage.retries = (session.usage.retries ?? 0) + (자식.usage.retries ?? 0);
|
|
1447
|
+
session.usage.못잰것 = (session.usage.못잰것 ?? 0) + (자식.usage.못잰것 ?? 0);
|
|
1436
1448
|
|
|
1437
1449
|
const 글 = 하위요약({
|
|
1438
1450
|
목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null,
|
|
@@ -1629,7 +1641,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1629
1641
|
* 그대로 되돌려 써서, 진짜 열쇠가 있던 자리에 표가 적힌다 — 비밀을
|
|
1630
1642
|
* 지키려다 비밀을 지우는 셈이다. 대신 무엇이 들어왔는지 알린다.
|
|
1631
1643
|
*/
|
|
1632
|
-
|
|
1644
|
+
/*
|
|
1645
|
+
* 가릴 값도 **환경에 있는 열쇠를 전부** 받는다.
|
|
1646
|
+
*
|
|
1647
|
+
* 여기가 `[conn.key, process.env.DEEL_API_KEY]` 였다. 지금 연결에 안
|
|
1648
|
+
* 쓰는 프로필 열쇠(`DEEL_KEY_PROD`)는 이 목록에 없어서, 자식이 뱉은
|
|
1649
|
+
* 글에 그 값이 그대로 있어도 **정확값 가리기가 못 잡았다.** 이름
|
|
1650
|
+
* 정규식(safety/secrets.js)도 `_PROD` 꼬리 때문에 못 잡는다.
|
|
1651
|
+
* 무엇이 열쇠인지는 config.js 한 곳만 안다 (환경속열쇠들).
|
|
1652
|
+
*/
|
|
1653
|
+
const 아는열쇠들 = [conn.key, ...환경속열쇠들()].filter(Boolean);
|
|
1633
1654
|
let 비밀 = [];
|
|
1634
1655
|
// 바깥으로 나가면 파일에서 읽어 온 글까지 가린다 (safety/secrets.js 의 가릴까).
|
|
1635
1656
|
const 이번엔가릴까 = 가릴까(call.name, { 바깥: 바깥으로나감 });
|
package/src/agent/recall.js
CHANGED
|
@@ -1,209 +1,222 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 지난 대화 찾기.
|
|
3
|
-
*
|
|
4
|
-
* 왜 필요한가:
|
|
5
|
-
* deel 은 대화를 .deel/sessions/*.jsonl 로 꼬박꼬박 남긴다. 그런데 지금까지
|
|
6
|
-
* 할 수 있는 일은 목록을 보는 것(/sessions)과 통째로 이어받는 것(--continue)
|
|
7
|
-
* 뿐이었다. "저번에 그 인코딩 문제 어떻게 풀었더라" 를 찾을 방법이 없었다.
|
|
8
|
-
* 기록이 있는데 못 찾으면 없는 것과 같다.
|
|
9
|
-
*
|
|
10
|
-
* 왜 색인을 안 만드나:
|
|
11
|
-
* FTS 색인을 두면 빠르지만, 색인은 반드시 낡는다 — 대화 파일은 매 턴 늘어나고,
|
|
12
|
-
* 딴 창에서도 늘어나고, 사람이 지우기도 한다. 낡은 색인은 **없는 것보다 나쁘다**:
|
|
13
|
-
* "못 찾았습니다" 가 "없습니다" 로 읽히기 때문이다.
|
|
14
|
-
* 그래서 그때그때 읽는다. 대신 **얼마나 읽었고 무엇을 못 읽었는지 반드시 말한다.**
|
|
15
|
-
*
|
|
16
|
-
* 의존성 0 이라 점수도 직접 매긴다. 대단한 순위가 필요한 게 아니다 —
|
|
17
|
-
* 낱말이 많이 맞고, 다 맞고, 최근인 것이 위로 오면 된다.
|
|
18
|
-
*/
|
|
19
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { sessionsDir } from './store.js';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// 한
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
let
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 지난 대화 찾기.
|
|
3
|
+
*
|
|
4
|
+
* 왜 필요한가:
|
|
5
|
+
* deel 은 대화를 .deel/sessions/*.jsonl 로 꼬박꼬박 남긴다. 그런데 지금까지
|
|
6
|
+
* 할 수 있는 일은 목록을 보는 것(/sessions)과 통째로 이어받는 것(--continue)
|
|
7
|
+
* 뿐이었다. "저번에 그 인코딩 문제 어떻게 풀었더라" 를 찾을 방법이 없었다.
|
|
8
|
+
* 기록이 있는데 못 찾으면 없는 것과 같다.
|
|
9
|
+
*
|
|
10
|
+
* 왜 색인을 안 만드나:
|
|
11
|
+
* FTS 색인을 두면 빠르지만, 색인은 반드시 낡는다 — 대화 파일은 매 턴 늘어나고,
|
|
12
|
+
* 딴 창에서도 늘어나고, 사람이 지우기도 한다. 낡은 색인은 **없는 것보다 나쁘다**:
|
|
13
|
+
* "못 찾았습니다" 가 "없습니다" 로 읽히기 때문이다.
|
|
14
|
+
* 그래서 그때그때 읽는다. 대신 **얼마나 읽었고 무엇을 못 읽었는지 반드시 말한다.**
|
|
15
|
+
*
|
|
16
|
+
* 의존성 0 이라 점수도 직접 매긴다. 대단한 순위가 필요한 게 아니다 —
|
|
17
|
+
* 낱말이 많이 맞고, 다 맞고, 최근인 것이 위로 오면 된다.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { sessionsDir } from './store.js';
|
|
22
|
+
// 도구 부름·결과·글이 어디 실리는지는 규격마다 다르다 (backend/adapter.js).
|
|
23
|
+
import { 부른것들, 결과들, 도구결과인가, 본문글 } from '../backend/adapter.js';
|
|
24
|
+
|
|
25
|
+
// 한 번 찾을 때 읽을 최대 바이트. 넘으면 거기서 멈추고 **멈췄다고 말한다.**
|
|
26
|
+
export const 읽기예산 = 64 * 1024 * 1024;
|
|
27
|
+
// 한 메시지에서 볼 최대 길이. 파일을 통째로 붙인 도구 결과가 수 MB 씩 있다.
|
|
28
|
+
const 메시지최대 = 200 * 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 물음을 낱말로 쪼갠다.
|
|
32
|
+
*
|
|
33
|
+
* 한국어는 띄어쓰기가 낱말 경계와 안 맞는다("인코딩을" ≠ "인코딩"). 그래서
|
|
34
|
+
* 조사처럼 보이는 꼬리를 떼어 **둘 다** 찾는다. 형태소 분석기를 붙일 수는 없다
|
|
35
|
+
* (의존성 0). 이 정도만 해도 "인코딩을 어떻게" 로 "인코딩" 을 찾아낸다.
|
|
36
|
+
*/
|
|
37
|
+
export function 낱말쪼개기(물음) {
|
|
38
|
+
const 조사 = /(을|를|이|가|은|는|에|에서|으로|로|와|과|의|도|만|까지|부터|에게|한테|보다|처럼|랑|이랑)$/;
|
|
39
|
+
const 본 = String(물음 ?? '').toLowerCase().split(/[\s,.;:!?()[\]{}"'`]+/).filter(Boolean);
|
|
40
|
+
const 낱말 = new Set();
|
|
41
|
+
for (const w of 본) {
|
|
42
|
+
if (w.length < 2) continue; // 한 글자는 아무 데나 걸린다
|
|
43
|
+
낱말.add(w);
|
|
44
|
+
const 짧은 = w.replace(조사, '');
|
|
45
|
+
if (짧은.length >= 2 && 짧은 !== w) 낱말.add(짧은);
|
|
46
|
+
}
|
|
47
|
+
return [...낱말];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 맞은 자리 앞뒤를 잘라 보여 줄 토막을 만든다. */
|
|
51
|
+
export function 토막내기(글, 낱말들, 폭 = 150) {
|
|
52
|
+
const s = String(글 ?? '').replace(/\s+/g, ' ').trim();
|
|
53
|
+
if (s.length <= 폭) return s;
|
|
54
|
+
const 낮은 = s.toLowerCase();
|
|
55
|
+
let 자리 = -1;
|
|
56
|
+
for (const w of 낱말들) {
|
|
57
|
+
const i = 낮은.indexOf(w);
|
|
58
|
+
if (i >= 0 && (자리 < 0 || i < 자리)) 자리 = i;
|
|
59
|
+
}
|
|
60
|
+
if (자리 < 0) return s.slice(0, 폭) + '…';
|
|
61
|
+
const 앞 = Math.max(0, 자리 - Math.floor(폭 / 3));
|
|
62
|
+
return (앞 > 0 ? '…' : '') + s.slice(앞, 앞 + 폭) + (앞 + 폭 < s.length ? '…' : '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 메시지 하나를 사람이 읽을 글로. 도구 호출은 이름과 인자만 남긴다. */
|
|
66
|
+
function 글로(m) {
|
|
67
|
+
/*
|
|
68
|
+
* 규격을 안 가린다.
|
|
69
|
+
*
|
|
70
|
+
* 여기가 `m.tool_calls` 와 `p?.text` 로만 읽고 있었다. Anthropic 꼴에서
|
|
71
|
+
* 부름은 `tool_use` 블록(`.text` 가 없다)이고 결과는 `tool_result`
|
|
72
|
+
* 블록(`.content` 다)이라 **둘 다 빈 글**로 떨어졌고, 빈 글은 아래에서
|
|
73
|
+
* 버려진다. 그래서 그 창구로 한 일은 `/recall` 로 아무리 찾아도 안 나왔다 —
|
|
74
|
+
* 「그런 얘기 한 적 없다」 와 구별이 안 되는 고장이다.
|
|
75
|
+
*/
|
|
76
|
+
const 조각 = [];
|
|
77
|
+
const 글 = 본문글(m);
|
|
78
|
+
if (글) 조각.push(글);
|
|
79
|
+
for (const t of 부른것들(m)) {
|
|
80
|
+
let 인자 = '';
|
|
81
|
+
try { 인자 = JSON.stringify(t.args ?? {}); } catch { /* 못 적어도 이름은 남긴다 */ }
|
|
82
|
+
조각.push(`${t.name ?? '도구'}(${인자})`);
|
|
83
|
+
}
|
|
84
|
+
for (const r of 결과들(m)) if (r.글) 조각.push(r.글);
|
|
85
|
+
return 조각.join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const 누구 = { user: '나', assistant: '모델', tool: '도구결과', system: '규칙' };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 대화가 스스로 적어 둔 시각. 없거나 말이 안 되면 null 을 준다.
|
|
92
|
+
*
|
|
93
|
+
* 옛 기록에는 meta 줄이 아예 없고, 손으로 고치다 깨진 파일도 있다.
|
|
94
|
+
* 여기서 터지면 /recall 이 통째로 죽는다 — 못 읽은 날짜 하나 때문에.
|
|
95
|
+
*/
|
|
96
|
+
function 때(meta) {
|
|
97
|
+
const v = meta?.at;
|
|
98
|
+
if (!v) return null;
|
|
99
|
+
const d = new Date(v);
|
|
100
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 지난 대화에서 찾는다.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} root 작업 폴더
|
|
107
|
+
* @param {string} 물음
|
|
108
|
+
* @param {{limit?: number, 예산?: number, 지금세션?: string, 도구결과까지?: boolean}} o
|
|
109
|
+
* @returns {{맞은것: object[], 본파일: number, 전체파일: number, 읽은바이트: number, 예산초과: boolean, 낱말: string[]}}
|
|
110
|
+
*/
|
|
111
|
+
export function 찾기(root, 물음, o = {}) {
|
|
112
|
+
const limit = o.limit ?? 8;
|
|
113
|
+
const 예산 = o.예산 ?? 읽기예산;
|
|
114
|
+
const 도구결과까지 = o.도구결과까지 ?? false;
|
|
115
|
+
const 낱말 = 낱말쪼개기(물음);
|
|
116
|
+
const dir = sessionsDir(root);
|
|
117
|
+
|
|
118
|
+
const 빈결과 = { 맞은것: [], 본파일: 0, 전체파일: 0, 읽은바이트: 0, 예산초과: false, 낱말 };
|
|
119
|
+
if (!낱말.length || !existsSync(dir)) return 빈결과;
|
|
120
|
+
|
|
121
|
+
// 최근 것부터 본다. 예산이 모자라 멈추더라도 **쓸모 있는 쪽**에서 멈춘다.
|
|
122
|
+
const 파일들 = [];
|
|
123
|
+
for (const name of readdirSync(dir)) {
|
|
124
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
125
|
+
try {
|
|
126
|
+
const file = join(dir, name);
|
|
127
|
+
const st = statSync(file);
|
|
128
|
+
파일들.push({ id: name.slice(0, -6), file, at: st.mtime, bytes: st.size });
|
|
129
|
+
} catch { /* 지워지는 중이거나 권한이 없다 */ }
|
|
130
|
+
}
|
|
131
|
+
파일들.sort((a, b) => b.at - a.at);
|
|
132
|
+
|
|
133
|
+
const 맞은것 = [];
|
|
134
|
+
let 읽은바이트 = 0;
|
|
135
|
+
let 본파일 = 0;
|
|
136
|
+
let 예산초과 = false;
|
|
137
|
+
const 가장최근 = 파일들[0]?.at?.getTime?.() ?? Date.now();
|
|
138
|
+
|
|
139
|
+
for (const f of 파일들) {
|
|
140
|
+
if (읽은바이트 + f.bytes > 예산) { 예산초과 = true; break; }
|
|
141
|
+
let 원문;
|
|
142
|
+
try { 원문 = readFileSync(f.file, 'utf8'); } catch { continue; }
|
|
143
|
+
읽은바이트 += f.bytes;
|
|
144
|
+
본파일 += 1;
|
|
145
|
+
|
|
146
|
+
// 파일 전체에 낱말이 하나도 없으면 줄을 쪼갤 것도 없다. 큰 파일에서 크게 빠르다.
|
|
147
|
+
const 낮은전체 = 원문.toLowerCase();
|
|
148
|
+
if (!낱말.some((w) => 낮은전체.includes(w))) continue;
|
|
149
|
+
|
|
150
|
+
let 차례 = 0;
|
|
151
|
+
let meta = null;
|
|
152
|
+
for (const line of 원문.split('\n')) {
|
|
153
|
+
if (!line) continue;
|
|
154
|
+
let j;
|
|
155
|
+
try { j = JSON.parse(line); } catch { continue; }
|
|
156
|
+
if (j.t === 'meta') { meta = j; continue; }
|
|
157
|
+
차례 += 1;
|
|
158
|
+
if (j.role === 'system') continue;
|
|
159
|
+
// 결과가 어느 role 에 실리는지도 규격마다 다르다 (도구결과인가).
|
|
160
|
+
if (!도구결과까지 && 도구결과인가(j)) continue;
|
|
161
|
+
|
|
162
|
+
const 글 = 글로(j).slice(0, 메시지최대);
|
|
163
|
+
if (!글) continue;
|
|
164
|
+
const 낮은 = 글.toLowerCase();
|
|
165
|
+
|
|
166
|
+
let 맞은낱말 = 0;
|
|
167
|
+
let 횟수 = 0;
|
|
168
|
+
for (const w of 낱말) {
|
|
169
|
+
let n = 0;
|
|
170
|
+
let i = 낮은.indexOf(w);
|
|
171
|
+
while (i >= 0 && n < 50) { n += 1; i = 낮은.indexOf(w, i + w.length); }
|
|
172
|
+
if (n) { 맞은낱말 += 1; 횟수 += n; }
|
|
173
|
+
}
|
|
174
|
+
if (!맞은낱말) continue;
|
|
175
|
+
|
|
176
|
+
/*
|
|
177
|
+
* 점수.
|
|
178
|
+
* 낱말이 **몇 개나** 맞았나가 제일 세다 — 두 낱말이 다 나오는 글이
|
|
179
|
+
* 한 낱말이 열 번 나오는 글보다 거의 언제나 원하는 것이다.
|
|
180
|
+
* 최근일수록 조금 올린다. 같은 것을 두 번 물어본 경우 최근 답이 낫다.
|
|
181
|
+
*/
|
|
182
|
+
const 다맞음 = 맞은낱말 === 낱말.length ? 12 : 0;
|
|
183
|
+
const 오래됨 = Math.max(0, (가장최근 - f.at.getTime()) / (1000 * 60 * 60 * 24));
|
|
184
|
+
const 최근점 = Math.max(0, 6 - 오래됨 / 7);
|
|
185
|
+
맞은것.push({
|
|
186
|
+
세션: f.id,
|
|
187
|
+
// 화면에 적는 날짜는 **대화가 적어 둔 날**이지 파일이 만져진 날이 아니다.
|
|
188
|
+
//
|
|
189
|
+
// 이 도구는 오프라인 PC 로 폴더째 옮겨 다니는 것을 전제로 만들었다.
|
|
190
|
+
// 그렇게 옮기면 mtime 은 옮긴 날로 전부 바뀐다 — 반 년 치 대화가
|
|
191
|
+
// 하나같이 "오늘" 이 되고, 그러면 이 화면은 아무 말도 안 하는 셈이다.
|
|
192
|
+
//
|
|
193
|
+
// 점수(최근점)는 그대로 mtime 을 쓴다. 그건 파일을 읽기 전에 정해야
|
|
194
|
+
// 하는 값이고(예산 순서), 순서가 조금 흔들리는 것은 날짜가 통째로
|
|
195
|
+
// 거짓말하는 것과 무게가 다르다.
|
|
196
|
+
언제: 때(meta) ?? f.at,
|
|
197
|
+
model: meta?.model ?? null,
|
|
198
|
+
role: j.role,
|
|
199
|
+
누구: 누구[j.role] ?? j.role,
|
|
200
|
+
차례,
|
|
201
|
+
글,
|
|
202
|
+
토막: 토막내기(글, 낱말),
|
|
203
|
+
점수: 맞은낱말 * 10 + 다맞음 + Math.min(6, 횟수) + 최근점,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
맞은것.sort((a, b) => b.점수 - a.점수 || b.언제 - a.언제);
|
|
209
|
+
|
|
210
|
+
// 한 대화에서 열 줄이 몰려 나오면 다른 대화를 못 본다. 세션당 셋까지만.
|
|
211
|
+
const 세션별 = new Map();
|
|
212
|
+
const 추린것 = [];
|
|
213
|
+
for (const h of 맞은것) {
|
|
214
|
+
const n = 세션별.get(h.세션) ?? 0;
|
|
215
|
+
if (n >= 3) continue;
|
|
216
|
+
세션별.set(h.세션, n + 1);
|
|
217
|
+
추린것.push(h);
|
|
218
|
+
if (추린것.length >= limit) break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { 맞은것: 추린것, 본파일, 전체파일: 파일들.length, 읽은바이트, 예산초과, 낱말, 전체맞음: 맞은것.length };
|
|
222
|
+
}
|
package/src/agent/session.js
CHANGED
|
@@ -296,7 +296,14 @@ export class Session {
|
|
|
296
296
|
* 「들어간 토큰」 자리에 in 을 적으면 캐시가 잘 맞을수록 숫자가 줄어서
|
|
297
297
|
* 일이 줄어든 것처럼 보인다 — 까닭은 backend/adapter.js 의 보낸토큰.
|
|
298
298
|
*/
|
|
299
|
-
|
|
299
|
+
/*
|
|
300
|
+
* `못잰것` 은 **usage 를 안 준 응답이 몇 번이었나** 이다.
|
|
301
|
+
*
|
|
302
|
+
* 0 을 더하면 합계는 그대로라 화면에 아무 표가 안 난다. 그런데 그 판은
|
|
303
|
+
* 유료로 불렀고, 쓴 것을 우리가 모를 뿐이다. 「0 원 썼다」 와 「얼마
|
|
304
|
+
* 썼는지 모른다」 는 사람이 하는 판단이 다르다 — 그래서 세어 둔다.
|
|
305
|
+
*/
|
|
306
|
+
this.usage = { in: 0, out: 0, prompt: 0, calls: 0, ms: 0, retries: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, 못잰것: 0 };
|
|
300
307
|
/*
|
|
301
308
|
* 이 대화의 이름. 게이트웨이에 「같은 대화다」 라고 알려 줄 때 쓴다.
|
|
302
309
|
*
|
|
@@ -655,8 +662,8 @@ export class Session {
|
|
|
655
662
|
const 조각 = this.시스템조각();
|
|
656
663
|
const 머리 = { role: 'system', content: 조각.join('') };
|
|
657
664
|
/*
|
|
658
|
-
* 나눈 자리를 같이 알려 준다 — 캐시 표식을 박는
|
|
659
|
-
* (backend/adapter.js 의 anthropic몸).
|
|
665
|
+
* 나눈 자리를 같이 알려 준다 — 캐시 표식을 박는 자리에서 쓴다
|
|
666
|
+
* (backend/adapter.js 의 anthropic몸 · openai몸 둘 다).
|
|
660
667
|
*
|
|
661
668
|
* Symbol 로 다는 것이 중요하다. 보통 이름으로 달면 openai 규격에서는
|
|
662
669
|
* 이 메시지가 그대로 몸통에 실려 나가고, 모르는 칸 하나가 그 게이트웨이
|