deel-local-cli 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +6 -5
- package/README.md +6 -5
- package/package.json +60 -60
- package/src/acp/map.js +30 -25
- package/src/acp/serve.js +810 -810
- package/src/agent/budget.js +156 -156
- package/src/agent/commit.js +32 -9
- package/src/agent/compact.js +441 -420
- package/src/agent/effort.js +308 -308
- package/src/agent/evolve.js +263 -263
- package/src/agent/loop.js +1728 -1707
- package/src/agent/memory.js +156 -156
- package/src/agent/mention.js +210 -210
- package/src/agent/recall.js +22 -9
- package/src/agent/session.js +1056 -1049
- package/src/backend/adapter.js +1355 -1159
- package/src/backend/cachemark.js +223 -150
- package/src/backend/detect.js +327 -327
- package/src/backend/http.js +3 -1
- package/src/backend/mcp.js +15 -3
- package/src/backend/price.js +69 -6
- package/src/backend/probe.js +17 -3
- package/src/backend/quota.js +274 -250
- package/src/backend/tokens.js +25 -3
- package/src/backend/toolfit.js +352 -352
- package/src/backend/wire.js +715 -600
- package/src/commands.js +3184 -3135
- package/src/config.js +39 -0
- package/src/i18n/en.js +545 -544
- package/src/i18n/ja.js +498 -497
- package/src/i18n/ko.js +592 -591
- package/src/i18n/zh.js +498 -497
- package/src/oneshot.js +625 -620
- package/src/pack/tar.js +23 -1
- package/src/pack/zip.js +19 -1
- package/src/plugins/manage.js +85 -5
- package/src/repl.js +2616 -2602
- package/src/safety/audit.js +22 -3
- package/src/safety/guard.js +124 -9
- package/src/safety/network.js +43 -0
- package/src/tools/fastgrep.js +15 -0
- package/src/tools/fsutil.js +291 -265
- package/src/tools/index.js +40 -3
- package/src/tools/jobs.js +87 -6
- package/src/tools/label.js +52 -0
- package/src/tools/spawn.js +114 -0
- package/src/tools/verify.js +358 -358
- package/src/tools/webfetch.js +28 -3
- package/src/ui/export.js +26 -10
- package/src/ui/pick.js +115 -115
- package/src/ui/status.js +617 -613
package/src/acp/serve.js
CHANGED
|
@@ -1,810 +1,810 @@
|
|
|
1
|
-
// ACP 에이전트. 에디터 안에서 deel 을 쓰는 길이다.
|
|
2
|
-
//
|
|
3
|
-
// ── 무엇인가 ────────────────────────────────────────────────────────────
|
|
4
|
-
//
|
|
5
|
-
// ACP(Agent Client Protocol)는 에디터와 코딩 에이전트 사이의 말이다. 이걸
|
|
6
|
-
// 지키면 Zed·JetBrains·Neovim·Emacs 가 **자기 쪽을 안 고치고** deel 을 붙인다.
|
|
7
|
-
// 에디터가 deel 을 자식 프로세스로 띄우고, 표준입출력으로 JSON 줄을 주고받는다.
|
|
8
|
-
//
|
|
9
|
-
// ── 왜 이게 이 프로그램에 중요한가 ──────────────────────────────────────
|
|
10
|
-
//
|
|
11
|
-
// 사내에 반입한 도구는 "터미널을 하나 더 띄우세요" 를 못 넘는다. 개발자는
|
|
12
|
-
// 하루 종일 IDE 안에 있고, 창을 옮겨 다녀야 하는 도구는 두 주쯤 뒤에 안 쓴다.
|
|
13
|
-
// 반입 심사를 통과한 판이 아무도 안 쓰는 판이 되는 것이 제일 아까운 결말이다.
|
|
14
|
-
//
|
|
15
|
-
// 그리고 이 프로토콜은 deel 이 이미 가진 것과 잘 맞는다 — 로컬 모델을 쓰므로
|
|
16
|
-
// 코드가 밖으로 안 나가는데, 그 성질은 IDE 안에서 쓸 때 비로소 값이 붙는다.
|
|
17
|
-
//
|
|
18
|
-
// ── 딱 하나 지켜야 하는 것 ──────────────────────────────────────────────
|
|
19
|
-
//
|
|
20
|
-
// **표준출력에는 ACP 메시지 말고 아무것도 나가면 안 된다.**
|
|
21
|
-
//
|
|
22
|
-
// 규격이 그렇게 못 박아 두었고, 어기면 조용히 안 깨진다 — 에디터가 그 줄을
|
|
23
|
-
// 파싱하다 실패하고, 화면에는 "에이전트가 응답하지 않습니다" 만 뜬다. 원인이
|
|
24
|
-
// 어디에도 안 남는다. deel 안에는 say() 로 화면에 적는 자리가 수십 군데다.
|
|
25
|
-
// 그 중 하나라도 이 모드에서 불리면 관이 깨진다.
|
|
26
|
-
//
|
|
27
|
-
// 그래서 여기서 process.stdout.write 를 통째로 바꿔 끼운다. 부르는 자리를
|
|
28
|
-
// 하나하나 찾아 막는 방법도 있지만, 그건 앞으로 새로 쓰는 코드까지 계속
|
|
29
|
-
// 조심해야 한다는 뜻이다 — 언젠가 반드시 한 군데를 빠뜨린다.
|
|
30
|
-
import { existsSync } from 'node:fs';
|
|
31
|
-
import { join } from 'node:path';
|
|
32
|
-
import { VERSION } from '../version.js';
|
|
33
|
-
import { 규칙모으기, 늘허락 } from '../safety/policy.js';
|
|
34
|
-
import { run } from '../agent/loop.js';
|
|
35
|
-
import { Session, repairToolPairs } from '../agent/session.js';
|
|
36
|
-
import { Store, sessionsDir, prune } from '../agent/store.js';
|
|
37
|
-
import { makeScope } from '../safety/guard.js';
|
|
38
|
-
import { History } from '../safety/undo.js';
|
|
39
|
-
import { Audit, 열쇠묻기 } from '../safety/audit.js';
|
|
40
|
-
import { activeProfile, load, resolveKey, homeDir, save as saveCfg } from '../config.js';
|
|
41
|
-
import { 말 as 옮긴말 } from '../i18n/index.js';
|
|
42
|
-
import { 알림채움 } from '../backend/retry.js';
|
|
43
|
-
import { 전선붙이기, 세션이름짓기 } from '../backend/wire.js';
|
|
44
|
-
import { discover } from '../skills/discover.js';
|
|
45
|
-
import { allowEndpoint, setOffline } from '../safety/network.js';
|
|
46
|
-
import { 지금모드, 바깥인가, 나갈수있나 } from '../safety/runmode.js';
|
|
47
|
-
import { 주소가리기 } from '../safety/secrets.js';
|
|
48
|
-
import { probeCtx, 기본값 as CTX_DEFAULT } from '../backend/ctxsize.js';
|
|
49
|
-
import { 다붙이기 } from '../backend/mcp.js';
|
|
50
|
-
import { 배움 } from '../agent/evolve.js';
|
|
51
|
-
import { 카드 } from '../agent/card.js';
|
|
52
|
-
import { 못박기 } from '../agent/pins.js';
|
|
53
|
-
import { route } from '../agent/route.js';
|
|
54
|
-
import { ORDER as 모드순서, get as getWork, normalize as 모드정리 } from '../agent/modes.js';
|
|
55
|
-
import { 모두끝내기 as 일감모두끝내기 } from '../tools/jobs.js';
|
|
56
|
-
import { 연결, 줄나누기, 모르는방법오류, 잘못된인자오류 } from './jsonrpc.js';
|
|
57
|
-
import { 도구시작, 도구끝남, 도구이름표, 도구갈래, 도구자리, 멈춘까닭, 프롬프트글, 되살린것 } from './map.js';
|
|
58
|
-
|
|
59
|
-
/** 우리가 말하는 규격 판. 정수 하나이고, 깨지는 변경에서만 올라간다. */
|
|
60
|
-
export const 규격판 = 1;
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* 이 모드에서 표준출력을 잠근다.
|
|
64
|
-
*
|
|
65
|
-
* 잠그고 나면 say() 로 적힌 것은 전부 표준오류로 간다. 규격이 에이전트의
|
|
66
|
-
* 표준오류에는 무엇을 적어도 좋다고 허락하므로, 버리지 않고 그리로 돌린다 —
|
|
67
|
-
* 무언가 잘못됐을 때 그 글이 유일한 단서다.
|
|
68
|
-
*
|
|
69
|
-
* @returns {(줄: string) => void} 진짜 표준출력으로 쓰는 함수. ACP 만 이걸 쓴다
|
|
70
|
-
*/
|
|
71
|
-
export function 표준출력잠그기() {
|
|
72
|
-
const 진짜 = process.stdout.write.bind(process.stdout);
|
|
73
|
-
process.stdout.write = function (덩이, enc, cb) {
|
|
74
|
-
return process.stderr.write(덩이, enc, cb);
|
|
75
|
-
};
|
|
76
|
-
return (줄) => 진짜(줄);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* ACP 에이전트를 띄운다. 표준입력이 닫힐 때까지 산다.
|
|
81
|
-
*
|
|
82
|
-
* @param {object} opts root/mode/work/think/effort/ctx/offline — 대화 시작 옵션과 같은 뜻
|
|
83
|
-
* @returns {Promise<number>} 종료코드
|
|
84
|
-
*/
|
|
85
|
-
export async function acp(opts = {}) {
|
|
86
|
-
const 내보내기 = 표준출력잠그기();
|
|
87
|
-
const 로그 = (s) => { try { process.stderr.write(`[acp] ${s}\n`); } catch { /* 여기서 또 터지면 할 게 없다 */ } };
|
|
88
|
-
|
|
89
|
-
/** 세션 하나. 에디터의 탭 하나에 해당한다. */
|
|
90
|
-
const 방들 = new Map();
|
|
91
|
-
let 클라이언트 = null; // initialize 로 받은 저쪽 소개
|
|
92
|
-
let 시작했나 = false;
|
|
93
|
-
|
|
94
|
-
const 관 = new 연결({
|
|
95
|
-
보내기: 내보내기,
|
|
96
|
-
다루기: (방법, 인자) => 다루기(방법, 인자),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// ── 방 만들기 ─────────────────────────────────────────────────────────
|
|
100
|
-
/**
|
|
101
|
-
* @param {object} 요청 session/new 또는 session/load 로 온 인자
|
|
102
|
-
* @param {object} o
|
|
103
|
-
* @param {string|null} o.아이디 이어할 대화 이름. 주면 그 파일에 이어 쓴다
|
|
104
|
-
*/
|
|
105
|
-
async function 방만들기(요청, { 아이디 = null } = {}) {
|
|
106
|
-
const cfg = load();
|
|
107
|
-
const prof = activeProfile(cfg);
|
|
108
|
-
if (!prof) {
|
|
109
|
-
throw 잘못된인자오류('저장된 연결이 없습니다. 터미널에서 `deel setup` 을 먼저 실행하세요.');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/*
|
|
113
|
-
* 작업 폴더는 에디터가 정한다.
|
|
114
|
-
*
|
|
115
|
-
* 규격은 cwd 를 절대 경로로 주라고 한다. 그 말을 믿고 그대로 쓴다 —
|
|
116
|
-
* 여기서 process.cwd() 로 대신하면 에디터가 연 프로젝트가 아니라 에디터를
|
|
117
|
-
* 띄운 자리를 기준으로 파일을 찾게 된다. 그러면 도구는 멀쩡히 도는데
|
|
118
|
-
* 엉뚱한 폴더를 고친다. 그게 제일 무서운 종류의 실수다.
|
|
119
|
-
*/
|
|
120
|
-
const root = typeof 요청?.cwd === 'string' && 요청.cwd ? 요청.cwd : (opts.root ?? process.cwd());
|
|
121
|
-
|
|
122
|
-
/*
|
|
123
|
-
* 대화는 폴더 안(.deel/sessions/)에 남는다 — 터미널에서 하던 것과 같은 자리다.
|
|
124
|
-
*
|
|
125
|
-
* 세션 이름을 그 파일 이름으로 삼는 데는 까닭이 있다. ACP 의 sessionId 는
|
|
126
|
-
* 에디터가 적어 뒀다가 **다음에 켤 때 session/load 로 도로 들고 오는** 이름이다.
|
|
127
|
-
* 'deel-1' 처럼 이 프로세스 안에서만 뜻이 있는 번호를 주면, 껐다 켠 순간 그
|
|
128
|
-
* 이름이 가리키는 것이 아무 데도 없다. 그러면 loadSession 은 못 지킬 약속이 된다.
|
|
129
|
-
*/
|
|
130
|
-
if (아이디 && !existsSync(join(sessionsDir(root), `${아이디}.jsonl`))) {
|
|
131
|
-
throw 잘못된인자오류(`그런 대화가 없습니다: ${아이디} (${root})`);
|
|
132
|
-
}
|
|
133
|
-
const store = new Store(root, 아이디);
|
|
134
|
-
|
|
135
|
-
const conn = {
|
|
136
|
-
kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
|
|
137
|
-
key: resolveKey(prof), model: prof.model,
|
|
138
|
-
ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
|
|
139
|
-
maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
|
|
140
|
-
streaming: prof.streaming ?? false,
|
|
141
|
-
tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
|
|
142
|
-
};
|
|
143
|
-
/*
|
|
144
|
-
* 바깥으로 나가는 연결은 허가가 있어야 연다 (safety/runmode.js).
|
|
145
|
-
*
|
|
146
|
-
* 에디터에는 승인 창이 있지만 그건 **도구 실행**을 묻는 창이고, 「이 대화가
|
|
147
|
-
* 통째로 바깥으로 나갑니다」 를 물을 자리는 규격에 없다. 그래서 여기서는
|
|
148
|
-
* 묻지 않고 또렷하게 거절한다 — 에디터 화면에 이 글이 그대로 뜬다.
|
|
149
|
-
*/
|
|
150
|
-
const 바깥연결 = 바깥인가(conn.base);
|
|
151
|
-
const 실행모드 = 지금모드({
|
|
152
|
-
online: opts.online === true,
|
|
153
|
-
// 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
|
|
154
|
-
offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
|
|
155
|
-
});
|
|
156
|
-
const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
|
|
157
|
-
if (나감.물어볼까) {
|
|
158
|
-
const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
|
|
159
|
-
throw 잘못된인자오류(
|
|
160
|
-
`이 연결은 이 컴퓨터 밖으로 나갑니다 (${주소가리기(어디)}).`
|
|
161
|
-
+ ' 터미널에서 deel 을 한 번 켜서 허락하거나, deel acp --online 으로 띄우세요.');
|
|
162
|
-
}
|
|
163
|
-
allowEndpoint(conn.base);
|
|
164
|
-
if (실행모드.허가무시) setOffline(true);
|
|
165
|
-
|
|
166
|
-
const session = new Session(conn, {
|
|
167
|
-
root,
|
|
168
|
-
// 에디터에는 승인 창이 있다. 그러니 대화 화면과 같은 기본값을 쓴다 —
|
|
169
|
-
// 비대화 모드처럼 무조건 거부할 이유가 없다. 물어볼 데가 있기 때문이다.
|
|
170
|
-
mode: opts.mode ?? 'auto',
|
|
171
|
-
work: opts.work ?? null,
|
|
172
|
-
think: opts.think ?? 'medium',
|
|
173
|
-
effort: opts.effort ?? 'save',
|
|
174
|
-
});
|
|
175
|
-
// 지금 어느 실행 모드인가. 화면이 첫 줄에 이걸 그린다(ui/status.js).
|
|
176
|
-
// session 에 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
|
|
177
|
-
session.실행모드 = 실행모드;
|
|
178
|
-
|
|
179
|
-
const found = discover(root);
|
|
180
|
-
session.skills = found.skills;
|
|
181
|
-
session.commands = found.commands;
|
|
182
|
-
session.plugins = found.plugins;
|
|
183
|
-
session.못박은것 = new 못박기();
|
|
184
|
-
|
|
185
|
-
/*
|
|
186
|
-
* 이어하기라면 적힌 것을 도로 채워 넣는다.
|
|
187
|
-
*
|
|
188
|
-
* repairToolPairs 를 반드시 거친다. 도구가 도는 중에 프로그램이 끊겼으면
|
|
189
|
-
* 부름만 적히고 답이 없는데, 그대로 보내면 규격 서버가 400 을 준다 —
|
|
190
|
-
* 되살리자마자 첫 마디에서 죽는다. 터미널의 --resume 과 같은 손질이다.
|
|
191
|
-
*/
|
|
192
|
-
if (아이디) {
|
|
193
|
-
const { messages: 적힌것 } = store.load();
|
|
194
|
-
const { messages, 고친것 } = repairToolPairs(적힌것);
|
|
195
|
-
session.messages = messages;
|
|
196
|
-
// 못 박아 둔 것도 같이 되살린다 (agent/pins.js). 이걸 빠뜨리면 '접어도
|
|
197
|
-
// 안 지워진다' 가 에디터를 닫았다 여는 한 번에 거짓이 된다.
|
|
198
|
-
const 박힌것 = store.못박은것읽기();
|
|
199
|
-
if (박힌것.length) session.못박은것 = new 못박기(박힌것);
|
|
200
|
-
로그(`${store.id} — 메시지 ${messages.length}개를 이어 받았습니다${고친것 ? ` (끊긴 도구 호출 ${고친것}개는 걷어냈습니다)` : ''}.`);
|
|
201
|
-
}
|
|
202
|
-
// 주소는 가려서 적는다. 열쇠가 주소에 박혀 오는 게이트웨이가 있어서,
|
|
203
|
-
// 그대로 적으면 대화 기록 파일에 열쇠가 남는다 (safety/secrets.js).
|
|
204
|
-
store.begin({ model: conn.model, base: 주소가리기(conn.base), root });
|
|
205
|
-
try { prune(root); } catch { /* 정리는 못 해도 대화는 된다 */ }
|
|
206
|
-
|
|
207
|
-
/*
|
|
208
|
-
* 밖에서 붙인 도구(MCP).
|
|
209
|
-
*
|
|
210
|
-
* 규격에는 클라이언트가 mcpServers 를 넘겨 주는 자리가 있다. 그런데 그걸
|
|
211
|
-
* 그대로 띄우면 **에디터 설정에 적힌 프로세스를 deel 이 대신 띄우는** 셈이
|
|
212
|
-
* 된다. 사내 반입 심사에서 "이 도구가 무엇을 띄우는가" 는 제일 먼저 묻는
|
|
213
|
-
* 것이라, 그 답이 '에디터가 시키는 대로' 가 되면 안 된다.
|
|
214
|
-
*
|
|
215
|
-
* 그래서 deel 은 늘 제 폴더의 .deel/mcp.json 만 본다. 사람이 직접 적은 것만
|
|
216
|
-
* 띄운다는 규칙이 대화 화면과 여기서 똑같이 유지된다.
|
|
217
|
-
*/
|
|
218
|
-
const mcp붙임 = await 다붙이기(root, {
|
|
219
|
-
offline: !!(opts.offline ?? prof.offline),
|
|
220
|
-
audit: null,
|
|
221
|
-
});
|
|
222
|
-
session.mcp = mcp붙임.서버들;
|
|
223
|
-
if (Array.isArray(요청?.mcpServers) && 요청.mcpServers.length) {
|
|
224
|
-
로그(`에디터가 MCP 서버 ${요청.mcpServers.length}대를 넘겼지만 띄우지 않습니다 — deel 은 .deel/mcp.json 에 적힌 것만 띄웁니다.`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const 방 = {
|
|
228
|
-
id: store.id,
|
|
229
|
-
root, conn, session, store,
|
|
230
|
-
턴: null,
|
|
231
|
-
늘허락: new Set(), // 이 세션에서 "앞으로 묻지 않기" 를 고른 도구들
|
|
232
|
-
mcp: mcp붙임.서버들,
|
|
233
|
-
도구번호: 0,
|
|
234
|
-
메시지번호: 0,
|
|
235
|
-
돌던도구: new Map(), // 도구 이름 → 아직 안 끝난 호출 아이디들
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
방.ctx = {
|
|
239
|
-
scope: makeScope(root),
|
|
240
|
-
get 모델컨텍스트() { return conn.ctx ?? null; },
|
|
241
|
-
// 이 모델이 그림을 볼 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
|
|
242
|
-
get 눈있나() { return !!conn.vision; },
|
|
243
|
-
// 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
|
|
244
|
-
규칙들: 규칙모으기(cfg),
|
|
245
|
-
history: new History(root),
|
|
246
|
-
audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
|
|
247
|
-
seen: new Set(),
|
|
248
|
-
mcp: mcp붙임.서버들,
|
|
249
|
-
skills: found.skills,
|
|
250
|
-
loadedSkills: new Set(),
|
|
251
|
-
/*
|
|
252
|
-
* 되묻는 자리.
|
|
253
|
-
*
|
|
254
|
-
* ACP 1판에는 '아무거나 물어보기' 가 없다. 승인은 있어도 자유 질문은
|
|
255
|
-
* 없다. 그러니 기다리면 안 된다 — 기다리면 에디터는 아무 창도 안 띄우고
|
|
256
|
-
* deel 은 영영 서 있는다. 기본값을 바로 돌려준다.
|
|
257
|
-
*/
|
|
258
|
-
ask: async (_라벨, o = {}) => o?.def ?? '',
|
|
259
|
-
askPassword: async () => null,
|
|
260
|
-
confirm: (이름, 인자) => 승인묻기(방, 이름, 인자),
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
방.ctx.배움 = new 배움(root, homeDir());
|
|
264
|
-
/*
|
|
265
|
-
* 전선 카드와 이 대화의 이름 (backend/wire.js).
|
|
266
|
-
*
|
|
267
|
-
* 에디터에서 붙어도 나가는 전선은 같다. 여기만 빼 두면 Zed 로 쓰는
|
|
268
|
-
* 사람만 캐시가 안 걸리고, 그건 화면 어디에도 안 나타난다.
|
|
269
|
-
*/
|
|
270
|
-
전선붙이기(conn, 방.ctx.배움);
|
|
271
|
-
session.세션이름 = 세션이름짓기(store.id);
|
|
272
|
-
conn.세션이름 = session.세션이름;
|
|
273
|
-
session.배움요약 = 방.ctx.배움.요약(conn.model);
|
|
274
|
-
const 아는배수 = 방.ctx.배움.아는보정(conn.model);
|
|
275
|
-
if (아는배수) { session.보정 = 아는배수; session.보정잰것 = 1; }
|
|
276
|
-
|
|
277
|
-
방.ctx.카드다시 = () => {
|
|
278
|
-
방.ctx.카드 = 카드(conn.model, session.본것, 방.ctx.배움?.현황(conn.model)?.모델);
|
|
279
|
-
return 방.ctx.카드;
|
|
280
|
-
};
|
|
281
|
-
방.ctx.카드다시();
|
|
282
|
-
|
|
283
|
-
// 컨텍스트 길이는 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
|
|
284
|
-
// 못 물어봐도 여기서 멈출 일은 아니다.
|
|
285
|
-
if (opts.ctx == null) {
|
|
286
|
-
try {
|
|
287
|
-
const r = await probeCtx(conn, { timeout: 6000 });
|
|
288
|
-
if (r?.value) conn.ctx = r.value;
|
|
289
|
-
} catch { /* 설정값 그대로 간다 */ }
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
방들.set(방.id, 방);
|
|
293
|
-
return 방;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function 방찾기(id) {
|
|
297
|
-
const 방 = 방들.get(String(id ?? ''));
|
|
298
|
-
if (!방) throw 잘못된인자오류(`그런 세션이 없습니다: ${id}`);
|
|
299
|
-
return 방;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// ── 승인 ──────────────────────────────────────────────────────────────
|
|
303
|
-
//
|
|
304
|
-
// deel 의 안전장치를 에디터의 승인 창으로 그대로 내보낸다. 이게 붙는 것과
|
|
305
|
-
// 안 붙는 것의 차이가 크다 — 안 붙으면 위험한 명령을 물어볼 데가 없어서
|
|
306
|
-
// 무조건 거부하게 되고, 그러면 에디터 안에서는 아무 일도 못 하는 도구가 된다.
|
|
307
|
-
async function 승인묻기(방, 이름, 인자) {
|
|
308
|
-
if (방.늘허락.has(이름)) return true;
|
|
309
|
-
|
|
310
|
-
const 아이디 = `t${++방.도구번호}`;
|
|
311
|
-
try {
|
|
312
|
-
const 답 = await 관.요청('session/request_permission', {
|
|
313
|
-
sessionId: 방.id,
|
|
314
|
-
toolCall: {
|
|
315
|
-
toolCallId: 아이디,
|
|
316
|
-
title: 도구이름표(이름, 인자),
|
|
317
|
-
kind: 도구갈래(이름),
|
|
318
|
-
status: 'pending',
|
|
319
|
-
locations: 도구자리(이름, 인자, null),
|
|
320
|
-
rawInput: 인자 ?? {},
|
|
321
|
-
},
|
|
322
|
-
options: [
|
|
323
|
-
{ optionId: 'allow_once', name: '이번만 실행', kind: 'allow_once' },
|
|
324
|
-
{ optionId: 'allow_always', name: `${이름} 은 앞으로 묻지 않기`, kind: 'allow_always' },
|
|
325
|
-
{ optionId: 'reject_once', name: '하지 않기', kind: 'reject_once' },
|
|
326
|
-
],
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
const 결과 = 답?.outcome ?? {};
|
|
330
|
-
if (결과.outcome !== 'selected') return false; // cancelled 도 여기로 온다
|
|
331
|
-
if (결과.optionId === 'allow_always') {
|
|
332
|
-
방.늘허락.add(이름);
|
|
333
|
-
/*
|
|
334
|
-
* '앞으로 묻지 않기' 는 **다음에 켤 때도** 안 물어야 한다.
|
|
335
|
-
*
|
|
336
|
-
* 이 켠 동안만 기억하면 사람은 편집기를 닫았다 열 때마다 같은 것을
|
|
337
|
-
* 다시 허락한다. 그러면 그 버튼은 '앞으로' 가 아니라 '이번 켠 동안' 이다.
|
|
338
|
-
* 설정에 적어 두고, 다음부터는 규칙이 먼저 통과시킨다 (safety/policy.js).
|
|
339
|
-
*
|
|
340
|
-
* 금지에 걸리는 것은 안 적는다 — 적어 봐야 금지가 이기는데 목록에는
|
|
341
|
-
* 허락으로 보여서, 사람이 풀린 줄 안다.
|
|
342
|
-
*/
|
|
343
|
-
try {
|
|
344
|
-
const cfg = load();
|
|
345
|
-
const r = 늘허락(cfg, 이름, 규칙모으기(cfg));
|
|
346
|
-
if (r.ok) saveCfg(cfg);
|
|
347
|
-
} catch { /* 못 적어도 이번 켠 동안은 먹는다 */ }
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
return 결과.optionId === 'allow_once';
|
|
351
|
-
} catch (err) {
|
|
352
|
-
/*
|
|
353
|
-
* 못 물어봤으면 안 한다.
|
|
354
|
-
*
|
|
355
|
-
* 여기서 true 를 돌려주고 싶은 유혹이 있다 — 안 그러면 승인 창을 아직
|
|
356
|
-
* 안 만든 클라이언트에서 아무것도 안 돌아가니까. 그런데 그건 "물어볼 수
|
|
357
|
-
* 없으면 마음대로 한다" 는 뜻이다. 사람이 안 보는 자리에서 되돌릴 수 없는
|
|
358
|
-
* 명령이 도는 것이 이 프로그램이 제일 피하려는 일이다.
|
|
359
|
-
*/
|
|
360
|
-
로그(`승인을 못 물어봐서 거부했습니다 (${이름}) — ${err?.message ?? err}`);
|
|
361
|
-
return false;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// ── 한 턴 ─────────────────────────────────────────────────────────────
|
|
366
|
-
async function 한턴(방, 덩이들) {
|
|
367
|
-
const 글 = 프롬프트글(덩이들);
|
|
368
|
-
if (!글) throw 잘못된인자오류('보낸 말이 비었습니다.');
|
|
369
|
-
|
|
370
|
-
// 앞 턴이 아직 돌고 있으면 끊고 시작한다. 규격은 턴을 겹쳐 보내지 말라고
|
|
371
|
-
// 하지만, 안 지키는 클라이언트가 있을 때 두 턴이 같은 세션을 같이 밟으면
|
|
372
|
-
// 오간 말이 뒤엉킨다. 그건 나중에 원인을 찾을 수 없는 종류의 고장이다.
|
|
373
|
-
if (방.턴 && !방.턴.signal.aborted) 방.턴.abort();
|
|
374
|
-
|
|
375
|
-
const 턴 = new AbortController();
|
|
376
|
-
방.턴 = 턴;
|
|
377
|
-
방.ctx.카드다시();
|
|
378
|
-
|
|
379
|
-
const 보내기 = (update) => 관.알림('session/update', { sessionId: 방.id, update });
|
|
380
|
-
const 말하기 = (글, 갈래 = 'agent_message_chunk') => 보내기({
|
|
381
|
-
sessionUpdate: 갈래,
|
|
382
|
-
content: { type: 'text', text: 글 },
|
|
383
|
-
messageId: `m${방.메시지번호}`,
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// 종합 모드면 이 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
|
|
387
|
-
방.session.routed = null;
|
|
388
|
-
if (방.session.work === 'auto') {
|
|
389
|
-
const 골라진 = route(글);
|
|
390
|
-
if (골라진.mode) 방.session.routed = 골라진.mode;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
let 까닭 = 'done';
|
|
394
|
-
let 왜 = '';
|
|
395
|
-
방.메시지번호++;
|
|
396
|
-
|
|
397
|
-
/*
|
|
398
|
-
* 어디까지 적었나. 자주 흘려 보낸다.
|
|
399
|
-
*
|
|
400
|
-
* 턴이 다 끝나고 한 번에 적으면, 도구를 열 번 돌린 뒤에 에디터가 닫힌 순간
|
|
401
|
-
* 그 열 번이 통째로 사라진다. 되살릴 때 제일 아쉬운 것이 바로 그 부분이라
|
|
402
|
-
* (무엇을 이미 확인했는지) 걸음마다 적는다.
|
|
403
|
-
*/
|
|
404
|
-
let 적은데까지 = 방.session.messages.length;
|
|
405
|
-
const 적기 = () => {
|
|
406
|
-
for (const m of 방.session.messages.slice(적은데까지)) 방.store.append(m);
|
|
407
|
-
적은데까지 = 방.session.messages.length;
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
try {
|
|
411
|
-
// 이번 걸음에 글이 흘러 나갔나. 거절 자리에서 같은 글을 두 벌 싣지 않으려고 센다.
|
|
412
|
-
let 흘렸나 = false;
|
|
413
|
-
for await (const ev of run(방.session, 방.ctx, 글, { signal: 턴.signal })) {
|
|
414
|
-
switch (ev.type) {
|
|
415
|
-
case 'stage':
|
|
416
|
-
방.메시지번호++;
|
|
417
|
-
흘렸나 = false;
|
|
418
|
-
break;
|
|
419
|
-
|
|
420
|
-
case 'thinking':
|
|
421
|
-
if (ev.text) 말하기(ev.text, 'agent_thought_chunk');
|
|
422
|
-
break;
|
|
423
|
-
|
|
424
|
-
case 'content':
|
|
425
|
-
if (ev.text) { 말하기(ev.text); 흘렸나 = true; }
|
|
426
|
-
break;
|
|
427
|
-
|
|
428
|
-
/*
|
|
429
|
-
* 다시 부르는 자리.
|
|
430
|
-
*
|
|
431
|
-
* 답이 상한에서 잘리면 루프가 상한을 올려 처음부터 다시 부른다.
|
|
432
|
-
* 그러면 방금 흘려보낸 글이 통째로 다시 온다. 아무 말 없이 두 번
|
|
433
|
-
* 보내면 에디터에는 같은 답이 두 벌 붙어 보인다 — 모델이 헛소리를
|
|
434
|
-
* 하는 것처럼 보이지만 사실은 우리가 안 알려 준 탓이다.
|
|
435
|
-
*/
|
|
436
|
-
case 'retry':
|
|
437
|
-
방.메시지번호++;
|
|
438
|
-
말하기(`\n\n_(${ev.why} — 다시 답을 받습니다)_\n\n`);
|
|
439
|
-
break;
|
|
440
|
-
|
|
441
|
-
// 답이 천장에서 잘렸고 더 못 올린다. 조용히 끝내면 에디터에는 중간에서
|
|
442
|
-
// 끊긴 답만 남아서, 사람은 모델이 대충 답한 줄 안다.
|
|
443
|
-
//
|
|
444
|
-
// 한국어를 박아 두고 있었다 — `/lang en` 으로 켠 사람이 이 자리에서만
|
|
445
|
-
// 한글을 본다. 말 표를 거친다.
|
|
446
|
-
case 'capped':
|
|
447
|
-
말하기(`\n\n_(${옮긴말('ev.capped', { 한계: ev.cap.toLocaleString() })})_\n\n`);
|
|
448
|
-
break;
|
|
449
|
-
|
|
450
|
-
/*
|
|
451
|
-
* ── 여기 셋은 에디터에 아무 말도 안 가고 있었다 ──────────────
|
|
452
|
-
*
|
|
453
|
-
* 특히 cutoff 가 나쁘다. 서버가 끝났다는 말도 없이 멈춘 반쪽 답이
|
|
454
|
-
* 에디터에는 **온전한 답**으로 뜬다. 사람은 그걸 읽고 다음 일로
|
|
455
|
-
* 넘어가는데, 정작 답의 뒷부분은 오지도 않았다.
|
|
456
|
-
*/
|
|
457
|
-
case 'cutoff':
|
|
458
|
-
말하기(`\n\n_(${옮긴말('ev.cutoff')})_\n\n`);
|
|
459
|
-
break;
|
|
460
|
-
|
|
461
|
-
case 'nudge':
|
|
462
|
-
말하기(`\n\n_(${ev.why === '요청누락'
|
|
463
|
-
? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
|
|
464
|
-
: 옮긴말('ev.nudgeRead')})_\n\n`);
|
|
465
|
-
break;
|
|
466
|
-
|
|
467
|
-
// 모델이 안 하겠다고 했다. 에디터 쪽에도 그렇게 적어 둔다 —
|
|
468
|
-
// 안 적으면 반쪽 답과 구별이 안 되고, 사람은 같은 말을 또 친다.
|
|
469
|
-
case 'refusal':
|
|
470
|
-
/*
|
|
471
|
-
* 흘러 나온 것이 없으면 거절 글을 여기서 싣는다.
|
|
472
|
-
*
|
|
473
|
-
* OpenAI 꼴은 거절 글 조각을 답 자리로 안 흘려보내고 맨 끝에 한
|
|
474
|
-
* 번에 모아 준다(backend/adapter.js 의 흡수). 그래서 이 자리가
|
|
475
|
-
* 없으면 에디터에는
|
|
476
|
-
* 「거절당했습니다」 한 줄만 남고, 무엇을 고쳐 다시 물어야 할지
|
|
477
|
-
* 알 길이 없다. 이미 흘러 나온 창구에서는 두 벌이 되니 안 싣는다.
|
|
478
|
-
*/
|
|
479
|
-
if (!흘렸나 && ev.text) 말하기(String(ev.text));
|
|
480
|
-
말하기(`\n\n_(${옮긴말('run.refusal')}${
|
|
481
|
-
ev.왜 && ev.왜 !== 'refusal' ? ` · ${옮긴말('run.refusalWhy', { 왜: ev.왜 })}` : ''})_\n\n`);
|
|
482
|
-
break;
|
|
483
|
-
|
|
484
|
-
case 'note':
|
|
485
|
-
말하기(`\n\n_(${ev.text})_\n\n`);
|
|
486
|
-
break;
|
|
487
|
-
|
|
488
|
-
case 'learned':
|
|
489
|
-
말하기(`\n\n_(${ev.what === 'ctx'
|
|
490
|
-
? 옮긴말('ev.learnedCtx', { 한계: ev.limit.toLocaleString() })
|
|
491
|
-
: 옮긴말('ev.learnedOut', { 한계: ev.limit.toLocaleString() })})_\n\n`);
|
|
492
|
-
break;
|
|
493
|
-
|
|
494
|
-
// 서버가 잠깐 막아 기다리는 중. 아직 흘러간 글이 없으니 답을 새로 시작하지는 않는다.
|
|
495
|
-
case 'backoff':
|
|
496
|
-
말하기(`\n\n_(${ev.미리
|
|
497
|
-
? 옮긴말('loop.quotaAhead', { 초: 알림채움(ev).초 })
|
|
498
|
-
: 옮긴말('loop.backoff', 알림채움(ev))})_\n\n`);
|
|
499
|
-
break;
|
|
500
|
-
|
|
501
|
-
// 서버가 안 받는 칸이 있어 전선 카드를 고쳤다 (backend/wire.js).
|
|
502
|
-
case 'wire':
|
|
503
|
-
말하기(`\n\n_(${옮긴말('loop.wire', { 무엇: ev.무엇 })})_\n\n`);
|
|
504
|
-
break;
|
|
505
|
-
|
|
506
|
-
case 'tool_start':
|
|
507
|
-
보내기(도구시작(도구맡기기(방, ev.name), ev.name, ev.args));
|
|
508
|
-
break;
|
|
509
|
-
|
|
510
|
-
case 'tools_start':
|
|
511
|
-
for (const 이름 of ev.names ?? []) {
|
|
512
|
-
보내기(도구시작(도구맡기기(방, 이름), 이름, null));
|
|
513
|
-
}
|
|
514
|
-
break;
|
|
515
|
-
|
|
516
|
-
case 'tool':
|
|
517
|
-
보내기(도구끝남(도구찾기(방, ev.name), ev));
|
|
518
|
-
적기();
|
|
519
|
-
break;
|
|
520
|
-
|
|
521
|
-
/*
|
|
522
|
-
* 접히면 이력이 통째로 바뀐다.
|
|
523
|
-
*
|
|
524
|
-
* 덧붙이기로는 못 맞춘다 — 앞의 것 여러 개가 요약 하나로 바뀌었으니,
|
|
525
|
-
* 이어 붙이면 파일에는 접히기 전과 접힌 뒤가 겹쳐 남는다. 새로 적는다.
|
|
526
|
-
*/
|
|
527
|
-
/*
|
|
528
|
-
* 자리가 다 차서 비웠다. 에디터에는 오류가 아니라 알림으로 간다 —
|
|
529
|
-
* 붉게 보내면 편집기가 턴이 끝난 줄 알고 되물음을 닫아 버린다.
|
|
530
|
-
*/
|
|
531
|
-
case 'reset':
|
|
532
|
-
말하기(`\n\n_(${(ev.kept?.할일 ?? 0)
|
|
533
|
-
? 옮긴말('ev.reset', { 버린수: ev.dropped, 할일: ev.kept.할일 })
|
|
534
|
-
: 옮긴말('ev.resetBare', { 버린수: ev.dropped })})_\n\n`);
|
|
535
|
-
방.store.replace(방.session.messages, `자리부족 — 앞선 대화 ${ev.dropped}개를 비움`);
|
|
536
|
-
적은데까지 = 방.session.messages.length;
|
|
537
|
-
break;
|
|
538
|
-
case 'compacted':
|
|
539
|
-
방.store.replace(방.session.messages, `압축 — ${ev.folded}개를 요약으로`);
|
|
540
|
-
적은데까지 = 방.session.messages.length;
|
|
541
|
-
break;
|
|
542
|
-
|
|
543
|
-
/*
|
|
544
|
-
* 하위 작업.
|
|
545
|
-
*
|
|
546
|
-
* ACP 에는 '작업 안의 작업' 이 없다. 그래서 도구 호출 하나로 보이게
|
|
547
|
-
* 둔다 — 없는 척하면 하위가 만진 파일 넷이 어디서 나왔는지 화면만
|
|
548
|
-
* 보고는 알 수 없다.
|
|
549
|
-
*/
|
|
550
|
-
case 'task_start':
|
|
551
|
-
보내기({
|
|
552
|
-
sessionUpdate: 'tool_call',
|
|
553
|
-
toolCallId: 도구맡기기(방, 'Task'),
|
|
554
|
-
title: `하위 작업: ${ev.목적 ?? ''}`,
|
|
555
|
-
kind: 'think',
|
|
556
|
-
status: 'in_progress',
|
|
557
|
-
});
|
|
558
|
-
break;
|
|
559
|
-
|
|
560
|
-
case 'task_done':
|
|
561
|
-
보내기({
|
|
562
|
-
sessionUpdate: 'tool_call_update',
|
|
563
|
-
toolCallId: 도구찾기(방, 'Task'),
|
|
564
|
-
status: ev.끝?.type === 'done' ? 'completed' : 'failed',
|
|
565
|
-
content: [{
|
|
566
|
-
type: 'content',
|
|
567
|
-
content: {
|
|
568
|
-
type: 'text',
|
|
569
|
-
text: ev.끝?.type === 'done'
|
|
570
|
-
? `끝냄 · ${ev.끝?.steps ?? 0}걸음`
|
|
571
|
-
: `다 못 했습니다 (${ev.끝?.type}) · ${ev.끝?.steps ?? 0}걸음`,
|
|
572
|
-
},
|
|
573
|
-
}],
|
|
574
|
-
});
|
|
575
|
-
break;
|
|
576
|
-
|
|
577
|
-
case 'limit':
|
|
578
|
-
까닭 = 'limit';
|
|
579
|
-
왜 = `도구 호출 ${ev.steps}회에서 멈췄습니다. 한 번에 하기엔 큰 일입니다 — 나눠서 시키세요.`;
|
|
580
|
-
break;
|
|
581
|
-
|
|
582
|
-
case 'stuck':
|
|
583
|
-
까닭 = 'stuck';
|
|
584
|
-
왜 = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다.');
|
|
585
|
-
break;
|
|
586
|
-
|
|
587
|
-
case 'aborted':
|
|
588
|
-
까닭 = 'aborted';
|
|
589
|
-
break;
|
|
590
|
-
|
|
591
|
-
case 'error':
|
|
592
|
-
까닭 = 'error';
|
|
593
|
-
왜 = String(ev.text ?? '알 수 없는 오류');
|
|
594
|
-
break;
|
|
595
|
-
|
|
596
|
-
case 'done':
|
|
597
|
-
까닭 = 'done';
|
|
598
|
-
break;
|
|
599
|
-
|
|
600
|
-
default:
|
|
601
|
-
break;
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
} catch (err) {
|
|
605
|
-
까닭 = 'error';
|
|
606
|
-
왜 = String(err?.message ?? err);
|
|
607
|
-
} finally {
|
|
608
|
-
if (방.턴 === 턴) 방.턴 = null;
|
|
609
|
-
방.돌던도구.clear();
|
|
610
|
-
// 끊겼든 터졌든 여기까지 오간 것은 남긴다. 끊긴 턴이야말로 다음에
|
|
611
|
-
// 되살려서 이어가고 싶은 자리다.
|
|
612
|
-
try { 적기(); } catch { /* 못 적어도 답은 돌려줘야 한다 */ }
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/*
|
|
616
|
-
* 규격에 없는 까닭은 말로 준다.
|
|
617
|
-
*
|
|
618
|
-
* '헛돌아서 멈췄다' 는 stopReason 다섯 낱말 중 어디에도 없다. 억지로
|
|
619
|
-
* refusal 에 밀어 넣으면 에디터가 이 대화를 버려야 하는 것으로 읽는다.
|
|
620
|
-
* 그러니 낱말은 end_turn 으로 두고, 왜 멈췄는지는 사람이 읽게 적어 준다.
|
|
621
|
-
*/
|
|
622
|
-
if (왜 && 까닭 !== 'aborted') {
|
|
623
|
-
방.메시지번호++;
|
|
624
|
-
말하기(`\n\n---\n**${까닭 === 'error' ? '오류' : '멈춤'}** — ${왜}\n`);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
return { stopReason: 멈춘까닭(까닭) };
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/*
|
|
631
|
-
* 도구 호출 하나에 번호를 매기고, 끝날 때 그 번호를 도로 찾는다.
|
|
632
|
-
*
|
|
633
|
-
* loop.js 는 시작 이벤트에 번호를 안 붙인다 — 화면에 그릴 때는 필요 없었다.
|
|
634
|
-
* ACP 는 시작과 끝을 같은 번호로 이어야 하나로 그린다. 그래서 이름별로
|
|
635
|
-
* 줄을 세워 두고 먼저 시작한 것부터 짝을 짓는다. 같은 이름을 나란히 여러 개
|
|
636
|
-
* 부르는 경우(도구를 한꺼번에 부를 때)에도 순서가 어긋나지 않는다.
|
|
637
|
-
*/
|
|
638
|
-
function 도구맡기기(방, 이름) {
|
|
639
|
-
const 아이디 = `t${++방.도구번호}`;
|
|
640
|
-
const 줄 = 방.돌던도구.get(이름) ?? [];
|
|
641
|
-
줄.push(아이디);
|
|
642
|
-
방.돌던도구.set(이름, 줄);
|
|
643
|
-
return 아이디;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function 도구찾기(방, 이름) {
|
|
647
|
-
const 줄 = 방.돌던도구.get(이름);
|
|
648
|
-
if (줄?.length) return 줄.shift();
|
|
649
|
-
// 시작을 못 본 도구. 거부당한 호출처럼 시작 이벤트 없이 끝만 오는 자리가
|
|
650
|
-
// 있다. 새 번호를 준다 — 짝이 없다고 버리면 그 호출이 화면에서 사라진다.
|
|
651
|
-
return `t${++방.도구번호}`;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// ── 방법표 ────────────────────────────────────────────────────────────
|
|
655
|
-
async function 다루기(방법, 인자) {
|
|
656
|
-
switch (방법) {
|
|
657
|
-
case 'initialize': {
|
|
658
|
-
시작했나 = true;
|
|
659
|
-
클라이언트 = 인자?.clientInfo ?? null;
|
|
660
|
-
const 저쪽판 = Number(인자?.protocolVersion);
|
|
661
|
-
로그(`붙었습니다 — ${클라이언트?.name ?? '이름 없는 클라이언트'} (규격 ${Number.isFinite(저쪽판) ? 저쪽판 : '?'}판)`);
|
|
662
|
-
return {
|
|
663
|
-
// 저쪽이 우리보다 새 판을 말하면 우리 판을 답한다. 규격이 그렇게 정했다.
|
|
664
|
-
protocolVersion: Number.isFinite(저쪽판) && 저쪽판 < 규격판 ? 저쪽판 : 규격판,
|
|
665
|
-
agentCapabilities: {
|
|
666
|
-
// 지난 대화를 되살릴 수 있다. 이 값이 false 면 에디터는 아예 안
|
|
667
|
-
// 물어보고 빈 대화를 연다 — 붙여 놓고 안 켜면 없는 것과 같다.
|
|
668
|
-
loadSession: true,
|
|
669
|
-
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
|
670
|
-
mcpCapabilities: { http: false, sse: false },
|
|
671
|
-
},
|
|
672
|
-
agentInfo: { name: 'deel', title: 'deel (로컬 모델 코딩 에이전트)', version: 판번호() },
|
|
673
|
-
authMethods: [], // 연결 설정은 `deel setup` 이 맡는다
|
|
674
|
-
};
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
case 'authenticate':
|
|
678
|
-
// 인증 방법을 하나도 안 걸었으니 여기 올 일이 없다. 와도 조용히 넘긴다.
|
|
679
|
-
return {};
|
|
680
|
-
|
|
681
|
-
case 'session/new': {
|
|
682
|
-
const 방 = await 방만들기(인자);
|
|
683
|
-
return {
|
|
684
|
-
sessionId: 방.id,
|
|
685
|
-
modes: 모드상태(방),
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/*
|
|
690
|
-
* 지난 대화 되살리기.
|
|
691
|
-
*
|
|
692
|
-
* ── 답에 대화를 실어 주는 자리가 없다 ────────────────────────────
|
|
693
|
-
*
|
|
694
|
-
* 규격은 오간 말을 **session/update 로 전부 다시 흘리라**고 한다. 답으로
|
|
695
|
-
* 한 번에 주는 자리가 아예 없다. 그래서 여기서 흘린 것이 에디터에 그려지는
|
|
696
|
-
* 지난 대화의 전부다.
|
|
697
|
-
*
|
|
698
|
-
* 흘리기 전에 방을 다 만들어야 한다. 반쯤 만들어 두고 흘리다가 설정이
|
|
699
|
-
* 없어서 터지면, 에디터에는 지난 대화가 절반만 그려진 채로 남는다.
|
|
700
|
-
*/
|
|
701
|
-
case 'session/load': {
|
|
702
|
-
const 아이디 = String(인자?.sessionId ?? '').trim();
|
|
703
|
-
if (!아이디) throw 잘못된인자오류('sessionId 가 없습니다.');
|
|
704
|
-
|
|
705
|
-
// 이 프로세스가 이미 열어 둔 방이면 그대로 쓴다. 다시 만들면 같은
|
|
706
|
-
// 대화를 두 곳에서 밟게 되고, 그때부터 어느 쪽이 참인지 알 수 없다.
|
|
707
|
-
const 방 = 방들.get(아이디) ?? await 방만들기(인자, { 아이디 });
|
|
708
|
-
|
|
709
|
-
/*
|
|
710
|
-
* 화면에 그릴 것과 모델에게 줄 것이 다르다.
|
|
711
|
-
*
|
|
712
|
-
* 모델에게는 손질한 것이 간다 — 답 없는 도구 부름이 섞이면 규격 서버가
|
|
713
|
-
* 400 을 준다. 그런데 **사람에게는 그 자리를 보여 줘야 한다.** 어제
|
|
714
|
-
* 무엇을 하다 끊겼는지가 오늘 무엇을 시킬지를 정하기 때문이다. 그래서
|
|
715
|
-
* 그리는 것은 적힌 그대로(손질 전)를 쓴다.
|
|
716
|
-
*
|
|
717
|
-
* 파일을 다시 읽는 까닭도 같다. 걸음마다 적어 두므로 파일이 늘 최신이고,
|
|
718
|
-
* 손질로 걷어낸 것까지 그대로 남아 있는 유일한 자리다.
|
|
719
|
-
*/
|
|
720
|
-
let 그릴것 = 방.session.messages;
|
|
721
|
-
try {
|
|
722
|
-
const 적힌것 = 방.store.load().messages;
|
|
723
|
-
if (적힌것.length) 그릴것 = 적힌것;
|
|
724
|
-
} catch { /* 못 읽으면 기억하고 있는 것으로 그린다 */ }
|
|
725
|
-
|
|
726
|
-
let 흘린것 = 0;
|
|
727
|
-
for (const update of 되살린것(그릴것)) {
|
|
728
|
-
관.알림('session/update', { sessionId: 방.id, update });
|
|
729
|
-
흘린것++;
|
|
730
|
-
}
|
|
731
|
-
로그(`${방.id} — 지난 대화 ${흘린것}덩이를 되살렸습니다.`);
|
|
732
|
-
return { modes: 모드상태(방) };
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
case 'session/prompt': {
|
|
736
|
-
const 방 = 방찾기(인자?.sessionId);
|
|
737
|
-
return await 한턴(방, 인자?.prompt);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
case 'session/cancel': {
|
|
741
|
-
// 알림이다. 답하지 않는다 — 답하면 저쪽이 짝 없는 답을 받는다.
|
|
742
|
-
const 방 = 방들.get(String(인자?.sessionId ?? ''));
|
|
743
|
-
if (방?.턴 && !방.턴.signal.aborted) 방.턴.abort();
|
|
744
|
-
return undefined;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
case 'session/set_mode': {
|
|
748
|
-
const 방 = 방찾기(인자?.sessionId);
|
|
749
|
-
const 고른것 = 모드정리(String(인자?.modeId ?? ''));
|
|
750
|
-
방.session.work = 고른것;
|
|
751
|
-
방.session.routed = null;
|
|
752
|
-
로그(`작업 모드를 ${고른것} 로 바꿨습니다.`);
|
|
753
|
-
return {};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
default:
|
|
757
|
-
throw 모르는방법오류(방법);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/*
|
|
762
|
-
* deel 의 작업 모드를 에디터의 모드 고르개로 내보낸다.
|
|
763
|
-
*
|
|
764
|
-
* 이게 붙으면 Zed 의 모드 단추가 deel 의 '계획 / 코드 / 설계' 를 그대로
|
|
765
|
-
* 고르게 된다. 프로토콜에 이미 있는 자리에 우리 것을 얹는 것이라 저쪽은
|
|
766
|
-
* 한 줄도 안 고쳐도 된다.
|
|
767
|
-
*/
|
|
768
|
-
function 모드상태(방) {
|
|
769
|
-
return {
|
|
770
|
-
currentModeId: 방.session.work ?? 'auto',
|
|
771
|
-
availableModes: 모드순서.map((id) => {
|
|
772
|
-
const w = getWork(id);
|
|
773
|
-
return { id, name: `${w.glyph} ${w.name} (${w.en})`, description: w.hint ?? null };
|
|
774
|
-
}),
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// ── 관 열기 ───────────────────────────────────────────────────────────
|
|
779
|
-
const 먹이기 = 줄나누기((줄) => 관.받았다(줄));
|
|
780
|
-
process.stdin.on('data', 먹이기);
|
|
781
|
-
process.stdin.resume();
|
|
782
|
-
|
|
783
|
-
await new Promise((끝) => {
|
|
784
|
-
const 마무리 = () => {
|
|
785
|
-
관.닫기('에디터와의 관이 닫혔습니다');
|
|
786
|
-
끝();
|
|
787
|
-
};
|
|
788
|
-
process.stdin.on('end', 마무리);
|
|
789
|
-
process.stdin.on('close', 마무리);
|
|
790
|
-
process.stdin.on('error', 마무리);
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
/*
|
|
794
|
-
* 뒤에서 돌던 명령을 반드시 거둔다.
|
|
795
|
-
*
|
|
796
|
-
* 에디터를 닫으면 우리 프로세스는 죽는데, 우리가 띄운 dev 서버는 안 죽는다.
|
|
797
|
-
* 다음에 열었을 때 포트가 잡혀 있고, 그 원인은 어디에도 안 남는다.
|
|
798
|
-
*/
|
|
799
|
-
const 껐다 = 일감모두끝내기();
|
|
800
|
-
if (껐다) 로그(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`);
|
|
801
|
-
for (const 방 of 방들.values()) {
|
|
802
|
-
for (const s of 방.mcp ?? []) { try { s.닫기(); } catch { /* 이미 죽은 것 */ } }
|
|
803
|
-
}
|
|
804
|
-
if (!시작했나) 로그('initialize 를 못 받고 끝났습니다 — 이 명령은 에디터가 자식 프로세스로 띄우는 자리입니다.');
|
|
805
|
-
return 0;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// 판 번호는 version.js 한 곳에서만 읽는다. 여기서 또 읽으면 두 벌이 되고,
|
|
809
|
-
// 두 벌이 되면 언젠가 한쪽만 고쳐진다 — 그 파일이 존재하는 이유가 그것이다.
|
|
810
|
-
const 판번호 = () => VERSION;
|
|
1
|
+
// ACP 에이전트. 에디터 안에서 deel 을 쓰는 길이다.
|
|
2
|
+
//
|
|
3
|
+
// ── 무엇인가 ────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// ACP(Agent Client Protocol)는 에디터와 코딩 에이전트 사이의 말이다. 이걸
|
|
6
|
+
// 지키면 Zed·JetBrains·Neovim·Emacs 가 **자기 쪽을 안 고치고** deel 을 붙인다.
|
|
7
|
+
// 에디터가 deel 을 자식 프로세스로 띄우고, 표준입출력으로 JSON 줄을 주고받는다.
|
|
8
|
+
//
|
|
9
|
+
// ── 왜 이게 이 프로그램에 중요한가 ──────────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// 사내에 반입한 도구는 "터미널을 하나 더 띄우세요" 를 못 넘는다. 개발자는
|
|
12
|
+
// 하루 종일 IDE 안에 있고, 창을 옮겨 다녀야 하는 도구는 두 주쯤 뒤에 안 쓴다.
|
|
13
|
+
// 반입 심사를 통과한 판이 아무도 안 쓰는 판이 되는 것이 제일 아까운 결말이다.
|
|
14
|
+
//
|
|
15
|
+
// 그리고 이 프로토콜은 deel 이 이미 가진 것과 잘 맞는다 — 로컬 모델을 쓰므로
|
|
16
|
+
// 코드가 밖으로 안 나가는데, 그 성질은 IDE 안에서 쓸 때 비로소 값이 붙는다.
|
|
17
|
+
//
|
|
18
|
+
// ── 딱 하나 지켜야 하는 것 ──────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// **표준출력에는 ACP 메시지 말고 아무것도 나가면 안 된다.**
|
|
21
|
+
//
|
|
22
|
+
// 규격이 그렇게 못 박아 두었고, 어기면 조용히 안 깨진다 — 에디터가 그 줄을
|
|
23
|
+
// 파싱하다 실패하고, 화면에는 "에이전트가 응답하지 않습니다" 만 뜬다. 원인이
|
|
24
|
+
// 어디에도 안 남는다. deel 안에는 say() 로 화면에 적는 자리가 수십 군데다.
|
|
25
|
+
// 그 중 하나라도 이 모드에서 불리면 관이 깨진다.
|
|
26
|
+
//
|
|
27
|
+
// 그래서 여기서 process.stdout.write 를 통째로 바꿔 끼운다. 부르는 자리를
|
|
28
|
+
// 하나하나 찾아 막는 방법도 있지만, 그건 앞으로 새로 쓰는 코드까지 계속
|
|
29
|
+
// 조심해야 한다는 뜻이다 — 언젠가 반드시 한 군데를 빠뜨린다.
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { VERSION } from '../version.js';
|
|
33
|
+
import { 규칙모으기, 늘허락 } from '../safety/policy.js';
|
|
34
|
+
import { run } from '../agent/loop.js';
|
|
35
|
+
import { Session, repairToolPairs } from '../agent/session.js';
|
|
36
|
+
import { Store, sessionsDir, prune } from '../agent/store.js';
|
|
37
|
+
import { makeScope } from '../safety/guard.js';
|
|
38
|
+
import { History } from '../safety/undo.js';
|
|
39
|
+
import { Audit, 열쇠묻기 } from '../safety/audit.js';
|
|
40
|
+
import { activeProfile, load, resolveKey, homeDir, save as saveCfg } from '../config.js';
|
|
41
|
+
import { 말 as 옮긴말 } from '../i18n/index.js';
|
|
42
|
+
import { 알림채움 } from '../backend/retry.js';
|
|
43
|
+
import { 전선붙이기, 세션이름짓기 } from '../backend/wire.js';
|
|
44
|
+
import { discover } from '../skills/discover.js';
|
|
45
|
+
import { allowEndpoint, setOffline } from '../safety/network.js';
|
|
46
|
+
import { 지금모드, 바깥인가, 나갈수있나 } from '../safety/runmode.js';
|
|
47
|
+
import { 주소가리기 } from '../safety/secrets.js';
|
|
48
|
+
import { probeCtx, 기본값 as CTX_DEFAULT } from '../backend/ctxsize.js';
|
|
49
|
+
import { 다붙이기 } from '../backend/mcp.js';
|
|
50
|
+
import { 배움 } from '../agent/evolve.js';
|
|
51
|
+
import { 카드 } from '../agent/card.js';
|
|
52
|
+
import { 못박기 } from '../agent/pins.js';
|
|
53
|
+
import { route } from '../agent/route.js';
|
|
54
|
+
import { ORDER as 모드순서, get as getWork, normalize as 모드정리 } from '../agent/modes.js';
|
|
55
|
+
import { 모두끝내기 as 일감모두끝내기 } from '../tools/jobs.js';
|
|
56
|
+
import { 연결, 줄나누기, 모르는방법오류, 잘못된인자오류 } from './jsonrpc.js';
|
|
57
|
+
import { 도구시작, 도구끝남, 도구이름표, 도구갈래, 도구자리, 멈춘까닭, 프롬프트글, 되살린것 } from './map.js';
|
|
58
|
+
|
|
59
|
+
/** 우리가 말하는 규격 판. 정수 하나이고, 깨지는 변경에서만 올라간다. */
|
|
60
|
+
export const 규격판 = 1;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 이 모드에서 표준출력을 잠근다.
|
|
64
|
+
*
|
|
65
|
+
* 잠그고 나면 say() 로 적힌 것은 전부 표준오류로 간다. 규격이 에이전트의
|
|
66
|
+
* 표준오류에는 무엇을 적어도 좋다고 허락하므로, 버리지 않고 그리로 돌린다 —
|
|
67
|
+
* 무언가 잘못됐을 때 그 글이 유일한 단서다.
|
|
68
|
+
*
|
|
69
|
+
* @returns {(줄: string) => void} 진짜 표준출력으로 쓰는 함수. ACP 만 이걸 쓴다
|
|
70
|
+
*/
|
|
71
|
+
export function 표준출력잠그기() {
|
|
72
|
+
const 진짜 = process.stdout.write.bind(process.stdout);
|
|
73
|
+
process.stdout.write = function (덩이, enc, cb) {
|
|
74
|
+
return process.stderr.write(덩이, enc, cb);
|
|
75
|
+
};
|
|
76
|
+
return (줄) => 진짜(줄);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* ACP 에이전트를 띄운다. 표준입력이 닫힐 때까지 산다.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} opts root/mode/work/think/effort/ctx/offline — 대화 시작 옵션과 같은 뜻
|
|
83
|
+
* @returns {Promise<number>} 종료코드
|
|
84
|
+
*/
|
|
85
|
+
export async function acp(opts = {}) {
|
|
86
|
+
const 내보내기 = 표준출력잠그기();
|
|
87
|
+
const 로그 = (s) => { try { process.stderr.write(`[acp] ${s}\n`); } catch { /* 여기서 또 터지면 할 게 없다 */ } };
|
|
88
|
+
|
|
89
|
+
/** 세션 하나. 에디터의 탭 하나에 해당한다. */
|
|
90
|
+
const 방들 = new Map();
|
|
91
|
+
let 클라이언트 = null; // initialize 로 받은 저쪽 소개
|
|
92
|
+
let 시작했나 = false;
|
|
93
|
+
|
|
94
|
+
const 관 = new 연결({
|
|
95
|
+
보내기: 내보내기,
|
|
96
|
+
다루기: (방법, 인자) => 다루기(방법, 인자),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── 방 만들기 ─────────────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} 요청 session/new 또는 session/load 로 온 인자
|
|
102
|
+
* @param {object} o
|
|
103
|
+
* @param {string|null} o.아이디 이어할 대화 이름. 주면 그 파일에 이어 쓴다
|
|
104
|
+
*/
|
|
105
|
+
async function 방만들기(요청, { 아이디 = null } = {}) {
|
|
106
|
+
const cfg = load();
|
|
107
|
+
const prof = activeProfile(cfg);
|
|
108
|
+
if (!prof) {
|
|
109
|
+
throw 잘못된인자오류('저장된 연결이 없습니다. 터미널에서 `deel setup` 을 먼저 실행하세요.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
* 작업 폴더는 에디터가 정한다.
|
|
114
|
+
*
|
|
115
|
+
* 규격은 cwd 를 절대 경로로 주라고 한다. 그 말을 믿고 그대로 쓴다 —
|
|
116
|
+
* 여기서 process.cwd() 로 대신하면 에디터가 연 프로젝트가 아니라 에디터를
|
|
117
|
+
* 띄운 자리를 기준으로 파일을 찾게 된다. 그러면 도구는 멀쩡히 도는데
|
|
118
|
+
* 엉뚱한 폴더를 고친다. 그게 제일 무서운 종류의 실수다.
|
|
119
|
+
*/
|
|
120
|
+
const root = typeof 요청?.cwd === 'string' && 요청.cwd ? 요청.cwd : (opts.root ?? process.cwd());
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* 대화는 폴더 안(.deel/sessions/)에 남는다 — 터미널에서 하던 것과 같은 자리다.
|
|
124
|
+
*
|
|
125
|
+
* 세션 이름을 그 파일 이름으로 삼는 데는 까닭이 있다. ACP 의 sessionId 는
|
|
126
|
+
* 에디터가 적어 뒀다가 **다음에 켤 때 session/load 로 도로 들고 오는** 이름이다.
|
|
127
|
+
* 'deel-1' 처럼 이 프로세스 안에서만 뜻이 있는 번호를 주면, 껐다 켠 순간 그
|
|
128
|
+
* 이름이 가리키는 것이 아무 데도 없다. 그러면 loadSession 은 못 지킬 약속이 된다.
|
|
129
|
+
*/
|
|
130
|
+
if (아이디 && !existsSync(join(sessionsDir(root), `${아이디}.jsonl`))) {
|
|
131
|
+
throw 잘못된인자오류(`그런 대화가 없습니다: ${아이디} (${root})`);
|
|
132
|
+
}
|
|
133
|
+
const store = new Store(root, 아이디);
|
|
134
|
+
|
|
135
|
+
const conn = {
|
|
136
|
+
kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
|
|
137
|
+
key: resolveKey(prof), model: prof.model,
|
|
138
|
+
ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
|
|
139
|
+
maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
|
|
140
|
+
streaming: prof.streaming ?? false,
|
|
141
|
+
tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
|
|
142
|
+
};
|
|
143
|
+
/*
|
|
144
|
+
* 바깥으로 나가는 연결은 허가가 있어야 연다 (safety/runmode.js).
|
|
145
|
+
*
|
|
146
|
+
* 에디터에는 승인 창이 있지만 그건 **도구 실행**을 묻는 창이고, 「이 대화가
|
|
147
|
+
* 통째로 바깥으로 나갑니다」 를 물을 자리는 규격에 없다. 그래서 여기서는
|
|
148
|
+
* 묻지 않고 또렷하게 거절한다 — 에디터 화면에 이 글이 그대로 뜬다.
|
|
149
|
+
*/
|
|
150
|
+
const 바깥연결 = 바깥인가(conn.base);
|
|
151
|
+
const 실행모드 = 지금모드({
|
|
152
|
+
online: opts.online === true,
|
|
153
|
+
// 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
|
|
154
|
+
offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
|
|
155
|
+
});
|
|
156
|
+
const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
|
|
157
|
+
if (나감.물어볼까) {
|
|
158
|
+
const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
|
|
159
|
+
throw 잘못된인자오류(
|
|
160
|
+
`이 연결은 이 컴퓨터 밖으로 나갑니다 (${주소가리기(어디)}).`
|
|
161
|
+
+ ' 터미널에서 deel 을 한 번 켜서 허락하거나, deel acp --online 으로 띄우세요.');
|
|
162
|
+
}
|
|
163
|
+
allowEndpoint(conn.base);
|
|
164
|
+
if (실행모드.허가무시) setOffline(true);
|
|
165
|
+
|
|
166
|
+
const session = new Session(conn, {
|
|
167
|
+
root,
|
|
168
|
+
// 에디터에는 승인 창이 있다. 그러니 대화 화면과 같은 기본값을 쓴다 —
|
|
169
|
+
// 비대화 모드처럼 무조건 거부할 이유가 없다. 물어볼 데가 있기 때문이다.
|
|
170
|
+
mode: opts.mode ?? 'auto',
|
|
171
|
+
work: opts.work ?? null,
|
|
172
|
+
think: opts.think ?? 'medium',
|
|
173
|
+
effort: opts.effort ?? 'save',
|
|
174
|
+
});
|
|
175
|
+
// 지금 어느 실행 모드인가. 화면이 첫 줄에 이걸 그린다(ui/status.js).
|
|
176
|
+
// session 에 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
|
|
177
|
+
session.실행모드 = 실행모드;
|
|
178
|
+
|
|
179
|
+
const found = discover(root);
|
|
180
|
+
session.skills = found.skills;
|
|
181
|
+
session.commands = found.commands;
|
|
182
|
+
session.plugins = found.plugins;
|
|
183
|
+
session.못박은것 = new 못박기();
|
|
184
|
+
|
|
185
|
+
/*
|
|
186
|
+
* 이어하기라면 적힌 것을 도로 채워 넣는다.
|
|
187
|
+
*
|
|
188
|
+
* repairToolPairs 를 반드시 거친다. 도구가 도는 중에 프로그램이 끊겼으면
|
|
189
|
+
* 부름만 적히고 답이 없는데, 그대로 보내면 규격 서버가 400 을 준다 —
|
|
190
|
+
* 되살리자마자 첫 마디에서 죽는다. 터미널의 --resume 과 같은 손질이다.
|
|
191
|
+
*/
|
|
192
|
+
if (아이디) {
|
|
193
|
+
const { messages: 적힌것 } = store.load();
|
|
194
|
+
const { messages, 고친것 } = repairToolPairs(적힌것);
|
|
195
|
+
session.messages = messages;
|
|
196
|
+
// 못 박아 둔 것도 같이 되살린다 (agent/pins.js). 이걸 빠뜨리면 '접어도
|
|
197
|
+
// 안 지워진다' 가 에디터를 닫았다 여는 한 번에 거짓이 된다.
|
|
198
|
+
const 박힌것 = store.못박은것읽기();
|
|
199
|
+
if (박힌것.length) session.못박은것 = new 못박기(박힌것);
|
|
200
|
+
로그(`${store.id} — 메시지 ${messages.length}개를 이어 받았습니다${고친것 ? ` (끊긴 도구 호출 ${고친것}개는 걷어냈습니다)` : ''}.`);
|
|
201
|
+
}
|
|
202
|
+
// 주소는 가려서 적는다. 열쇠가 주소에 박혀 오는 게이트웨이가 있어서,
|
|
203
|
+
// 그대로 적으면 대화 기록 파일에 열쇠가 남는다 (safety/secrets.js).
|
|
204
|
+
store.begin({ model: conn.model, base: 주소가리기(conn.base), root });
|
|
205
|
+
try { prune(root); } catch { /* 정리는 못 해도 대화는 된다 */ }
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* 밖에서 붙인 도구(MCP).
|
|
209
|
+
*
|
|
210
|
+
* 규격에는 클라이언트가 mcpServers 를 넘겨 주는 자리가 있다. 그런데 그걸
|
|
211
|
+
* 그대로 띄우면 **에디터 설정에 적힌 프로세스를 deel 이 대신 띄우는** 셈이
|
|
212
|
+
* 된다. 사내 반입 심사에서 "이 도구가 무엇을 띄우는가" 는 제일 먼저 묻는
|
|
213
|
+
* 것이라, 그 답이 '에디터가 시키는 대로' 가 되면 안 된다.
|
|
214
|
+
*
|
|
215
|
+
* 그래서 deel 은 늘 제 폴더의 .deel/mcp.json 만 본다. 사람이 직접 적은 것만
|
|
216
|
+
* 띄운다는 규칙이 대화 화면과 여기서 똑같이 유지된다.
|
|
217
|
+
*/
|
|
218
|
+
const mcp붙임 = await 다붙이기(root, {
|
|
219
|
+
offline: !!(opts.offline ?? prof.offline),
|
|
220
|
+
audit: null,
|
|
221
|
+
});
|
|
222
|
+
session.mcp = mcp붙임.서버들;
|
|
223
|
+
if (Array.isArray(요청?.mcpServers) && 요청.mcpServers.length) {
|
|
224
|
+
로그(`에디터가 MCP 서버 ${요청.mcpServers.length}대를 넘겼지만 띄우지 않습니다 — deel 은 .deel/mcp.json 에 적힌 것만 띄웁니다.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const 방 = {
|
|
228
|
+
id: store.id,
|
|
229
|
+
root, conn, session, store,
|
|
230
|
+
턴: null,
|
|
231
|
+
늘허락: new Set(), // 이 세션에서 "앞으로 묻지 않기" 를 고른 도구들
|
|
232
|
+
mcp: mcp붙임.서버들,
|
|
233
|
+
도구번호: 0,
|
|
234
|
+
메시지번호: 0,
|
|
235
|
+
돌던도구: new Map(), // 도구 이름 → 아직 안 끝난 호출 아이디들
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
방.ctx = {
|
|
239
|
+
scope: makeScope(root),
|
|
240
|
+
get 모델컨텍스트() { return conn.ctx ?? null; },
|
|
241
|
+
// 이 모델이 그림을 볼 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
|
|
242
|
+
get 눈있나() { return !!conn.vision; },
|
|
243
|
+
// 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
|
|
244
|
+
규칙들: 규칙모으기(cfg),
|
|
245
|
+
history: new History(root),
|
|
246
|
+
audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
|
|
247
|
+
seen: new Set(),
|
|
248
|
+
mcp: mcp붙임.서버들,
|
|
249
|
+
skills: found.skills,
|
|
250
|
+
loadedSkills: new Set(),
|
|
251
|
+
/*
|
|
252
|
+
* 되묻는 자리.
|
|
253
|
+
*
|
|
254
|
+
* ACP 1판에는 '아무거나 물어보기' 가 없다. 승인은 있어도 자유 질문은
|
|
255
|
+
* 없다. 그러니 기다리면 안 된다 — 기다리면 에디터는 아무 창도 안 띄우고
|
|
256
|
+
* deel 은 영영 서 있는다. 기본값을 바로 돌려준다.
|
|
257
|
+
*/
|
|
258
|
+
ask: async (_라벨, o = {}) => o?.def ?? '',
|
|
259
|
+
askPassword: async () => null,
|
|
260
|
+
confirm: (이름, 인자) => 승인묻기(방, 이름, 인자),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
방.ctx.배움 = new 배움(root, homeDir());
|
|
264
|
+
/*
|
|
265
|
+
* 전선 카드와 이 대화의 이름 (backend/wire.js).
|
|
266
|
+
*
|
|
267
|
+
* 에디터에서 붙어도 나가는 전선은 같다. 여기만 빼 두면 Zed 로 쓰는
|
|
268
|
+
* 사람만 캐시가 안 걸리고, 그건 화면 어디에도 안 나타난다.
|
|
269
|
+
*/
|
|
270
|
+
전선붙이기(conn, 방.ctx.배움);
|
|
271
|
+
session.세션이름 = 세션이름짓기(store.id);
|
|
272
|
+
conn.세션이름 = session.세션이름;
|
|
273
|
+
session.배움요약 = 방.ctx.배움.요약(conn.model);
|
|
274
|
+
const 아는배수 = 방.ctx.배움.아는보정(conn.model);
|
|
275
|
+
if (아는배수) { session.보정 = 아는배수; session.보정잰것 = 1; }
|
|
276
|
+
|
|
277
|
+
방.ctx.카드다시 = () => {
|
|
278
|
+
방.ctx.카드 = 카드(conn.model, session.본것, 방.ctx.배움?.현황(conn.model)?.모델);
|
|
279
|
+
return 방.ctx.카드;
|
|
280
|
+
};
|
|
281
|
+
방.ctx.카드다시();
|
|
282
|
+
|
|
283
|
+
// 컨텍스트 길이는 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
|
|
284
|
+
// 못 물어봐도 여기서 멈출 일은 아니다.
|
|
285
|
+
if (opts.ctx == null) {
|
|
286
|
+
try {
|
|
287
|
+
const r = await probeCtx(conn, { timeout: 6000 });
|
|
288
|
+
if (r?.value) conn.ctx = r.value;
|
|
289
|
+
} catch { /* 설정값 그대로 간다 */ }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
방들.set(방.id, 방);
|
|
293
|
+
return 방;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function 방찾기(id) {
|
|
297
|
+
const 방 = 방들.get(String(id ?? ''));
|
|
298
|
+
if (!방) throw 잘못된인자오류(`그런 세션이 없습니다: ${id}`);
|
|
299
|
+
return 방;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── 승인 ──────────────────────────────────────────────────────────────
|
|
303
|
+
//
|
|
304
|
+
// deel 의 안전장치를 에디터의 승인 창으로 그대로 내보낸다. 이게 붙는 것과
|
|
305
|
+
// 안 붙는 것의 차이가 크다 — 안 붙으면 위험한 명령을 물어볼 데가 없어서
|
|
306
|
+
// 무조건 거부하게 되고, 그러면 에디터 안에서는 아무 일도 못 하는 도구가 된다.
|
|
307
|
+
async function 승인묻기(방, 이름, 인자) {
|
|
308
|
+
if (방.늘허락.has(이름)) return true;
|
|
309
|
+
|
|
310
|
+
const 아이디 = `t${++방.도구번호}`;
|
|
311
|
+
try {
|
|
312
|
+
const 답 = await 관.요청('session/request_permission', {
|
|
313
|
+
sessionId: 방.id,
|
|
314
|
+
toolCall: {
|
|
315
|
+
toolCallId: 아이디,
|
|
316
|
+
title: 도구이름표(이름, 인자),
|
|
317
|
+
kind: 도구갈래(이름),
|
|
318
|
+
status: 'pending',
|
|
319
|
+
locations: 도구자리(이름, 인자, null),
|
|
320
|
+
rawInput: 인자 ?? {},
|
|
321
|
+
},
|
|
322
|
+
options: [
|
|
323
|
+
{ optionId: 'allow_once', name: '이번만 실행', kind: 'allow_once' },
|
|
324
|
+
{ optionId: 'allow_always', name: `${이름} 은 앞으로 묻지 않기`, kind: 'allow_always' },
|
|
325
|
+
{ optionId: 'reject_once', name: '하지 않기', kind: 'reject_once' },
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const 결과 = 답?.outcome ?? {};
|
|
330
|
+
if (결과.outcome !== 'selected') return false; // cancelled 도 여기로 온다
|
|
331
|
+
if (결과.optionId === 'allow_always') {
|
|
332
|
+
방.늘허락.add(이름);
|
|
333
|
+
/*
|
|
334
|
+
* '앞으로 묻지 않기' 는 **다음에 켤 때도** 안 물어야 한다.
|
|
335
|
+
*
|
|
336
|
+
* 이 켠 동안만 기억하면 사람은 편집기를 닫았다 열 때마다 같은 것을
|
|
337
|
+
* 다시 허락한다. 그러면 그 버튼은 '앞으로' 가 아니라 '이번 켠 동안' 이다.
|
|
338
|
+
* 설정에 적어 두고, 다음부터는 규칙이 먼저 통과시킨다 (safety/policy.js).
|
|
339
|
+
*
|
|
340
|
+
* 금지에 걸리는 것은 안 적는다 — 적어 봐야 금지가 이기는데 목록에는
|
|
341
|
+
* 허락으로 보여서, 사람이 풀린 줄 안다.
|
|
342
|
+
*/
|
|
343
|
+
try {
|
|
344
|
+
const cfg = load();
|
|
345
|
+
const r = 늘허락(cfg, 이름, 규칙모으기(cfg));
|
|
346
|
+
if (r.ok) saveCfg(cfg);
|
|
347
|
+
} catch { /* 못 적어도 이번 켠 동안은 먹는다 */ }
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return 결과.optionId === 'allow_once';
|
|
351
|
+
} catch (err) {
|
|
352
|
+
/*
|
|
353
|
+
* 못 물어봤으면 안 한다.
|
|
354
|
+
*
|
|
355
|
+
* 여기서 true 를 돌려주고 싶은 유혹이 있다 — 안 그러면 승인 창을 아직
|
|
356
|
+
* 안 만든 클라이언트에서 아무것도 안 돌아가니까. 그런데 그건 "물어볼 수
|
|
357
|
+
* 없으면 마음대로 한다" 는 뜻이다. 사람이 안 보는 자리에서 되돌릴 수 없는
|
|
358
|
+
* 명령이 도는 것이 이 프로그램이 제일 피하려는 일이다.
|
|
359
|
+
*/
|
|
360
|
+
로그(`승인을 못 물어봐서 거부했습니다 (${이름}) — ${err?.message ?? err}`);
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── 한 턴 ─────────────────────────────────────────────────────────────
|
|
366
|
+
async function 한턴(방, 덩이들) {
|
|
367
|
+
const 글 = 프롬프트글(덩이들);
|
|
368
|
+
if (!글) throw 잘못된인자오류('보낸 말이 비었습니다.');
|
|
369
|
+
|
|
370
|
+
// 앞 턴이 아직 돌고 있으면 끊고 시작한다. 규격은 턴을 겹쳐 보내지 말라고
|
|
371
|
+
// 하지만, 안 지키는 클라이언트가 있을 때 두 턴이 같은 세션을 같이 밟으면
|
|
372
|
+
// 오간 말이 뒤엉킨다. 그건 나중에 원인을 찾을 수 없는 종류의 고장이다.
|
|
373
|
+
if (방.턴 && !방.턴.signal.aborted) 방.턴.abort();
|
|
374
|
+
|
|
375
|
+
const 턴 = new AbortController();
|
|
376
|
+
방.턴 = 턴;
|
|
377
|
+
방.ctx.카드다시();
|
|
378
|
+
|
|
379
|
+
const 보내기 = (update) => 관.알림('session/update', { sessionId: 방.id, update });
|
|
380
|
+
const 말하기 = (글, 갈래 = 'agent_message_chunk') => 보내기({
|
|
381
|
+
sessionUpdate: 갈래,
|
|
382
|
+
content: { type: 'text', text: 글 },
|
|
383
|
+
messageId: `m${방.메시지번호}`,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// 종합 모드면 이 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
|
|
387
|
+
방.session.routed = null;
|
|
388
|
+
if (방.session.work === 'auto') {
|
|
389
|
+
const 골라진 = route(글);
|
|
390
|
+
if (골라진.mode) 방.session.routed = 골라진.mode;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let 까닭 = 'done';
|
|
394
|
+
let 왜 = '';
|
|
395
|
+
방.메시지번호++;
|
|
396
|
+
|
|
397
|
+
/*
|
|
398
|
+
* 어디까지 적었나. 자주 흘려 보낸다.
|
|
399
|
+
*
|
|
400
|
+
* 턴이 다 끝나고 한 번에 적으면, 도구를 열 번 돌린 뒤에 에디터가 닫힌 순간
|
|
401
|
+
* 그 열 번이 통째로 사라진다. 되살릴 때 제일 아쉬운 것이 바로 그 부분이라
|
|
402
|
+
* (무엇을 이미 확인했는지) 걸음마다 적는다.
|
|
403
|
+
*/
|
|
404
|
+
let 적은데까지 = 방.session.messages.length;
|
|
405
|
+
const 적기 = () => {
|
|
406
|
+
for (const m of 방.session.messages.slice(적은데까지)) 방.store.append(m);
|
|
407
|
+
적은데까지 = 방.session.messages.length;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
// 이번 걸음에 글이 흘러 나갔나. 거절 자리에서 같은 글을 두 벌 싣지 않으려고 센다.
|
|
412
|
+
let 흘렸나 = false;
|
|
413
|
+
for await (const ev of run(방.session, 방.ctx, 글, { signal: 턴.signal })) {
|
|
414
|
+
switch (ev.type) {
|
|
415
|
+
case 'stage':
|
|
416
|
+
방.메시지번호++;
|
|
417
|
+
흘렸나 = false;
|
|
418
|
+
break;
|
|
419
|
+
|
|
420
|
+
case 'thinking':
|
|
421
|
+
if (ev.text) 말하기(ev.text, 'agent_thought_chunk');
|
|
422
|
+
break;
|
|
423
|
+
|
|
424
|
+
case 'content':
|
|
425
|
+
if (ev.text) { 말하기(ev.text); 흘렸나 = true; }
|
|
426
|
+
break;
|
|
427
|
+
|
|
428
|
+
/*
|
|
429
|
+
* 다시 부르는 자리.
|
|
430
|
+
*
|
|
431
|
+
* 답이 상한에서 잘리면 루프가 상한을 올려 처음부터 다시 부른다.
|
|
432
|
+
* 그러면 방금 흘려보낸 글이 통째로 다시 온다. 아무 말 없이 두 번
|
|
433
|
+
* 보내면 에디터에는 같은 답이 두 벌 붙어 보인다 — 모델이 헛소리를
|
|
434
|
+
* 하는 것처럼 보이지만 사실은 우리가 안 알려 준 탓이다.
|
|
435
|
+
*/
|
|
436
|
+
case 'retry':
|
|
437
|
+
방.메시지번호++;
|
|
438
|
+
말하기(`\n\n_(${ev.why} — 다시 답을 받습니다)_\n\n`);
|
|
439
|
+
break;
|
|
440
|
+
|
|
441
|
+
// 답이 천장에서 잘렸고 더 못 올린다. 조용히 끝내면 에디터에는 중간에서
|
|
442
|
+
// 끊긴 답만 남아서, 사람은 모델이 대충 답한 줄 안다.
|
|
443
|
+
//
|
|
444
|
+
// 한국어를 박아 두고 있었다 — `/lang en` 으로 켠 사람이 이 자리에서만
|
|
445
|
+
// 한글을 본다. 말 표를 거친다.
|
|
446
|
+
case 'capped':
|
|
447
|
+
말하기(`\n\n_(${옮긴말('ev.capped', { 한계: ev.cap.toLocaleString() })})_\n\n`);
|
|
448
|
+
break;
|
|
449
|
+
|
|
450
|
+
/*
|
|
451
|
+
* ── 여기 셋은 에디터에 아무 말도 안 가고 있었다 ──────────────
|
|
452
|
+
*
|
|
453
|
+
* 특히 cutoff 가 나쁘다. 서버가 끝났다는 말도 없이 멈춘 반쪽 답이
|
|
454
|
+
* 에디터에는 **온전한 답**으로 뜬다. 사람은 그걸 읽고 다음 일로
|
|
455
|
+
* 넘어가는데, 정작 답의 뒷부분은 오지도 않았다.
|
|
456
|
+
*/
|
|
457
|
+
case 'cutoff':
|
|
458
|
+
말하기(`\n\n_(${옮긴말('ev.cutoff')})_\n\n`);
|
|
459
|
+
break;
|
|
460
|
+
|
|
461
|
+
case 'nudge':
|
|
462
|
+
말하기(`\n\n_(${ev.why === '요청누락'
|
|
463
|
+
? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
|
|
464
|
+
: 옮긴말('ev.nudgeRead')})_\n\n`);
|
|
465
|
+
break;
|
|
466
|
+
|
|
467
|
+
// 모델이 안 하겠다고 했다. 에디터 쪽에도 그렇게 적어 둔다 —
|
|
468
|
+
// 안 적으면 반쪽 답과 구별이 안 되고, 사람은 같은 말을 또 친다.
|
|
469
|
+
case 'refusal':
|
|
470
|
+
/*
|
|
471
|
+
* 흘러 나온 것이 없으면 거절 글을 여기서 싣는다.
|
|
472
|
+
*
|
|
473
|
+
* OpenAI 꼴은 거절 글 조각을 답 자리로 안 흘려보내고 맨 끝에 한
|
|
474
|
+
* 번에 모아 준다(backend/adapter.js 의 흡수). 그래서 이 자리가
|
|
475
|
+
* 없으면 에디터에는
|
|
476
|
+
* 「거절당했습니다」 한 줄만 남고, 무엇을 고쳐 다시 물어야 할지
|
|
477
|
+
* 알 길이 없다. 이미 흘러 나온 창구에서는 두 벌이 되니 안 싣는다.
|
|
478
|
+
*/
|
|
479
|
+
if (!흘렸나 && ev.text) 말하기(String(ev.text));
|
|
480
|
+
말하기(`\n\n_(${옮긴말('run.refusal')}${
|
|
481
|
+
ev.왜 && ev.왜 !== 'refusal' ? ` · ${옮긴말('run.refusalWhy', { 왜: ev.왜 })}` : ''})_\n\n`);
|
|
482
|
+
break;
|
|
483
|
+
|
|
484
|
+
case 'note':
|
|
485
|
+
말하기(`\n\n_(${ev.text})_\n\n`);
|
|
486
|
+
break;
|
|
487
|
+
|
|
488
|
+
case 'learned':
|
|
489
|
+
말하기(`\n\n_(${ev.what === 'ctx'
|
|
490
|
+
? 옮긴말('ev.learnedCtx', { 한계: ev.limit.toLocaleString() })
|
|
491
|
+
: 옮긴말('ev.learnedOut', { 한계: ev.limit.toLocaleString() })})_\n\n`);
|
|
492
|
+
break;
|
|
493
|
+
|
|
494
|
+
// 서버가 잠깐 막아 기다리는 중. 아직 흘러간 글이 없으니 답을 새로 시작하지는 않는다.
|
|
495
|
+
case 'backoff':
|
|
496
|
+
말하기(`\n\n_(${ev.미리
|
|
497
|
+
? 옮긴말('loop.quotaAhead', { 초: 알림채움(ev).초 })
|
|
498
|
+
: 옮긴말('loop.backoff', 알림채움(ev))})_\n\n`);
|
|
499
|
+
break;
|
|
500
|
+
|
|
501
|
+
// 서버가 안 받는 칸이 있어 전선 카드를 고쳤다 (backend/wire.js).
|
|
502
|
+
case 'wire':
|
|
503
|
+
말하기(`\n\n_(${옮긴말('loop.wire', { 무엇: ev.무엇 })})_\n\n`);
|
|
504
|
+
break;
|
|
505
|
+
|
|
506
|
+
case 'tool_start':
|
|
507
|
+
보내기(도구시작(도구맡기기(방, ev.name), ev.name, ev.args));
|
|
508
|
+
break;
|
|
509
|
+
|
|
510
|
+
case 'tools_start':
|
|
511
|
+
for (const 이름 of ev.names ?? []) {
|
|
512
|
+
보내기(도구시작(도구맡기기(방, 이름), 이름, null));
|
|
513
|
+
}
|
|
514
|
+
break;
|
|
515
|
+
|
|
516
|
+
case 'tool':
|
|
517
|
+
보내기(도구끝남(도구찾기(방, ev.name), ev));
|
|
518
|
+
적기();
|
|
519
|
+
break;
|
|
520
|
+
|
|
521
|
+
/*
|
|
522
|
+
* 접히면 이력이 통째로 바뀐다.
|
|
523
|
+
*
|
|
524
|
+
* 덧붙이기로는 못 맞춘다 — 앞의 것 여러 개가 요약 하나로 바뀌었으니,
|
|
525
|
+
* 이어 붙이면 파일에는 접히기 전과 접힌 뒤가 겹쳐 남는다. 새로 적는다.
|
|
526
|
+
*/
|
|
527
|
+
/*
|
|
528
|
+
* 자리가 다 차서 비웠다. 에디터에는 오류가 아니라 알림으로 간다 —
|
|
529
|
+
* 붉게 보내면 편집기가 턴이 끝난 줄 알고 되물음을 닫아 버린다.
|
|
530
|
+
*/
|
|
531
|
+
case 'reset':
|
|
532
|
+
말하기(`\n\n_(${(ev.kept?.할일 ?? 0)
|
|
533
|
+
? 옮긴말('ev.reset', { 버린수: ev.dropped, 할일: ev.kept.할일 })
|
|
534
|
+
: 옮긴말('ev.resetBare', { 버린수: ev.dropped })})_\n\n`);
|
|
535
|
+
방.store.replace(방.session.messages, `자리부족 — 앞선 대화 ${ev.dropped}개를 비움`);
|
|
536
|
+
적은데까지 = 방.session.messages.length;
|
|
537
|
+
break;
|
|
538
|
+
case 'compacted':
|
|
539
|
+
방.store.replace(방.session.messages, `압축 — ${ev.folded}개를 요약으로`);
|
|
540
|
+
적은데까지 = 방.session.messages.length;
|
|
541
|
+
break;
|
|
542
|
+
|
|
543
|
+
/*
|
|
544
|
+
* 하위 작업.
|
|
545
|
+
*
|
|
546
|
+
* ACP 에는 '작업 안의 작업' 이 없다. 그래서 도구 호출 하나로 보이게
|
|
547
|
+
* 둔다 — 없는 척하면 하위가 만진 파일 넷이 어디서 나왔는지 화면만
|
|
548
|
+
* 보고는 알 수 없다.
|
|
549
|
+
*/
|
|
550
|
+
case 'task_start':
|
|
551
|
+
보내기({
|
|
552
|
+
sessionUpdate: 'tool_call',
|
|
553
|
+
toolCallId: 도구맡기기(방, 'Task'),
|
|
554
|
+
title: `하위 작업: ${ev.목적 ?? ''}`,
|
|
555
|
+
kind: 'think',
|
|
556
|
+
status: 'in_progress',
|
|
557
|
+
});
|
|
558
|
+
break;
|
|
559
|
+
|
|
560
|
+
case 'task_done':
|
|
561
|
+
보내기({
|
|
562
|
+
sessionUpdate: 'tool_call_update',
|
|
563
|
+
toolCallId: 도구찾기(방, 'Task'),
|
|
564
|
+
status: ev.끝?.type === 'done' ? 'completed' : 'failed',
|
|
565
|
+
content: [{
|
|
566
|
+
type: 'content',
|
|
567
|
+
content: {
|
|
568
|
+
type: 'text',
|
|
569
|
+
text: ev.끝?.type === 'done'
|
|
570
|
+
? `끝냄 · ${ev.끝?.steps ?? 0}걸음`
|
|
571
|
+
: `다 못 했습니다 (${ev.끝?.type}) · ${ev.끝?.steps ?? 0}걸음`,
|
|
572
|
+
},
|
|
573
|
+
}],
|
|
574
|
+
});
|
|
575
|
+
break;
|
|
576
|
+
|
|
577
|
+
case 'limit':
|
|
578
|
+
까닭 = 'limit';
|
|
579
|
+
왜 = `도구 호출 ${ev.steps}회에서 멈췄습니다. 한 번에 하기엔 큰 일입니다 — 나눠서 시키세요.`;
|
|
580
|
+
break;
|
|
581
|
+
|
|
582
|
+
case 'stuck':
|
|
583
|
+
까닭 = 'stuck';
|
|
584
|
+
왜 = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다.');
|
|
585
|
+
break;
|
|
586
|
+
|
|
587
|
+
case 'aborted':
|
|
588
|
+
까닭 = 'aborted';
|
|
589
|
+
break;
|
|
590
|
+
|
|
591
|
+
case 'error':
|
|
592
|
+
까닭 = 'error';
|
|
593
|
+
왜 = String(ev.text ?? '알 수 없는 오류');
|
|
594
|
+
break;
|
|
595
|
+
|
|
596
|
+
case 'done':
|
|
597
|
+
까닭 = 'done';
|
|
598
|
+
break;
|
|
599
|
+
|
|
600
|
+
default:
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} catch (err) {
|
|
605
|
+
까닭 = 'error';
|
|
606
|
+
왜 = String(err?.message ?? err);
|
|
607
|
+
} finally {
|
|
608
|
+
if (방.턴 === 턴) 방.턴 = null;
|
|
609
|
+
방.돌던도구.clear();
|
|
610
|
+
// 끊겼든 터졌든 여기까지 오간 것은 남긴다. 끊긴 턴이야말로 다음에
|
|
611
|
+
// 되살려서 이어가고 싶은 자리다.
|
|
612
|
+
try { 적기(); } catch { /* 못 적어도 답은 돌려줘야 한다 */ }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/*
|
|
616
|
+
* 규격에 없는 까닭은 말로 준다.
|
|
617
|
+
*
|
|
618
|
+
* '헛돌아서 멈췄다' 는 stopReason 다섯 낱말 중 어디에도 없다. 억지로
|
|
619
|
+
* refusal 에 밀어 넣으면 에디터가 이 대화를 버려야 하는 것으로 읽는다.
|
|
620
|
+
* 그러니 낱말은 end_turn 으로 두고, 왜 멈췄는지는 사람이 읽게 적어 준다.
|
|
621
|
+
*/
|
|
622
|
+
if (왜 && 까닭 !== 'aborted') {
|
|
623
|
+
방.메시지번호++;
|
|
624
|
+
말하기(`\n\n---\n**${까닭 === 'error' ? '오류' : '멈춤'}** — ${왜}\n`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return { stopReason: 멈춘까닭(까닭) };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/*
|
|
631
|
+
* 도구 호출 하나에 번호를 매기고, 끝날 때 그 번호를 도로 찾는다.
|
|
632
|
+
*
|
|
633
|
+
* loop.js 는 시작 이벤트에 번호를 안 붙인다 — 화면에 그릴 때는 필요 없었다.
|
|
634
|
+
* ACP 는 시작과 끝을 같은 번호로 이어야 하나로 그린다. 그래서 이름별로
|
|
635
|
+
* 줄을 세워 두고 먼저 시작한 것부터 짝을 짓는다. 같은 이름을 나란히 여러 개
|
|
636
|
+
* 부르는 경우(도구를 한꺼번에 부를 때)에도 순서가 어긋나지 않는다.
|
|
637
|
+
*/
|
|
638
|
+
function 도구맡기기(방, 이름) {
|
|
639
|
+
const 아이디 = `t${++방.도구번호}`;
|
|
640
|
+
const 줄 = 방.돌던도구.get(이름) ?? [];
|
|
641
|
+
줄.push(아이디);
|
|
642
|
+
방.돌던도구.set(이름, 줄);
|
|
643
|
+
return 아이디;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function 도구찾기(방, 이름) {
|
|
647
|
+
const 줄 = 방.돌던도구.get(이름);
|
|
648
|
+
if (줄?.length) return 줄.shift();
|
|
649
|
+
// 시작을 못 본 도구. 거부당한 호출처럼 시작 이벤트 없이 끝만 오는 자리가
|
|
650
|
+
// 있다. 새 번호를 준다 — 짝이 없다고 버리면 그 호출이 화면에서 사라진다.
|
|
651
|
+
return `t${++방.도구번호}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── 방법표 ────────────────────────────────────────────────────────────
|
|
655
|
+
async function 다루기(방법, 인자) {
|
|
656
|
+
switch (방법) {
|
|
657
|
+
case 'initialize': {
|
|
658
|
+
시작했나 = true;
|
|
659
|
+
클라이언트 = 인자?.clientInfo ?? null;
|
|
660
|
+
const 저쪽판 = Number(인자?.protocolVersion);
|
|
661
|
+
로그(`붙었습니다 — ${클라이언트?.name ?? '이름 없는 클라이언트'} (규격 ${Number.isFinite(저쪽판) ? 저쪽판 : '?'}판)`);
|
|
662
|
+
return {
|
|
663
|
+
// 저쪽이 우리보다 새 판을 말하면 우리 판을 답한다. 규격이 그렇게 정했다.
|
|
664
|
+
protocolVersion: Number.isFinite(저쪽판) && 저쪽판 < 규격판 ? 저쪽판 : 규격판,
|
|
665
|
+
agentCapabilities: {
|
|
666
|
+
// 지난 대화를 되살릴 수 있다. 이 값이 false 면 에디터는 아예 안
|
|
667
|
+
// 물어보고 빈 대화를 연다 — 붙여 놓고 안 켜면 없는 것과 같다.
|
|
668
|
+
loadSession: true,
|
|
669
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
|
670
|
+
mcpCapabilities: { http: false, sse: false },
|
|
671
|
+
},
|
|
672
|
+
agentInfo: { name: 'deel', title: 'deel (로컬 모델 코딩 에이전트)', version: 판번호() },
|
|
673
|
+
authMethods: [], // 연결 설정은 `deel setup` 이 맡는다
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
case 'authenticate':
|
|
678
|
+
// 인증 방법을 하나도 안 걸었으니 여기 올 일이 없다. 와도 조용히 넘긴다.
|
|
679
|
+
return {};
|
|
680
|
+
|
|
681
|
+
case 'session/new': {
|
|
682
|
+
const 방 = await 방만들기(인자);
|
|
683
|
+
return {
|
|
684
|
+
sessionId: 방.id,
|
|
685
|
+
modes: 모드상태(방),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/*
|
|
690
|
+
* 지난 대화 되살리기.
|
|
691
|
+
*
|
|
692
|
+
* ── 답에 대화를 실어 주는 자리가 없다 ────────────────────────────
|
|
693
|
+
*
|
|
694
|
+
* 규격은 오간 말을 **session/update 로 전부 다시 흘리라**고 한다. 답으로
|
|
695
|
+
* 한 번에 주는 자리가 아예 없다. 그래서 여기서 흘린 것이 에디터에 그려지는
|
|
696
|
+
* 지난 대화의 전부다.
|
|
697
|
+
*
|
|
698
|
+
* 흘리기 전에 방을 다 만들어야 한다. 반쯤 만들어 두고 흘리다가 설정이
|
|
699
|
+
* 없어서 터지면, 에디터에는 지난 대화가 절반만 그려진 채로 남는다.
|
|
700
|
+
*/
|
|
701
|
+
case 'session/load': {
|
|
702
|
+
const 아이디 = String(인자?.sessionId ?? '').trim();
|
|
703
|
+
if (!아이디) throw 잘못된인자오류('sessionId 가 없습니다.');
|
|
704
|
+
|
|
705
|
+
// 이 프로세스가 이미 열어 둔 방이면 그대로 쓴다. 다시 만들면 같은
|
|
706
|
+
// 대화를 두 곳에서 밟게 되고, 그때부터 어느 쪽이 참인지 알 수 없다.
|
|
707
|
+
const 방 = 방들.get(아이디) ?? await 방만들기(인자, { 아이디 });
|
|
708
|
+
|
|
709
|
+
/*
|
|
710
|
+
* 화면에 그릴 것과 모델에게 줄 것이 다르다.
|
|
711
|
+
*
|
|
712
|
+
* 모델에게는 손질한 것이 간다 — 답 없는 도구 부름이 섞이면 규격 서버가
|
|
713
|
+
* 400 을 준다. 그런데 **사람에게는 그 자리를 보여 줘야 한다.** 어제
|
|
714
|
+
* 무엇을 하다 끊겼는지가 오늘 무엇을 시킬지를 정하기 때문이다. 그래서
|
|
715
|
+
* 그리는 것은 적힌 그대로(손질 전)를 쓴다.
|
|
716
|
+
*
|
|
717
|
+
* 파일을 다시 읽는 까닭도 같다. 걸음마다 적어 두므로 파일이 늘 최신이고,
|
|
718
|
+
* 손질로 걷어낸 것까지 그대로 남아 있는 유일한 자리다.
|
|
719
|
+
*/
|
|
720
|
+
let 그릴것 = 방.session.messages;
|
|
721
|
+
try {
|
|
722
|
+
const 적힌것 = 방.store.load().messages;
|
|
723
|
+
if (적힌것.length) 그릴것 = 적힌것;
|
|
724
|
+
} catch { /* 못 읽으면 기억하고 있는 것으로 그린다 */ }
|
|
725
|
+
|
|
726
|
+
let 흘린것 = 0;
|
|
727
|
+
for (const update of 되살린것(그릴것)) {
|
|
728
|
+
관.알림('session/update', { sessionId: 방.id, update });
|
|
729
|
+
흘린것++;
|
|
730
|
+
}
|
|
731
|
+
로그(`${방.id} — 지난 대화 ${흘린것}덩이를 되살렸습니다.`);
|
|
732
|
+
return { modes: 모드상태(방) };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
case 'session/prompt': {
|
|
736
|
+
const 방 = 방찾기(인자?.sessionId);
|
|
737
|
+
return await 한턴(방, 인자?.prompt);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
case 'session/cancel': {
|
|
741
|
+
// 알림이다. 답하지 않는다 — 답하면 저쪽이 짝 없는 답을 받는다.
|
|
742
|
+
const 방 = 방들.get(String(인자?.sessionId ?? ''));
|
|
743
|
+
if (방?.턴 && !방.턴.signal.aborted) 방.턴.abort();
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
case 'session/set_mode': {
|
|
748
|
+
const 방 = 방찾기(인자?.sessionId);
|
|
749
|
+
const 고른것 = 모드정리(String(인자?.modeId ?? ''));
|
|
750
|
+
방.session.work = 고른것;
|
|
751
|
+
방.session.routed = null;
|
|
752
|
+
로그(`작업 모드를 ${고른것} 로 바꿨습니다.`);
|
|
753
|
+
return {};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
default:
|
|
757
|
+
throw 모르는방법오류(방법);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/*
|
|
762
|
+
* deel 의 작업 모드를 에디터의 모드 고르개로 내보낸다.
|
|
763
|
+
*
|
|
764
|
+
* 이게 붙으면 Zed 의 모드 단추가 deel 의 '계획 / 코드 / 설계' 를 그대로
|
|
765
|
+
* 고르게 된다. 프로토콜에 이미 있는 자리에 우리 것을 얹는 것이라 저쪽은
|
|
766
|
+
* 한 줄도 안 고쳐도 된다.
|
|
767
|
+
*/
|
|
768
|
+
function 모드상태(방) {
|
|
769
|
+
return {
|
|
770
|
+
currentModeId: 방.session.work ?? 'auto',
|
|
771
|
+
availableModes: 모드순서.map((id) => {
|
|
772
|
+
const w = getWork(id);
|
|
773
|
+
return { id, name: `${w.glyph} ${w.name} (${w.en})`, description: w.hint ?? null };
|
|
774
|
+
}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── 관 열기 ───────────────────────────────────────────────────────────
|
|
779
|
+
const 먹이기 = 줄나누기((줄) => 관.받았다(줄));
|
|
780
|
+
process.stdin.on('data', 먹이기);
|
|
781
|
+
process.stdin.resume();
|
|
782
|
+
|
|
783
|
+
await new Promise((끝) => {
|
|
784
|
+
const 마무리 = () => {
|
|
785
|
+
관.닫기('에디터와의 관이 닫혔습니다');
|
|
786
|
+
끝();
|
|
787
|
+
};
|
|
788
|
+
process.stdin.on('end', 마무리);
|
|
789
|
+
process.stdin.on('close', 마무리);
|
|
790
|
+
process.stdin.on('error', 마무리);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
/*
|
|
794
|
+
* 뒤에서 돌던 명령을 반드시 거둔다.
|
|
795
|
+
*
|
|
796
|
+
* 에디터를 닫으면 우리 프로세스는 죽는데, 우리가 띄운 dev 서버는 안 죽는다.
|
|
797
|
+
* 다음에 열었을 때 포트가 잡혀 있고, 그 원인은 어디에도 안 남는다.
|
|
798
|
+
*/
|
|
799
|
+
const 껐다 = 일감모두끝내기();
|
|
800
|
+
if (껐다) 로그(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`);
|
|
801
|
+
for (const 방 of 방들.values()) {
|
|
802
|
+
for (const s of 방.mcp ?? []) { try { s.닫기(); } catch { /* 이미 죽은 것 */ } }
|
|
803
|
+
}
|
|
804
|
+
if (!시작했나) 로그('initialize 를 못 받고 끝났습니다 — 이 명령은 에디터가 자식 프로세스로 띄우는 자리입니다.');
|
|
805
|
+
return 0;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 판 번호는 version.js 한 곳에서만 읽는다. 여기서 또 읽으면 두 벌이 되고,
|
|
809
|
+
// 두 벌이 되면 언젠가 한쪽만 고쳐진다 — 그 파일이 존재하는 이유가 그것이다.
|
|
810
|
+
const 판번호 = () => VERSION;
|