deel-local-cli 0.5.0 → 0.8.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 +526 -135
- package/README.md +652 -149
- package/bin/deel.js +67 -1
- package/package.json +4 -3
- package/src/agent/compact.js +138 -0
- package/src/agent/effort.js +139 -0
- package/src/agent/loop.js +186 -60
- package/src/agent/modes.js +153 -0
- package/src/agent/session.js +14 -3
- package/src/agent/sessionui.js +59 -0
- package/src/agent/store.js +193 -0
- package/src/backend/adapter.js +12 -2
- package/src/backend/http.js +21 -3
- package/src/backend/scan.js +162 -0
- package/src/backend/scanui.js +147 -0
- package/src/commands.js +307 -17
- package/src/config.js +19 -6
- package/src/pack/selfpack.js +248 -0
- package/src/pack/tar.js +69 -0
- package/src/pack/zip.js +217 -0
- package/src/plugins/manage.js +272 -0
- package/src/repl.js +230 -38
- package/src/safety/network.js +95 -0
- package/src/safety/undo.js +46 -1
- package/src/setup.js +4 -0
- package/src/tools/encoding.js +333 -0
- package/src/tools/excel-com.js +254 -0
- package/src/tools/excel.js +118 -0
- package/src/tools/fsutil.js +47 -4
- package/src/tools/index.js +128 -14
- package/src/tools/todo.js +92 -0
- package/src/tools/webfetch.js +110 -0
- package/src/tools/xlsx.js +319 -0
- package/src/ui/ansi.js +78 -4
- package/src/ui/level.js +104 -0
- package/src/ui/prompt.js +34 -0
- package/src/ui/status.js +151 -0
package/src/repl.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
|
|
2
|
-
import { createInterface } from 'node:readline';
|
|
3
|
-
import { c, say, mark, cursor,
|
|
4
|
-
import {
|
|
2
|
+
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { c, say, mark, cursor, box, clip, cols } from './ui/ansi.js';
|
|
4
|
+
import { statusLine, headerLines, contextWarning } from './ui/status.js';
|
|
5
|
+
import { STAGES } from './agent/effort.js';
|
|
6
|
+
import { handle } from './commands.js';
|
|
7
|
+
import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
|
|
5
8
|
import { run } from './agent/loop.js';
|
|
6
9
|
import { Session } from './agent/session.js';
|
|
7
10
|
import { makeScope } from './safety/guard.js';
|
|
@@ -9,21 +12,41 @@ import { History } from './safety/undo.js';
|
|
|
9
12
|
import { Audit } from './safety/audit.js';
|
|
10
13
|
import { activeProfile, load, resolveKey } from './config.js';
|
|
11
14
|
import { discover } from './skills/discover.js';
|
|
15
|
+
import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
|
|
16
|
+
import { Store, latest, prune } from './agent/store.js';
|
|
17
|
+
import { askHidden } from './ui/prompt.js';
|
|
18
|
+
import { explain } from './ui/level.js';
|
|
19
|
+
|
|
20
|
+
// 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
|
|
21
|
+
const TOOL_GLYPH = {
|
|
22
|
+
Read: c.blue('◧'),
|
|
23
|
+
Write: c.green('◆'),
|
|
24
|
+
Edit: c.yellow('◈'),
|
|
25
|
+
Glob: c.magenta('❋'),
|
|
26
|
+
Grep: c.magenta('❊'),
|
|
27
|
+
Bash: c.hcyan('▶'),
|
|
28
|
+
Skill: c.hmagenta('✦'),
|
|
29
|
+
WebFetch: c.hblue('◍'),
|
|
30
|
+
TodoWrite: c.hyellow('☰'),
|
|
31
|
+
};
|
|
12
32
|
|
|
13
33
|
// 도구 호출을 한 줄로 요약 — Read(src/a.js) 처럼.
|
|
14
34
|
function toolLabel(name, args) {
|
|
15
35
|
const a = args ?? {};
|
|
16
36
|
const first =
|
|
17
|
-
a.file_path ?? a.pattern ?? a.path ??
|
|
18
|
-
(a.command ? String(a.command).slice(0,
|
|
19
|
-
|
|
37
|
+
a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ??
|
|
38
|
+
(a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
|
|
39
|
+
// 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
|
|
40
|
+
(Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
|
|
41
|
+
const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
|
|
42
|
+
const 안 = clip(String(first ?? ''), 56);
|
|
43
|
+
return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
function toolResultLine(result, ms) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `${c.gray('└')} ${c.gray(s)}${t}`;
|
|
47
|
+
const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
|
|
48
|
+
if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
|
|
49
|
+
return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${t}`;
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
export async function chatLoop(opts = {}) {
|
|
@@ -44,13 +67,42 @@ export async function chatLoop(opts = {}) {
|
|
|
44
67
|
tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
|
|
45
68
|
};
|
|
46
69
|
|
|
70
|
+
// 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
|
|
71
|
+
allowEndpoint(conn.base);
|
|
72
|
+
if (opts.offline ?? prof.offline) setOffline(true);
|
|
73
|
+
|
|
47
74
|
const session = new Session(conn, {
|
|
48
75
|
root,
|
|
49
76
|
mode: opts.mode ?? 'auto',
|
|
77
|
+
// 수준은 설정에 남는다. 한 번 고르면 다음에 켤 때도 그대로다.
|
|
78
|
+
level: opts.level ?? cfg.level ?? null,
|
|
50
79
|
think: opts.think ?? 'medium',
|
|
80
|
+
effort: opts.effort ?? 'save',
|
|
51
81
|
maxSteps: opts.maxSteps ?? 24,
|
|
52
82
|
});
|
|
53
83
|
|
|
84
|
+
// ── 대화 이어하기 ─────────────────────────────────────────────────────
|
|
85
|
+
// 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
|
|
86
|
+
let store = null;
|
|
87
|
+
if (opts.sessionId || opts.continue) {
|
|
88
|
+
const target = opts.sessionId ?? latest(root)?.id;
|
|
89
|
+
if (!target) {
|
|
90
|
+
say('');
|
|
91
|
+
say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
|
|
92
|
+
} else {
|
|
93
|
+
store = new Store(root, target);
|
|
94
|
+
const { messages } = store.load();
|
|
95
|
+
if (messages.length) {
|
|
96
|
+
session.messages = messages;
|
|
97
|
+
say('');
|
|
98
|
+
say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!store) store = new Store(root);
|
|
103
|
+
store.begin({ model: conn.model, base: conn.base, root });
|
|
104
|
+
try { prune(root); } catch {}
|
|
105
|
+
|
|
54
106
|
// 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
|
|
55
107
|
const found = discover(root);
|
|
56
108
|
session.skills = found.skills;
|
|
@@ -66,7 +118,7 @@ export async function chatLoop(opts = {}) {
|
|
|
66
118
|
const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
|
|
67
119
|
|
|
68
120
|
rl.on('line', (l) => {
|
|
69
|
-
if (echo) say(l);
|
|
121
|
+
if (echo) say(c.gray(l));
|
|
70
122
|
if (waiter) { const w = waiter; waiter = null; w(l); }
|
|
71
123
|
else queue.push(l);
|
|
72
124
|
});
|
|
@@ -75,6 +127,23 @@ export async function chatLoop(opts = {}) {
|
|
|
75
127
|
if (waiter) { const w = waiter; waiter = null; w(null); }
|
|
76
128
|
});
|
|
77
129
|
|
|
130
|
+
// Shift+Tab 으로 작업 모드를 차례로 돌린다.
|
|
131
|
+
//
|
|
132
|
+
// 터미널일 때만 한다. 파이프로 넣을 때 키를 가로채면 입력이 깨진다 —
|
|
133
|
+
// 검사와 데모가 그렇게 돌아간다.
|
|
134
|
+
if (process.stdin.isTTY) {
|
|
135
|
+
emitKeypressEvents(process.stdin, rl);
|
|
136
|
+
process.stdin.on('keypress', (_ch, key) => {
|
|
137
|
+
if (!key || key.name !== 'tab' || !key.shift) return;
|
|
138
|
+
session.work = nextWork(session.work);
|
|
139
|
+
const w = getWork(session.work);
|
|
140
|
+
cursor.clearLine();
|
|
141
|
+
say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
|
|
142
|
+
+ (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
|
|
143
|
+
prompt();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
78
147
|
const nextLine = () => {
|
|
79
148
|
if (queue.length) return Promise.resolve(queue.shift());
|
|
80
149
|
if (closed) return Promise.resolve(null);
|
|
@@ -88,6 +157,22 @@ export async function chatLoop(opts = {}) {
|
|
|
88
157
|
return a.trim() || o.def || '';
|
|
89
158
|
};
|
|
90
159
|
|
|
160
|
+
/**
|
|
161
|
+
* 오류를 이 사람 수준에 맞게 보여준다.
|
|
162
|
+
*
|
|
163
|
+
* 쉬움 수준에서는 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 회색으로 뒤에 남긴다.
|
|
164
|
+
* 원인을 지우지 않는 것이 중요하다 — 지우면 물어볼 수도 없게 된다.
|
|
165
|
+
* 개발자 수준에서는 원래 문구 그대로다.
|
|
166
|
+
*/
|
|
167
|
+
const 오류보이기 = (message) => {
|
|
168
|
+
const r = explain(session.level, message);
|
|
169
|
+
if (!r.plain) { say(` ${c.red('✗')} ${String(message)}`); return; }
|
|
170
|
+
const [머리, ...나머지] = r.text.split('\n');
|
|
171
|
+
say(` ${c.red('✗')} ${머리}`);
|
|
172
|
+
for (const l of 나머지) say(` ${l}`);
|
|
173
|
+
if (r.detail) say(` ${c.gray(`(원래 문구: ${clip(String(r.detail).split('\n')[0], 90)})`)}`);
|
|
174
|
+
};
|
|
175
|
+
|
|
91
176
|
const ctx = {
|
|
92
177
|
scope: makeScope(root),
|
|
93
178
|
history: new History(root),
|
|
@@ -96,6 +181,13 @@ export async function chatLoop(opts = {}) {
|
|
|
96
181
|
skills: found.skills,
|
|
97
182
|
loadedSkills: new Set(),
|
|
98
183
|
ask,
|
|
184
|
+
// 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다 —
|
|
185
|
+
// 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 안 남는다.
|
|
186
|
+
askPassword: async (label) => {
|
|
187
|
+
if (closed) return null;
|
|
188
|
+
const pw = await askHidden(rl, label, nextLine);
|
|
189
|
+
return pw === null || pw === '' ? null : pw;
|
|
190
|
+
},
|
|
99
191
|
confirm: async (name, args) => {
|
|
100
192
|
say('');
|
|
101
193
|
say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
|
|
@@ -104,30 +196,44 @@ export async function chatLoop(opts = {}) {
|
|
|
104
196
|
},
|
|
105
197
|
};
|
|
106
198
|
|
|
107
|
-
// 머리말
|
|
199
|
+
// ── 머리말 ────────────────────────────────────────────────────────────
|
|
108
200
|
say('');
|
|
109
|
-
|
|
201
|
+
for (const l of box(headerLines(session, found), { tone: c.gray })) say(' ' + l);
|
|
110
202
|
const warn = [];
|
|
111
|
-
if (!conn.tools) warn.push('도구
|
|
112
|
-
if (!conn.streaming) warn.push('
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
203
|
+
if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
|
|
204
|
+
if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
|
|
205
|
+
for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
|
|
206
|
+
say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
|
|
207
|
+
|
|
208
|
+
// 입력 자리. 위에 상태줄을 한 줄 깔고 그 아래에 커서를 둔다.
|
|
209
|
+
const prompt = () => {
|
|
210
|
+
say('');
|
|
211
|
+
say(statusLine(session));
|
|
212
|
+
const w = contextWarning(session);
|
|
213
|
+
if (w) say(` ${w}`);
|
|
214
|
+
process.stdout.write(` ${c.hcyan('❯')} `);
|
|
215
|
+
};
|
|
119
216
|
|
|
217
|
+
// Ctrl+C 는 상황에 따라 뜻이 다르다.
|
|
218
|
+
// 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
|
|
219
|
+
// 입력을 기다리는 중 → 한 번은 경고, 두 번이면 끝낸다
|
|
220
|
+
// 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
|
|
120
221
|
let interrupted = false;
|
|
222
|
+
let turn = null; // 지금 도는 턴의 AbortController
|
|
121
223
|
rl.on('SIGINT', () => {
|
|
224
|
+
if (turn && !turn.signal.aborted) {
|
|
225
|
+
turn.abort();
|
|
226
|
+
return; // 화면 정리는 루프 쪽 'aborted' 이벤트가 한다
|
|
227
|
+
}
|
|
122
228
|
if (interrupted) { rl.close(); return; }
|
|
123
229
|
interrupted = true;
|
|
124
230
|
say('');
|
|
125
231
|
say(` ${c.gray('한 번 더 Ctrl+C 를 누르면 끝냅니다.')}`);
|
|
126
|
-
|
|
232
|
+
prompt();
|
|
127
233
|
});
|
|
128
234
|
|
|
129
235
|
for (;;) {
|
|
130
|
-
|
|
236
|
+
prompt();
|
|
131
237
|
const line = await nextLine();
|
|
132
238
|
if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
|
|
133
239
|
interrupted = false;
|
|
@@ -141,82 +247,168 @@ export async function chatLoop(opts = {}) {
|
|
|
141
247
|
|
|
142
248
|
say('');
|
|
143
249
|
const started = Date.now();
|
|
250
|
+
const before = { in: session.usage.in, out: session.usage.out };
|
|
251
|
+
|
|
252
|
+
// 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
|
|
253
|
+
let saved = session.messages.length;
|
|
254
|
+
const flush = () => {
|
|
255
|
+
for (const m of session.messages.slice(saved)) store.append(m);
|
|
256
|
+
saved = session.messages.length;
|
|
257
|
+
};
|
|
144
258
|
let tools = 0;
|
|
145
259
|
let thinkChars = 0;
|
|
146
260
|
let streamed = false;
|
|
147
261
|
let thinkingShown = false;
|
|
262
|
+
let stage = null;
|
|
148
263
|
|
|
264
|
+
const clearThinking = () => { if (thinkingShown) { cursor.clearLine(); thinkingShown = false; } };
|
|
265
|
+
|
|
266
|
+
turn = new AbortController();
|
|
149
267
|
try {
|
|
150
|
-
for await (const ev of run(session, ctx, toSend)) {
|
|
268
|
+
for await (const ev of run(session, ctx, toSend, { signal: turn.signal })) {
|
|
151
269
|
switch (ev.type) {
|
|
270
|
+
// 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
|
|
271
|
+
case 'stage':
|
|
272
|
+
stage = ev;
|
|
273
|
+
thinkChars = 0;
|
|
274
|
+
break;
|
|
275
|
+
|
|
276
|
+
case 'retry':
|
|
277
|
+
clearThinking();
|
|
278
|
+
say(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
|
|
279
|
+
break;
|
|
280
|
+
|
|
152
281
|
case 'waiting':
|
|
153
|
-
process.stdout.write(` ${c.gray('생각 중…')}\r`);
|
|
282
|
+
process.stdout.write(` ${c.gray(stageTag(stage) + ' 생각 중…')}\r`);
|
|
154
283
|
break;
|
|
155
284
|
|
|
156
285
|
case 'thinking':
|
|
157
286
|
thinkChars += ev.text.length;
|
|
158
287
|
if (process.stdout.isTTY) {
|
|
159
288
|
cursor.clearLine();
|
|
160
|
-
process.stdout.write(` ${c.
|
|
289
|
+
process.stdout.write(` ${mark.think} ${c.gray(stageTag(stage))} ${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
|
|
161
290
|
thinkingShown = true;
|
|
162
291
|
}
|
|
163
292
|
break;
|
|
164
293
|
|
|
165
294
|
case 'content':
|
|
166
|
-
|
|
295
|
+
clearThinking();
|
|
167
296
|
if (!streamed) { streamed = true; process.stdout.write(' '); }
|
|
168
297
|
process.stdout.write(ev.text.replace(/\n/g, '\n '));
|
|
169
298
|
break;
|
|
170
299
|
|
|
171
300
|
case 'tool_start':
|
|
172
|
-
|
|
301
|
+
clearThinking();
|
|
302
|
+
if (streamed) { say(''); streamed = false; }
|
|
303
|
+
say('');
|
|
304
|
+
say(` ${toolLabel(ev.name, ev.args)}`);
|
|
305
|
+
break;
|
|
306
|
+
|
|
307
|
+
// 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
|
|
308
|
+
case 'tools_start':
|
|
309
|
+
clearThinking();
|
|
173
310
|
if (streamed) { say(''); streamed = false; }
|
|
174
311
|
say('');
|
|
175
|
-
say(` ${c.
|
|
312
|
+
say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
|
|
176
313
|
break;
|
|
177
314
|
|
|
178
315
|
case 'tool':
|
|
179
316
|
tools++;
|
|
180
|
-
|
|
317
|
+
// 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
|
|
318
|
+
if (ev.parallel) say(` ${toolLabel(ev.name, ev.args)}`);
|
|
319
|
+
if (ev.name === 'TodoWrite' && ev.result?.todos) {
|
|
320
|
+
for (const t of ev.result.todos) {
|
|
321
|
+
const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
|
|
322
|
+
const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
|
|
323
|
+
say(` ${표} ${clip(글, 74)}`);
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
|
|
327
|
+
}
|
|
328
|
+
flush(); // 도구가 하나 끝날 때마다 적어 둔다
|
|
181
329
|
break;
|
|
182
330
|
|
|
183
331
|
case 'trimmed':
|
|
184
332
|
say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
|
|
185
333
|
break;
|
|
186
334
|
|
|
335
|
+
case 'compacting':
|
|
336
|
+
clearThinking();
|
|
337
|
+
process.stdout.write(` ${c.gray('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…')}\r`);
|
|
338
|
+
break;
|
|
339
|
+
|
|
340
|
+
case 'compacted': {
|
|
341
|
+
if (process.stdout.isTTY) cursor.clearLine();
|
|
342
|
+
const 줄인 = ev.before - ev.after;
|
|
343
|
+
say(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 — `)}` +
|
|
344
|
+
`${c.gray(ev.before.toLocaleString())} ${c.gray('→')} ${c.white(ev.after.toLocaleString())} ${c.gray('토큰')} ` +
|
|
345
|
+
`${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
|
|
346
|
+
if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
|
|
347
|
+
// 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
|
|
348
|
+
store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
|
|
349
|
+
saved = session.messages.length;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
case 'compact_failed':
|
|
354
|
+
if (process.stdout.isTTY) cursor.clearLine();
|
|
355
|
+
say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
|
|
356
|
+
break;
|
|
357
|
+
|
|
187
358
|
case 'limit':
|
|
188
359
|
say('');
|
|
189
|
-
say(` ${
|
|
360
|
+
say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
|
|
361
|
+
break;
|
|
362
|
+
|
|
363
|
+
case 'aborted':
|
|
364
|
+
clearThinking();
|
|
365
|
+
if (streamed) { say(''); streamed = false; }
|
|
366
|
+
say('');
|
|
367
|
+
say(` ${c.yellow('⊘')} ${c.gray('중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.')}`);
|
|
190
368
|
break;
|
|
191
369
|
|
|
192
370
|
case 'error':
|
|
193
|
-
|
|
371
|
+
clearThinking();
|
|
194
372
|
say('');
|
|
195
|
-
|
|
373
|
+
오류보이기(ev.text);
|
|
196
374
|
break;
|
|
197
375
|
|
|
198
376
|
case 'done':
|
|
377
|
+
clearThinking();
|
|
199
378
|
if (streamed) say('');
|
|
200
379
|
break;
|
|
201
380
|
}
|
|
202
381
|
}
|
|
203
382
|
} catch (err) {
|
|
383
|
+
clearThinking();
|
|
204
384
|
say('');
|
|
205
|
-
|
|
385
|
+
오류보이기(err.message);
|
|
206
386
|
}
|
|
387
|
+
turn = null;
|
|
388
|
+
interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
|
|
389
|
+
flush(); // 오류로 끝났어도 여기까지는 남긴다
|
|
207
390
|
|
|
208
|
-
// 꼬리말 — 이번
|
|
391
|
+
// ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
|
|
209
392
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
210
393
|
const bits = [`${secs}초`];
|
|
211
394
|
if (tools) bits.push(`도구 ${tools}회`);
|
|
212
|
-
|
|
395
|
+
const dIn = session.usage.in - before.in;
|
|
396
|
+
const dOut = session.usage.out - before.out;
|
|
397
|
+
if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
|
|
213
398
|
say('');
|
|
214
|
-
say(` ${c.gray('─
|
|
399
|
+
say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
|
|
215
400
|
}
|
|
216
401
|
|
|
217
402
|
rl.close();
|
|
218
403
|
say('');
|
|
219
|
-
say(` ${c.gray('끝냅니다.')} ${c.gray(
|
|
404
|
+
say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
|
|
220
405
|
say('');
|
|
221
406
|
return 0;
|
|
222
407
|
}
|
|
408
|
+
|
|
409
|
+
// "첫 판단·high" 처럼 지금 도는 단계를 짧게.
|
|
410
|
+
function stageTag(ev) {
|
|
411
|
+
if (!ev) return '';
|
|
412
|
+
const label = STAGES[ev.stage]?.label ?? ev.stage;
|
|
413
|
+
return `${label}·${ev.level}`;
|
|
414
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// 어디로 말을 걸 수 있는지 한 곳에서 정한다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가:
|
|
4
|
+
// 코딩 에이전트는 소스 코드를 통째로 모델에 보낸다. 그 주소가 어디인지가 전부다.
|
|
5
|
+
// "설정한 곳으로만 갑니다" 를 말로 하면 언젠가 거짓이 된다. 코드가 막아야 한다.
|
|
6
|
+
//
|
|
7
|
+
// 규칙:
|
|
8
|
+
// 1) 기본은 전부 거절. 허용 목록에 오른 자리만 통과.
|
|
9
|
+
// 2) 모델 호출은 setup 에서 정한 그 주소 하나만.
|
|
10
|
+
// 3) 플러그인 받기(github)는 사용자가 그 명령을 칠 때만 잠깐 열린다.
|
|
11
|
+
// 4) offline 이면 이 컴퓨터 밖은 전부 거절 — 3번도 막힌다.
|
|
12
|
+
//
|
|
13
|
+
// 여기를 지나지 않는 요청은 없다. http.js 의 req() 가 매번 물어본다.
|
|
14
|
+
|
|
15
|
+
export class NetBlocked extends Error {
|
|
16
|
+
constructor(url, why) {
|
|
17
|
+
super(`허용되지 않은 주소입니다: ${url}\n ${why}`);
|
|
18
|
+
this.name = 'NetBlocked';
|
|
19
|
+
this.url = url;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]']);
|
|
24
|
+
|
|
25
|
+
export const isLocalHost = (h) => LOCAL.has(String(h).toLowerCase()) ||
|
|
26
|
+
/^127\./.test(h) ||
|
|
27
|
+
/^10\./.test(h) ||
|
|
28
|
+
/^192\.168\./.test(h) ||
|
|
29
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(h);
|
|
30
|
+
|
|
31
|
+
function originOf(url) {
|
|
32
|
+
const u = new URL(url);
|
|
33
|
+
return `${u.protocol}//${u.host}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 하나뿐인 문지기. 모듈 하나에 상태를 두는 것은 일부러다 —
|
|
37
|
+
// 여기저기서 각자 예외를 두면 자물쇠가 아니게 된다.
|
|
38
|
+
const gate = {
|
|
39
|
+
allow: new Set(), // 통과시킬 origin 들
|
|
40
|
+
offline: false, // true 면 이 컴퓨터 밖은 전부 거절
|
|
41
|
+
log: [], // 실제로 나간 곳 (사람이 확인용)
|
|
42
|
+
enforced: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** 모델 연결 주소를 허용 목록에 올린다. 이전에 올린 것은 지운다. */
|
|
46
|
+
export function allowEndpoint(baseUrl) {
|
|
47
|
+
gate.allow.clear();
|
|
48
|
+
if (baseUrl) gate.allow.add(originOf(baseUrl));
|
|
49
|
+
return [...gate.allow];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 잠깐 한 곳을 더 연다. 되돌리는 함수를 준다 — 반드시 finally 에서 부른다. */
|
|
53
|
+
export function allowTemporarily(url) {
|
|
54
|
+
const o = originOf(url);
|
|
55
|
+
const had = gate.allow.has(o);
|
|
56
|
+
gate.allow.add(o);
|
|
57
|
+
return () => { if (!had) gate.allow.delete(o); };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setOffline(on) { gate.offline = !!on; return gate.offline; }
|
|
61
|
+
export function isOffline() { return gate.offline; }
|
|
62
|
+
export function allowed() { return [...gate.allow]; }
|
|
63
|
+
export function contacted() { return gate.log.slice(); }
|
|
64
|
+
export function resetNet() { gate.allow.clear(); gate.log.length = 0; gate.offline = false; }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 이 주소로 나가도 되는가. 안 되면 던진다.
|
|
68
|
+
* 통과한 것은 기록에 남는다 — "무엇이 어디로 갔나" 를 나중에 보여 주기 위해서다.
|
|
69
|
+
*/
|
|
70
|
+
export function checkUrl(url) {
|
|
71
|
+
let u;
|
|
72
|
+
try { u = new URL(url); } catch { throw new NetBlocked(url, '주소 형식이 아닙니다.'); }
|
|
73
|
+
|
|
74
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
75
|
+
throw new NetBlocked(url, `${u.protocol} 는 쓰지 않습니다.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const local = isLocalHost(u.hostname);
|
|
79
|
+
if (gate.offline && !local) {
|
|
80
|
+
throw new NetBlocked(url, '오프라인 모드입니다 — 이 컴퓨터 밖으로는 나가지 않습니다.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const origin = `${u.protocol}//${u.host}`;
|
|
84
|
+
if (!gate.allow.has(origin)) {
|
|
85
|
+
throw new NetBlocked(url,
|
|
86
|
+
gate.allow.size
|
|
87
|
+
? `지금 허용된 곳: ${[...gate.allow].join(', ')}`
|
|
88
|
+
: '연결이 정해지지 않았습니다. deel setup 을 먼저 하세요.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const seen = gate.log.find((x) => x.origin === origin);
|
|
92
|
+
if (seen) seen.n++;
|
|
93
|
+
else gate.log.push({ origin, n: 1, local });
|
|
94
|
+
return true;
|
|
95
|
+
}
|
package/src/safety/undo.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
|
|
2
2
|
// 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// 되돌리기 이력은 파일 내용을 통째로 담는다. 이만큼 커지면 오래된 턴을 버린다.
|
|
7
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
const KEEP_TURNS = 50;
|
|
5
9
|
|
|
6
10
|
export class History {
|
|
7
11
|
constructor(root) {
|
|
@@ -22,9 +26,50 @@ export class History {
|
|
|
22
26
|
const before = existsSync(absPath) ? safeRead(absPath) : null;
|
|
23
27
|
const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before, label };
|
|
24
28
|
appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
|
|
29
|
+
this.#maybePrune();
|
|
25
30
|
return rec;
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 이력이 끝없이 자라는 것을 막는다.
|
|
35
|
+
*
|
|
36
|
+
* 스냅샷은 파일 내용을 통째로 담는다. 큰 파일을 여러 번 고치면 금방 수십 MB 가 된다.
|
|
37
|
+
* 그런데 아무 때나 자르면 안 된다 — 방금 한 일을 못 되돌리게 되면 안전망이 아니다.
|
|
38
|
+
* 그래서 최근 KEEP_TURNS 개 턴은 무조건 남기고, 그보다 오래된 것만 버린다.
|
|
39
|
+
*
|
|
40
|
+
* 매번 확인하면 파일을 계속 다시 읽게 되므로, 커졌을 때만 본다.
|
|
41
|
+
*/
|
|
42
|
+
#maybePrune() {
|
|
43
|
+
this.#writes = (this.#writes ?? 0) + 1;
|
|
44
|
+
if (this.#writes % 20 !== 0) return;
|
|
45
|
+
try {
|
|
46
|
+
if (statSync(this.file).size < MAX_BYTES) return;
|
|
47
|
+
} catch { return; }
|
|
48
|
+
this.prune();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#writes = 0;
|
|
52
|
+
|
|
53
|
+
/** 최근 keep 개 턴만 남기고 자른다. 버린 줄 수를 돌려준다. */
|
|
54
|
+
prune({ keep = KEEP_TURNS } = {}) {
|
|
55
|
+
const recs = this.all();
|
|
56
|
+
const turns = this.turns();
|
|
57
|
+
if (turns.length <= keep) return 0;
|
|
58
|
+
const 남길턴 = new Set(turns.slice(-keep));
|
|
59
|
+
const 남길것 = recs.filter((r) => 남길턴.has(r.turn));
|
|
60
|
+
const 버린수 = recs.length - 남길것.length;
|
|
61
|
+
if (!버린수) return 0;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(this.file, 남길것.map((r) => JSON.stringify(r)).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
|
|
64
|
+
} catch { return 0; }
|
|
65
|
+
return 버린수;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 지금 이력이 얼마나 되나. /status 에서 보여 준다. */
|
|
69
|
+
size() {
|
|
70
|
+
try { return statSync(this.file).size; } catch { return 0; }
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
all() {
|
|
29
74
|
if (!existsSync(this.file)) return [];
|
|
30
75
|
return readFileSync(this.file, 'utf8')
|
package/src/setup.js
CHANGED
|
@@ -6,6 +6,7 @@ import { detect } from './backend/detect.js';
|
|
|
6
6
|
import { probe } from './backend/probe.js';
|
|
7
7
|
import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
|
|
8
8
|
import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
|
|
9
|
+
import { allowEndpoint } from './safety/network.js';
|
|
9
10
|
import { writeFileSync } from 'node:fs';
|
|
10
11
|
|
|
11
12
|
export function banner() {
|
|
@@ -17,6 +18,8 @@ export function banner() {
|
|
|
17
18
|
// 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
|
|
18
19
|
async function connect(url, key) {
|
|
19
20
|
const s = spin(`${url} 확인 중...`);
|
|
21
|
+
// 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
|
|
22
|
+
allowEndpoint(/^https?:\/\//i.test(url) ? url : 'http://' + url);
|
|
20
23
|
const found = await detect(url, key);
|
|
21
24
|
if (!found.kind) {
|
|
22
25
|
s.stop(` ${mark.no} ${c.red('연결 실패')}`);
|
|
@@ -51,6 +54,7 @@ async function chooseModel(found) {
|
|
|
51
54
|
|
|
52
55
|
// 진단을 돌리고 결과를 화면에 그린다.
|
|
53
56
|
export async function runProbe(conn, { out = null } = {}) {
|
|
57
|
+
allowEndpoint(conn.base); // 진단도 이 주소 하나로만 나간다
|
|
54
58
|
renderHeader({ shape: conn.kind, base: conn.base, auth: conn.auth, model: conn.model });
|
|
55
59
|
const { facts, results } = await probe(conn, renderLine);
|
|
56
60
|
const v = verdict(facts, results);
|