deel-local-cli 0.9.0 → 1.0.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/src/repl.js CHANGED
@@ -1,479 +1,715 @@
1
- // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
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';
9
- import { run } from './agent/loop.js';
10
- import { Session } from './agent/session.js';
11
- import { makeScope } from './safety/guard.js';
12
- import { History } from './safety/undo.js';
13
- import { Audit } from './safety/audit.js';
14
- import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
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
- };
35
-
36
- // 도구 호출을 한 줄로 요약 — Read(src/a.js) 처럼.
37
- function toolLabel(name, args) {
38
- const a = args ?? {};
39
- const first =
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(')')}` : ''}`;
47
- }
48
-
49
- function toolResultLine(result, ms) {
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}`;
53
- }
54
-
55
- export async function chatLoop(opts = {}) {
56
- const cfg = load();
57
- const prof = activeProfile(cfg);
58
- if (!prof) {
59
- say('');
60
- say(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
61
- say('');
62
- return 1;
63
- }
64
-
65
- const root = opts.root ? opts.root : process.cwd();
66
- const conn = {
67
- kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
68
- key: resolveKey(prof), model: prof.model,
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,
76
- tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
77
- };
78
-
79
- // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
80
- allowEndpoint(conn.base);
81
- if (opts.offline ?? prof.offline) setOffline(true);
82
-
83
- const session = new Session(conn, {
84
- root,
85
- mode: opts.mode ?? 'auto',
86
- // 처음부터 원하는 모드로 시작할 있다 deel --work plan
87
- work: opts.work ?? null,
88
- // 수준은 설정에 남는다. 번 고르면 다음에 켤 때도 그대로다.
89
- level: opts.level ?? cfg.level ?? null,
90
- think: opts.think ?? 'medium',
91
- effort: opts.effort ?? 'save',
92
- maxSteps: opts.maxSteps ?? 24,
93
- });
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
-
117
- // PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
118
- const found = discover(root);
119
- session.skills = found.skills;
120
- session.commands = found.commands;
121
- session.plugins = found.plugins;
122
-
123
- const rl = createInterface({ input: process.stdin, output: process.stdout, historySize: 200 });
124
-
125
- // 입력을 큐로 받는다. rl.question 을 겹쳐 쓰면 파이프로 넣을 때 닫혀 버린다.
126
- const queue = [];
127
- let waiter = null;
128
- let closed = false;
129
- const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
130
-
131
- rl.on('line', (l) => {
132
- if (echo) say(c.gray(l));
133
- if (waiter) { const w = waiter; waiter = null; w(l); }
134
- else queue.push(l);
135
- });
136
- rl.on('close', () => {
137
- closed = true;
138
- if (waiter) { const w = waiter; waiter = null; w(null); }
139
- });
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
-
158
- const nextLine = () => {
159
- if (queue.length) return Promise.resolve(queue.shift());
160
- if (closed) return Promise.resolve(null);
161
- return new Promise((res) => { waiter = res; });
162
- };
163
-
164
- const ask = async (label, o = {}) => {
165
- process.stdout.write(` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`);
166
- const a = await nextLine();
167
- if (a === null) return o.def ?? '';
168
- return a.trim() || o.def || '';
169
- };
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
-
187
- const ctx = {
188
- scope: makeScope(root),
189
- history: new History(root),
190
- audit: new Audit(root),
191
- seen: new Set(),
192
- skills: found.skills,
193
- loadedSkills: new Set(),
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
- },
202
- confirm: async (name, args) => {
203
- say('');
204
- say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
205
- const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
206
- return a === 'y' || a === 'yes' || a === 'ㅇ';
207
- },
208
- };
209
-
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
- }
241
- }
242
-
243
- // ── 머리말 ────────────────────────────────────────────────────────────
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 중단·끝내기')}`);
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
- // 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
268
- let interrupted = false;
269
- let turn = null; // 지금 도는 턴의 AbortController
270
- rl.on('SIGINT', () => {
271
- if (turn && !turn.signal.aborted) {
272
- turn.abort();
273
- return; // 화면 정리는 루프 쪽 'aborted' 이벤트가 한다
274
- }
275
- if (interrupted) { rl.close(); return; }
276
- interrupted = true;
277
- say('');
278
- say(` ${c.gray('한 Ctrl+C 누르면 끝냅니다.')}`);
279
- prompt();
280
- });
281
-
282
- for (;;) {
283
- prompt();
284
- const line = await nextLine();
285
- if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
286
- interrupted = false;
287
- const text = line.trim();
288
- if (!text) continue;
289
-
290
- const cmd = await handle(text, session, ctx);
291
- if (cmd.exit) break;
292
- if (cmd.handled) continue;
293
- const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
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
-
313
- say('');
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
- };
323
- let tools = 0;
324
- let thinkChars = 0;
325
- let streamed = false;
326
- let thinkingShown = false;
327
- let stage = null;
328
-
329
- const clearThinking = () => { if (thinkingShown) { cursor.clearLine(); thinkingShown = false; } };
330
-
331
- turn = new AbortController();
332
- try {
333
- for await (const ev of run(session, ctx, toSend, { signal: turn.signal })) {
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
-
346
- case 'waiting':
347
- process.stdout.write(` ${c.gray(stageTag(stage) + ' 생각 중…')}\r`);
348
- break;
349
-
350
- case 'thinking':
351
- thinkChars += ev.text.length;
352
- if (process.stdout.isTTY) {
353
- cursor.clearLine();
354
- process.stdout.write(` ${mark.think} ${c.gray(stageTag(stage))} ${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
355
- thinkingShown = true;
356
- }
357
- break;
358
-
359
- case 'content':
360
- clearThinking();
361
- if (!streamed) { streamed = true; process.stdout.write(' '); }
362
- process.stdout.write(ev.text.replace(/\n/g, '\n '));
363
- break;
364
-
365
- case 'tool_start':
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();
375
- if (streamed) { say(''); streamed = false; }
376
- say('');
377
- say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
378
- break;
379
-
380
- case 'tool':
381
- tools++;
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(); // 도구가 하나 끝날 때마다 적어 둔다
394
- break;
395
-
396
- case 'trimmed':
397
- say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
398
- break;
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
-
423
- case 'limit':
424
- 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('중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.')}`);
433
- break;
434
-
435
- case 'error':
436
- clearThinking();
437
- say('');
438
- 오류보이기(ev.text);
439
- break;
440
-
441
- case 'done':
442
- clearThinking();
443
- if (streamed) say('');
444
- break;
445
- }
446
- }
447
- } catch (err) {
448
- clearThinking();
449
- say('');
450
- 오류보이기(err.message);
451
- }
452
- turn = null;
453
- interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
454
- flush(); // 오류로 끝났어도 여기까지는 남긴다
455
-
456
- // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
457
- const secs = ((Date.now() - started) / 1000).toFixed(1);
458
- const bits = [`${secs}초`];
459
- if (tools) bits.push(`도구 ${tools}회`);
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()}`);
463
- say('');
464
- say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
465
- }
466
-
467
- rl.close();
468
- say('');
469
- say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
470
- say('');
471
- return 0;
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
- }
1
+ // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
+ import { createInterface, emitKeypressEvents } from 'node:readline';
3
+ import { homedir } from 'node:os';
4
+ import { resolve } from 'node:path';
5
+ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
6
+ import { headerLines } from './ui/status.js';
7
+ import { 화면고르기 } from './ui/screen.js';
8
+ import { STAGES } from './agent/effort.js';
9
+ import { handle } from './commands.js';
10
+ import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
11
+ import { route } from './agent/route.js';
12
+ import { run } from './agent/loop.js';
13
+ import { Session } from './agent/session.js';
14
+ import { makeScope } from './safety/guard.js';
15
+ import { History } from './safety/undo.js';
16
+ import { Audit } from './safety/audit.js';
17
+ import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
18
+ import { discover } from './skills/discover.js';
19
+ import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
20
+ import { Store, latest, prune } from './agent/store.js';
21
+ import { askHidden } from './ui/prompt.js';
22
+ import { explain } from './ui/level.js';
23
+ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
24
+ import { renderDiff, shortStat } from './ui/diff.js';
25
+ import { expand as expandMentions } from './agent/mention.js';
26
+ import { 다붙이기 } from './backend/mcp.js';
27
+ import { 프롬프트토막 as 기억토막, 읽기 as 기억읽기 } from './agent/memory.js';
28
+
29
+ // 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
30
+ const TOOL_GLYPH = {
31
+ Read: c.blue(''),
32
+ Write: c.green(''),
33
+ Edit: c.yellow(''),
34
+ Glob: c.magenta('❋'),
35
+ Grep: c.magenta('❊'),
36
+ Bash: c.hcyan('▶'),
37
+ Skill: c.hmagenta('✦'),
38
+ WebFetch: c.hblue('◍'),
39
+ TodoWrite: c.hyellow('☰'),
40
+ };
41
+
42
+ // 도구 호출을 줄로 요약 Read(src/a.js) 처럼.
43
+ function toolLabel(name, args) {
44
+ const a = args ?? {};
45
+ const first =
46
+ a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ??
47
+ (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
48
+ // 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
49
+ (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
50
+ const g = TOOL_GLYPH[name] ?? c.cyan('');
51
+ const = clip(String(first ?? ''), 56);
52
+ return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
53
+ }
54
+
55
+ function toolResultLine(result, ms) {
56
+ const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
57
+ if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
58
+ // 고친 자리는 몇 줄이 늘고 줄었는지를 요약 옆에 붙인다.
59
+ const 셈 = result?.diff ? ` ${shortStat(result.diff)}` : '';
60
+ return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${셈}${t}`;
61
+ }
62
+
63
+ // 수준별로 몇 줄까지 펼칠지. 초보에게 60줄을 쏟으면 아무것도 안 읽는다.
64
+ const DIFF_LINES = { 쉬움: 14, 개발자: 40 };
65
+
66
+ // 모델의 앞에 세우는 세로줄. 도구 줄과 눈으로 갈리게 하는 유일한 표시다.
67
+ const 답표시 = c.hcyan('▌');
68
+
69
+ export async function chatLoop(opts = {}) {
70
+ const cfg = load();
71
+ const prof = activeProfile(cfg);
72
+ // 연결이 없으면 화면을 세우기 전에 끝난다. 전체화면은 터미널을 통째로
73
+ // 바꿔치기하므로, 세웠다가 바로 나가면 안내가 화면과 함께 사라진다.
74
+ if (!prof) {
75
+ 바로쓰기('');
76
+ 바로쓰기(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 먼저 실행하세요.`);
77
+ 바로쓰기('');
78
+ return 1;
79
+ }
80
+
81
+ /*
82
+ * 여기서부터 화면에 나가는 것은 전부 `화면` 을 거친다.
83
+ *
84
+ * `say` 를 지역 이름으로 다시 묶은 이유: 이 함수 안에 출력이 79군데 있었다.
85
+ * 이름을 60번 바꿔 적으면 그중 한둘은 반드시 어긋나고, 어긋난 자리는
86
+ * 화면에서만 티가 난다. 이름은 그대로 두고 **가는 곳만** 바꾼다.
87
+ * 그러면 갈라내기가 옳은지를 지금 있는 검사들이 그대로 재 준다 —
88
+ * 글자 하나라도 달라지면 검사가 잡는다.
89
+ *
90
+ * 파일 밖으로 나가는 이름이 아니다. 화면을 세우기 전에 쓰는 자리는
91
+ * 위처럼 `바로쓰기` 를 쓴다.
92
+ */
93
+ const 화면 = await 화면고르기({ tui: opts.tui });
94
+ const say = (s = '') => 화면.줄(s);
95
+
96
+ const root = opts.root ? opts.root : process.cwd();
97
+ const conn = {
98
+ kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
99
+ key: resolveKey(prof), model: prof.model,
100
+ // 컨텍스트 길이. 순서가 곧 우선순위다 —
101
+ // deel --ctx 655360 > 프로필에 저장된 값 > 기본값
102
+ // 기본값으로 떨어졌다는 것은 '아직 쟀다' 뜻이다. 아래에서 그렇다고 말해 준다.
103
+ ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
104
+ // 길이 상한. 컨텍스트와 다른 축이다 — 없으면 effort.js 의 울타리를 쓴다.
105
+ // deel --max-tokens 32k > 프로필에 저장된 값 > 서버에서 알아낸 값 > 기본값
106
+ maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
107
+ streaming: prof.streaming ?? false,
108
+ tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
109
+ };
110
+
111
+ // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
112
+ allowEndpoint(conn.base);
113
+ if (opts.offline ?? prof.offline) setOffline(true);
114
+
115
+ const session = new Session(conn, {
116
+ root,
117
+ mode: opts.mode ?? 'auto',
118
+ // 처음부터 원하는 모드로 시작할 수 있다 — deel --work plan
119
+ work: opts.work ?? null,
120
+ // 수준은 설정에 남는다. 한 번 고르면 다음에 켤 때도 그대로다.
121
+ level: opts.level ?? cfg.level ?? null,
122
+ think: opts.think ?? 'medium',
123
+ effort: opts.effort ?? 'save',
124
+ maxSteps: opts.maxSteps ?? 24,
125
+ });
126
+
127
+ // ── 대화 이어하기 ─────────────────────────────────────────────────────
128
+ // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
129
+ let store = null;
130
+ if (opts.sessionId || opts.continue) {
131
+ const target = opts.sessionId ?? latest(root)?.id;
132
+ if (!target) {
133
+ say('');
134
+ say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
135
+ } else {
136
+ store = new Store(root, target);
137
+ const { messages } = store.load();
138
+ if (messages.length) {
139
+ session.messages = messages;
140
+ say('');
141
+ say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
142
+ }
143
+ }
144
+ }
145
+ if (!store) store = new Store(root);
146
+ store.begin({ model: conn.model, base: conn.base, root });
147
+ try { prune(root); } catch {}
148
+
149
+ /*
150
+ * 밖에서 붙인 도구(MCP) 서버를 띄운다.
151
+ *
152
+ * 기본은 꺼져 있다 — .deel/mcp.json 사람이 직접 적어야만 뜬다. 남의
153
+ * 프로그램을 띄우는 일이라, 프로젝트가 존재하는 이유('미승인 SW 반입 금지')
154
+ * 와 정면으로 부딪히기 때문이다. 자물쇠(--offline)가 걸려 있으면 아예 안 띄운다.
155
+ */
156
+ const mcp붙임 = await 다붙이기(root, {
157
+ offline: isOffline(),
158
+ audit: new Audit(root),
159
+ });
160
+
161
+ // PC 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
162
+ const found = discover(root);
163
+ session.skills = found.skills;
164
+ session.commands = found.commands;
165
+ session.plugins = found.plugins;
166
+ // 세션도 알아야 한다 — 밖에서 붙인 도구도 스키마가 매 요청에 실린다.
167
+ // 세면 컨텍스트가 그만큼 조용히 줄어든다.
168
+ session.mcp = mcp붙임.서버들;
169
+ // 지난 대화에서 정해 둔 것을 들고 시작한다.
170
+ session.memory = 기억토막(root);
171
+
172
+ const rl = createInterface({ input: process.stdin, output: process.stdout, historySize: 200 });
173
+
174
+ // 입력을 큐로 받는다. rl.question 겹쳐 쓰면 파이프로 넣을 닫혀 버린다.
175
+ const queue = [];
176
+ let waiter = null;
177
+ let closed = false;
178
+ const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
179
+
180
+ rl.on('line', (l) => {
181
+ if (echo) say(c.gray(l));
182
+ if (waiter) { const w = waiter; waiter = null; w(l); }
183
+ else queue.push(l);
184
+ });
185
+ rl.on('close', () => {
186
+ closed = true;
187
+ if (waiter) { const w = waiter; waiter = null; w(null); }
188
+ });
189
+
190
+ // Shift+Tab 으로 작업 모드를 차례로 돌린다.
191
+ //
192
+ // 터미널일 때만 한다. 파이프로 넣을 때 키를 가로채면 입력이 깨진다 —
193
+ // 검사와 데모가 그렇게 돌아간다.
194
+ if (process.stdin.isTTY) {
195
+ emitKeypressEvents(process.stdin, rl);
196
+ process.stdin.on('keypress', (_ch, key) => {
197
+ if (!key || key.name !== 'tab' || !key.shift) return;
198
+ session.work = nextWork(session.work);
199
+ const w = getWork(session.work);
200
+ 화면.입력지움();
201
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
202
+ + (canWrite(session.work) ? '' : ` ${c.green( 파일을 바꿉니다')}`));
203
+ prompt();
204
+ });
205
+ }
206
+
207
+ const nextLine = () => {
208
+ if (queue.length) return Promise.resolve(queue.shift());
209
+ if (closed) return Promise.resolve(null);
210
+ return new Promise((res) => { waiter = res; });
211
+ };
212
+
213
+ const ask = async (label, o = {}) => {
214
+ 화면.붙임(` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`);
215
+ const a = await nextLine();
216
+ if (a === null) return o.def ?? '';
217
+ return a.trim() || o.def || '';
218
+ };
219
+
220
+ /**
221
+ * 오류를 사람 수준에 맞게 보여준다.
222
+ *
223
+ * 쉬움 수준에서는 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 회색으로 뒤에 남긴다.
224
+ * 원인을 지우지 않는 것이 중요하다 지우면 물어볼 수도 없게 된다.
225
+ * 개발자 수준에서는 원래 문구 그대로다.
226
+ */
227
+ const 오류보이기 = (message) => {
228
+ const r = explain(session.level, message);
229
+ if (!r.plain) { say(` ${c.red('✗')} ${String(message)}`); return; }
230
+ const [머리, ...나머지] = r.text.split('\n');
231
+ say(` ${c.red('✗')} ${머리}`);
232
+ for (const l of 나머지) say(` ${l}`);
233
+ if (r.detail) say(` ${c.gray(`(원래 문구: ${clip(String(r.detail).split('\n')[0], 90)})`)}`);
234
+ };
235
+
236
+ /**
237
+ * 이번 턴에 만들어진 파일을 **디스크를 보고** 말해 준다.
238
+ *
239
+ * 모델이 "만들었습니다" 라고 답을 맺어도 그건 모델의 말이다. 도구가 실패했는데
240
+ * 그렇게 맺는 경우가 실제로 있다. 사용자는 그 말을 믿고 다음 일로 넘어가고,
241
+ * 한참 뒤에야 파일이 없다는 걸 안다. 그때는 대화가 이미 흘러가 있다.
242
+ *
243
+ * 그래서 대신 파일을 본다. 몇 줄인지 · 몇 KB 인지까지 적는다 —
244
+ * '만들어지긴 했는데 반쪽' 인 경우를 그 숫자로 바로 알아볼 수 있다.
245
+ */
246
+ const 만든파일보이기 = (files) => {
247
+ if (!Array.isArray(files) || !files.length) return;
248
+ say('');
249
+ for (const f of files) {
250
+ const 이름 = ctx?.scope ? ctx.scope.show(f.path) : f.path;
251
+ if (f.missing) {
252
+ say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray('— 만들어지지 않았습니다')}`);
253
+ continue;
254
+ }
255
+ if (f.dir) continue;
256
+ const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
257
+ say(` ${c.green('')} ${c.white(이름)} ${c.gray(`· ${f.lines.toLocaleString()}줄 · ${kb}`)}`);
258
+ }
259
+ };
260
+
261
+ const ctx = {
262
+ scope: makeScope(root),
263
+ history: new History(root),
264
+ audit: new Audit(root),
265
+ seen: new Set(),
266
+ // 붙은 MCP 서버. 도구를 부를 여기서 찾는다.
267
+ mcp: mcp붙임.서버들,
268
+ skills: found.skills,
269
+ loadedSkills: new Set(),
270
+ ask,
271
+ // 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다 —
272
+ // 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 안 남는다.
273
+ askPassword: async (label) => {
274
+ if (closed) return null;
275
+ const pw = await askHidden(rl, label, nextLine);
276
+ return pw === null || pw === '' ? null : pw;
277
+ },
278
+ confirm: async (name, args) => {
279
+ say('');
280
+ say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
281
+ const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
282
+ return a === 'y' || a === 'yes' || a === 'ㅇ';
283
+ },
284
+ };
285
+
286
+ // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
287
+ //
288
+ // 때마다 서버에 물어본다. 저장된 값을 그대로 믿지 않는다 —
289
+ // 같은 이름의 모델이라도 서버에서 몇 k 로 올렸는지가 그때그때 다르고,
290
+ // 차이를 화면에 보면 조용히 작아진 채로 쓰게 된다.
291
+ //
292
+ // --ctx 로 직접 주신 값이 있으면 안 건드린다. 사람이 고른 것을 뒤집지 않는다.
293
+ const 길이알림 = []; // 소식
294
+ const 길이경고 = []; // 손을 봐야 하는 것
295
+ if (opts.ctx == null) {
296
+ // 이 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
297
+ // 무슨 일이 일어나는 중인지 알려 준다 멈춘 것처럼 보이면 된다.
298
+ 화면.돌리기('모델에 걸린 컨텍스트 길이를 확인하는 중…');
299
+ let r = null;
300
+ try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
301
+ 화면.돌림멈춤('');
302
+ if (r?.value) {
303
+ const = conn.ctx;
304
+ conn.ctx = r.value;
305
+ // 알아낸 값은 프로필에 남긴다. 다음에 켤 때 화면이 곧바로 맞게 뜬다.
306
+ if (prof.ctx !== r.value) {
307
+ prof.ctx = r.value;
308
+ try { const cfg2 = load(); const t = cfg2.profiles.find((p) => p.id === prof.id); if (t) { t.ctx = r.value; saveCfg(cfg2); } } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
309
+ }
310
+ if (전 !== r.value) 길이알림.push(`컨텍스트를 ${전.toLocaleString()} ${c.gray('→')} ${c.white(r.value.toLocaleString())} 로 맞췄습니다 ${c.gray('(' + (r.source ?? '서버') + '에서 읽음)')}`);
311
+ if (r.max && r.loaded && r.max > r.loaded) {
312
+ 길이경고.push(`이 모델은 ${c.white(r.max.toLocaleString())} 까지 됩니다 — 서버에서 더 올린 뒤 ${c.cyan('/ctx auto')}`);
313
+ }
314
+ } else if (prof.ctx == null) {
315
+ 길이경고.push(`컨텍스트를 서버가 알려줍니다 우선 ${CTX_DEFAULT.toLocaleString()} 으로 잡았습니다. ${c.cyan('/ctx 655360')} 처럼 직접 지정하세요`);
316
+ }
317
+ }
318
+
319
+ // ── 머리말 ────────────────────────────────────────────────────────────
320
+ 화면.머리말(headerLines(session, found));
321
+ const warn = [];
322
+ // 홈 폴더에서 켠 경우. 작업 범위가 집 전체가 된다.
323
+ //
324
+ // 실제로 이렇게 켠 화면을 봤다. 그러면 Glob 이 홈 전체를 훑어 느려지고,
325
+ // 모델이 ~/.deel, ~/.claude, ~/package.json 같은 상관없는 것부터 읽는다.
326
+ // 막혀 있어서 안전하긴 하지만, 애초에 여기서 켤 일이 아니다.
327
+ // 윈도우는 같은 폴더라도 대소문자가 다르게 올 수 있다. 맞춰서 견준다.
328
+ const 같은폴더 = (a, b) => {
329
+ const n = (p) => resolve(String(p ?? '')).replace(/[\\/]+$/, '');
330
+ return process.platform === 'win32'
331
+ ? n(a).toLowerCase() === n(b).toLowerCase()
332
+ : n(a) === n(b);
333
+ };
334
+ if (같은폴더(root, homedir())) {
335
+ warn.push('홈 폴더에서 켰습니다 작업 범위가 전체입니다. 일할 폴더로 옮겨 다시 켜는 편이 빠르고 안전합니다');
336
+ }
337
+ // 밖에서 붙인 도구는 **붙었다고 반드시 말한다.** 남의 프로그램이 이 컴퓨터에서
338
+ // 돌고 있다는 사실을 조용히 넘기면 안 된다 — 그게 이 도구가 심사를 통과한 근거다.
339
+ // 기억을 들고 시작한다는 것을 반드시 말한다. 조용히 실으면 사람은 왜 모델이
340
+ // 안 알려준 것을 아는지 모른다 — 그게 불안하다.
341
+ {
342
+ const 기억 = 기억읽기(root);
343
+ if (기억.줄들.length) 길이알림.push(`지난 대화에서 정한 ${기억.줄들.length}개를 들고 시작합니다 — ${c.cyan('/memory')}`);
344
+ }
345
+ if (mcp붙임.서버들.length) {
346
+ const 도구수 = mcp붙임.서버들.reduce((n, s) => n + s.도구.length, 0);
347
+ 길이알림.push(`밖에서 붙인 도구 ${도구수}개 ${c.gray(`(서버 ${mcp붙임.서버들.length}대: ${mcp붙임.서버들.map((s) => s.이름).join(' · ')})`)} — ${c.cyan('/mcp')}`);
348
+ }
349
+ // 안 뜬 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
350
+ for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
351
+ if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
352
+ if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
353
+ warn.push(...길이경고);
354
+ // 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
355
+ for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
356
+ for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
357
+ say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
358
+
359
+ // 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
360
+ // 줄화면은 상태줄을 깔고 그 아래 ❯ 를, 전체화면은 아래 칸에 상자를 그린다.
361
+ const prompt = () => 화면.입력자리(session);
362
+
363
+ // Ctrl+C 는 상황에 따라 뜻이 다르다.
364
+ // 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
365
+ // 입력을 기다리는 중 → 한 번은 경고, 두 번이면 끝낸다
366
+ // 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
367
+ let interrupted = false;
368
+ let turn = null; // 지금 도는 턴의 AbortController
369
+ rl.on('SIGINT', () => {
370
+ if (turn && !turn.signal.aborted) {
371
+ turn.abort();
372
+ return; // 화면 정리는 루프 'aborted' 이벤트가 한다
373
+ }
374
+ if (interrupted) { rl.close(); return; }
375
+ interrupted = true;
376
+ say('');
377
+ say(` ${c.gray('한 Ctrl+C 누르면 끝냅니다.')}`);
378
+ prompt();
379
+ });
380
+
381
+ for (;;) {
382
+ prompt();
383
+ const line = await nextLine();
384
+ if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
385
+ interrupted = false;
386
+ const text = line.trim();
387
+ if (!text) continue;
388
+
389
+ const cmd = await handle(text, session, ctx);
390
+ if (cmd.exit) break;
391
+ if (cmd.handled) continue;
392
+ const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
393
+
394
+ // @파일 을 지목했으면 그 파일을 바로 붙여 보낸다.
395
+ //
396
+ // 붙인 것은 화면에 반드시 알린다. 사람이 안 보낸 줄 아는 글이 대화에
397
+ // 들어가 있으면 안 된다 — 컨텍스트가 줄었는지도 모르게 된다.
398
+ let 보낼글 = toSend;
399
+ if (toSend.includes('@')) {
400
+ const 예산 = Math.min(20000, Math.floor((session.conn.ctx ?? CTX_DEFAULT) * 0.25));
401
+ const r = expandMentions(toSend, {
402
+ scope: ctx.scope, budget: 예산, seen: ctx.seen,
403
+ onRead: (p, t) => session.noteRead(p, t),
404
+ });
405
+ 보낼글 = r.text;
406
+ for (const a of r.attached) {
407
+ say(` ${c.blue('◧')} ${c.gray('붙임')} ${c.white(a.show)}${a.full ? '' : c.gray(' (앞부분만)')}`);
408
+ }
409
+ for (const b of r.blocked) {
410
+ say(` ${mark.warn} ${c.gray(`${b.path} 작업 범위 밖이라 안 붙였습니다.`)}`);
411
+ }
412
+ }
413
+
414
+ // 종합 모드면 이 한마디가 무슨 일인지 보고 알맞은 모드로 옮긴다.
415
+ //
416
+ // 기본 모드는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
417
+ // 사용자가 직접 고른 모드가 있으면 여기 안 들어온다. 사람이 고른 것을 뒤집지 않는다.
418
+ session.routed = null;
419
+ if (session.work === 'auto') {
420
+ const 골라진 = route(toSend);
421
+ if (골라진.mode) {
422
+ session.routed = 골라진.mode;
423
+ const w = getWork(골라진.mode);
424
+ say('');
425
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')}`
426
+ + ` ${c.gray('말 속에 ' + 골라진.why + ' 가 있어서')}`
427
+ + (canWrite(골라진.mode) ? '' : ` ${c.green('· 파일은 안 바꿉니다')}`));
428
+ say(` ${c.gray('다르면')} ${c.cyan('/code')} ${c.gray('처럼 직접 고르세요. 그때부터는 안 바뀝니다.')}`);
429
+ }
430
+ }
431
+
432
+ say('');
433
+ const started = Date.now();
434
+ const before = { in: session.usage.in, out: session.usage.out };
435
+
436
+ // 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
437
+ let saved = session.messages.length;
438
+ const flush = () => {
439
+ for (const m of session.messages.slice(saved)) store.append(m);
440
+ saved = session.messages.length;
441
+ };
442
+ let tools = 0;
443
+ let thinkChars = 0;
444
+ let streamed = false;
445
+ let thinkingShown = false;
446
+ let stage = null;
447
+ // 접는 표시. 끝나거나 다른 글을 찍기 전에 반드시 멈춰야 한다.
448
+ let 접는중 = false;
449
+ const 접기멈춤 = () => { if (접는중) { 화면.돌림멈춤(); 접는중 = false; } };
450
+
451
+ const clearThinking = () => { 화면.임시지움(); thinkingShown = false; };
452
+ // 단계 꼬리표 — 붙을 때만 뒤에 한 칸을 같이 붙인다. 쉬움 수준에서는 빈 글자라
453
+ // '생각 중…' 앞에 빈칸 개가 뜨는 일이 없다.
454
+ const 꼬리표 = (ev) => { const t = stageTag(ev, session.level); return t ? t + ' ' : ''; };
455
+
456
+ turn = new AbortController();
457
+ try {
458
+ for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
459
+ switch (ev.type) {
460
+ // 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
461
+ case 'stage':
462
+ stage = ev;
463
+ thinkChars = 0;
464
+ break;
465
+
466
+ // 다시 부르는 이유. 쉬움 수준에서는 토큰 숫자를 안 꺼낸다 —
467
+ // 9984 → 16384 가 무슨 뜻인지 설명할 자리가 여기가 아니다.
468
+ case 'retry':
469
+ clearThinking();
470
+ say(session.level === '쉬움'
471
+ ? ` ${c.yellow('↻')} ${c.gray('답이 잘려서 더 길게 다시 받습니다')}`
472
+ : ` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
473
+ break;
474
+
475
+ case 'waiting':
476
+ 화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
477
+ // 지워야 줄이 화면에 있다고 표시해 둔다.
478
+ //
479
+ // 전에는 이 표시를 안 세웠다. \r 로 커서만 앞으로 보내 놓고 지우지는
480
+ // 않으니, 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍혔다 —
481
+ // "이어가기·low 생각 중… 합계를 sum 으로…" 처럼 앞말이 남았다.
482
+ if (process.stdout.isTTY) thinkingShown = true;
483
+ break;
484
+
485
+ case 'thinking':
486
+ thinkChars += ev.text.length;
487
+ 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
488
+ if (process.stdout.isTTY) thinkingShown = true;
489
+ break;
490
+
491
+ /*
492
+ * 모델이 사람에게 하는 말.
493
+ *
494
+ * 왼쪽에 세로줄을 세워 도구 줄과 가른다. 전에는 들여쓰기도 색도 도구와
495
+ * 같아서, 화면을 훑을 때 '모델이 뭐라고 했는지' 를 눈으로 못 찾았다.
496
+ * 도구 이름·결과·바뀐 자리가 줄줄이 지나간 끝에 답이 섞여 있었다.
497
+ *
498
+ * 세로줄 하나면 된다. 전체화면 UI 로 갈 이유가 없다 —
499
+ * 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
500
+ */
501
+ case 'content':
502
+ clearThinking();
503
+ if (!streamed) { streamed = true; 화면.붙임(` ${답표시} `); }
504
+ 화면.붙임(ev.text.replace(/\n/g, `\n ${답표시} `));
505
+ break;
506
+
507
+ case 'tool_start':
508
+ clearThinking();
509
+ if (streamed) { say(''); streamed = false; }
510
+ say('');
511
+ say(` ${toolLabel(ev.name, ev.args)}`);
512
+ break;
513
+
514
+ // 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
515
+ case 'tools_start':
516
+ clearThinking();
517
+ if (streamed) { say(''); streamed = false; }
518
+ say('');
519
+ say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
520
+ break;
521
+
522
+ case 'tool':
523
+ tools++;
524
+ // 걸러져 나온 것(인자가 잘렸거나, 모르는 도구거나, 거부된 것)은
525
+ // '시작' 을 안 거쳤다. 그래서 '생각 중…' 줄이 안 지워진 채로 결과가
526
+ // 그 줄 뒤에 가서 붙고, 이름도 없이 "└ 인자가 잘렸습니다" 만 남는다.
527
+ // 무슨 도구가 왜 그랬는지 알 수 없는 화면이 된다.
528
+ if (ev.showLabel) { clearThinking(); if (streamed) { say(''); streamed = false; } say(''); }
529
+ // 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
530
+ if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
531
+ if (ev.name === 'TodoWrite' && ev.result?.todos) {
532
+ for (const t of ev.result.todos) {
533
+ const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
534
+ const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
535
+ say(` ${표} ${clip(글, 74)}`);
536
+ }
537
+ // 전체화면이면 오른쪽 칸에도 세워 둔다. 흘러가 버리지 않게 —
538
+ // 긴 일에서 '어디까지 했나' 는 늘 보여야 하는 정보다.
539
+ 화면.할일칸(ev.result.todos.map((t) => {
540
+ const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
541
+ return `${표} ${t.state === 'doing' ? c.white(t.text) : c.gray(t.text)}`;
542
+ }));
543
+ } else {
544
+ say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
545
+ // 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
546
+ //
547
+ // auto 모드는 안 물어보고 고친다. 여기서 안 보여주면 사람이
548
+ // 무엇이 바뀐지 볼 방법이 아예 없다 — 되돌릴지 말지도 못 정한다.
549
+ if (ev.result?.diff) {
550
+ // 도구가 돌려준 절대경로를 쓴다. 인자로 온 file_path 는 'a.py' 처럼
551
+ // 상대경로일 수 있고, 그러면 나중에 지금 폴더 기준으로 풀려서
552
+ // 엉뚱한 자리를 가리킨다 — 목록에 ../../.. 가 찍힌다.
553
+ session.noteChange(ev.result.changed ?? ev.args?.file_path, ev.result.diff);
554
+ for (const l of renderDiff(ev.result.diff, { maxLines: DIFF_LINES[session.level] ?? 20 })) say(l);
555
+ // 오른쪽 칸에 '이번 대화에서 뭘 건드렸나' 를 세워 둔다.
556
+ 화면.파일칸([...session.changes].map(([p, d]) => {
557
+ const 이름 = ctx?.scope ? ctx.scope.show(p) : p;
558
+ return `${c.white(이름)} ${c.green('+' + d.added)}${c.red('-' + d.removed)}`;
559
+ }));
560
+ }
561
+ }
562
+ flush(); // 도구가 하나 끝날 때마다 적어 둔다
563
+ break;
564
+
565
+ case 'trimmed':
566
+ say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
567
+ break;
568
+
569
+ /*
570
+ * 접는 중.
571
+ *
572
+ * 여기는 모델을 한 번 더 부르는 자리라 최대 1분이 걸린다. 전에는 글자
573
+ * 한 줄을 \r 로 찍어 놓고 끝이었다 — 움직이는 것이 없으니 답 도중에
574
+ * 화면이 멈춘 것처럼 보였다. 실제로 그렇게 보고 강제 종료한 적이 있다.
575
+ * 돌아가는 표시를 세워 두면 '기다리면 되는 것' 임을 알 수 있다.
576
+ * (Ctrl+C 로 멈출 수도 있다 — 그건 compact.js 쪽에서 받는다.)
577
+ */
578
+ case 'compacting':
579
+ clearThinking();
580
+ 화면.돌리기('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…');
581
+ 접는중 = true;
582
+ break;
583
+
584
+ case 'compacted': {
585
+ 접기멈춤();
586
+ const 줄인 = ev.before - ev.after;
587
+ say(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 — `)}` +
588
+ `${c.gray(ev.before.toLocaleString())} ${c.gray('→')} ${c.white(ev.after.toLocaleString())} ${c.gray('토큰')} ` +
589
+ `${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
590
+ if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
591
+ // 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
592
+ store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
593
+ saved = session.messages.length;
594
+ break;
595
+ }
596
+
597
+ // 서버가 거절하면서 알려 준 한계를 받아 적었다. 실패로 보이면 안 된다 —
598
+ // 사용자 눈에는 잠깐 멈췄다가 그냥 잘 되는 것으로 보여야 맞다.
599
+ case 'learned':
600
+ clearThinking();
601
+ say(` ${c.cyan('◎')} ${c.gray(
602
+ ev.what === 'ctx'
603
+ ? `서버가 알려 준 컨텍스트 한계 ${ev.limit.toLocaleString()} 으로 맞추고 다시 부릅니다`
604
+ + (ev.asked ? c.gray(` (${ev.asked.toLocaleString()} 을 보냈었습니다)`) : '')
605
+ + (ev.guessed ? c.gray(' — 숫자를 안 알려 줘서 절반으로 줄여 봅니다') : '')
606
+ : `서버가 알려 준 답 길이 한계 ${ev.limit.toLocaleString()} 으로 맞추고 다시 부릅니다`,
607
+ )}`);
608
+ if (session.level === '개발자' && ev.from) say(` ${c.gray(clip(ev.from, 110))}`);
609
+ break;
610
+
611
+ case 'compact_failed':
612
+ 접기멈춤();
613
+ say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
614
+ break;
615
+
616
+ case 'limit':
617
+ say('');
618
+ say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
619
+ 만든파일보이기(ev.files);
620
+ break;
621
+
622
+ // 같은 자리를 계속 반복하고 있다. 두면 컨텍스트만 차고 아무것도 안 나온다.
623
+ case 'stuck':
624
+ clearThinking();
625
+ if (streamed) { say(''); streamed = false; }
626
+ say('');
627
+ say(` ${c.yellow('⊘')} ${c.bold('같은 자리에서 헛돌고 있어 멈췄습니다.')}`);
628
+ say(` ${c.gray(ev.why)}`);
629
+ if (/잘렸|잘립니다|잘려/.test(String(ev.why))) {
630
+ say(` ${c.gray('한 번에 만들 내용이 모델의 출력 한도보다 큽니다.')}`);
631
+ // 여기서 /think 를 권한 적이 있다. 틀린 안내였다 — /think 는 '얼마나 생각하나'
632
+ // 이지 '얼마나 길게 답하나' 가 아니다. 오히려 같은 예산에서 생각을 더 하게
633
+ // 만들어 답을 더 잘리게 한다. 출력 상한은 /out 이다.
634
+ say(` ${c.gray('출력 한도를 올리려면')} ${c.cyan('/out')}${c.gray(' 로 지금 값을 보고 올려 보세요.')}`);
635
+ say(` ${c.gray('그래도 안 되면 나눠서 시키셔도 됩니다 — 예: "뼈대만 먼저" → "표 추가" → "그래프 추가"')}`);
636
+ } else {
637
+ say(` ${c.gray('같은 방법으로는 안 됩니다. 다르게 시켜 보시거나, 무엇을 하려는지 한 줄로 알려 주세요.')}`);
638
+ }
639
+ // 멈췄어도 여기까지 만든 것은 있다. 그것부터 알려 준다 —
640
+ // 없는 줄 알고 다시 시키면 앞서 만든 것을 덮어쓴다.
641
+ 만든파일보이기(ev.files);
642
+ break;
643
+
644
+ case 'aborted':
645
+ clearThinking();
646
+ if (streamed) { say(''); streamed = false; }
647
+ say('');
648
+ // 남았는지 아닌지를 **사실대로** 말한다. 전에는 무조건 '남아 있다' 고 했는데
649
+ // 실제로는 아무것도 안 남는 경우가 있었다. 그러면 "이어서 해줘" 라고 했을 때
650
+ // 모델이 방금 제가 한 말을 모른다. 안내가 거짓이면 안 하느니만 못하다.
651
+ say(` ${c.yellow('⊘')} ${c.gray(ev.kept
652
+ ? '중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.'
653
+ : '중단했습니다. 아직 받은 것이 없어 대화에는 아무것도 안 남았습니다 — 다시 말씀하셔야 합니다.')}`);
654
+ break;
655
+
656
+ case 'error':
657
+ clearThinking();
658
+ say('');
659
+ 오류보이기(ev.text);
660
+ break;
661
+
662
+ case 'done':
663
+ clearThinking();
664
+ if (streamed) say('');
665
+ 만든파일보이기(ev.files);
666
+ break;
667
+ }
668
+ }
669
+ } catch (err) {
670
+ 접기멈춤();
671
+ clearThinking();
672
+ say('');
673
+ 오류보이기(err.message);
674
+ }
675
+ 접기멈춤();
676
+ turn = null;
677
+ interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
678
+ flush(); // 오류로 끝났어도 여기까지는 남긴다
679
+
680
+ // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
681
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
682
+ const bits = [`${secs}초`];
683
+ if (tools) bits.push(`도구 ${tools}회`);
684
+ const dIn = session.usage.in - before.in;
685
+ const dOut = session.usage.out - before.out;
686
+ if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
687
+ say('');
688
+ say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
689
+ }
690
+
691
+ rl.close();
692
+ // 띄운 남의 프로세스는 반드시 거둔다. 안 거두면 deel 을 껐는데도
693
+ // 그 서버가 계속 돌고 있게 된다 — 사람 눈에는 안 보이는 채로.
694
+ for (const s of mcp붙임.서버들) s.닫기();
695
+ // 끝맺음은 화면을 접기 **전에** 그린다. 전체화면은 close() 에서 터미널을
696
+ // 원래대로 되돌리는데, 그 뒤에 찍으면 되돌아간 화면에 뜬금없이 한 줄이 남는다.
697
+ say('');
698
+ say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
699
+ say('');
700
+ 화면.close();
701
+ return 0;
702
+ }
703
+
704
+ /**
705
+ * "첫 판단·high" 처럼 지금 도는 단계를 짧게.
706
+ *
707
+ * 쉬움 수준에서는 아무것도 안 붙인다. '이어가기·low' 는 우리 내부 단계 이름과
708
+ * 추론 강도이지 사람이 시킨 것이 아니다 — 고를 일이 없는 사람에게 보여 주면
709
+ * 그것부터 무슨 뜻인지 걱정하게 된다. 그냥 '생각 중…' 이면 된다.
710
+ */
711
+ function stageTag(ev, level) {
712
+ if (!ev || level === '쉬움') return '';
713
+ const label = STAGES[ev.stage]?.label ?? ev.stage;
714
+ return `${label}·${ev.level}`;
715
+ }