deel-local-cli 0.9.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.
@@ -0,0 +1,182 @@
1
+ // 잘려 온 도구 호출에서 **건질 수 있는 만큼 건진다.**
2
+ //
3
+ // 무슨 일이 있었나:
4
+ // 대시보드 HTML 한 장을 만들라고 시켰다. 모델이 Write 를 부르는데 인자 JSON 이
5
+ // 출력 상한에서 잘렸다. 잘린 JSON 은 읽을 수 없으니 통째로 버렸다. 모델은
6
+ // 똑같이 다시 시도했고, 또 같은 자리에서 잘렸다. 13번 만에 컨텍스트가 다 찼다.
7
+ // 71초를 쓰고 **파일은 한 글자도 안 생겼다.**
8
+ //
9
+ // 그런데 잘린 그 JSON 안에는 이미 받아 놓은 250줄이 들어 있었다. 경로도 온전했다.
10
+ // 버릴 이유가 없다. 250줄을 쓰고 "여기까지 썼으니 이어 붙여라" 라고 하면
11
+ // 그 다음 호출은 나머지만 보내면 된다 — 잘릴 일이 없다.
12
+ //
13
+ // 왜 JSON.parse 로는 안 되나:
14
+ // JSON.parse 는 전부 맞거나 전부 틀리거나 둘 중 하나다. 마지막 따옴표가 없다는
15
+ // 이유로 앞의 250줄까지 같이 버린다. 그래서 앞에서부터 읽어 나가다가 더 못
16
+ // 읽는 자리에서 멈추는 방식으로 따로 만든다.
17
+ //
18
+ // 조심할 것:
19
+ // 여기서 건진 값은 **완전하지 않다.** 마지막 값은 잘려 있다. 그래서 이 결과를
20
+ // 쓰는 쪽은 어느 값이 잘렸는지(truncatedKey) 반드시 보고 판단해야 한다.
21
+ // Edit 의 old_string 을 반쪽만 건져 쓰면 엉뚱한 자리를 고친다. loop.js 가
22
+ // Write·Append 만 살리는 이유다.
23
+
24
+ /**
25
+ * 잘린 JSON 에서 읽히는 데까지 읽는다.
26
+ *
27
+ * @returns {{args: object, keys: string[], truncatedKey: string|null, complete: boolean}}
28
+ * args 온전히 읽은 값들 (+ 잘린 값 하나는 받은 데까지)
29
+ * truncatedKey 잘린 값의 이름. null 이면 잘린 값 없이 그냥 끝난 것
30
+ * complete 닫는 괄호까지 제대로 나왔나
31
+ */
32
+ export function partialParse(raw) {
33
+ const s = String(raw ?? '');
34
+ const 빈것 = { args: {}, keys: [], truncatedKey: null, complete: false };
35
+ let i = 0;
36
+
37
+ const 공백건너뛰기 = () => { while (i < s.length && /\s/.test(s[i])) i++; };
38
+
39
+ 공백건너뛰기();
40
+ if (s[i] !== '{') return 빈것;
41
+ i++;
42
+
43
+ const args = {};
44
+ const keys = [];
45
+
46
+ for (;;) {
47
+ 공백건너뛰기();
48
+ if (i >= s.length) return { args, keys, truncatedKey: null, complete: false };
49
+ if (s[i] === '}') return { args, keys, truncatedKey: null, complete: true };
50
+ if (s[i] === ',') { i++; continue; }
51
+ if (s[i] !== '"') return { args, keys, truncatedKey: null, complete: false };
52
+
53
+ const k = 글읽기(s, i);
54
+ if (!k.complete) return { args, keys, truncatedKey: null, complete: false }; // 이름이 잘렸으면 값도 없다
55
+ i = k.end;
56
+ 공백건너뛰기();
57
+ if (s[i] !== ':') return { args, keys, truncatedKey: null, complete: false };
58
+ i++;
59
+ 공백건너뛰기();
60
+ if (i >= s.length) return { args, keys, truncatedKey: null, complete: false };
61
+
62
+ if (s[i] === '"') {
63
+ const v = 글읽기(s, i);
64
+ args[k.text] = v.text;
65
+ keys.push(k.text);
66
+ if (!v.complete) return { args, keys, truncatedKey: k.text, complete: false };
67
+ i = v.end;
68
+ } else if (s[i] === '{' || s[i] === '[') {
69
+ const end = 짝찾기(s, i);
70
+ // 안 닫힌 덩어리는 통째로 버린다. 반쪽 객체는 건져도 쓸 데가 없고,
71
+ // 잘못 건지면 있지도 않은 값이 있는 것처럼 보인다.
72
+ if (end < 0) return { args, keys, truncatedKey: null, complete: false };
73
+ try { args[k.text] = JSON.parse(s.slice(i, end + 1)); keys.push(k.text); } catch { /* 못 읽으면 버린다 */ }
74
+ i = end + 1;
75
+ } else {
76
+ // 숫자·true·false·null
77
+ let j = i;
78
+ while (j < s.length && !',}'.includes(s[j])) j++;
79
+ const 조각 = s.slice(i, j).trim();
80
+ if (j >= s.length) return { args, keys, truncatedKey: null, complete: false }; // 값이 잘렸다
81
+ try { args[k.text] = JSON.parse(조각); keys.push(k.text); } catch { /* 버린다 */ }
82
+ i = j;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * JSON 문자열 하나를 읽는다. 끝까지 못 읽어도 읽은 데까지 돌려준다.
89
+ *
90
+ * 이 함수가 이 파일의 핵심이다. \n · \" · \\ 는 물론 \uXXXX 도 풀어야 한다 —
91
+ * 한글이 그 모양으로 오는 게이트웨이가 있다. 실제로 사용자 게이트웨이가 그랬다.
92
+ * 마지막에 escape 가 반토막 나 있으면(\ 하나만, 또는 \u12) 그건 버린다.
93
+ */
94
+ function 글읽기(s, start) {
95
+ let i = start + 1; // 여는 따옴표 다음
96
+ let out = '';
97
+ while (i < s.length) {
98
+ const ch = s[i];
99
+ if (ch === '"') return { text: out, end: i + 1, complete: true };
100
+ if (ch !== '\\') { out += ch; i++; continue; }
101
+
102
+ const n = s[i + 1];
103
+ if (n === undefined) break; // \ 하나만 남고 끝났다 — 버린다
104
+ if (n === 'u') {
105
+ const hex = s.slice(i + 2, i + 6);
106
+ if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break; // \u12 에서 끊겼다
107
+ out += String.fromCharCode(parseInt(hex, 16));
108
+ i += 6;
109
+ continue;
110
+ }
111
+ const 표 = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
112
+ if (n in 표) { out += 표[n]; i += 2; continue; }
113
+ out += n; i += 2; // 모르는 escape 는 글자 그대로
114
+ }
115
+ return { text: out, end: s.length, complete: false };
116
+ }
117
+
118
+ /** 여는 괄호에 맞는 닫는 괄호 자리. 없으면 -1. 따옴표 안은 안 센다. */
119
+ function 짝찾기(s, start) {
120
+ const 열림 = s[start];
121
+ const 닫힘 = 열림 === '{' ? '}' : ']';
122
+ let depth = 0;
123
+ let 글속 = false;
124
+ for (let i = start; i < s.length; i++) {
125
+ const ch = s[i];
126
+ if (글속) {
127
+ if (ch === '\\') { i++; continue; }
128
+ if (ch === '"') 글속 = false;
129
+ continue;
130
+ }
131
+ if (ch === '"') { 글속 = true; continue; }
132
+ if (ch === 열림) depth++;
133
+ else if (ch === 닫힘) { depth--; if (depth === 0) return i; }
134
+ }
135
+ return -1;
136
+ }
137
+
138
+ /**
139
+ * 잘린 Write 호출을 '지금 바로 쓸 수 있는 것' 으로 바꾼다.
140
+ *
141
+ * 마지막 줄은 버린다. 반쪽 줄(`<div class="ca`)로 끝나 있으면 이어 붙이는 쪽이
142
+ * 그 반쪽에 정확히 맞춰 이어야 하는데, 약한 모델은 그걸 못 한다. 온전한 줄까지만
143
+ * 남기고 "그 다음 줄부터 쓰라" 고 하면 훨씬 쉬운 문제가 된다.
144
+ *
145
+ * @returns {{path: string, content: string, lines: number, lastLine: string}|null}
146
+ */
147
+ export function 살린쓰기(raw) {
148
+ const r = partialParse(raw);
149
+ const path = r.args.file_path ?? r.args.path ?? r.args.filePath;
150
+ const body = r.args.content;
151
+ if (typeof path !== 'string' || !path.trim()) return null;
152
+ if (typeof body !== 'string' || !body) return null;
153
+ // 경로 자체가 잘렸으면 엉뚱한 자리에 쓴다. 그건 안 건진다.
154
+ if (r.truncatedKey === 'file_path' || r.truncatedKey === 'path') return null;
155
+
156
+ // 내용이 온전히 다 왔는데도 JSON 이 깨진 것이라면(뒤쪽 다른 인자에서 잘림)
157
+ // 마지막 줄을 버릴 이유가 없다.
158
+ const 내용잘림 = r.truncatedKey === 'content';
159
+ let content = body;
160
+ if (내용잘림) {
161
+ const 끝 = body.lastIndexOf('\n');
162
+ // 줄바꿈이 한 번도 없으면 **아무것도 안 건진다.**
163
+ //
164
+ // 이어 붙이라고 하려면 '어디까지 썼는지' 를 줄 단위로 말해 줄 수 있어야 한다.
165
+ // 온전한 줄이 하나도 없으면 반쪽 줄에 정확히 이어 붙이라고 부탁하는 셈인데,
166
+ // 약한 모델은 그걸 못 한다 — 앞을 다시 쓰거나 이음매가 어긋난다.
167
+ // 그럴 바에는 안 쓰고 '나눠 보내라' 고 하는 편이 낫다.
168
+ if (끝 < 0) return null;
169
+ content = body.slice(0, 끝 + 1);
170
+ }
171
+ if (!content) return null;
172
+
173
+ const lines = content.split('\n');
174
+ const 마지막 = [...lines].reverse().find((l) => l.trim()) ?? '';
175
+ return {
176
+ path: path.trim(),
177
+ content,
178
+ lines: content.endsWith('\n') ? lines.length - 1 : lines.length,
179
+ lastLine: 마지막.trim().slice(0, 60),
180
+ cut: 내용잘림,
181
+ };
182
+ }
@@ -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 } = {}) {
@@ -43,6 +57,7 @@ export class Session {
43
57
  this.maxSteps = maxSteps;
44
58
  this.messages = [];
45
59
  this.filesRead = new Map(); // 경로 → 추정 토큰
60
+ this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
46
61
  this.skills = []; // 켜질 때 이 PC 에서 찾은 것들
47
62
  this.commands = [];
48
63
  this.plugins = [];
@@ -82,12 +97,28 @@ export class Session {
82
97
  const w = workMode(this.effectiveWork());
83
98
  parts.push(`\n--- 지금 모드: ${w.name} (${w.en}) ---\n${w.say}`);
84
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);
85
113
  const listed = this.listedSkills();
86
114
  if (listed.length) {
87
115
  parts.push(
88
116
  '\n--- 쓸 수 있는 스킬 ---\n' +
89
117
  '필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n' +
90
- listed.map((s) => `- ${s.name}: ${s.description.slice(0, this.maxSkillDesc)}`).join('\n')
118
+ // 설명이 없는 스킬이 섞일 수 있다 — 남의 폴더에서 오는 파일이라
119
+ // 앞머리(frontmatter)가 빠지곤 한다. 여기서 터지면 시스템 프롬프트를
120
+ // 못 만들어 **매 턴** 죽는다. 목록 명령 하나가 아니라 대화 전체가 막힌다.
121
+ listed.map((s) => `- ${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n')
91
122
  );
92
123
  const rest = this.skills.filter((s) => s.enabled).length - listed.length;
93
124
  if (rest > 0) parts.push(`(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
@@ -110,18 +141,45 @@ export class Session {
110
141
 
111
142
  noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
112
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
+
113
159
  // 모델에 실제로 보낼 배열.
114
160
  wire() {
115
161
  return [{ role: 'system', content: this.systemPrompt() }, ...this.messages];
116
162
  }
117
163
 
118
- // /context 가 그릴 내역.
164
+ /**
165
+ * /context 가 그릴 내역.
166
+ *
167
+ * 여기서 나온 used 는 화면 숫자로만 쓰이는 게 아니다. effort.js 가 이 값으로
168
+ * '남은 자리' 를 셈해 출력 상한을 정한다. 그래서 여기서 덜 세면 **매 요청마다
169
+ * 그만큼 몰래 나간다** — 다 찼는데 안 찼다고 알고 있는 상태가 된다.
170
+ *
171
+ * 전에는 두 가지를 안 셌다.
172
+ * · 도구 스키마 JSON — 도구 11종의 설명과 인자 정의. 약 1,800토큰이다.
173
+ * 매 요청에 통째로 들어가는데 어느 칸에도 안 잡혔다.
174
+ * · 지금 모드 문구 — modes.js 의 say. 모드마다 수백 토큰이다.
175
+ */
119
176
  breakdown() {
120
- const sys = estimateTokens(BASE_RULES) + estimateTokens(`작업 폴더: ${this.root}`);
177
+ const sys = estimateTokens(BASE_RULES) + estimateTokens(`작업 폴더: ${this.root}`)
178
+ + estimateTokens(workMode(this.effectiveWork()).say ?? '');
121
179
  const rules = this.rules ? estimateTokens(this.rules.text) : 0;
122
180
  const listed = this.listedSkills();
123
181
  const skills = listed.length
124
- ? 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'))
125
183
  : 0;
126
184
 
127
185
  let history = 0;
@@ -132,10 +190,20 @@ export class Session {
132
190
  if (m.role === 'tool') files += t; else history += t;
133
191
  }
134
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
+
135
201
  const rows = [
136
202
  { label: '시스템 프롬프트', n: sys },
137
203
  { label: this.rules ? `규칙 (${this.rules.name})` : '규칙 (없음)', n: rules },
204
+ { label: `기억 (${기억줄}줄)`, n: 기억 },
138
205
  { label: `스킬 목록 (${listed.length}/${this.skills.length}개)`, n: skills },
206
+ { label: '도구 정의', n: 도구 },
139
207
  { label: '대화 이력', n: history },
140
208
  { label: `도구 결과 (파일 ${this.filesRead.size}개)`, n: files },
141
209
  ];
@@ -144,18 +212,80 @@ export class Session {
144
212
  return { rows, used, total, left: Math.max(0, total - used) };
145
213
  }
146
214
 
147
- // 오래된 대화를 잘라낸다. 앞의 2턴과 최근 절반만 남긴다.
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
+ */
148
252
  trim() {
149
253
  if (this.messages.length < 12) return 0;
150
- const keepHead = 2;
151
- const keepTail = Math.floor(this.messages.length / 2);
152
- const dropped = this.messages.length - keepHead - keepTail;
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;
153
257
  if (dropped <= 0) return 0;
154
258
  this.messages = [
155
259
  ...this.messages.slice(0, keepHead),
156
260
  { role: 'user', content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)` },
157
- ...this.messages.slice(-keepTail),
261
+ ...this.messages.slice(keepTail),
158
262
  ];
159
263
  return dropped;
160
264
  }
161
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
+ }
@@ -143,30 +143,53 @@ function firstAsk(messages) {
143
143
  /**
144
144
  * 이 폴더에 남아 있는 대화 목록. 최근 것이 위로.
145
145
  */
146
- export function list(root, { limit = 20 } = {}) {
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
- const out = [];
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
- const { meta, messages } = new Store(root, id).load();
157
- if (!messages.length) continue;
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
- id,
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.sort((a, b) => b.at - a.at).slice(0, limit);
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
- const all = list(root, { limit: 1000 });
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;