deel-local-cli 1.1.1 → 1.4.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 +826 -19
- package/README.md +689 -14
- package/bin/deel.js +73 -4
- package/package.json +7 -3
- package/src/acp/jsonrpc.js +230 -0
- package/src/acp/map.js +219 -0
- package/src/acp/serve.js +556 -0
- package/src/agent/budget.js +0 -14
- package/src/agent/card.js +110 -0
- package/src/agent/compact.js +95 -0
- package/src/agent/evidence.js +186 -0
- package/src/agent/evolve.js +213 -0
- package/src/agent/grade.js +20 -0
- package/src/agent/loop.js +172 -14
- package/src/agent/models.js +169 -0
- package/src/agent/modes.js +49 -1
- package/src/agent/pins.js +140 -0
- package/src/agent/project.js +10 -4
- package/src/agent/session.js +359 -11
- package/src/agent/store.js +31 -0
- package/src/agent/threads.js +154 -0
- package/src/commands.js +751 -55
- package/src/config.js +10 -1
- package/src/i18n/en.js +244 -0
- package/src/i18n/index.js +126 -0
- package/src/i18n/ko.js +231 -0
- package/src/lsp/client.js +459 -0
- package/src/lsp/diag.js +112 -0
- package/src/lsp/rpc.js +84 -0
- package/src/lsp/servers.js +218 -0
- package/src/oneshot.js +23 -0
- package/src/pack/sbom.js +218 -0
- package/src/pack/selfpack.js +19 -3
- package/src/repl.js +273 -22
- package/src/safety/guard.js +37 -2
- package/src/safety/secrets.js +205 -0
- package/src/safety/undo.js +11 -4
- package/src/setup.js +2 -2
- package/src/skills/discover.js +1 -1
- package/src/tools/desc.en.js +219 -0
- 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 +149 -6
- package/src/tools/lsp.js +327 -0
- package/src/tools/task.js +30 -2
- package/src/tools/todo.js +19 -3
- package/src/ui/ansi.js +42 -2
- package/src/ui/approve.js +25 -21
- package/src/ui/inputbox.js +69 -13
- package/src/ui/intro.js +174 -0
- package/src/ui/level.js +11 -5
- package/src/ui/md.js +227 -0
- package/src/ui/notify.js +101 -0
- package/src/ui/prompt.js +1 -1
- package/src/ui/screen.js +1 -1
- package/src/ui/status.js +190 -34
- package/src/ui/working.js +0 -3
- package/src/ui/wrap.js +1 -8
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 못 박은 것 — 접혀도, 요약돼도, 비워도 남는 말.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 만드나 ───────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 긴 대화는 결국 접히고 요약된다. deel 도 그렇게 한다. 그런데 요약은 **되돌릴 수
|
|
6
|
+
// 없고**, 무엇을 버렸는지 아무도 안 알려 준다.
|
|
7
|
+
//
|
|
8
|
+
// 2026년에 나온 재기로는 요약 압축이 안전 제약의 **절반만** 남긴다. 132k 토큰을
|
|
9
|
+
// 2.3k 로 줄이면서(98%) 그 안에 섞여 있던 "이건 하지 마라" 가 같이 사라진다.
|
|
10
|
+
//
|
|
11
|
+
// 그러면 이런 일이 벌어진다. 대화 초반에 "운영 DB 는 건드리지 마라" 하고 말해
|
|
12
|
+
// 두었는데, 두 시간 뒤 요약이 한 번 돌고 나면 모델은 **그 말을 들은 적 없는
|
|
13
|
+
// 상태로** 계속 일한다. 사람은 이미 말했다고 믿고 있으니 다시 말하지 않는다.
|
|
14
|
+
// 둘 다 서로 다른 것을 알고 있는 채로 나아간다 — 제일 나쁜 모양이다.
|
|
15
|
+
//
|
|
16
|
+
// ── 어떻게 막나 ─────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// 방법은 간단하다. **메시지로 두지 않는다.**
|
|
19
|
+
//
|
|
20
|
+
// 접기도 요약도 `session.messages` 를 손본다. 그러니 지켜야 할 말은 아예 그
|
|
21
|
+
// 바깥, **시스템 프롬프트** 쪽에 둔다. 그러면 접는 코드가 아무리 세게 돌아도
|
|
22
|
+
// 닿을 수가 없다. 지우는 길은 사람이 `/pin 지우기` 를 치는 것 하나뿐이다.
|
|
23
|
+
//
|
|
24
|
+
// ── 값을 치르는 자리 ────────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// 공짜가 아니다. 이건 **매 턴** 프롬프트에 실린다. 백 줄을 박아 두면 그것만으로
|
|
27
|
+
// 창이 차고, 그러면 못 박은 것 때문에 대화가 못 가는 우스운 일이 된다.
|
|
28
|
+
// 그래서 개수와 토큰에 상한을 못 박는다 — 못 박는 것에도 상한이 있다.
|
|
29
|
+
import { estimateTokens } from './session.js';
|
|
30
|
+
|
|
31
|
+
export const 최대개수 = 12;
|
|
32
|
+
export const 최대토큰 = 240; // 프롬프트에 실을 상한
|
|
33
|
+
export const 한줄최대 = 200; // 한 줄이 문단이 되면 그건 규칙 파일에 적을 일이다
|
|
34
|
+
|
|
35
|
+
export class 못박기 {
|
|
36
|
+
/** @param {Array<{말:string}>} 처음 저장해 둔 것을 되살릴 때 */
|
|
37
|
+
constructor(처음 = []) {
|
|
38
|
+
this.것들 = [];
|
|
39
|
+
for (const x of Array.isArray(처음) ? 처음 : []) {
|
|
40
|
+
const 말 = 다듬기(typeof x === 'string' ? x : x?.말);
|
|
41
|
+
if (말 && !this.#이미있나(말)) this.것들.push(말);
|
|
42
|
+
}
|
|
43
|
+
this.것들 = this.것들.slice(0, 최대개수);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#이미있나(말) {
|
|
47
|
+
const 낮 = 말.toLowerCase();
|
|
48
|
+
return this.것들.some((x) => x.toLowerCase() === 낮);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 하나 박는다.
|
|
53
|
+
* @returns {{ok:true, 번호:number} | {ok:false, why:string}}
|
|
54
|
+
*/
|
|
55
|
+
더하기(말) {
|
|
56
|
+
const s = 다듬기(말);
|
|
57
|
+
if (!s) return { ok: false, why: '무엇을 못 박을지 적어 주세요.' };
|
|
58
|
+
// 같은 말을 두 번 박아도 늘어나기만 한다. 자리는 먹고 얻는 것은 없다.
|
|
59
|
+
if (this.#이미있나(s)) return { ok: false, why: '이미 못 박아 둔 말입니다.' };
|
|
60
|
+
if (this.것들.length >= 최대개수) {
|
|
61
|
+
return { ok: false, why: `못 박을 수 있는 것은 ${최대개수}개까지입니다 — 매 턴 실리는 글이라 상한을 둡니다.` };
|
|
62
|
+
}
|
|
63
|
+
this.것들.push(s);
|
|
64
|
+
return { ok: true, 번호: this.것들.length };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 뺀다. 번호(1부터)나 '전부'.
|
|
69
|
+
* @returns {{ok:true, 뺀것:string|number} | {ok:false, why:string}}
|
|
70
|
+
*/
|
|
71
|
+
지우기(말) {
|
|
72
|
+
const s = String(말 ?? '').trim();
|
|
73
|
+
if (/^(전부|다|all|\*)$/i.test(s)) {
|
|
74
|
+
const n = this.것들.length;
|
|
75
|
+
this.것들 = [];
|
|
76
|
+
return { ok: true, 뺀것: n };
|
|
77
|
+
}
|
|
78
|
+
if (!/^\d+$/.test(s)) return { ok: false, why: '번호를 적거나 `전부` 라고 하세요.' };
|
|
79
|
+
const i = Number(s) - 1;
|
|
80
|
+
if (i < 0 || i >= this.것들.length) return { ok: false, why: '그런 번호가 없습니다.' };
|
|
81
|
+
const [뺀것] = this.것들.splice(i, 1);
|
|
82
|
+
return { ok: true, 뺀것 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 화면에 뿌릴 것. 번호는 볼 때마다 다시 매긴다 — 사람이 세는 번호와 같아야 한다. */
|
|
86
|
+
목록() {
|
|
87
|
+
return this.것들.map((말, i) => ({ 번호: i + 1, 말 }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
개수() { return this.것들.length; }
|
|
91
|
+
|
|
92
|
+
/** 저장해 둘 모양. */
|
|
93
|
+
직렬화() { return this.목록(); }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 프롬프트에 실을 몇 줄. 없으면 null — 안 쓰는 사람 프롬프트는 그대로여야 한다.
|
|
97
|
+
*
|
|
98
|
+
* 상한을 넘으면 **뒤에서부터** 버린다. 먼저 박은 것이 대개 더 근본적인 제약이다.
|
|
99
|
+
*/
|
|
100
|
+
요약(상한 = 최대토큰) {
|
|
101
|
+
return this.#고른것(상한).글;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 실제로 프롬프트에 실리는 것이 몇 개이고 얼마나 먹는가.
|
|
106
|
+
*
|
|
107
|
+
* 화면 쪽에서 세지 않게 여기서 알려 준다. 부르는 쪽이 estimateTokens 를 또
|
|
108
|
+
* 들여오면 같은 셈이 두 군데로 갈라지고, 한쪽만 고치는 날이 온다.
|
|
109
|
+
*/
|
|
110
|
+
실린것(상한 = 최대토큰) {
|
|
111
|
+
const r = this.#고른것(상한);
|
|
112
|
+
return { 개수: r.개수, 토큰: r.글 ? estimateTokens(r.글) : 0, 다실렸나: r.개수 >= this.것들.length };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 상한 안에 드는 것만 골라 글로 만든다. 글과 **개수를 같이** 돌려준다.
|
|
117
|
+
*
|
|
118
|
+
* 글에서 줄 수를 다시 세면 안 된다 — 머리글이 몇 줄인지에 따라 답이 달라지고,
|
|
119
|
+
* 머리글 문장을 손보는 날 개수가 조용히 어긋난다. 세는 자리는 여기 하나다.
|
|
120
|
+
*/
|
|
121
|
+
#고른것(상한) {
|
|
122
|
+
if (!this.것들.length) return { 글: null, 개수: 0 };
|
|
123
|
+
const 머리 = '\n--- 못 박은 것 (사람이 직접 박았다. 접거나 요약해도 안 지워진다) ---\n'
|
|
124
|
+
+ '아래는 이 대화 내내 반드시 지킨다. 어길 것 같으면 먼저 물어봐라.';
|
|
125
|
+
const out = [머리];
|
|
126
|
+
let 개수 = 0;
|
|
127
|
+
for (const [i, 말] of this.것들.entries()) {
|
|
128
|
+
const 줄 = `${i + 1}. ${말}`;
|
|
129
|
+
if (estimateTokens([...out, 줄].join('\n')) > 상한) break;
|
|
130
|
+
out.push(줄);
|
|
131
|
+
개수++;
|
|
132
|
+
}
|
|
133
|
+
return { 글: 개수 ? out.join('\n') : null, 개수 };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function 다듬기(말) {
|
|
138
|
+
const s = String(말 ?? '').replace(/\s+/g, ' ').trim();
|
|
139
|
+
return s ? s.slice(0, 한줄최대) : '';
|
|
140
|
+
}
|
package/src/agent/project.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
33
33
|
import { join } from 'node:path';
|
|
34
|
+
import { 언어 } from '../i18n/index.js';
|
|
34
35
|
|
|
35
36
|
// 위쪽에 있어도 사람에게 아무 말도 안 해 주는 것들. 적어 봐야 자리만 먹는다.
|
|
36
37
|
const 안적을것 = new Set([
|
|
@@ -150,11 +151,14 @@ export function 지문(root, 창 = null) {
|
|
|
150
151
|
const 명 = 명령들(root, 갈래, 명령상한);
|
|
151
152
|
// 명령은 제일 값이 크다. 좁은 창에서 무엇 하나를 남긴다면 이것이다 —
|
|
152
153
|
// '이 프로젝트에서 검사를 어떻게 돌리나' 가 여기 다 들어 있다.
|
|
153
|
-
|
|
154
|
+
// 이 토막도 모델이 읽는 글이라 화면 말을 따라간다. 값(명령 이름·파일 이름)은
|
|
155
|
+
// 그대로 둔다 — 그건 이 폴더에 실제로 있는 것이라 옮길 것이 아니다.
|
|
156
|
+
const 영 = 언어() === 'en';
|
|
157
|
+
if (명.length) 줄들.push(`${영 ? 'Runnable here' : '돌릴 수 있는 것'}: ${명.join(' · ')}`);
|
|
154
158
|
|
|
155
159
|
const w = 위쪽(root, 위쪽상한);
|
|
156
160
|
if (w.목록.length) {
|
|
157
|
-
줄들.push(
|
|
161
|
+
줄들.push(`${영 ? 'Top level' : '위쪽'}: ${w.목록.join(' ')}${w.더 ? ` (${영 ? `${w.더} more` : `그 밖에 ${w.더}개`})` : ''}`);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
if (!줄들.length) return null;
|
|
@@ -166,6 +170,8 @@ export function 지문(root, 창 = null) {
|
|
|
166
170
|
* 모델은 이걸 프로젝트 전체 지도로 여기고 Outline 을 안 부른다 —
|
|
167
171
|
* 그러면 이 토막이 오히려 손해가 된다.
|
|
168
172
|
*/
|
|
169
|
-
줄들.push(
|
|
170
|
-
|
|
173
|
+
줄들.push(영
|
|
174
|
+
? 'This is the top level only. Call Outline when you need to know what is inside.'
|
|
175
|
+
: '위쪽 한 겹만 본 것이다. 안을 알아야 하면 Outline 을 불러라.');
|
|
176
|
+
return `\n--- ${영 ? 'this folder' : '이 폴더'} ---\n${줄들.join('\n')}`;
|
|
171
177
|
}
|
package/src/agent/session.js
CHANGED
|
@@ -7,6 +7,8 @@ import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js
|
|
|
7
7
|
import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
|
|
8
8
|
import { 지문 } from './project.js';
|
|
9
9
|
import { 프롬프트토막 as 기억토막 } from './memory.js';
|
|
10
|
+
import { 못박기 } from './pins.js';
|
|
11
|
+
import { 언어 } from '../i18n/index.js';
|
|
10
12
|
|
|
11
13
|
// 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
|
|
12
14
|
// 한글은 글자당 약 1토큰, 영문·코드는 약 4글자당 1토큰으로 본다.
|
|
@@ -74,6 +76,71 @@ const BASE_RULES_짧게 = `너는 deel 다. 사용자의 작업 폴더에서 코
|
|
|
74
76
|
- 답은 한국어로 짧게. 코드를 통째로 붙여넣지 마라.
|
|
75
77
|
- 사용자가 정한 규칙은 Remember 로 한 줄 남긴다. "저번에" 라고 하면 Recall 로 찾는다.`;
|
|
76
78
|
|
|
79
|
+
/*
|
|
80
|
+
* ── 영어판 ──────────────────────────────────────────────────────────────
|
|
81
|
+
*
|
|
82
|
+
* /lang en 일 때 **모델이 읽는 글도** 영어로 간다. 화면 말만 바꾸는 1단계와
|
|
83
|
+
* 여기가 다른 점이고, 다르게 한 데는 두 가지 이유가 있다.
|
|
84
|
+
*
|
|
85
|
+
* 1) 안 바꾸면 답이 한국어로 온다. 위 규칙에 "사용자에게 답할 때는 한국어로"
|
|
86
|
+
* 가 박혀 있어서다. 화면 글자만 영어로 갈아 끼워 놓고 모델은 계속 한국어로
|
|
87
|
+
* 답하면, 영어권 사람에게는 아무것도 안 고친 것과 같다.
|
|
88
|
+
*
|
|
89
|
+
* 2) 토큰이 눈에 띄게 싸다. 한글은 글자당 약 1토큰이고 영문·코드는 약 3.6자당
|
|
90
|
+
* 1토큰이다(estimateTokens 를 볼 것). 32k 창에서 고정 몫이 15% 를 먹고
|
|
91
|
+
* 있었는데, 그 몫이 줄면 그만큼 대화가 쓸 자리가 는다. 작은 창에서는
|
|
92
|
+
* 이게 '파일 한 개를 더 읽을 수 있나' 를 가르는 크기다.
|
|
93
|
+
*
|
|
94
|
+
* 규칙 자체는 한 줄도 안 뺐다. 옮기면서 규칙이 느슨해지면 영어로 켠 사람만
|
|
95
|
+
* 다른 프로그램을 쓰는 셈이 된다 — 특히 "확인 못 했으면 확인 못 했다고 말해라"
|
|
96
|
+
* 같은 줄은 이 프로그램이 거짓말을 안 하게 하는 자리라 글자 그대로 옮겼다.
|
|
97
|
+
*/
|
|
98
|
+
const BASE_RULES_EN = `You are deel, a tool that reads and edits code inside the user's working folder.
|
|
99
|
+
|
|
100
|
+
**Finish the job.** Do not stop at a plan.
|
|
101
|
+
|
|
102
|
+
- Start now. Create missing files and folders — that is part of the job.
|
|
103
|
+
- Ask back **only when no tool can tell you**. If you can decide, decide, and say what you decided it from.
|
|
104
|
+
- If the job needs several files, make them all. Do not touch one and stop.
|
|
105
|
+
- When you are done, check. Run it, and fix it if it fails. If you could not check, say "I could not verify this."
|
|
106
|
+
|
|
107
|
+
Rules:
|
|
108
|
+
- Do not guess — confirm with a tool. Always Read an existing file before editing it.
|
|
109
|
+
- Edit's old_string must match the file exactly, whitespace and indentation included. Do not trim it short; include plenty of surrounding context.
|
|
110
|
+
- Do not put a large file in one call. Write the first ~300 lines, then call Append repeatedly until the rest is in place.
|
|
111
|
+
Append needs no Read first. Do not resend the earlier part when appending — it will just get cut at the same place again.
|
|
112
|
+
- Do not call the same tool with the same arguments twice. The result will be the same. Remember what you saw and move on.
|
|
113
|
+
- If the user names the scope to look at, stay inside it. Otherwise look as far as you need.
|
|
114
|
+
- Use Bash when you need to run a command. Commands that cannot be undone are blocked, so find another way.
|
|
115
|
+
- Answer the user in English, briefly. Do not paste whole files back — say what changed.
|
|
116
|
+
|
|
117
|
+
Keep what will be needed again:
|
|
118
|
+
- When the user sets a rule or tells you not to do something, leave one line with Remember. That line rides on every
|
|
119
|
+
later request, so keep it to one sentence. Do not record anything that only applies to this one job.
|
|
120
|
+
- When the user points back ("last time", "as we decided"), search with Recall before asking again.
|
|
121
|
+
- When you finish a procedure you will do again, write it to .deel/skills/<name>/SKILL.md.
|
|
122
|
+
Put name and description in the front matter (fenced with ---) and the steps below. If you find a mistake in a
|
|
123
|
+
skill you used, fix that file.`;
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* 작은 창을 위한 짧은 영어판. 위 짧은 판과 같은 생각이다 —
|
|
127
|
+
* 빠진 규칙은 없고 설득하는 문장만 없다.
|
|
128
|
+
*/
|
|
129
|
+
const BASE_RULES_짧게_EN = `You are deel. You read and edit code in the user's working folder.
|
|
130
|
+
|
|
131
|
+
Finish the job. Do not stop at a plan.
|
|
132
|
+
- Start now. Create missing files and folders.
|
|
133
|
+
- If a tool can tell you, decide instead of asking. Say what you decided it from.
|
|
134
|
+
- If there are several files, make them all. Do not do one and stop.
|
|
135
|
+
- Verify before you finish. If you could not verify, say so.
|
|
136
|
+
|
|
137
|
+
- Read a file before you edit it.
|
|
138
|
+
- Edit's old_string must match the file exactly, whitespace included. Include plenty of context.
|
|
139
|
+
- For a long file, Write the first part and Append the rest. Do not resend the earlier part.
|
|
140
|
+
- Do not call the same tool with the same arguments twice. The result will be the same.
|
|
141
|
+
- Answer in English, briefly. Do not paste whole files.
|
|
142
|
+
- Record rules the user sets with Remember. When they say "last time", search with Recall.`;
|
|
143
|
+
|
|
77
144
|
/**
|
|
78
145
|
* 이 창 크기에 맞는 기본 규칙.
|
|
79
146
|
*
|
|
@@ -82,7 +149,9 @@ const BASE_RULES_짧게 = `너는 deel 다. 사용자의 작업 폴더에서 코
|
|
|
82
149
|
* 같이 움직여야 '작은 창에서는 고정 몫을 줄인다' 가 한 가지 결정이 된다.
|
|
83
150
|
*/
|
|
84
151
|
function 기본규칙(ctx) {
|
|
85
|
-
|
|
152
|
+
const 짧게 = Number(ctx) > 0 && Number(ctx) < 24000;
|
|
153
|
+
if (언어() === 'en') return 짧게 ? BASE_RULES_짧게_EN : BASE_RULES_EN;
|
|
154
|
+
return 짧게 ? BASE_RULES_짧게 : BASE_RULES;
|
|
86
155
|
}
|
|
87
156
|
|
|
88
157
|
export class Session {
|
|
@@ -112,9 +181,41 @@ export class Session {
|
|
|
112
181
|
this.stepsSet = maxSteps != null;
|
|
113
182
|
this.maxSteps = maxSteps ?? 24;
|
|
114
183
|
this.messages = [];
|
|
184
|
+
// 추정 × 보정 = 실제. 서버가 알려 주는 진짜 토큰 수로 매 턴 고쳐 나간다.
|
|
185
|
+
// 1 은 '아직 안 배웠다' 이고, 그때는 추정을 그대로 쓴다. 배운다() 를 볼 것.
|
|
186
|
+
this.보정 = 1;
|
|
187
|
+
this.보정잰것 = 0;
|
|
188
|
+
// 겪어 본 것 요약 (agent/evolve.js). 켤 때 repl 이 채운다.
|
|
189
|
+
this.배움요약 = null;
|
|
190
|
+
/*
|
|
191
|
+
* 못 박은 것 (agent/pins.js).
|
|
192
|
+
*
|
|
193
|
+
* 여기에 두는 것이 핵심이다. messages 안에 넣으면 접기와 요약이 언젠가
|
|
194
|
+
* 가져간다 — 그래서 아예 그 바깥, 시스템 프롬프트 쪽에 둔다.
|
|
195
|
+
*/
|
|
196
|
+
this.못박은것 = new 못박기();
|
|
115
197
|
this.filesRead = new Map(); // 경로 → 추정 토큰
|
|
116
198
|
this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
|
|
199
|
+
/*
|
|
200
|
+
* 상태줄이 보는 두 숫자.
|
|
201
|
+
*
|
|
202
|
+
* 여기 들고 있는 이유는 **화면을 그릴 때마다 디스크를 읽지 않기 위해서**다.
|
|
203
|
+
* 상태줄은 사람이 글자 하나 칠 때마다 다시 그려진다. 거기서 되돌리기
|
|
204
|
+
* 이력 파일을 열면 타이핑이 끊긴다 — 화면 꾸미기가 입력을 느리게 만드는
|
|
205
|
+
* 것만큼 나쁜 것이 없다. repl 이 턴이 끝날 때 한 번씩 채워 준다.
|
|
206
|
+
*/
|
|
207
|
+
this.되돌릴턴 = 0;
|
|
208
|
+
this.검증 = { 돈횟수: 0, 확인: 0, 탈: 0 };
|
|
117
209
|
this.skills = []; // 켜질 때 이 PC 에서 찾은 것들
|
|
210
|
+
/*
|
|
211
|
+
* 이 자리에 언어 서버가 있나 (Def·Refs 를 목록에 넣을지).
|
|
212
|
+
*
|
|
213
|
+
* 켤 때 repl 이 한 번 재서 넣어 준다. 여기서 직접 안 재는 이유는 폴더를
|
|
214
|
+
* 훑어야 알 수 있어서다 — 세션은 시험에서도 수없이 만들어지는데, 그때마다
|
|
215
|
+
* 폴더를 훑으면 시험이 느려지고 그 자리에 뭐가 깔렸는지에 따라 결과가
|
|
216
|
+
* 달라진다. 기본은 꺼짐이고, 켜 주는 자리가 딱 하나다.
|
|
217
|
+
*/
|
|
218
|
+
this.lsp = false;
|
|
118
219
|
this.commands = [];
|
|
119
220
|
this.plugins = [];
|
|
120
221
|
this.maxSkillsListed = 40; // 프롬프트에 올릴 최대 개수
|
|
@@ -180,13 +281,19 @@ export class Session {
|
|
|
180
281
|
급값() { return 급값(this.급().급); }
|
|
181
282
|
|
|
182
283
|
systemPrompt() {
|
|
284
|
+
const 영 = 언어() === 'en';
|
|
183
285
|
const parts = [기본규칙(this.conn?.ctx)];
|
|
184
|
-
|
|
286
|
+
// 범위를 못 박는 줄. 이건 모델이 읽는 글이라 화면 말을 따라간다.
|
|
287
|
+
parts.push(영
|
|
288
|
+
? `\nWorking folder: ${this.root}\nYou can neither read nor write files outside this folder.`
|
|
289
|
+
: `\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
|
|
185
290
|
|
|
186
291
|
// 지금 무슨 일을 하는 중인지. 도구 목록도 이 모드에 맞춰 이미 걸러져 있다.
|
|
187
292
|
const w = workMode(this.effectiveWork());
|
|
188
293
|
// 창이 좁으면 짧은 판을 쓴다 (modes.js 의 말()). 규칙은 같고 설득하는 문장만 빠진다.
|
|
189
|
-
parts.push(
|
|
294
|
+
parts.push(영
|
|
295
|
+
? `\n--- current mode: ${w.en} ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`
|
|
296
|
+
: `\n--- 지금 모드: ${w.name} (${w.en}) ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`);
|
|
190
297
|
/*
|
|
191
298
|
* 모델 급에 맞춘 한 문단 (grade.js).
|
|
192
299
|
*
|
|
@@ -205,7 +312,13 @@ export class Session {
|
|
|
205
312
|
*/
|
|
206
313
|
if (this.프로젝트) parts.push(this.프로젝트);
|
|
207
314
|
|
|
208
|
-
if (this.rules)
|
|
315
|
+
if (this.rules) {
|
|
316
|
+
// 사용자 규칙 파일의 **내용은 안 건드린다.** 사람이 쓴 글이고, 그 사람의
|
|
317
|
+
// 말로 모델에게 가야 한다. 여기서 바뀌는 것은 그것을 소개하는 머리말뿐이다.
|
|
318
|
+
parts.push(영
|
|
319
|
+
? `\n--- ${this.rules.name} (user rules — these win over the principles above) ---\n${this.rules.text}`
|
|
320
|
+
: `\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
|
|
321
|
+
}
|
|
209
322
|
|
|
210
323
|
/*
|
|
211
324
|
* 지난 대화에서 정한 것.
|
|
@@ -219,19 +332,42 @@ export class Session {
|
|
|
219
332
|
* 바뀌는 셈이 된다 — 무엇 때문에 답이 달라졌는지 알 길이 없어진다.
|
|
220
333
|
*/
|
|
221
334
|
if (this.memory) parts.push(this.memory);
|
|
335
|
+
/*
|
|
336
|
+
* 이 PC·이 폴더에서 겪어 본 것 (agent/evolve.js).
|
|
337
|
+
*
|
|
338
|
+
* 기억(memory)은 사람이 적어 주는 것이고, 이건 **겪어서 저절로 쌓인 것**이다.
|
|
339
|
+
* 여기 한 줄이 헛도는 걸음 서너 개를 없앤다 — 안 되는 명령을 또 부르고,
|
|
340
|
+
* 잘릴 걸 알면서 큰 Write 를 또 보내는 걸음들이다. 그래서 자리를 내줄 값이 있다.
|
|
341
|
+
* 상한은 evolve.js 가 못 박는다(220토큰).
|
|
342
|
+
*/
|
|
343
|
+
if (this.배움요약) parts.push(this.배움요약);
|
|
222
344
|
const listed = this.listedSkills();
|
|
223
345
|
if (listed.length) {
|
|
224
346
|
parts.push(
|
|
225
|
-
|
|
226
|
-
|
|
347
|
+
(영
|
|
348
|
+
? '\n--- skills available ---\nCall the Skill tool with a name to get its body. If none fits, just carry on.\n'
|
|
349
|
+
: '\n--- 쓸 수 있는 스킬 ---\n필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n') +
|
|
227
350
|
// 설명이 없는 스킬이 섞일 수 있다 — 남의 폴더에서 오는 파일이라
|
|
228
351
|
// 앞머리(frontmatter)가 빠지곤 한다. 여기서 터지면 시스템 프롬프트를
|
|
229
352
|
// 못 만들어 **매 턴** 죽는다. 목록 명령 하나가 아니라 대화 전체가 막힌다.
|
|
230
353
|
listed.map((s) => `- ${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n')
|
|
231
354
|
);
|
|
232
355
|
const rest = this.skills.filter((s) => s.enabled).length - listed.length;
|
|
233
|
-
if (rest > 0)
|
|
356
|
+
if (rest > 0) {
|
|
357
|
+
parts.push(영
|
|
358
|
+
? `(${rest} more exist but did not fit.)`
|
|
359
|
+
: `(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
|
|
360
|
+
}
|
|
234
361
|
}
|
|
362
|
+
/*
|
|
363
|
+
* 못 박은 것은 **맨 끝**에 붙인다 (agent/pins.js).
|
|
364
|
+
*
|
|
365
|
+
* 긴 글의 가운데는 흘려 읽힌다 — 'lost in the middle' 이라 부르는 것이고,
|
|
366
|
+
* 어느 모델에서나 잰다. 사람이 직접 못 박은 말은 그 가운데에 묻히면 안 되므로
|
|
367
|
+
* 가장 마지막, 대화 바로 앞에 둔다.
|
|
368
|
+
*/
|
|
369
|
+
const 못박은글 = this.못박은것?.요약();
|
|
370
|
+
if (못박은글) parts.push(못박은글);
|
|
235
371
|
return parts.join('\n');
|
|
236
372
|
}
|
|
237
373
|
|
|
@@ -245,8 +381,90 @@ export class Session {
|
|
|
245
381
|
.slice(0, this.maxSkillsListed);
|
|
246
382
|
}
|
|
247
383
|
|
|
248
|
-
|
|
249
|
-
|
|
384
|
+
/*
|
|
385
|
+
* ── 턴이 어디서 시작했는지 ────────────────────────────────────────────
|
|
386
|
+
*
|
|
387
|
+
* 되돌리기(/undo)는 파일만 되돌렸다. 대화에는 "src/runner.js 를 고쳤습니다" 가
|
|
388
|
+
* 그대로 남아 있어서, 되돌린 다음 턴에 모델은 **이미 고쳐 놓은 줄 알고** 그
|
|
389
|
+
* 위에 이어 일했다 — 없는 코드를 고치려 들고, 없는 함수를 부른다. 사람 눈에는
|
|
390
|
+
* 모델이 헛소리하는 것으로 보이지만, 사실은 우리가 모델에게 거짓말을 남겨 둔
|
|
391
|
+
* 것이다. 그러니 파일을 되감을 때 말도 같이 걷어내야 한다.
|
|
392
|
+
*
|
|
393
|
+
* 자리를 **숫자로 적어 두지 않는다.** 접기(compact)와 줄이기(trim)가 messages
|
|
394
|
+
* 를 통째로 갈아 끼우기 때문에, 적어 둔 3번은 다음 순간 엉뚱한 말을 가리킨다.
|
|
395
|
+
* 잘못된 자리에서 자르는 되돌리기는 안 하느니만 못하다. 그래서 **메시지 객체
|
|
396
|
+
* 자체**를 들고 있다가 그때그때 indexOf 로 찾는다. 접혀서 사라졌으면 못 찾고,
|
|
397
|
+
* 못 찾으면 그 턴은 되감을 수 없다고 정직하게 말한다.
|
|
398
|
+
*/
|
|
399
|
+
#턴표 = [];
|
|
400
|
+
#다음턴 = null;
|
|
401
|
+
static #표최대 = 200;
|
|
402
|
+
|
|
403
|
+
/** 새 턴을 연다. 바로 다음에 push 되는 말이 이 턴의 첫 말이 된다. */
|
|
404
|
+
턴시작(턴) {
|
|
405
|
+
if (턴 == null) return this;
|
|
406
|
+
// 접혀 없어진 표는 여기서 턴다 — 안 그러면 긴 대화에서 끝없이 쌓인다.
|
|
407
|
+
this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
|
|
408
|
+
if (this.#턴표.length > Session.#표최대) this.#턴표 = this.#턴표.slice(-Session.#표최대);
|
|
409
|
+
this.#다음턴 = 턴;
|
|
410
|
+
return this;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 살아 있는 턴 표시들. 접혀 사라진 것은 빠진다. @returns {{턴:number, 자리:number}[]} */
|
|
414
|
+
턴자리() {
|
|
415
|
+
const out = [];
|
|
416
|
+
for (const x of this.#턴표) {
|
|
417
|
+
const i = this.messages.indexOf(x.표);
|
|
418
|
+
if (i >= 0) out.push({ 턴: x.턴, 자리: i });
|
|
419
|
+
}
|
|
420
|
+
return out.sort((a, b) => a.자리 - b.자리);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 주어진 턴들의 말을 걷어낸다.
|
|
425
|
+
*
|
|
426
|
+
* 여러 턴이면 그중 **제일 이른** 자리까지 간다 — 그 뒤는 어차피 되돌린 파일
|
|
427
|
+
* 위에서 나눈 이야기라 남겨 둘 이유가 없다. 사람이 쳤던 말은 돌려준다,
|
|
428
|
+
* 다시 치기 쉽게.
|
|
429
|
+
*
|
|
430
|
+
* 자르고 나서 repairToolPairs 를 반드시 한 번 돌린다. 도구를 부른 assistant
|
|
431
|
+
* 만 남고 그 결과가 없으면 그 뒤 모든 요청이 400 으로 튕긴다 — 되돌리기가
|
|
432
|
+
* 대화를 아예 못 쓰게 만드는 셈이다. 턴 경계는 보통 깨끗하지만, 여기서만은
|
|
433
|
+
* '보통' 에 기대지 않는다.
|
|
434
|
+
*/
|
|
435
|
+
되감기(턴들) {
|
|
436
|
+
const 찾을것 = new Set((Array.isArray(턴들) ? 턴들 : []).filter((t) => t != null));
|
|
437
|
+
const 빈것 = { 걷은것: 0, 고친것: 0, 사람말: null, 턴: [] };
|
|
438
|
+
if (!찾을것.size) return 빈것;
|
|
439
|
+
|
|
440
|
+
const 표들 = this.턴자리().filter((x) => 찾을것.has(x.턴));
|
|
441
|
+
if (!표들.length) return 빈것;
|
|
442
|
+
|
|
443
|
+
const 자리 = 표들[0].자리;
|
|
444
|
+
const 첫말 = this.messages[자리];
|
|
445
|
+
const 사람말 = 첫말?.role === 'user' && typeof 첫말.content === 'string' ? 첫말.content : null;
|
|
446
|
+
|
|
447
|
+
const 전 = this.messages.length;
|
|
448
|
+
const 고침 = repairToolPairs(this.messages.slice(0, 자리));
|
|
449
|
+
this.messages = 고침.messages;
|
|
450
|
+
this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
|
|
451
|
+
this.#다음턴 = null;
|
|
452
|
+
return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
push(msg) {
|
|
456
|
+
if (this.#다음턴 != null) { this.#턴표.push({ 턴: this.#다음턴, 표: msg }); this.#다음턴 = null; }
|
|
457
|
+
this.messages.push(msg);
|
|
458
|
+
return this;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
clear() {
|
|
462
|
+
this.messages = [];
|
|
463
|
+
this.filesRead.clear();
|
|
464
|
+
this.#턴표 = [];
|
|
465
|
+
this.#다음턴 = null;
|
|
466
|
+
return this;
|
|
467
|
+
}
|
|
250
468
|
|
|
251
469
|
noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
|
|
252
470
|
|
|
@@ -282,7 +500,41 @@ export class Session {
|
|
|
282
500
|
* 매 요청에 통째로 들어가는데 어느 칸에도 안 잡혔다.
|
|
283
501
|
* · 지금 모드 문구 — modes.js 의 say. 모드마다 수백 토큰이다.
|
|
284
502
|
*/
|
|
285
|
-
|
|
503
|
+
/**
|
|
504
|
+
* 추정을 실제에 맞춰 간다.
|
|
505
|
+
*
|
|
506
|
+
* estimateTokens 는 추정이다 — 토크나이저를 안 싣기 때문이다(의존성 0개).
|
|
507
|
+
* 그런데 서버는 매 응답에 **진짜 값**을 실어 준다(usage.prompt_tokens ·
|
|
508
|
+
* prompt_eval_count). 여태 그 값은 /cost 에만 쓰고 버렸다.
|
|
509
|
+
*
|
|
510
|
+
* 안 맞으면 두 가지가 조용히 나빠진다.
|
|
511
|
+
* · 적게 잡으면 — 남은 자리를 넉넉히 보고 답 상한을 크게 잡는다. 답이 잘린다.
|
|
512
|
+
* · 많이 잡으면 — 아직 자리가 있는데 접기 시작한다. 창을 놀린다.
|
|
513
|
+
* 둘 다 화면에는 아무 말도 안 뜬다. 그래서 스스로 재게 한다.
|
|
514
|
+
*
|
|
515
|
+
* 조심한 것:
|
|
516
|
+
* · 실제값을 안 주는 서버가 있다. 0 이면 아무것도 안 배운다.
|
|
517
|
+
* · 표본이 작으면 비율이 튄다. 첫 몇백 토큰짜리 대화는 건너뛴다.
|
|
518
|
+
* · 0.5~2배 밖은 안 믿는다. 서버가 딴 것을 세고 있을 수 있다.
|
|
519
|
+
* · 되먹임이 겹치지 않게, 비교는 **보정 안 먹인 추정**으로 한다.
|
|
520
|
+
*
|
|
521
|
+
* @returns {number|null} 새 보정 배수. 못 배웠으면 null
|
|
522
|
+
*/
|
|
523
|
+
배운다(실제) {
|
|
524
|
+
const n = Number(실제);
|
|
525
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
526
|
+
const 추정 = this.#원추정().used;
|
|
527
|
+
if (추정 < 200) return null;
|
|
528
|
+
const 비율 = n / 추정;
|
|
529
|
+
if (비율 < 0.5 || 비율 > 2) return null;
|
|
530
|
+
// 첫 번은 그대로 받고, 그 뒤로는 천천히 따라간다. 한 번 튄 값에 안 휘둘린다.
|
|
531
|
+
this.보정 = this.보정잰것 ? this.보정 + (비율 - this.보정) * 0.3 : 비율;
|
|
532
|
+
this.보정잰것++;
|
|
533
|
+
return this.보정;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
|
|
537
|
+
#원추정() {
|
|
286
538
|
// 폴더 지문도 매 요청에 통째로 나간다. 시스템 프롬프트 쪽에 같이 센다 —
|
|
287
539
|
// 안 세면 '남은 자리' 가 그만큼 뻥튀기되고, effort.js 가 그 값으로 출력
|
|
288
540
|
// 상한을 잡으므로 답이 조용히 잘리기 시작한다.
|
|
@@ -315,6 +567,7 @@ export class Session {
|
|
|
315
567
|
{ label: '시스템 프롬프트', n: sys },
|
|
316
568
|
{ label: this.rules ? `규칙 (${this.rules.name})` : '규칙 (없음)', n: rules },
|
|
317
569
|
{ label: `기억 (${기억줄}줄)`, n: 기억 },
|
|
570
|
+
{ label: '겪어 본 것', n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
|
|
318
571
|
{ label: `스킬 목록 (${listed.length}/${this.skills.length}개)`, n: skills },
|
|
319
572
|
{ label: '도구 정의', n: 도구 },
|
|
320
573
|
{ label: '대화 이력', n: history },
|
|
@@ -325,6 +578,27 @@ export class Session {
|
|
|
325
578
|
return { rows, used, total, left: Math.max(0, total - used) };
|
|
326
579
|
}
|
|
327
580
|
|
|
581
|
+
/**
|
|
582
|
+
* 화면과 예산이 보는 값. 배운 보정을 먹여서 내놓는다.
|
|
583
|
+
*
|
|
584
|
+
* 줄마다 보정을 먹인다 — 합계만 고치면 표의 줄을 더한 값과 합계가 안 맞아서,
|
|
585
|
+
* 보는 사람이 어느 쪽을 믿어야 할지 모르게 된다.
|
|
586
|
+
*/
|
|
587
|
+
breakdown() {
|
|
588
|
+
const 날것 = this.#원추정();
|
|
589
|
+
if (!(this.보정잰것 > 0) || this.보정 === 1) return 날것;
|
|
590
|
+
const rows = 날것.rows.map((r) => ({ ...r, n: Math.round(r.n * this.보정) }));
|
|
591
|
+
const used = rows.reduce((a, r) => a + r.n, 0);
|
|
592
|
+
return {
|
|
593
|
+
rows,
|
|
594
|
+
used,
|
|
595
|
+
total: 날것.total,
|
|
596
|
+
left: Math.max(0, 날것.total - used),
|
|
597
|
+
보정: this.보정,
|
|
598
|
+
보정잰것: this.보정잰것,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
328
602
|
/**
|
|
329
603
|
* 도구 정의가 몇 토큰인가.
|
|
330
604
|
*
|
|
@@ -337,7 +611,7 @@ export class Session {
|
|
|
337
611
|
const mcp수 = (this.mcp ?? []).reduce((n, s) => n + (s.도구?.length ?? 0), 0);
|
|
338
612
|
// 창 크기도 열쇠에 넣는다. 설명을 창에 맞춰 줄여 싣기 때문에(budget.js),
|
|
339
613
|
// /ctx 로 창을 다시 잡으면 이 값도 달라져야 한다. 안 넣으면 옛 값이 남는다.
|
|
340
|
-
const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
|
|
614
|
+
const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
|
|
341
615
|
if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
|
|
342
616
|
let n = 0;
|
|
343
617
|
try {
|
|
@@ -346,6 +620,7 @@ export class Session {
|
|
|
346
620
|
web: this.web !== false,
|
|
347
621
|
work: this.effectiveWork(),
|
|
348
622
|
mcp: this.mcp ?? null,
|
|
623
|
+
lsp: this.lsp === true,
|
|
349
624
|
// 실제로 나가는 것과 **같은 것**을 재야 한다. 안 넘기면 안 줄인 것을
|
|
350
625
|
// 재게 되고, 그러면 /context 가 실제보다 크게 말한다 — 그 값으로
|
|
351
626
|
// effort.js 가 출력 상한을 잡으므로 답이 이유 없이 짧아진다.
|
|
@@ -408,3 +683,76 @@ export function safeHead(messages, k) {
|
|
|
408
683
|
while (h > 0 && messages[h - 1]?.tool_calls?.length) h--;
|
|
409
684
|
return h;
|
|
410
685
|
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 도중에 죽은 대화를 다시 쓸 수 있게 손본다.
|
|
689
|
+
*
|
|
690
|
+
* 왜 필요한가:
|
|
691
|
+
* store 는 메시지가 오갈 때마다 즉시 적는다(jsonl). 그래서 도구를 **돌리는
|
|
692
|
+
* 도중에** 죽으면 `assistant(tool_calls)` 만 적히고 그 결과가 없다. 긴 Bash 를
|
|
693
|
+
* 돌리는 중, 전원이 나가는 중, 창을 닫는 중 — 흔한 자리다.
|
|
694
|
+
*
|
|
695
|
+
* 그 이력을 그대로 이어받아 보내면 OpenAI 규격 서버는 400 을 낸다. 호출 뒤에는
|
|
696
|
+
* 결과가 와야 한다는 규격이다. compact.js 가 safeCut 으로 막고 있는 바로 그
|
|
697
|
+
* 사고인데, **이어받기 길에는 그 안전망이 없었다.** 이어받자마자 첫 마디에서
|
|
698
|
+
* 죽으니, 사람 눈에는 '이어하기가 고장 났다' 로 보인다.
|
|
699
|
+
*
|
|
700
|
+
* 무엇을 하나:
|
|
701
|
+
* 결과가 없는 호출을 **지운다.** 가짜 결과를 채우지 않는다 — 안 돌아간 도구를
|
|
702
|
+
* 돌았다고 적으면 모델이 그 거짓말 위에서 계속한다. 파일을 안 고쳤는데 고친
|
|
703
|
+
* 줄 알고 다음 단계로 넘어가는 것이 제일 나쁘다.
|
|
704
|
+
* 호출이 전부 빠졌는데 할 말도 없으면 그 메시지째 지운다.
|
|
705
|
+
* 짝 없는 결과(앞에 호출이 없는 tool)도 지운다 — 머리가 잘려 나간 이력이다.
|
|
706
|
+
*
|
|
707
|
+
* 규격이 둘이라 짝짓는 법도 둘이다 (adapter.js 의 toolMessage):
|
|
708
|
+
* OpenAI { role:'tool', tool_call_id } → id 로 짝짓는다
|
|
709
|
+
* Ollama { role:'tool', tool_name } → id 가 없다. 순서로 짝짓는다
|
|
710
|
+
*
|
|
711
|
+
* @returns {{messages: object[], 고친것: number}} 고친것 = 걷어낸 호출·결과 수
|
|
712
|
+
*/
|
|
713
|
+
export function repairToolPairs(messages) {
|
|
714
|
+
const out = [];
|
|
715
|
+
let 고친것 = 0;
|
|
716
|
+
|
|
717
|
+
for (let i = 0; i < (messages?.length ?? 0); i++) {
|
|
718
|
+
const m = messages[i];
|
|
719
|
+
|
|
720
|
+
// 적다 만 줄. 도중에 죽으면 JSONL 한 줄이 반만 적히고, 그 자리가 빈 값이나
|
|
721
|
+
// role 없는 조각으로 읽힌다. 그대로 보내면 서버가 거절한다 — 걷어내는 것이
|
|
722
|
+
// 이 함수의 일이므로 여기서 같이 턴다.
|
|
723
|
+
if (!m || typeof m !== 'object' || typeof m.role !== 'string') { 고친것++; continue; }
|
|
724
|
+
|
|
725
|
+
// 호출 없이 굴러다니는 결과. 앞이 잘려 나간 이력이다.
|
|
726
|
+
if (m.role === 'tool') { 고친것++; continue; }
|
|
727
|
+
|
|
728
|
+
if (m?.role !== 'assistant' || !m.tool_calls?.length) { out.push(m); continue; }
|
|
729
|
+
|
|
730
|
+
// 이 호출에 딸린 결과 묶음 — 바로 뒤에 붙어 있는 tool 들이 전부다.
|
|
731
|
+
const 결과 = [];
|
|
732
|
+
let j = i + 1;
|
|
733
|
+
while (j < messages.length && messages[j]?.role === 'tool') 결과.push(messages[j++]);
|
|
734
|
+
i = j - 1; // 결과는 여기서 같이 처리한다
|
|
735
|
+
|
|
736
|
+
const 있는id = new Set(결과.map((r) => r?.tool_call_id).filter(Boolean));
|
|
737
|
+
const 남길호출 = 있는id.size
|
|
738
|
+
? m.tool_calls.filter((c) => 있는id.has(c?.id))
|
|
739
|
+
: m.tool_calls.slice(0, 결과.length); // id 가 없는 규격 — 순서로 본다
|
|
740
|
+
const 남길id = new Set(남길호출.map((c) => c?.id).filter(Boolean));
|
|
741
|
+
const 남길결과 = 있는id.size
|
|
742
|
+
? 결과.filter((r) => 남길id.has(r.tool_call_id))
|
|
743
|
+
: 결과.slice(0, 남길호출.length);
|
|
744
|
+
|
|
745
|
+
고친것 += (m.tool_calls.length - 남길호출.length) + (결과.length - 남길결과.length);
|
|
746
|
+
|
|
747
|
+
if (남길호출.length) {
|
|
748
|
+
out.push(남길호출.length === m.tool_calls.length ? m : { ...m, tool_calls: 남길호출 });
|
|
749
|
+
out.push(...남길결과);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
// 남은 호출이 없다. 할 말이라도 있으면 그건 살린다.
|
|
753
|
+
const 글 = typeof m.content === 'string' ? m.content.trim() : '';
|
|
754
|
+
if (글) { const { tool_calls: _버림, ...나머지 } = m; out.push(나머지); }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return { messages: out, 고친것 };
|
|
758
|
+
}
|