deel-local-cli 1.1.1 → 1.2.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 +347 -9
- package/README.md +231 -4
- package/package.json +2 -2
- package/src/agent/budget.js +0 -14
- package/src/agent/compact.js +87 -0
- package/src/agent/evolve.js +213 -0
- package/src/agent/loop.js +38 -1
- package/src/agent/modes.js +2 -0
- package/src/agent/session.js +145 -1
- package/src/agent/threads.js +154 -0
- package/src/commands.js +167 -5
- package/src/config.js +10 -1
- package/src/oneshot.js +4 -0
- package/src/repl.js +83 -17
- package/src/safety/guard.js +37 -2
- package/src/safety/undo.js +1 -1
- package/src/setup.js +2 -2
- package/src/skills/discover.js +1 -1
- package/src/tools/edit-match.js +16 -5
- package/src/tools/excel-com.js +1 -1
- package/src/tools/excel.js +1 -1
- package/src/tools/fsutil.js +0 -8
- package/src/tools/index.js +1 -1
- package/src/tools/todo.js +19 -3
- package/src/ui/ansi.js +6 -2
- package/src/ui/inputbox.js +32 -6
- package/src/ui/md.js +227 -0
- package/src/ui/prompt.js +1 -1
- package/src/ui/screen.js +1 -1
- package/src/ui/status.js +14 -1
- package/src/ui/working.js +0 -3
- package/src/ui/wrap.js +1 -8
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// 쓸수록 이 PC 에 맞춰 나아진다.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 만드나 ───────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// deel 은 이미 세 군데서 뭔가를 배우고 있었다. 그런데 셋 다 **끄면 사라졌다.**
|
|
6
|
+
//
|
|
7
|
+
// backend/learn.js 서버가 거절하면 그 말에서 상한을 배운다 → 그 세션에서만
|
|
8
|
+
// agent/grade.js 모델이 인자를 자주 잘라 먹는지 지켜본다 → 그 세션에서만
|
|
9
|
+
// session.배운다() 토큰 추정을 실제값에 맞춘다 → 그 세션에서만
|
|
10
|
+
//
|
|
11
|
+
// 그래서 켤 때마다 처음부터 다시 겪는다. 어제 `pnpm` 이 이 PC 에 없다는 걸
|
|
12
|
+
// 알아냈어도 오늘 또 부르고, 또 실패하고, 또 우회한다. 같은 실수를 매일 한다.
|
|
13
|
+
//
|
|
14
|
+
// 여기서는 **겪은 것을 조금씩 쌓아 두고**, 다음에 켤 때 몇 줄로 요약해 넘긴다.
|
|
15
|
+
//
|
|
16
|
+
// ── 학습이 아니다 ───────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// 모델을 고치는 것이 아니고, 대화를 통째로 쌓아 두는 것도 아니다. 그러면
|
|
19
|
+
// 컨텍스트만 먹는다. 여기 쌓는 것은 **확인된 사실 몇 줄**이다.
|
|
20
|
+
//
|
|
21
|
+
// · 세어 두기만 한다 — 무엇이 몇 번 됐고 몇 번 안 됐나
|
|
22
|
+
// · 확신이 설 때만 말한다 — 한 번 겪은 것은 우연일 수 있다
|
|
23
|
+
// · 상한을 못 박는다 — 프롬프트에 실리는 것은 220토큰까지다
|
|
24
|
+
//
|
|
25
|
+
// 자리를 아끼는 쪽이 오히려 값이 크다. 여기 한 줄이 모델이 헛도는 걸음
|
|
26
|
+
// 서너 개를 없앤다 — 그 걸음 하나가 파일 내용 한 벌씩이다.
|
|
27
|
+
//
|
|
28
|
+
// ── 무엇을 어디에 쌓나 ──────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// <작업폴더>/.deel/배운것.json 이 프로젝트에서만 맞는 것 (명령·인코딩)
|
|
31
|
+
// <설정폴더>/배운것.json 이 PC·이 모델에 붙는 것 (버릇·토큰 보정)
|
|
32
|
+
//
|
|
33
|
+
// 나눠 두는 이유는 하나다. "`npm test` 가 된다" 는 이 폴더의 사실이고,
|
|
34
|
+
// "이 모델은 인자를 잘라 먹는다" 는 모델의 사실이다. 섞어 두면 폴더를 옮길
|
|
35
|
+
// 때마다 모델에 대해 알아낸 것을 통째로 잃는다.
|
|
36
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { join, dirname } from 'node:path';
|
|
38
|
+
import { estimateTokens } from './session.js';
|
|
39
|
+
|
|
40
|
+
export const 최대토큰 = 220; // 프롬프트에 실을 상한
|
|
41
|
+
const 명령최대 = 40; // 표가 끝없이 자라지 않게
|
|
42
|
+
const 모델최대 = 10;
|
|
43
|
+
|
|
44
|
+
const 빈것 = () => ({ 판: 1, 명령: {}, 모델: {} });
|
|
45
|
+
|
|
46
|
+
function 읽기(파일) {
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(파일)) return 빈것();
|
|
49
|
+
const j = JSON.parse(readFileSync(파일, 'utf8'));
|
|
50
|
+
return { ...빈것(), ...(j && typeof j === 'object' ? j : {}) };
|
|
51
|
+
} catch { return 빈것(); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function 쓰기(파일, 값) {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(파일), { recursive: true });
|
|
57
|
+
writeFileSync(파일, JSON.stringify(값, null, 2), 'utf8');
|
|
58
|
+
} catch { /* 못 적어도 대화는 계속돼야 한다 */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 오래된 것부터 버려서 표를 상한 안에 둔다. */
|
|
62
|
+
function 줄이기(표, 상한) {
|
|
63
|
+
const 열쇠 = Object.keys(표);
|
|
64
|
+
if (열쇠.length <= 상한) return 표;
|
|
65
|
+
열쇠.sort((a, b) => String(표[a]?.at ?? '').localeCompare(String(표[b]?.at ?? '')));
|
|
66
|
+
for (const k of 열쇠.slice(0, 열쇠.length - 상한)) delete 표[k];
|
|
67
|
+
return 표;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class 배움 {
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} root 작업 폴더
|
|
73
|
+
* @param {string} home 설정 폴더 (~/.deel 또는 DEEL_HOME)
|
|
74
|
+
* @param {string} 지금 시각. 검사가 고정할 수 있게 밖에서 받는다
|
|
75
|
+
*/
|
|
76
|
+
constructor(root, home, 지금 = new Date().toISOString()) {
|
|
77
|
+
this.지금 = 지금;
|
|
78
|
+
this.폴더파일 = root ? join(root, '.deel', '배운것.json') : null;
|
|
79
|
+
this.집파일 = home ? join(home, '배운것.json') : null;
|
|
80
|
+
this.폴더 = this.폴더파일 ? 읽기(this.폴더파일) : 빈것();
|
|
81
|
+
this.집 = this.집파일 ? 읽기(this.집파일) : 빈것();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 명령 하나가 됐는지 안 됐는지.
|
|
86
|
+
*
|
|
87
|
+
* 명령줄 전체가 아니라 **앞머리 두 낱말**만 센다. `npm test -- --watch` 와
|
|
88
|
+
* `npm test` 는 같은 사실이고, 경로·인자까지 세면 표가 금세 쓸모없어진다.
|
|
89
|
+
*
|
|
90
|
+
* 다만 **프로그램이 아예 없어서** 실패한 것은 첫 낱말만 센다. `pnpm install`
|
|
91
|
+
* 이 안 되고 `pnpm add` 도 안 됐다면 그건 두 가지 사실이 아니라 "이 PC 에
|
|
92
|
+
* pnpm 이 없다" 는 한 가지 사실이다. 두 낱말로 세면 서로 다른 칸에 하나씩
|
|
93
|
+
* 쌓여서 영영 확신에 못 이른다 — 매번 다시 겪는다.
|
|
94
|
+
*/
|
|
95
|
+
명령본것(명령, 됐나, 이유 = '') {
|
|
96
|
+
const 낱말 = String(명령 ?? '').trim().split(/\s+/);
|
|
97
|
+
const 없는프로그램 = !됐나
|
|
98
|
+
&& /not found|not recognized|ENOENT|찾을 수 없|없는 명령/i.test(String(이유 ?? ''));
|
|
99
|
+
const 열쇠 = (없는프로그램 ? 낱말.slice(0, 1) : 낱말.slice(0, 2)).join(' ').slice(0, 40);
|
|
100
|
+
if (!열쇠) return this;
|
|
101
|
+
const 표 = this.폴더.명령;
|
|
102
|
+
const r = 표[열쇠] ?? { ok: 0, no: 0, at: this.지금 };
|
|
103
|
+
if (됐나) r.ok++; else r.no++;
|
|
104
|
+
r.at = this.지금;
|
|
105
|
+
표[열쇠] = r;
|
|
106
|
+
줄이기(표, 명령최대);
|
|
107
|
+
this.#저장폴더();
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 모델이 보인 버릇 하나. grade.js 의 지켜본것 과 같은 이름을 쓴다. */
|
|
112
|
+
모델본것(모델, 무엇, n = 1) {
|
|
113
|
+
const r = this.#모델칸(모델);
|
|
114
|
+
if (!r) return this;
|
|
115
|
+
r[무엇] = (r[무엇] ?? 0) + n;
|
|
116
|
+
r.at = this.지금;
|
|
117
|
+
this.#저장집();
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 토큰 추정 배수. 다음에 켤 때 이 값으로 시작한다. */
|
|
122
|
+
보정본것(모델, 배수) {
|
|
123
|
+
const n = Number(배수);
|
|
124
|
+
if (!Number.isFinite(n) || n < 0.5 || n > 2) return this;
|
|
125
|
+
const r = this.#모델칸(모델);
|
|
126
|
+
if (!r) return this;
|
|
127
|
+
// 갑자기 튀지 않게 지난 값과 섞는다. 대화마다 조금씩 옮겨 간다.
|
|
128
|
+
r.보정 = r.보정 ? r.보정 + (n - r.보정) * 0.5 : n;
|
|
129
|
+
r.at = this.지금;
|
|
130
|
+
this.#저장집();
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 지난번에 알아낸 토큰 배수. 없으면 null. */
|
|
135
|
+
아는보정(모델) {
|
|
136
|
+
const r = this.집.모델?.[String(모델 ?? '')];
|
|
137
|
+
return r?.보정 && r.보정 >= 0.5 && r.보정 <= 2 ? r.보정 : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#모델칸(모델) {
|
|
141
|
+
const 이름 = String(모델 ?? '').trim();
|
|
142
|
+
if (!이름) return null;
|
|
143
|
+
const 표 = this.집.모델;
|
|
144
|
+
표[이름] = 표[이름] ?? { 걸음: 0, at: this.지금 };
|
|
145
|
+
줄이기(표, 모델최대);
|
|
146
|
+
return 표[이름];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#저장폴더() { if (this.폴더파일) 쓰기(this.폴더파일, this.폴더); }
|
|
150
|
+
#저장집() { if (this.집파일) 쓰기(this.집파일, this.집); }
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 프롬프트에 실을 몇 줄.
|
|
154
|
+
*
|
|
155
|
+
* 확신이 서는 것만 말한다. 두 번 겪지 않은 것은 우연일 수 있고, 우연을
|
|
156
|
+
* 사실처럼 적으면 모델이 그 위에서 엉뚱한 판단을 한다 — 안 배우느니만 못하다.
|
|
157
|
+
*
|
|
158
|
+
* @returns {string|null} 없으면 null (그때는 프롬프트에 아무것도 안 붙는다)
|
|
159
|
+
*/
|
|
160
|
+
요약(모델 = '', 상한 = 최대토큰) {
|
|
161
|
+
const 줄 = [];
|
|
162
|
+
|
|
163
|
+
// 되는 명령 / 안 되는 명령. 이 폴더의 사실이다.
|
|
164
|
+
const 되는것 = [];
|
|
165
|
+
const 안되는것 = [];
|
|
166
|
+
for (const [열쇠, r] of Object.entries(this.폴더.명령 ?? {})) {
|
|
167
|
+
if (r.ok >= 2 && r.no === 0) 되는것.push(열쇠);
|
|
168
|
+
else if (r.no >= 2 && r.ok === 0) 안되는것.push(열쇠);
|
|
169
|
+
}
|
|
170
|
+
if (되는것.length) 줄.push(`- 여기서 되는 명령: ${되는것.slice(0, 6).map((x) => `\`${x}\``).join(' · ')}`);
|
|
171
|
+
if (안되는것.length) 줄.push(`- 이 PC 에서 안 되는 명령(다시 부르지 마라): ${안되는것.slice(0, 6).map((x) => `\`${x}\``).join(' · ')}`);
|
|
172
|
+
|
|
173
|
+
// 모델 버릇. 걸음이 쌓여야 말이 된다.
|
|
174
|
+
const m = this.집.모델?.[String(모델 ?? '')];
|
|
175
|
+
if (m && (m.걸음 ?? 0) >= 10) {
|
|
176
|
+
if ((m.잘린인자 ?? 0) / m.걸음 >= 0.15) {
|
|
177
|
+
줄.push('- 이 모델은 큰 인자를 자주 잘라 먹었다. 파일은 Write 한 번에 다 쓰지 말고 Append 로 나눠 써라.');
|
|
178
|
+
}
|
|
179
|
+
if ((m.편집실패 ?? 0) / m.걸음 >= 0.15) {
|
|
180
|
+
줄.push('- Edit 이 자주 빗나갔다. old_string 은 Read 로 본 그대로, 공백까지 옮겨 담아라.');
|
|
181
|
+
}
|
|
182
|
+
if ((m.되풀이 ?? 0) / m.걸음 >= 0.15) {
|
|
183
|
+
줄.push('- 같은 호출을 되풀이한 적이 잦다. 한 번 실패한 방법은 바꿔서 시도해라.');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!줄.length) return null;
|
|
188
|
+
|
|
189
|
+
const 머리 = '## 여기서 겪어 본 것 (자동으로 쌓임)';
|
|
190
|
+
const out = [머리];
|
|
191
|
+
for (const l of 줄) {
|
|
192
|
+
if (estimateTokens([...out, l].join('\n')) > 상한) break;
|
|
193
|
+
out.push(l);
|
|
194
|
+
}
|
|
195
|
+
return out.length > 1 ? out.join('\n') : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 사람이 지우라고 한 것. 파일째 비운다. */
|
|
199
|
+
지우기(어디 = '전부') {
|
|
200
|
+
if (어디 === '전부' || 어디 === '폴더') { this.폴더 = 빈것(); this.#저장폴더(); }
|
|
201
|
+
if (어디 === '전부' || 어디 === '모델') { this.집 = 빈것(); this.#저장집(); }
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 화면에 뿌릴 것. */
|
|
206
|
+
현황(모델 = '') {
|
|
207
|
+
const 명령 = Object.entries(this.폴더.명령 ?? {})
|
|
208
|
+
.map(([이름, r]) => ({ 이름, ...r }))
|
|
209
|
+
.sort((a, b) => (b.ok + b.no) - (a.ok + a.no));
|
|
210
|
+
const m = this.집.모델?.[String(모델 ?? '')] ?? null;
|
|
211
|
+
return { 명령, 모델: m, 모델이름: String(모델 ?? '') };
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/agent/loop.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isMutating } from '../safety/guard.js';
|
|
|
6
6
|
import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
|
|
7
7
|
import { 살린쓰기 } from './salvage.js';
|
|
8
8
|
import { 배울것, 길이문제인가 } from '../backend/learn.js';
|
|
9
|
-
import { compact, shouldCompact } from './compact.js';
|
|
9
|
+
import { compact, shouldCompact, shouldFold, foldToolResults } from './compact.js';
|
|
10
10
|
import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
|
|
11
11
|
import { Session } from './session.js';
|
|
12
12
|
import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
|
|
@@ -240,6 +240,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
240
240
|
* 세기만 한다 — 판단은 grade.js 가 하고, 프롬프트는 다음 걸음부터 달라진다.
|
|
241
241
|
*/
|
|
242
242
|
session.본것?.걸음셈();
|
|
243
|
+
ctx.배움?.모델본것(conn.model, '걸음');
|
|
243
244
|
// 단계마다 필요한 생각의 양이 다르다. effort.js 가 그 배분을 갖고 있다.
|
|
244
245
|
const stage = steps === 1 ? 'plan' : lastToolFailed ? 'fix' : 'work';
|
|
245
246
|
const level = effortFor(think, effort, stage);
|
|
@@ -332,6 +333,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
332
333
|
}
|
|
333
334
|
if (빈답인가(msg)) {
|
|
334
335
|
session.본것?.본것('빈답');
|
|
336
|
+
ctx.배움?.모델본것(conn.model, '빈답');
|
|
335
337
|
yield {
|
|
336
338
|
type: 'error',
|
|
337
339
|
text: '서버가 빈 답을 보냈습니다.\n'
|
|
@@ -403,6 +405,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
403
405
|
session.usage.out += msg.usage?.out ?? 0;
|
|
404
406
|
session.usage.calls++;
|
|
405
407
|
|
|
408
|
+
/*
|
|
409
|
+
* 방금 보낸 것이 실제로 몇 토큰이었는지 서버가 알려 줬다. 우리 추정과
|
|
410
|
+
* 견줘서 배운다 — 여기가 유일하게 정답을 아는 자리다.
|
|
411
|
+
*
|
|
412
|
+
* 답을 push 하기 **전**이어야 한다. 지금 이력이 곧 방금 보낸 프롬프트다.
|
|
413
|
+
*/
|
|
414
|
+
const 새보정 = session.배운다?.(msg.usage?.in);
|
|
415
|
+
// 배운 배수를 디스크에도 남긴다. 다음에 켤 때 이 값으로 시작한다.
|
|
416
|
+
if (새보정) ctx.배움?.보정본것(conn.model, 새보정);
|
|
417
|
+
|
|
406
418
|
session.push(assistantMessage(conn.kind, msg));
|
|
407
419
|
|
|
408
420
|
if (!msg.toolCalls?.length) {
|
|
@@ -438,6 +450,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
438
450
|
// 모델은 고칠 게 없다고 보고 똑같이 다시 시도한다. 그래서 끝없이 돈다.
|
|
439
451
|
if (call.argsBroken) {
|
|
440
452
|
session.본것?.본것('잘린인자');
|
|
453
|
+
ctx.배움?.모델본것(conn.model, '잘린인자');
|
|
441
454
|
// ── 버리기 전에, 건질 수 있는지 먼저 본다 ──────────────────────
|
|
442
455
|
//
|
|
443
456
|
// 잘린 JSON 안에는 이미 받아 놓은 내용이 들어 있다. 경로도 대개 온전하다.
|
|
@@ -704,6 +717,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
704
717
|
if (call.name === 'Edit' && result.error) session.본것?.본것('편집실패');
|
|
705
718
|
else if (!result.error) session.본것?.본것('도구성공');
|
|
706
719
|
|
|
720
|
+
/*
|
|
721
|
+
* 겪은 것을 디스크에도 쌓는다 (agent/evolve.js).
|
|
722
|
+
*
|
|
723
|
+
* 위의 지켜본것은 이 대화가 끝나면 사라진다. 그래서 어제 알아낸 것을
|
|
724
|
+
* 오늘 또 알아내야 했다 — `pnpm` 이 이 PC 에 없다는 걸 매일 다시 겪는다.
|
|
725
|
+
* 명령의 성패는 이 폴더의 사실이므로 폴더 쪽에 남긴다.
|
|
726
|
+
*/
|
|
727
|
+
if (call.name === 'Bash' && call.args?.command) {
|
|
728
|
+
ctx.배움?.명령본것(call.args.command, !result.error, result.error ?? '');
|
|
729
|
+
}
|
|
730
|
+
if (call.name === 'Edit' && result.error) ctx.배움?.모델본것(conn.model, '편집실패');
|
|
731
|
+
|
|
707
732
|
if (result.error) {
|
|
708
733
|
lastToolFailed = true;
|
|
709
734
|
// 같은 도구가 같은 이유로 계속 실패하면 헛돌고 있는 것이다.
|
|
@@ -755,6 +780,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
755
780
|
return;
|
|
756
781
|
}
|
|
757
782
|
|
|
783
|
+
/*
|
|
784
|
+
* 요약해서 접기 **전에** 도구 결과부터 접는다.
|
|
785
|
+
*
|
|
786
|
+
* 자리를 먹는 것은 대개 사람 말이 아니라 옛날에 읽어 둔 파일이다. 그쪽을
|
|
787
|
+
* 먼저 비우면 요약 압축을 한참 미룰 수 있고, 미루는 동안 대화는 한 글자도
|
|
788
|
+
* 안 잃는다. 그래도 차면 아래에서 통째로 요약한다.
|
|
789
|
+
*/
|
|
790
|
+
if (shouldFold(session)) {
|
|
791
|
+
const f = foldToolResults(session);
|
|
792
|
+
if (f.접은것) yield { type: 'folded', ...f };
|
|
793
|
+
}
|
|
794
|
+
|
|
758
795
|
// 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
|
|
759
796
|
if (shouldCompact(session)) {
|
|
760
797
|
yield { type: 'compacting' };
|
package/src/agent/modes.js
CHANGED
|
@@ -233,6 +233,8 @@ export const MODES = {
|
|
|
233
233
|
" 6. 확인 방법 — 무엇을 돌려 보면 됐는지 알 수 있는가",
|
|
234
234
|
"",
|
|
235
235
|
"단계는 TodoWrite 로도 적어라. 승인 뒤 그대로 이어서 하게 된다.",
|
|
236
|
+
" 단계 수는 정해져 있지 않다 — 일의 크기에 맞춰라. 세 개로 맞추지 마라.",
|
|
237
|
+
" 작은 일이면 두세 개로 끝내고, 큰 일이면 열 개가 넘어도 그대로 다 적어라.",
|
|
236
238
|
"모르는 것이 있으면 지어내지 말고 \"이건 확인이 필요하다\" 고 적어라.",
|
|
237
239
|
"",
|
|
238
240
|
"마지막에 \"이대로 진행할까요?\" 로 끝내라. 승인을 받으면 /code 로 바꿔 실행한다.",
|
package/src/agent/session.js
CHANGED
|
@@ -112,6 +112,12 @@ export class Session {
|
|
|
112
112
|
this.stepsSet = maxSteps != null;
|
|
113
113
|
this.maxSteps = maxSteps ?? 24;
|
|
114
114
|
this.messages = [];
|
|
115
|
+
// 추정 × 보정 = 실제. 서버가 알려 주는 진짜 토큰 수로 매 턴 고쳐 나간다.
|
|
116
|
+
// 1 은 '아직 안 배웠다' 이고, 그때는 추정을 그대로 쓴다. 배운다() 를 볼 것.
|
|
117
|
+
this.보정 = 1;
|
|
118
|
+
this.보정잰것 = 0;
|
|
119
|
+
// 겪어 본 것 요약 (agent/evolve.js). 켤 때 repl 이 채운다.
|
|
120
|
+
this.배움요약 = null;
|
|
115
121
|
this.filesRead = new Map(); // 경로 → 추정 토큰
|
|
116
122
|
this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
|
|
117
123
|
this.skills = []; // 켜질 때 이 PC 에서 찾은 것들
|
|
@@ -219,6 +225,15 @@ export class Session {
|
|
|
219
225
|
* 바뀌는 셈이 된다 — 무엇 때문에 답이 달라졌는지 알 길이 없어진다.
|
|
220
226
|
*/
|
|
221
227
|
if (this.memory) parts.push(this.memory);
|
|
228
|
+
/*
|
|
229
|
+
* 이 PC·이 폴더에서 겪어 본 것 (agent/evolve.js).
|
|
230
|
+
*
|
|
231
|
+
* 기억(memory)은 사람이 적어 주는 것이고, 이건 **겪어서 저절로 쌓인 것**이다.
|
|
232
|
+
* 여기 한 줄이 헛도는 걸음 서너 개를 없앤다 — 안 되는 명령을 또 부르고,
|
|
233
|
+
* 잘릴 걸 알면서 큰 Write 를 또 보내는 걸음들이다. 그래서 자리를 내줄 값이 있다.
|
|
234
|
+
* 상한은 evolve.js 가 못 박는다(220토큰).
|
|
235
|
+
*/
|
|
236
|
+
if (this.배움요약) parts.push(this.배움요약);
|
|
222
237
|
const listed = this.listedSkills();
|
|
223
238
|
if (listed.length) {
|
|
224
239
|
parts.push(
|
|
@@ -282,7 +297,41 @@ export class Session {
|
|
|
282
297
|
* 매 요청에 통째로 들어가는데 어느 칸에도 안 잡혔다.
|
|
283
298
|
* · 지금 모드 문구 — modes.js 의 say. 모드마다 수백 토큰이다.
|
|
284
299
|
*/
|
|
285
|
-
|
|
300
|
+
/**
|
|
301
|
+
* 추정을 실제에 맞춰 간다.
|
|
302
|
+
*
|
|
303
|
+
* estimateTokens 는 추정이다 — 토크나이저를 안 싣기 때문이다(의존성 0개).
|
|
304
|
+
* 그런데 서버는 매 응답에 **진짜 값**을 실어 준다(usage.prompt_tokens ·
|
|
305
|
+
* prompt_eval_count). 여태 그 값은 /cost 에만 쓰고 버렸다.
|
|
306
|
+
*
|
|
307
|
+
* 안 맞으면 두 가지가 조용히 나빠진다.
|
|
308
|
+
* · 적게 잡으면 — 남은 자리를 넉넉히 보고 답 상한을 크게 잡는다. 답이 잘린다.
|
|
309
|
+
* · 많이 잡으면 — 아직 자리가 있는데 접기 시작한다. 창을 놀린다.
|
|
310
|
+
* 둘 다 화면에는 아무 말도 안 뜬다. 그래서 스스로 재게 한다.
|
|
311
|
+
*
|
|
312
|
+
* 조심한 것:
|
|
313
|
+
* · 실제값을 안 주는 서버가 있다. 0 이면 아무것도 안 배운다.
|
|
314
|
+
* · 표본이 작으면 비율이 튄다. 첫 몇백 토큰짜리 대화는 건너뛴다.
|
|
315
|
+
* · 0.5~2배 밖은 안 믿는다. 서버가 딴 것을 세고 있을 수 있다.
|
|
316
|
+
* · 되먹임이 겹치지 않게, 비교는 **보정 안 먹인 추정**으로 한다.
|
|
317
|
+
*
|
|
318
|
+
* @returns {number|null} 새 보정 배수. 못 배웠으면 null
|
|
319
|
+
*/
|
|
320
|
+
배운다(실제) {
|
|
321
|
+
const n = Number(실제);
|
|
322
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
323
|
+
const 추정 = this.#원추정().used;
|
|
324
|
+
if (추정 < 200) return null;
|
|
325
|
+
const 비율 = n / 추정;
|
|
326
|
+
if (비율 < 0.5 || 비율 > 2) return null;
|
|
327
|
+
// 첫 번은 그대로 받고, 그 뒤로는 천천히 따라간다. 한 번 튄 값에 안 휘둘린다.
|
|
328
|
+
this.보정 = this.보정잰것 ? this.보정 + (비율 - this.보정) * 0.3 : 비율;
|
|
329
|
+
this.보정잰것++;
|
|
330
|
+
return this.보정;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
|
|
334
|
+
#원추정() {
|
|
286
335
|
// 폴더 지문도 매 요청에 통째로 나간다. 시스템 프롬프트 쪽에 같이 센다 —
|
|
287
336
|
// 안 세면 '남은 자리' 가 그만큼 뻥튀기되고, effort.js 가 그 값으로 출력
|
|
288
337
|
// 상한을 잡으므로 답이 조용히 잘리기 시작한다.
|
|
@@ -315,6 +364,7 @@ export class Session {
|
|
|
315
364
|
{ label: '시스템 프롬프트', n: sys },
|
|
316
365
|
{ label: this.rules ? `규칙 (${this.rules.name})` : '규칙 (없음)', n: rules },
|
|
317
366
|
{ label: `기억 (${기억줄}줄)`, n: 기억 },
|
|
367
|
+
{ label: '겪어 본 것', n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
|
|
318
368
|
{ label: `스킬 목록 (${listed.length}/${this.skills.length}개)`, n: skills },
|
|
319
369
|
{ label: '도구 정의', n: 도구 },
|
|
320
370
|
{ label: '대화 이력', n: history },
|
|
@@ -325,6 +375,27 @@ export class Session {
|
|
|
325
375
|
return { rows, used, total, left: Math.max(0, total - used) };
|
|
326
376
|
}
|
|
327
377
|
|
|
378
|
+
/**
|
|
379
|
+
* 화면과 예산이 보는 값. 배운 보정을 먹여서 내놓는다.
|
|
380
|
+
*
|
|
381
|
+
* 줄마다 보정을 먹인다 — 합계만 고치면 표의 줄을 더한 값과 합계가 안 맞아서,
|
|
382
|
+
* 보는 사람이 어느 쪽을 믿어야 할지 모르게 된다.
|
|
383
|
+
*/
|
|
384
|
+
breakdown() {
|
|
385
|
+
const 날것 = this.#원추정();
|
|
386
|
+
if (!(this.보정잰것 > 0) || this.보정 === 1) return 날것;
|
|
387
|
+
const rows = 날것.rows.map((r) => ({ ...r, n: Math.round(r.n * this.보정) }));
|
|
388
|
+
const used = rows.reduce((a, r) => a + r.n, 0);
|
|
389
|
+
return {
|
|
390
|
+
rows,
|
|
391
|
+
used,
|
|
392
|
+
total: 날것.total,
|
|
393
|
+
left: Math.max(0, 날것.total - used),
|
|
394
|
+
보정: this.보정,
|
|
395
|
+
보정잰것: this.보정잰것,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
328
399
|
/**
|
|
329
400
|
* 도구 정의가 몇 토큰인가.
|
|
330
401
|
*
|
|
@@ -408,3 +479,76 @@ export function safeHead(messages, k) {
|
|
|
408
479
|
while (h > 0 && messages[h - 1]?.tool_calls?.length) h--;
|
|
409
480
|
return h;
|
|
410
481
|
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 도중에 죽은 대화를 다시 쓸 수 있게 손본다.
|
|
485
|
+
*
|
|
486
|
+
* 왜 필요한가:
|
|
487
|
+
* store 는 메시지가 오갈 때마다 즉시 적는다(jsonl). 그래서 도구를 **돌리는
|
|
488
|
+
* 도중에** 죽으면 `assistant(tool_calls)` 만 적히고 그 결과가 없다. 긴 Bash 를
|
|
489
|
+
* 돌리는 중, 전원이 나가는 중, 창을 닫는 중 — 흔한 자리다.
|
|
490
|
+
*
|
|
491
|
+
* 그 이력을 그대로 이어받아 보내면 OpenAI 규격 서버는 400 을 낸다. 호출 뒤에는
|
|
492
|
+
* 결과가 와야 한다는 규격이다. compact.js 가 safeCut 으로 막고 있는 바로 그
|
|
493
|
+
* 사고인데, **이어받기 길에는 그 안전망이 없었다.** 이어받자마자 첫 마디에서
|
|
494
|
+
* 죽으니, 사람 눈에는 '이어하기가 고장 났다' 로 보인다.
|
|
495
|
+
*
|
|
496
|
+
* 무엇을 하나:
|
|
497
|
+
* 결과가 없는 호출을 **지운다.** 가짜 결과를 채우지 않는다 — 안 돌아간 도구를
|
|
498
|
+
* 돌았다고 적으면 모델이 그 거짓말 위에서 계속한다. 파일을 안 고쳤는데 고친
|
|
499
|
+
* 줄 알고 다음 단계로 넘어가는 것이 제일 나쁘다.
|
|
500
|
+
* 호출이 전부 빠졌는데 할 말도 없으면 그 메시지째 지운다.
|
|
501
|
+
* 짝 없는 결과(앞에 호출이 없는 tool)도 지운다 — 머리가 잘려 나간 이력이다.
|
|
502
|
+
*
|
|
503
|
+
* 규격이 둘이라 짝짓는 법도 둘이다 (adapter.js 의 toolMessage):
|
|
504
|
+
* OpenAI { role:'tool', tool_call_id } → id 로 짝짓는다
|
|
505
|
+
* Ollama { role:'tool', tool_name } → id 가 없다. 순서로 짝짓는다
|
|
506
|
+
*
|
|
507
|
+
* @returns {{messages: object[], 고친것: number}} 고친것 = 걷어낸 호출·결과 수
|
|
508
|
+
*/
|
|
509
|
+
export function repairToolPairs(messages) {
|
|
510
|
+
const out = [];
|
|
511
|
+
let 고친것 = 0;
|
|
512
|
+
|
|
513
|
+
for (let i = 0; i < (messages?.length ?? 0); i++) {
|
|
514
|
+
const m = messages[i];
|
|
515
|
+
|
|
516
|
+
// 적다 만 줄. 도중에 죽으면 JSONL 한 줄이 반만 적히고, 그 자리가 빈 값이나
|
|
517
|
+
// role 없는 조각으로 읽힌다. 그대로 보내면 서버가 거절한다 — 걷어내는 것이
|
|
518
|
+
// 이 함수의 일이므로 여기서 같이 턴다.
|
|
519
|
+
if (!m || typeof m !== 'object' || typeof m.role !== 'string') { 고친것++; continue; }
|
|
520
|
+
|
|
521
|
+
// 호출 없이 굴러다니는 결과. 앞이 잘려 나간 이력이다.
|
|
522
|
+
if (m.role === 'tool') { 고친것++; continue; }
|
|
523
|
+
|
|
524
|
+
if (m?.role !== 'assistant' || !m.tool_calls?.length) { out.push(m); continue; }
|
|
525
|
+
|
|
526
|
+
// 이 호출에 딸린 결과 묶음 — 바로 뒤에 붙어 있는 tool 들이 전부다.
|
|
527
|
+
const 결과 = [];
|
|
528
|
+
let j = i + 1;
|
|
529
|
+
while (j < messages.length && messages[j]?.role === 'tool') 결과.push(messages[j++]);
|
|
530
|
+
i = j - 1; // 결과는 여기서 같이 처리한다
|
|
531
|
+
|
|
532
|
+
const 있는id = new Set(결과.map((r) => r?.tool_call_id).filter(Boolean));
|
|
533
|
+
const 남길호출 = 있는id.size
|
|
534
|
+
? m.tool_calls.filter((c) => 있는id.has(c?.id))
|
|
535
|
+
: m.tool_calls.slice(0, 결과.length); // id 가 없는 규격 — 순서로 본다
|
|
536
|
+
const 남길id = new Set(남길호출.map((c) => c?.id).filter(Boolean));
|
|
537
|
+
const 남길결과 = 있는id.size
|
|
538
|
+
? 결과.filter((r) => 남길id.has(r.tool_call_id))
|
|
539
|
+
: 결과.slice(0, 남길호출.length);
|
|
540
|
+
|
|
541
|
+
고친것 += (m.tool_calls.length - 남길호출.length) + (결과.length - 남길결과.length);
|
|
542
|
+
|
|
543
|
+
if (남길호출.length) {
|
|
544
|
+
out.push(남길호출.length === m.tool_calls.length ? m : { ...m, tool_calls: 남길호출 });
|
|
545
|
+
out.push(...남길결과);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
// 남은 호출이 없다. 할 말이라도 있으면 그건 살린다.
|
|
549
|
+
const 글 = typeof m.content === 'string' ? m.content.trim() : '';
|
|
550
|
+
if (글) { const { tool_calls: _버림, ...나머지 } = m; out.push(나머지); }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return { messages: out, 고친것 };
|
|
554
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// 한 창에서 대화를 여러 갈래로 굴린다.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 만드나 ───────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 일하다 보면 곁가지가 생긴다. "이 함수 왜 이래?" "이 오류 뭐야?" 같은 것들이다.
|
|
6
|
+
// 지금은 그걸 하던 대화에 그냥 던진다. 그러면 두 가지가 같이 나빠진다.
|
|
7
|
+
//
|
|
8
|
+
// 1) 곁가지가 읽어 온 파일·오류·시행착오가 **본 줄기의 자리를 먹는다.**
|
|
9
|
+
// 한 번 물어본 것 때문에 하던 일이 접히는 자리까지 밀려간다.
|
|
10
|
+
// 2) 모델이 방금 본 곁가지에 끌려간다. 하던 일로 돌아오라고 다시 말해야 한다.
|
|
11
|
+
//
|
|
12
|
+
// 창을 하나 더 띄우면 되지 않느냐 — 안 된다. 새 창은 연결도, 스킬도, 폴더 지문도,
|
|
13
|
+
// 되돌리기 이력도 처음부터 다시 잡는다. 켜는 데만 몇 초씩 들고, `/undo` 는 창마다
|
|
14
|
+
// 따로 놀아서 어느 창이 뭘 되돌리는지 알 수 없게 된다.
|
|
15
|
+
//
|
|
16
|
+
// 그래서 **대화만** 여러 벌 갖는다.
|
|
17
|
+
//
|
|
18
|
+
// 같이 쓰는 것 연결·모델·도구·스킬·작업 범위·되돌리기·감사 기록
|
|
19
|
+
// 갈래마다 따로 오간 말·토큰 셈·할 일 목록·저장 파일
|
|
20
|
+
//
|
|
21
|
+
// Task 도구와는 다른 것이다. Task 는 모델이 스스로 떼어 내는 것이고 결과만 돌아온다.
|
|
22
|
+
// 갈래는 **사람이** 오가는 것이고, 오간 말이 그대로 남아서 나중에 되돌아올 수 있다.
|
|
23
|
+
//
|
|
24
|
+
// ── 조심한 것 ───────────────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// 갈래마다 저장 파일을 따로 준다. 한 파일에 섞어 적으면 이어하기가 두 갈래를
|
|
27
|
+
// 한 줄기로 읽어서, 서로 상관없는 대화가 한 덩어리가 된다.
|
|
28
|
+
|
|
29
|
+
/** 이름이 없을 때 붙여 줄 이름. 사람이 목록에서 알아볼 수 있으면 된다. */
|
|
30
|
+
function 기본이름(n) { return `갈래 ${n}`; }
|
|
31
|
+
|
|
32
|
+
export class Threads {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} session 지금 도는 대화. 첫 갈래가 된다.
|
|
35
|
+
* @param {object} ctx 할 일 목록이 여기 붙어 있다
|
|
36
|
+
* @param {function} 새store () => Store — 갈래마다 저장 파일을 새로 연다
|
|
37
|
+
* @param {object} 첫store 지금 대화가 쓰고 있던 저장 파일
|
|
38
|
+
*/
|
|
39
|
+
constructor(session, ctx, 새store, 첫store) {
|
|
40
|
+
this.session = session;
|
|
41
|
+
this.ctx = ctx;
|
|
42
|
+
this.새store = 새store;
|
|
43
|
+
this.갈래들 = [{ 이름: '본줄기', messages: session.messages, usage: session.usage, todos: ctx?.todos ?? null, store: 첫store }];
|
|
44
|
+
this.자리 = 0;
|
|
45
|
+
this.센것 = 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
현재() { return this.갈래들[this.자리]; }
|
|
49
|
+
현재store() { return this.현재().store; }
|
|
50
|
+
개수() { return this.갈래들.length; }
|
|
51
|
+
|
|
52
|
+
/** 지금 화면에서 오가던 것을 갈래에 넣어 둔다. 옮기기 전에 반드시 부른다. */
|
|
53
|
+
#담아두기() {
|
|
54
|
+
const g = this.현재();
|
|
55
|
+
g.messages = this.session.messages;
|
|
56
|
+
g.usage = this.session.usage;
|
|
57
|
+
g.todos = this.ctx?.todos ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 갈래 하나를 화면으로 꺼낸다. */
|
|
61
|
+
#꺼내기(i) {
|
|
62
|
+
this.자리 = i;
|
|
63
|
+
const g = this.현재();
|
|
64
|
+
this.session.messages = g.messages;
|
|
65
|
+
this.session.usage = g.usage;
|
|
66
|
+
if (this.ctx) this.ctx.todos = g.todos;
|
|
67
|
+
// 상태줄이 지금 어느 갈래인지 보여 줄 수 있게 남긴다.
|
|
68
|
+
// 갈래가 하나뿐이면 안 보인다 — 안 쓰는 사람 화면은 그대로여야 한다.
|
|
69
|
+
this.session.갈래표 = this.갈래들.length > 1 ? g.이름 : null;
|
|
70
|
+
return g;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 새 갈래. 빈 대화로 시작한다.
|
|
75
|
+
* @param {string} 이름
|
|
76
|
+
* @param {object[]} 물려줄것 갈라내기면 지금 대화의 사본
|
|
77
|
+
*/
|
|
78
|
+
새로(이름 = '', 물려줄것 = null) {
|
|
79
|
+
this.#담아두기();
|
|
80
|
+
this.센것++;
|
|
81
|
+
const store = this.새store();
|
|
82
|
+
const g = {
|
|
83
|
+
이름: String(이름 ?? '').trim() || 기본이름(this.센것),
|
|
84
|
+
messages: 물려줄것 ? [...물려줄것] : [],
|
|
85
|
+
usage: { in: 0, out: 0, calls: 0, ms: 0 },
|
|
86
|
+
todos: null,
|
|
87
|
+
store,
|
|
88
|
+
};
|
|
89
|
+
this.갈래들.push(g);
|
|
90
|
+
// 갈라낸 것은 지금까지 오간 말을 새 파일에도 적어 둔다. 안 적으면 그 갈래를
|
|
91
|
+
// 나중에 이어할 때 앞부분이 없어서 무슨 얘기였는지 알 수 없다.
|
|
92
|
+
if (물려줄것?.length) { for (const m of 물려줄것) store.append(m); }
|
|
93
|
+
return this.#꺼내기(this.갈래들.length - 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 지금 대화를 그대로 복사해 새 갈래로 나간다. */
|
|
97
|
+
갈라내기(이름 = '') {
|
|
98
|
+
return this.새로(이름, this.session.messages);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 갈래를 찾는다. 번호(1부터)도 되고 이름 일부도 된다.
|
|
103
|
+
* @returns {number} 못 찾으면 -1
|
|
104
|
+
*/
|
|
105
|
+
찾기(말) {
|
|
106
|
+
const s = String(말 ?? '').trim();
|
|
107
|
+
if (!s) return -1;
|
|
108
|
+
if (/^\d+$/.test(s)) {
|
|
109
|
+
const i = Number(s) - 1;
|
|
110
|
+
return i >= 0 && i < this.갈래들.length ? i : -1;
|
|
111
|
+
}
|
|
112
|
+
const 낮 = s.toLowerCase();
|
|
113
|
+
const 딱 = this.갈래들.findIndex((g) => g.이름.toLowerCase() === 낮);
|
|
114
|
+
if (딱 >= 0) return 딱;
|
|
115
|
+
return this.갈래들.findIndex((g) => g.이름.toLowerCase().includes(낮));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
옮기기(말) {
|
|
119
|
+
const i = this.찾기(말);
|
|
120
|
+
if (i < 0) return null;
|
|
121
|
+
if (i === this.자리) return this.현재();
|
|
122
|
+
this.#담아두기();
|
|
123
|
+
return this.#꺼내기(i);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 갈래를 닫는다. 마지막 하나는 못 닫는다 — 닫으면 대화가 없어진다.
|
|
128
|
+
* 저장 파일은 안 지운다. `/sessions` 로 다시 찾아갈 수 있어야 한다.
|
|
129
|
+
*/
|
|
130
|
+
닫기(말 = '') {
|
|
131
|
+
if (this.갈래들.length <= 1) return { ok: false, why: '갈래가 하나뿐입니다.' };
|
|
132
|
+
const i = 말 ? this.찾기(말) : this.자리;
|
|
133
|
+
if (i < 0) return { ok: false, why: '그런 갈래가 없습니다.' };
|
|
134
|
+
if (i === this.자리) this.#담아두기();
|
|
135
|
+
const [닫은것] = this.갈래들.splice(i, 1);
|
|
136
|
+
// 닫힌 자리보다 뒤에 있었으면 번호가 하나 당겨진다.
|
|
137
|
+
const 다음 = i === this.자리 ? Math.max(0, i - 1) : (this.자리 > i ? this.자리 - 1 : this.자리);
|
|
138
|
+
this.자리 = 다음;
|
|
139
|
+
this.#꺼내기(다음);
|
|
140
|
+
return { ok: true, 닫은것, 지금: this.현재() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 화면에 뿌릴 목록. */
|
|
144
|
+
목록() {
|
|
145
|
+
return this.갈래들.map((g, i) => ({
|
|
146
|
+
번호: i + 1,
|
|
147
|
+
이름: g.이름,
|
|
148
|
+
지금: i === this.자리,
|
|
149
|
+
말수: (i === this.자리 ? this.session.messages : g.messages).length,
|
|
150
|
+
토큰: (i === this.자리 ? this.session.usage : g.usage)?.in ?? 0,
|
|
151
|
+
id: g.store?.id ?? null,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
}
|