deel-local-cli 0.5.0 → 0.9.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 +738 -132
- package/README.md +742 -147
- package/bin/deel.js +96 -4
- 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 +252 -0
- package/src/agent/route.js +156 -0
- package/src/agent/session.js +28 -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/ctxsize.js +174 -0
- package/src/backend/http.js +44 -3
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +256 -0
- package/src/backend/scanui.js +147 -0
- package/src/commands.js +658 -33
- 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 +297 -40
- package/src/report.js +1 -1
- 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 +97 -5
- package/src/ui/level.js +106 -0
- package/src/ui/prompt.js +34 -0
- package/src/ui/status.js +161 -0
package/src/repl.js
CHANGED
|
@@ -1,29 +1,55 @@
|
|
|
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';
|
|
8
|
+
import { route } from './agent/route.js';
|
|
5
9
|
import { run } from './agent/loop.js';
|
|
6
10
|
import { Session } from './agent/session.js';
|
|
7
11
|
import { makeScope } from './safety/guard.js';
|
|
8
12
|
import { History } from './safety/undo.js';
|
|
9
13
|
import { Audit } from './safety/audit.js';
|
|
10
|
-
import { activeProfile, load, resolveKey } from './config.js';
|
|
14
|
+
import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
|
|
11
15
|
import { discover } from './skills/discover.js';
|
|
16
|
+
import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
|
|
17
|
+
import { Store, latest, prune } from './agent/store.js';
|
|
18
|
+
import { askHidden } from './ui/prompt.js';
|
|
19
|
+
import { spin } from './ui/spinner.js';
|
|
20
|
+
import { explain } from './ui/level.js';
|
|
21
|
+
import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
|
|
22
|
+
|
|
23
|
+
// 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
|
|
24
|
+
const TOOL_GLYPH = {
|
|
25
|
+
Read: c.blue('◧'),
|
|
26
|
+
Write: c.green('◆'),
|
|
27
|
+
Edit: c.yellow('◈'),
|
|
28
|
+
Glob: c.magenta('❋'),
|
|
29
|
+
Grep: c.magenta('❊'),
|
|
30
|
+
Bash: c.hcyan('▶'),
|
|
31
|
+
Skill: c.hmagenta('✦'),
|
|
32
|
+
WebFetch: c.hblue('◍'),
|
|
33
|
+
TodoWrite: c.hyellow('☰'),
|
|
34
|
+
};
|
|
12
35
|
|
|
13
36
|
// 도구 호출을 한 줄로 요약 — Read(src/a.js) 처럼.
|
|
14
37
|
function toolLabel(name, args) {
|
|
15
38
|
const a = args ?? {};
|
|
16
39
|
const first =
|
|
17
|
-
a.file_path ?? a.pattern ?? a.path ??
|
|
18
|
-
(a.command ? String(a.command).slice(0,
|
|
19
|
-
|
|
40
|
+
a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ??
|
|
41
|
+
(a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
|
|
42
|
+
// 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
|
|
43
|
+
(Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
|
|
44
|
+
const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
|
|
45
|
+
const 안 = clip(String(first ?? ''), 56);
|
|
46
|
+
return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
|
|
20
47
|
}
|
|
21
48
|
|
|
22
49
|
function toolResultLine(result, ms) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `${c.gray('└')} ${c.gray(s)}${t}`;
|
|
50
|
+
const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
|
|
51
|
+
if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
|
|
52
|
+
return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${t}`;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
55
|
export async function chatLoop(opts = {}) {
|
|
@@ -40,17 +66,54 @@ export async function chatLoop(opts = {}) {
|
|
|
40
66
|
const conn = {
|
|
41
67
|
kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
|
|
42
68
|
key: resolveKey(prof), model: prof.model,
|
|
43
|
-
|
|
69
|
+
// 컨텍스트 길이. 순서가 곧 우선순위다 —
|
|
70
|
+
// deel --ctx 655360 > 프로필에 저장된 값 > 기본값
|
|
71
|
+
// 기본값으로 떨어졌다는 것은 '아직 못 쟀다' 는 뜻이다. 아래에서 그렇다고 말해 준다.
|
|
72
|
+
ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
|
|
73
|
+
// 답 길이 상한. 컨텍스트와 다른 축이다 — 없으면 effort.js 의 울타리를 쓴다.
|
|
74
|
+
maxTokens: prof.maxTokens ?? null,
|
|
75
|
+
streaming: prof.streaming ?? false,
|
|
44
76
|
tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
|
|
45
77
|
};
|
|
46
78
|
|
|
79
|
+
// 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
|
|
80
|
+
allowEndpoint(conn.base);
|
|
81
|
+
if (opts.offline ?? prof.offline) setOffline(true);
|
|
82
|
+
|
|
47
83
|
const session = new Session(conn, {
|
|
48
84
|
root,
|
|
49
85
|
mode: opts.mode ?? 'auto',
|
|
86
|
+
// 처음부터 원하는 모드로 시작할 수 있다 — deel --work plan
|
|
87
|
+
work: opts.work ?? null,
|
|
88
|
+
// 수준은 설정에 남는다. 한 번 고르면 다음에 켤 때도 그대로다.
|
|
89
|
+
level: opts.level ?? cfg.level ?? null,
|
|
50
90
|
think: opts.think ?? 'medium',
|
|
91
|
+
effort: opts.effort ?? 'save',
|
|
51
92
|
maxSteps: opts.maxSteps ?? 24,
|
|
52
93
|
});
|
|
53
94
|
|
|
95
|
+
// ── 대화 이어하기 ─────────────────────────────────────────────────────
|
|
96
|
+
// 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
|
|
97
|
+
let store = null;
|
|
98
|
+
if (opts.sessionId || opts.continue) {
|
|
99
|
+
const target = opts.sessionId ?? latest(root)?.id;
|
|
100
|
+
if (!target) {
|
|
101
|
+
say('');
|
|
102
|
+
say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
|
|
103
|
+
} else {
|
|
104
|
+
store = new Store(root, target);
|
|
105
|
+
const { messages } = store.load();
|
|
106
|
+
if (messages.length) {
|
|
107
|
+
session.messages = messages;
|
|
108
|
+
say('');
|
|
109
|
+
say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!store) store = new Store(root);
|
|
114
|
+
store.begin({ model: conn.model, base: conn.base, root });
|
|
115
|
+
try { prune(root); } catch {}
|
|
116
|
+
|
|
54
117
|
// 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
|
|
55
118
|
const found = discover(root);
|
|
56
119
|
session.skills = found.skills;
|
|
@@ -66,7 +129,7 @@ export async function chatLoop(opts = {}) {
|
|
|
66
129
|
const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
|
|
67
130
|
|
|
68
131
|
rl.on('line', (l) => {
|
|
69
|
-
if (echo) say(l);
|
|
132
|
+
if (echo) say(c.gray(l));
|
|
70
133
|
if (waiter) { const w = waiter; waiter = null; w(l); }
|
|
71
134
|
else queue.push(l);
|
|
72
135
|
});
|
|
@@ -75,6 +138,23 @@ export async function chatLoop(opts = {}) {
|
|
|
75
138
|
if (waiter) { const w = waiter; waiter = null; w(null); }
|
|
76
139
|
});
|
|
77
140
|
|
|
141
|
+
// Shift+Tab 으로 작업 모드를 차례로 돌린다.
|
|
142
|
+
//
|
|
143
|
+
// 터미널일 때만 한다. 파이프로 넣을 때 키를 가로채면 입력이 깨진다 —
|
|
144
|
+
// 검사와 데모가 그렇게 돌아간다.
|
|
145
|
+
if (process.stdin.isTTY) {
|
|
146
|
+
emitKeypressEvents(process.stdin, rl);
|
|
147
|
+
process.stdin.on('keypress', (_ch, key) => {
|
|
148
|
+
if (!key || key.name !== 'tab' || !key.shift) return;
|
|
149
|
+
session.work = nextWork(session.work);
|
|
150
|
+
const w = getWork(session.work);
|
|
151
|
+
cursor.clearLine();
|
|
152
|
+
say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
|
|
153
|
+
+ (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
|
|
154
|
+
prompt();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
78
158
|
const nextLine = () => {
|
|
79
159
|
if (queue.length) return Promise.resolve(queue.shift());
|
|
80
160
|
if (closed) return Promise.resolve(null);
|
|
@@ -88,6 +168,22 @@ export async function chatLoop(opts = {}) {
|
|
|
88
168
|
return a.trim() || o.def || '';
|
|
89
169
|
};
|
|
90
170
|
|
|
171
|
+
/**
|
|
172
|
+
* 오류를 이 사람 수준에 맞게 보여준다.
|
|
173
|
+
*
|
|
174
|
+
* 쉬움 수준에서는 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 회색으로 뒤에 남긴다.
|
|
175
|
+
* 원인을 지우지 않는 것이 중요하다 — 지우면 물어볼 수도 없게 된다.
|
|
176
|
+
* 개발자 수준에서는 원래 문구 그대로다.
|
|
177
|
+
*/
|
|
178
|
+
const 오류보이기 = (message) => {
|
|
179
|
+
const r = explain(session.level, message);
|
|
180
|
+
if (!r.plain) { say(` ${c.red('✗')} ${String(message)}`); return; }
|
|
181
|
+
const [머리, ...나머지] = r.text.split('\n');
|
|
182
|
+
say(` ${c.red('✗')} ${머리}`);
|
|
183
|
+
for (const l of 나머지) say(` ${l}`);
|
|
184
|
+
if (r.detail) say(` ${c.gray(`(원래 문구: ${clip(String(r.detail).split('\n')[0], 90)})`)}`);
|
|
185
|
+
};
|
|
186
|
+
|
|
91
187
|
const ctx = {
|
|
92
188
|
scope: makeScope(root),
|
|
93
189
|
history: new History(root),
|
|
@@ -96,6 +192,13 @@ export async function chatLoop(opts = {}) {
|
|
|
96
192
|
skills: found.skills,
|
|
97
193
|
loadedSkills: new Set(),
|
|
98
194
|
ask,
|
|
195
|
+
// 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다 —
|
|
196
|
+
// 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 안 남는다.
|
|
197
|
+
askPassword: async (label) => {
|
|
198
|
+
if (closed) return null;
|
|
199
|
+
const pw = await askHidden(rl, label, nextLine);
|
|
200
|
+
return pw === null || pw === '' ? null : pw;
|
|
201
|
+
},
|
|
99
202
|
confirm: async (name, args) => {
|
|
100
203
|
say('');
|
|
101
204
|
say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
|
|
@@ -104,30 +207,80 @@ export async function chatLoop(opts = {}) {
|
|
|
104
207
|
},
|
|
105
208
|
};
|
|
106
209
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
210
|
+
// ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
|
|
211
|
+
//
|
|
212
|
+
// 켤 때마다 서버에 물어본다. 저장된 값을 그대로 믿지 않는다 —
|
|
213
|
+
// 같은 이름의 모델이라도 서버에서 몇 k 로 올렸는지가 그때그때 다르고,
|
|
214
|
+
// 그 차이를 화면에 못 보면 조용히 작아진 채로 쓰게 된다.
|
|
215
|
+
//
|
|
216
|
+
// --ctx 로 직접 주신 값이 있으면 안 건드린다. 사람이 고른 것을 뒤집지 않는다.
|
|
217
|
+
const 길이알림 = []; // 잘 된 소식
|
|
218
|
+
const 길이경고 = []; // 손을 봐야 하는 것
|
|
219
|
+
if (opts.ctx == null) {
|
|
220
|
+
// 이 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
|
|
221
|
+
// 무슨 일이 일어나는 중인지 알려 준다 — 멈춘 것처럼 보이면 안 된다.
|
|
222
|
+
const s = spin('모델에 걸린 컨텍스트 길이를 확인하는 중…');
|
|
223
|
+
let r = null;
|
|
224
|
+
try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
|
|
225
|
+
s.stop('');
|
|
226
|
+
if (r?.value) {
|
|
227
|
+
const 전 = conn.ctx;
|
|
228
|
+
conn.ctx = r.value;
|
|
229
|
+
// 알아낸 값은 프로필에 남긴다. 다음에 켤 때 화면이 곧바로 맞게 뜬다.
|
|
230
|
+
if (prof.ctx !== r.value) {
|
|
231
|
+
prof.ctx = r.value;
|
|
232
|
+
try { const cfg2 = load(); const t = cfg2.profiles.find((p) => p.id === prof.id); if (t) { t.ctx = r.value; saveCfg(cfg2); } } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
|
|
233
|
+
}
|
|
234
|
+
if (전 !== r.value) 길이알림.push(`컨텍스트를 ${전.toLocaleString()} ${c.gray('→')} ${c.white(r.value.toLocaleString())} 로 맞췄습니다 ${c.gray('(' + (r.source ?? '서버') + '에서 읽음)')}`);
|
|
235
|
+
if (r.max && r.loaded && r.max > r.loaded) {
|
|
236
|
+
길이경고.push(`이 모델은 ${c.white(r.max.toLocaleString())} 까지 됩니다 — 서버에서 더 올린 뒤 ${c.cyan('/ctx auto')}`);
|
|
237
|
+
}
|
|
238
|
+
} else if (prof.ctx == null) {
|
|
239
|
+
길이경고.push(`컨텍스트를 서버가 안 알려줍니다 — 우선 ${CTX_DEFAULT.toLocaleString()} 으로 잡았습니다. ${c.cyan('/ctx 655360')} 처럼 직접 지정하세요`);
|
|
240
|
+
}
|
|
116
241
|
}
|
|
117
|
-
|
|
242
|
+
|
|
243
|
+
// ── 머리말 ────────────────────────────────────────────────────────────
|
|
118
244
|
say('');
|
|
245
|
+
for (const l of box(headerLines(session, found), { tone: c.gray })) say(' ' + l);
|
|
246
|
+
const warn = [];
|
|
247
|
+
if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
|
|
248
|
+
if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
|
|
249
|
+
warn.push(...길이경고);
|
|
250
|
+
// 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
|
|
251
|
+
for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
|
|
252
|
+
for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
|
|
253
|
+
say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
|
|
119
254
|
|
|
255
|
+
// 입력 자리. 위에 상태줄을 한 줄 깔고 그 아래에 커서를 둔다.
|
|
256
|
+
const prompt = () => {
|
|
257
|
+
say('');
|
|
258
|
+
say(statusLine(session));
|
|
259
|
+
const w = contextWarning(session);
|
|
260
|
+
if (w) say(` ${w}`);
|
|
261
|
+
process.stdout.write(` ${c.hcyan('❯')} `);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Ctrl+C 는 상황에 따라 뜻이 다르다.
|
|
265
|
+
// 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
|
|
266
|
+
// 입력을 기다리는 중 → 한 번은 경고, 두 번이면 끝낸다
|
|
267
|
+
// 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
|
|
120
268
|
let interrupted = false;
|
|
269
|
+
let turn = null; // 지금 도는 턴의 AbortController
|
|
121
270
|
rl.on('SIGINT', () => {
|
|
271
|
+
if (turn && !turn.signal.aborted) {
|
|
272
|
+
turn.abort();
|
|
273
|
+
return; // 화면 정리는 루프 쪽 'aborted' 이벤트가 한다
|
|
274
|
+
}
|
|
122
275
|
if (interrupted) { rl.close(); return; }
|
|
123
276
|
interrupted = true;
|
|
124
277
|
say('');
|
|
125
278
|
say(` ${c.gray('한 번 더 Ctrl+C 를 누르면 끝냅니다.')}`);
|
|
126
|
-
|
|
279
|
+
prompt();
|
|
127
280
|
});
|
|
128
281
|
|
|
129
282
|
for (;;) {
|
|
130
|
-
|
|
283
|
+
prompt();
|
|
131
284
|
const line = await nextLine();
|
|
132
285
|
if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
|
|
133
286
|
interrupted = false;
|
|
@@ -139,84 +292,188 @@ export async function chatLoop(opts = {}) {
|
|
|
139
292
|
if (cmd.handled) continue;
|
|
140
293
|
const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
|
|
141
294
|
|
|
295
|
+
// 종합 모드면 이 한마디가 무슨 일인지 보고 알맞은 모드로 옮긴다.
|
|
296
|
+
//
|
|
297
|
+
// 기본 모드는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
|
|
298
|
+
// 사용자가 직접 고른 모드가 있으면 여기 안 들어온다. 사람이 고른 것을 뒤집지 않는다.
|
|
299
|
+
session.routed = null;
|
|
300
|
+
if (session.work === 'auto') {
|
|
301
|
+
const 골라진 = route(toSend);
|
|
302
|
+
if (골라진.mode) {
|
|
303
|
+
session.routed = 골라진.mode;
|
|
304
|
+
const w = getWork(골라진.mode);
|
|
305
|
+
say('');
|
|
306
|
+
say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')}`
|
|
307
|
+
+ ` ${c.gray('말 속에 ' + 골라진.why + ' 가 있어서')}`
|
|
308
|
+
+ (canWrite(골라진.mode) ? '' : ` ${c.green('· 파일은 안 바꿉니다')}`));
|
|
309
|
+
say(` ${c.gray('다르면')} ${c.cyan('/code')} ${c.gray('처럼 직접 고르세요. 그때부터는 안 바뀝니다.')}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
142
313
|
say('');
|
|
143
314
|
const started = Date.now();
|
|
315
|
+
const before = { in: session.usage.in, out: session.usage.out };
|
|
316
|
+
|
|
317
|
+
// 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
|
|
318
|
+
let saved = session.messages.length;
|
|
319
|
+
const flush = () => {
|
|
320
|
+
for (const m of session.messages.slice(saved)) store.append(m);
|
|
321
|
+
saved = session.messages.length;
|
|
322
|
+
};
|
|
144
323
|
let tools = 0;
|
|
145
324
|
let thinkChars = 0;
|
|
146
325
|
let streamed = false;
|
|
147
326
|
let thinkingShown = false;
|
|
327
|
+
let stage = null;
|
|
328
|
+
|
|
329
|
+
const clearThinking = () => { if (thinkingShown) { cursor.clearLine(); thinkingShown = false; } };
|
|
148
330
|
|
|
331
|
+
turn = new AbortController();
|
|
149
332
|
try {
|
|
150
|
-
for await (const ev of run(session, ctx, toSend)) {
|
|
333
|
+
for await (const ev of run(session, ctx, toSend, { signal: turn.signal })) {
|
|
151
334
|
switch (ev.type) {
|
|
335
|
+
// 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
|
|
336
|
+
case 'stage':
|
|
337
|
+
stage = ev;
|
|
338
|
+
thinkChars = 0;
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
case 'retry':
|
|
342
|
+
clearThinking();
|
|
343
|
+
say(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
|
|
344
|
+
break;
|
|
345
|
+
|
|
152
346
|
case 'waiting':
|
|
153
|
-
process.stdout.write(` ${c.gray('생각 중…')}\r`);
|
|
347
|
+
process.stdout.write(` ${c.gray(stageTag(stage) + ' 생각 중…')}\r`);
|
|
154
348
|
break;
|
|
155
349
|
|
|
156
350
|
case 'thinking':
|
|
157
351
|
thinkChars += ev.text.length;
|
|
158
352
|
if (process.stdout.isTTY) {
|
|
159
353
|
cursor.clearLine();
|
|
160
|
-
process.stdout.write(` ${c.
|
|
354
|
+
process.stdout.write(` ${mark.think} ${c.gray(stageTag(stage))} ${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
|
|
161
355
|
thinkingShown = true;
|
|
162
356
|
}
|
|
163
357
|
break;
|
|
164
358
|
|
|
165
359
|
case 'content':
|
|
166
|
-
|
|
360
|
+
clearThinking();
|
|
167
361
|
if (!streamed) { streamed = true; process.stdout.write(' '); }
|
|
168
362
|
process.stdout.write(ev.text.replace(/\n/g, '\n '));
|
|
169
363
|
break;
|
|
170
364
|
|
|
171
365
|
case 'tool_start':
|
|
172
|
-
|
|
366
|
+
clearThinking();
|
|
367
|
+
if (streamed) { say(''); streamed = false; }
|
|
368
|
+
say('');
|
|
369
|
+
say(` ${toolLabel(ev.name, ev.args)}`);
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
// 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
|
|
373
|
+
case 'tools_start':
|
|
374
|
+
clearThinking();
|
|
173
375
|
if (streamed) { say(''); streamed = false; }
|
|
174
376
|
say('');
|
|
175
|
-
say(` ${c.
|
|
377
|
+
say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
|
|
176
378
|
break;
|
|
177
379
|
|
|
178
380
|
case 'tool':
|
|
179
381
|
tools++;
|
|
180
|
-
|
|
382
|
+
// 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
|
|
383
|
+
if (ev.parallel) say(` ${toolLabel(ev.name, ev.args)}`);
|
|
384
|
+
if (ev.name === 'TodoWrite' && ev.result?.todos) {
|
|
385
|
+
for (const t of ev.result.todos) {
|
|
386
|
+
const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
|
|
387
|
+
const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
|
|
388
|
+
say(` ${표} ${clip(글, 74)}`);
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
|
|
392
|
+
}
|
|
393
|
+
flush(); // 도구가 하나 끝날 때마다 적어 둔다
|
|
181
394
|
break;
|
|
182
395
|
|
|
183
396
|
case 'trimmed':
|
|
184
397
|
say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
|
|
185
398
|
break;
|
|
186
399
|
|
|
400
|
+
case 'compacting':
|
|
401
|
+
clearThinking();
|
|
402
|
+
process.stdout.write(` ${c.gray('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…')}\r`);
|
|
403
|
+
break;
|
|
404
|
+
|
|
405
|
+
case 'compacted': {
|
|
406
|
+
if (process.stdout.isTTY) cursor.clearLine();
|
|
407
|
+
const 줄인 = ev.before - ev.after;
|
|
408
|
+
say(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 — `)}` +
|
|
409
|
+
`${c.gray(ev.before.toLocaleString())} ${c.gray('→')} ${c.white(ev.after.toLocaleString())} ${c.gray('토큰')} ` +
|
|
410
|
+
`${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
|
|
411
|
+
if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
|
|
412
|
+
// 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
|
|
413
|
+
store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
|
|
414
|
+
saved = session.messages.length;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
case 'compact_failed':
|
|
419
|
+
if (process.stdout.isTTY) cursor.clearLine();
|
|
420
|
+
say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
|
|
421
|
+
break;
|
|
422
|
+
|
|
187
423
|
case 'limit':
|
|
188
424
|
say('');
|
|
189
|
-
say(` ${
|
|
425
|
+
say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
|
|
426
|
+
break;
|
|
427
|
+
|
|
428
|
+
case 'aborted':
|
|
429
|
+
clearThinking();
|
|
430
|
+
if (streamed) { say(''); streamed = false; }
|
|
431
|
+
say('');
|
|
432
|
+
say(` ${c.yellow('⊘')} ${c.gray('중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.')}`);
|
|
190
433
|
break;
|
|
191
434
|
|
|
192
435
|
case 'error':
|
|
193
|
-
|
|
436
|
+
clearThinking();
|
|
194
437
|
say('');
|
|
195
|
-
|
|
438
|
+
오류보이기(ev.text);
|
|
196
439
|
break;
|
|
197
440
|
|
|
198
441
|
case 'done':
|
|
442
|
+
clearThinking();
|
|
199
443
|
if (streamed) say('');
|
|
200
444
|
break;
|
|
201
445
|
}
|
|
202
446
|
}
|
|
203
447
|
} catch (err) {
|
|
448
|
+
clearThinking();
|
|
204
449
|
say('');
|
|
205
|
-
|
|
450
|
+
오류보이기(err.message);
|
|
206
451
|
}
|
|
452
|
+
turn = null;
|
|
453
|
+
interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
|
|
454
|
+
flush(); // 오류로 끝났어도 여기까지는 남긴다
|
|
207
455
|
|
|
208
|
-
// 꼬리말 — 이번
|
|
456
|
+
// ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
|
|
209
457
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
210
458
|
const bits = [`${secs}초`];
|
|
211
459
|
if (tools) bits.push(`도구 ${tools}회`);
|
|
212
|
-
|
|
460
|
+
const dIn = session.usage.in - before.in;
|
|
461
|
+
const dOut = session.usage.out - before.out;
|
|
462
|
+
if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
|
|
213
463
|
say('');
|
|
214
|
-
say(` ${c.gray('─
|
|
464
|
+
say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
|
|
215
465
|
}
|
|
216
466
|
|
|
217
467
|
rl.close();
|
|
218
468
|
say('');
|
|
219
|
-
say(` ${c.gray('끝냅니다.')} ${c.gray(
|
|
469
|
+
say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
|
|
220
470
|
say('');
|
|
221
471
|
return 0;
|
|
222
472
|
}
|
|
473
|
+
|
|
474
|
+
// "첫 판단·high" 처럼 지금 도는 단계를 짧게.
|
|
475
|
+
function stageTag(ev) {
|
|
476
|
+
if (!ev) return '';
|
|
477
|
+
const label = STAGES[ev.stage]?.label ?? ev.stage;
|
|
478
|
+
return `${label}·${ev.level}`;
|
|
479
|
+
}
|
package/src/report.js
CHANGED
|
@@ -57,7 +57,7 @@ export function verdict(facts, results) {
|
|
|
57
57
|
if (get('json') !== 'ok') notes.push('구조적 출력이 약합니다 — 편집 형식을 프롬프트로 강제하고 검사를 붙입니다.');
|
|
58
58
|
if (get('stream') !== 'ok') notes.push('스트리밍이 없습니다 — 화면은 스피너로 대체하고 기능은 동일하게 갑니다.');
|
|
59
59
|
if (get('system') === 'warn') notes.push('시스템 지시를 약하게 따릅니다 — 스킬을 적게, 짧게 올려야 합니다.');
|
|
60
|
-
if (get('think') === 'ok') notes.push('추론 강도가 모델 층에서
|
|
60
|
+
if (get('think') === 'ok') notes.push('추론 강도가 모델 층에서 적용됩니다 — /think 로 바로 조절됩니다.');
|
|
61
61
|
else notes.push('추론 강도는 루프 층(계획 강제·도구 호출 상한·자기검증 횟수)으로 조절합니다.');
|
|
62
62
|
if (!facts.ctx) notes.push('컨텍스트 길이를 서버가 안 알려줍니다 — 설정에서 직접 넣어야 합니다.');
|
|
63
63
|
|
|
@@ -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')
|