deel-local-cli 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +686 -28
- package/README.md +571 -31
- package/bin/deel.js +234 -160
- package/package.json +3 -2
- package/src/agent/compact.js +131 -138
- package/src/agent/effort.js +44 -11
- package/src/agent/loop.js +574 -251
- package/src/agent/memory.js +152 -0
- package/src/agent/mention.js +164 -0
- package/src/agent/modes.js +127 -20
- package/src/agent/recall.js +209 -0
- package/src/agent/route.js +156 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +155 -11
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +246 -0
- package/src/backend/detect.js +10 -1
- package/src/backend/http.js +23 -0
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +97 -3
- package/src/commands.js +825 -50
- package/src/oneshot.js +327 -0
- package/src/repl.js +715 -414
- package/src/report.js +20 -6
- package/src/safety/guard.js +123 -7
- package/src/safety/undo.js +96 -11
- package/src/tools/encoding.js +24 -2
- package/src/tools/fsutil.js +70 -0
- package/src/tools/index.js +471 -27
- package/src/tools/webfetch.js +37 -1
- package/src/ui/ansi.js +32 -2
- package/src/ui/diff.js +255 -0
- package/src/ui/level.js +6 -2
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +169 -0
- package/src/ui/status.js +98 -24
- package/src/ui/tui.js +419 -0
- package/src/version.js +28 -0
package/src/agent/session.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { get as workMode, DEFAULT as WORK_DEFAULT } from './modes.js';
|
|
5
|
+
import { toolSchemas } from '../tools/index.js';
|
|
5
6
|
import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
|
|
6
7
|
|
|
7
8
|
// 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
|
|
@@ -23,8 +24,21 @@ const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드
|
|
|
23
24
|
- 추측하지 말고 도구로 확인한다. 파일을 고치기 전에는 반드시 Read 로 읽는다.
|
|
24
25
|
- Edit 의 old_string 은 공백과 들여쓰기까지 파일과 정확히 같아야 한다. 짧게 자르지 말고 앞뒤로 넉넉히 포함한다.
|
|
25
26
|
- 한 번에 하나씩 고치고, 고친 뒤에는 무엇을 왜 고쳤는지 한 줄로 말한다.
|
|
27
|
+
- 큰 파일은 한 번에 다 담지 않는다. 앞부분 300줄쯤을 Write 로 만들고, 나머지는 Append 를
|
|
28
|
+
여러 번 불러 끝까지 이어 붙인다. Append 는 Read 없이 바로 쓸 수 있다.
|
|
29
|
+
이어 붙일 때 앞부분을 다시 보내지 않는다 — 그러면 또 같은 자리에서 잘린다.
|
|
30
|
+
- 같은 도구를 같은 인자로 다시 부르지 않는다. 결과는 같다. 본 것은 기억하고 다음으로 넘어간다.
|
|
31
|
+
- 사용자가 볼 범위를 좁혀 말하면 그대로 따른다. 시키지 않은 폴더를 뒤지지 않는다.
|
|
26
32
|
- 명령 실행이 필요하면 Bash 를 쓴다. 되돌릴 수 없는 명령은 막히니 다른 방법을 찾는다.
|
|
27
|
-
- 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지
|
|
33
|
+
- 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.
|
|
34
|
+
|
|
35
|
+
다음에도 쓸 것은 남긴다:
|
|
36
|
+
- 사용자가 규칙을 정하거나 하지 말라고 하면 Remember 로 한 줄 남긴다. 그 글은 앞으로
|
|
37
|
+
모든 요청에 실리니 한 문장으로. 이번 일에서만 쓰는 것은 안 남긴다.
|
|
38
|
+
- "저번에"·"전에 정한 대로" 처럼 앞선 대화를 가리키면 되묻기 전에 Recall 로 찾는다.
|
|
39
|
+
- 또 하게 될 절차를 끝냈으면 .deel/skills/<이름>/SKILL.md 로 적어 둔다.
|
|
40
|
+
앞머리에 name 과 description 을 넣고(--- 로 감싼다) 아래에 순서를 적는다.
|
|
41
|
+
쓰던 스킬에서 틀린 데를 찾으면 그 파일을 고친다.`;
|
|
28
42
|
|
|
29
43
|
export class Session {
|
|
30
44
|
constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = 24 } = {}) {
|
|
@@ -32,6 +46,9 @@ export class Session {
|
|
|
32
46
|
this.root = root;
|
|
33
47
|
this.mode = mode; // 승인 정책 — 얼마나 물어보나 (auto/confirm/strict)
|
|
34
48
|
this.work = work ?? WORK_DEFAULT; // 작업 모드 — 무슨 일을 하는 중인가 (modes.js)
|
|
49
|
+
// 이번 한마디에만 쓸 모드. 종합 모드일 때 요청을 보고 골라 넣는다 (agent/route.js).
|
|
50
|
+
// 기본 모드(this.work)는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
|
|
51
|
+
this.routed = null;
|
|
35
52
|
// 사용자 수준 — 화면에 무엇을 내놓을지만 정한다. 안전 장치는 안 바꾼다 (ui/level.js)
|
|
36
53
|
this.level = normLevel(level) ?? LEVEL_DEFAULT;
|
|
37
54
|
this.think = think; // 기준 강도
|
|
@@ -40,6 +57,7 @@ export class Session {
|
|
|
40
57
|
this.maxSteps = maxSteps;
|
|
41
58
|
this.messages = [];
|
|
42
59
|
this.filesRead = new Map(); // 경로 → 추정 토큰
|
|
60
|
+
this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
|
|
43
61
|
this.skills = []; // 켜질 때 이 PC 에서 찾은 것들
|
|
44
62
|
this.commands = [];
|
|
45
63
|
this.plugins = [];
|
|
@@ -60,20 +78,47 @@ export class Session {
|
|
|
60
78
|
return null;
|
|
61
79
|
}
|
|
62
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 지금 이 순간 실제로 쓰는 작업 모드.
|
|
83
|
+
*
|
|
84
|
+
* 종합 모드에서는 한마디마다 골라 넣은 것(routed)이 있고, 그때는 그것이 답이다.
|
|
85
|
+
* 직접 고른 모드가 있으면 routed 는 비어 있으므로 기본 모드가 그대로 답이 된다.
|
|
86
|
+
* 도구·추론·프롬프트가 전부 이 값을 봐야 한다. 하나라도 빠뜨리면 어긋난다.
|
|
87
|
+
*/
|
|
88
|
+
effectiveWork() {
|
|
89
|
+
return this.routed ?? this.work;
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
systemPrompt() {
|
|
64
93
|
const parts = [BASE_RULES];
|
|
65
94
|
parts.push(`\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
|
|
66
95
|
|
|
67
96
|
// 지금 무슨 일을 하는 중인지. 도구 목록도 이 모드에 맞춰 이미 걸러져 있다.
|
|
68
|
-
const w = workMode(this.
|
|
97
|
+
const w = workMode(this.effectiveWork());
|
|
69
98
|
parts.push(`\n--- 지금 모드: ${w.name} (${w.en}) ---\n${w.say}`);
|
|
70
99
|
if (this.rules) parts.push(`\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* 지난 대화에서 정한 것.
|
|
103
|
+
*
|
|
104
|
+
* 이건 '찾으면 나오는' 것이 아니라 **처음부터 들어가 있어야** 하는 것이다.
|
|
105
|
+
* "우리 문서는 CP949 다" 를 매번 다시 설명하게 하면 두 번째부터 짜증이 나고
|
|
106
|
+
* 세 번째부터는 그냥 안 쓴다.
|
|
107
|
+
*
|
|
108
|
+
* 켤 때 한 번 읽어 들고 있는다. 매 턴 파일을 읽으면 긴 대화에서 그 횟수가
|
|
109
|
+
* 수십 번이 되고, 그 사이 사람이 파일을 고쳐 놓으면 대화 도중에 규칙이
|
|
110
|
+
* 바뀌는 셈이 된다 — 무엇 때문에 답이 달라졌는지 알 길이 없어진다.
|
|
111
|
+
*/
|
|
112
|
+
if (this.memory) parts.push(this.memory);
|
|
71
113
|
const listed = this.listedSkills();
|
|
72
114
|
if (listed.length) {
|
|
73
115
|
parts.push(
|
|
74
116
|
'\n--- 쓸 수 있는 스킬 ---\n' +
|
|
75
117
|
'필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n' +
|
|
76
|
-
|
|
118
|
+
// 설명이 없는 스킬이 섞일 수 있다 — 남의 폴더에서 오는 파일이라
|
|
119
|
+
// 앞머리(frontmatter)가 빠지곤 한다. 여기서 터지면 시스템 프롬프트를
|
|
120
|
+
// 못 만들어 **매 턴** 죽는다. 목록 명령 하나가 아니라 대화 전체가 막힌다.
|
|
121
|
+
listed.map((s) => `- ${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n')
|
|
77
122
|
);
|
|
78
123
|
const rest = this.skills.filter((s) => s.enabled).length - listed.length;
|
|
79
124
|
if (rest > 0) parts.push(`(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
|
|
@@ -96,18 +141,45 @@ export class Session {
|
|
|
96
141
|
|
|
97
142
|
noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
|
|
98
143
|
|
|
144
|
+
/**
|
|
145
|
+
* 이번 대화에서 어느 파일이 얼마나 바뀌었는지 적어 둔다. /diff 가 이걸 본다.
|
|
146
|
+
*
|
|
147
|
+
* 견준 결과를 통째로 들고 있지는 않는다 — 파일 내용 두 벌이 딸려 온다.
|
|
148
|
+
* 스무 번 고치면 그것만으로 수십 MB 다. 여기서는 숫자만 센다.
|
|
149
|
+
*/
|
|
150
|
+
noteChange(path, d) {
|
|
151
|
+
if (!path || !d) return;
|
|
152
|
+
const 앞 = this.changes.get(path) ?? { added: 0, removed: 0, times: 0 };
|
|
153
|
+
앞.added += d.added ?? 0;
|
|
154
|
+
앞.removed += d.removed ?? 0;
|
|
155
|
+
앞.times += 1;
|
|
156
|
+
this.changes.set(path, 앞);
|
|
157
|
+
}
|
|
158
|
+
|
|
99
159
|
// 모델에 실제로 보낼 배열.
|
|
100
160
|
wire() {
|
|
101
161
|
return [{ role: 'system', content: this.systemPrompt() }, ...this.messages];
|
|
102
162
|
}
|
|
103
163
|
|
|
104
|
-
|
|
164
|
+
/**
|
|
165
|
+
* /context 가 그릴 내역.
|
|
166
|
+
*
|
|
167
|
+
* 여기서 나온 used 는 화면 숫자로만 쓰이는 게 아니다. effort.js 가 이 값으로
|
|
168
|
+
* '남은 자리' 를 셈해 출력 상한을 정한다. 그래서 여기서 덜 세면 **매 요청마다
|
|
169
|
+
* 그만큼 몰래 나간다** — 다 찼는데 안 찼다고 알고 있는 상태가 된다.
|
|
170
|
+
*
|
|
171
|
+
* 전에는 두 가지를 안 셌다.
|
|
172
|
+
* · 도구 스키마 JSON — 도구 11종의 설명과 인자 정의. 약 1,800토큰이다.
|
|
173
|
+
* 매 요청에 통째로 들어가는데 어느 칸에도 안 잡혔다.
|
|
174
|
+
* · 지금 모드 문구 — modes.js 의 say. 모드마다 수백 토큰이다.
|
|
175
|
+
*/
|
|
105
176
|
breakdown() {
|
|
106
|
-
const sys = estimateTokens(BASE_RULES) + estimateTokens(`작업 폴더: ${this.root}`)
|
|
177
|
+
const sys = estimateTokens(BASE_RULES) + estimateTokens(`작업 폴더: ${this.root}`)
|
|
178
|
+
+ estimateTokens(workMode(this.effectiveWork()).say ?? '');
|
|
107
179
|
const rules = this.rules ? estimateTokens(this.rules.text) : 0;
|
|
108
180
|
const listed = this.listedSkills();
|
|
109
181
|
const skills = listed.length
|
|
110
|
-
? estimateTokens(listed.map((s) => `${s.name}: ${s.description.slice(0, this.maxSkillDesc)}`).join('\n'))
|
|
182
|
+
? estimateTokens(listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
|
|
111
183
|
: 0;
|
|
112
184
|
|
|
113
185
|
let history = 0;
|
|
@@ -118,10 +190,20 @@ export class Session {
|
|
|
118
190
|
if (m.role === 'tool') files += t; else history += t;
|
|
119
191
|
}
|
|
120
192
|
|
|
193
|
+
// 도구 정의도 매 요청에 실려 나간다. 세는 값이라기보다 '이미 나간 값' 이다.
|
|
194
|
+
const 도구 = this.#도구토큰();
|
|
195
|
+
|
|
196
|
+
// 기억도 매 요청에 통째로 나간다. 안 세면 '남은 자리' 가 그만큼 뻥튀기되고,
|
|
197
|
+
// effort.js 가 그 값으로 출력 상한을 잡으므로 답이 조용히 잘리기 시작한다.
|
|
198
|
+
const 기억 = this.memory ? estimateTokens(this.memory) : 0;
|
|
199
|
+
const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
|
|
200
|
+
|
|
121
201
|
const rows = [
|
|
122
202
|
{ label: '시스템 프롬프트', n: sys },
|
|
123
203
|
{ label: this.rules ? `규칙 (${this.rules.name})` : '규칙 (없음)', n: rules },
|
|
204
|
+
{ label: `기억 (${기억줄}줄)`, n: 기억 },
|
|
124
205
|
{ label: `스킬 목록 (${listed.length}/${this.skills.length}개)`, n: skills },
|
|
206
|
+
{ label: '도구 정의', n: 도구 },
|
|
125
207
|
{ label: '대화 이력', n: history },
|
|
126
208
|
{ label: `도구 결과 (파일 ${this.filesRead.size}개)`, n: files },
|
|
127
209
|
];
|
|
@@ -130,18 +212,80 @@ export class Session {
|
|
|
130
212
|
return { rows, used, total, left: Math.max(0, total - used) };
|
|
131
213
|
}
|
|
132
214
|
|
|
133
|
-
|
|
215
|
+
/**
|
|
216
|
+
* 도구 정의가 몇 토큰인가.
|
|
217
|
+
*
|
|
218
|
+
* 모드가 바뀌면 도구 목록도 바뀌므로 모드별로 한 번씩만 재고 넣어 둔다.
|
|
219
|
+
* 매 턴 JSON.stringify 를 하면 그 자체가 아깝다 — 값은 안 바뀌는데.
|
|
220
|
+
*/
|
|
221
|
+
#도구잰것 = new Map();
|
|
222
|
+
#도구토큰() {
|
|
223
|
+
// 밖에서 붙인 도구 수까지 열쇠에 넣는다. 서버가 붙고 떨어지면 값이 달라진다.
|
|
224
|
+
const mcp수 = (this.mcp ?? []).reduce((n, s) => n + (s.도구?.length ?? 0), 0);
|
|
225
|
+
const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|mcp${mcp수}`;
|
|
226
|
+
if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
|
|
227
|
+
let n = 0;
|
|
228
|
+
try {
|
|
229
|
+
const list = toolSchemas(null, {
|
|
230
|
+
hasSkills: (this.skills?.length ?? 0) > 0,
|
|
231
|
+
web: this.web !== false,
|
|
232
|
+
work: this.effectiveWork(),
|
|
233
|
+
mcp: this.mcp ?? null,
|
|
234
|
+
});
|
|
235
|
+
n = estimateTokens(JSON.stringify(list));
|
|
236
|
+
} catch { n = 0; }
|
|
237
|
+
this.#도구잰것.set(열쇠, n);
|
|
238
|
+
return n;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 오래된 대화를 잘라낸다. 앞의 2턴과 최근 절반만 남긴다.
|
|
243
|
+
*
|
|
244
|
+
* 자르는 자리를 **아무 데나 잡으면 안 된다.** 도구 호출과 그 결과는 한 몸이라,
|
|
245
|
+
* 사이를 끊으면 규격 위반이 되어 그 뒤 모든 요청이 400 으로 거절당한다.
|
|
246
|
+
*
|
|
247
|
+
* 이게 실제로 있었던 길이다 — 접기(compact)가 요약을 못 받으면 여기로
|
|
248
|
+
* 물러섰는데, 여기가 짝을 안 맞췄다. 화면에는 노란 안내 한 줄이 뜨고,
|
|
249
|
+
* 그 뒤로 무엇을 해도 400 이 났다. 원인은 두 화면 전이라 이어 붙일 수 없고,
|
|
250
|
+
* /clear 말고는 길이 없었다. 물러설 자리가 더 큰 사고를 만든 셈이다.
|
|
251
|
+
*/
|
|
134
252
|
trim() {
|
|
135
253
|
if (this.messages.length < 12) return 0;
|
|
136
|
-
const keepHead = 2;
|
|
137
|
-
const keepTail = Math.floor(this.messages.length / 2);
|
|
138
|
-
const dropped =
|
|
254
|
+
const keepHead = safeHead(this.messages, 2);
|
|
255
|
+
const keepTail = safeCut(this.messages, this.messages.length - Math.floor(this.messages.length / 2));
|
|
256
|
+
const dropped = keepTail - keepHead;
|
|
139
257
|
if (dropped <= 0) return 0;
|
|
140
258
|
this.messages = [
|
|
141
259
|
...this.messages.slice(0, keepHead),
|
|
142
260
|
{ role: 'user', content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)` },
|
|
143
|
-
...this.messages.slice(
|
|
261
|
+
...this.messages.slice(keepTail),
|
|
144
262
|
];
|
|
145
263
|
return dropped;
|
|
146
264
|
}
|
|
147
265
|
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 도구 호출과 결과가 갈라지지 않는 자리를 찾는다.
|
|
269
|
+
* i 번째부터 남긴다고 할 때, 안전한 i 로 옮겨 준다.
|
|
270
|
+
*
|
|
271
|
+
* 접기(compact.js)와 그냥 줄이기(trim)가 **같은 함수**를 쓴다. 전에는 접기에만
|
|
272
|
+
* 있었고 줄이기는 제멋대로 잘랐다. 그래서 접기가 실패해 줄이기로 물러선 순간
|
|
273
|
+
* 대화가 망가졌다 — 안전망이 사고를 만드는 자리는 늘 이런 모양이다.
|
|
274
|
+
*/
|
|
275
|
+
export function safeCut(messages, i) {
|
|
276
|
+
let k = Math.max(0, Math.min(i, messages.length));
|
|
277
|
+
// tool 결과로 시작하면 그 앞의 assistant(tool_calls) 가 없어 규격이 깨진다. 앞으로 당긴다.
|
|
278
|
+
while (k > 0 && messages[k]?.role === 'tool') k--;
|
|
279
|
+
return k;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 머리 쪽 자르는 자리.
|
|
284
|
+
* 머리가 '결과를 기다리는 도구 호출' 로 끝나면 그 결과가 접혀 없어져 짝이 깨진다.
|
|
285
|
+
* 그런 assistant 는 머리에서 뺀다 — 접히는 쪽에 같이 넘긴다.
|
|
286
|
+
*/
|
|
287
|
+
export function safeHead(messages, k) {
|
|
288
|
+
let h = Math.max(0, Math.min(k, messages.length));
|
|
289
|
+
while (h > 0 && messages[h - 1]?.tool_calls?.length) h--;
|
|
290
|
+
return h;
|
|
291
|
+
}
|
package/src/agent/store.js
CHANGED
|
@@ -143,30 +143,53 @@ function firstAsk(messages) {
|
|
|
143
143
|
/**
|
|
144
144
|
* 이 폴더에 남아 있는 대화 목록. 최근 것이 위로.
|
|
145
145
|
*/
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* 지난 대화 목록.
|
|
148
|
+
*
|
|
149
|
+
* **먼저 정렬하고 나서 필요한 것만 읽는다.** 순서가 중요하다.
|
|
150
|
+
*
|
|
151
|
+
* 전에는 폴더에 있는 파일을 **전부 열어서** 한 줄씩 JSON 으로 풀고, 그 다음에
|
|
152
|
+
* 정렬해서 20개만 남겼다. 몇 주 쓰면 수십 MB 를 매번 읽는 셈이다. 그리고 이건
|
|
153
|
+
* 켤 때마다 돈다(repl 이 prune 을 부른다) — 머리글도 안 뜬 채로 몇 초씩 멈춰
|
|
154
|
+
* 있어서, 사용자 눈에는 프로그램이 죽은 것처럼 보인다.
|
|
155
|
+
*
|
|
156
|
+
* 정렬에 필요한 것은 파일 시각뿐이고, 그건 안 열어도 안다.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} o
|
|
159
|
+
* @param {boolean} o.속까지 false 면 파일을 안 열고 시각·크기만 본다 (prune 용)
|
|
160
|
+
*/
|
|
161
|
+
export function list(root, { limit = 20, 속까지 = true } = {}) {
|
|
147
162
|
const dir = sessionsDir(root);
|
|
148
163
|
if (!existsSync(dir)) return [];
|
|
149
|
-
|
|
164
|
+
|
|
165
|
+
// 1) 시각만 모은다. 파일을 열지 않는다.
|
|
166
|
+
const 후보 = [];
|
|
150
167
|
for (const name of readdirSync(dir)) {
|
|
151
168
|
if (!name.endsWith('.jsonl')) continue;
|
|
152
|
-
const id = name.slice(0, -6);
|
|
153
169
|
const file = join(dir, name);
|
|
154
170
|
let st;
|
|
155
171
|
try { st = statSync(file); } catch { continue; }
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
후보.push({ id: name.slice(0, -6), file, at: st.mtime, bytes: st.size });
|
|
173
|
+
}
|
|
174
|
+
후보.sort((a, b) => b.at - a.at);
|
|
175
|
+
|
|
176
|
+
if (!속까지) return 후보.slice(0, limit);
|
|
177
|
+
|
|
178
|
+
// 2) 앞에서부터 필요한 개수만 실제로 읽는다.
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const c of 후보) {
|
|
181
|
+
if (out.length >= limit) break;
|
|
182
|
+
const { meta, messages } = new Store(root, c.id).load();
|
|
183
|
+
if (!messages.length) continue; // 빈 파일은 목록에 안 올린다
|
|
158
184
|
out.push({
|
|
159
|
-
|
|
160
|
-
file,
|
|
161
|
-
at: st.mtime,
|
|
162
|
-
bytes: st.size,
|
|
185
|
+
...c,
|
|
163
186
|
model: meta?.model ?? '?',
|
|
164
187
|
turns: messages.filter((m) => m.role === 'user').length,
|
|
165
188
|
messages: messages.length,
|
|
166
189
|
first: firstAsk(messages),
|
|
167
190
|
});
|
|
168
191
|
}
|
|
169
|
-
return out
|
|
192
|
+
return out;
|
|
170
193
|
}
|
|
171
194
|
|
|
172
195
|
export function remove(root, id) {
|
|
@@ -186,7 +209,9 @@ export function latest(root) {
|
|
|
186
209
|
* 최근 keep 개는 무조건 남기고, 그보다 오래되고 days 를 넘긴 것만 지운다.
|
|
187
210
|
*/
|
|
188
211
|
export function prune(root, { keep = 30, days = 30 } = {}) {
|
|
189
|
-
|
|
212
|
+
// 지울지 말지는 파일 시각만 보면 안다. 내용은 필요 없다 —
|
|
213
|
+
// 여기서 전부 읽던 것이 켤 때 몇 초씩 멈추던 원인이었다.
|
|
214
|
+
const all = list(root, { limit: 1000, 속까지: false });
|
|
190
215
|
const 자를것 = all.slice(keep).filter((s) => (Date.now() - s.at.getTime()) > days * 86400000);
|
|
191
216
|
for (const s of 자를것) { try { rmSync(s.file, { force: true }); } catch {} }
|
|
192
217
|
return 자를것.length;
|