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/src/repl.js CHANGED
@@ -1,29 +1,55 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
- import { createInterface } from 'node:readline';
3
- import { c, say, mark, cursor, width } from './ui/ansi.js';
4
- import { handle, COMMANDS } from './commands.js';
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, 48) : '') ?? '';
19
- return `${name}(${c.gray(String(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(')')}` : ''}`;
20
47
  }
21
48
 
22
49
  function toolResultLine(result, ms) {
23
- if (result?.error) return `${c.red('└')} ${c.red(String(result.error).split('\n')[0])}`;
24
- const s = result?.summary ?? '완료';
25
- const t = ms > 1000 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
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
- ctx: prof.ctx ?? 32768, streaming: prof.streaming ?? false,
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
- say('');
109
- say(` ${c.cyan('deel')} ${c.gray(prof.model)} ${c.gray('·')} ${c.gray(ctx.scope.root)}`);
110
- const warn = [];
111
- if (!conn.tools) warn.push('도구 호출 미확인');
112
- if (!conn.streaming) warn.push('스트리밍 없음');
113
- if (warn.length) say(` ${c.yellow('⚠')} ${c.gray(warn.join(' · '))}`);
114
- if (found.skills.length || found.commands.length) {
115
- say(` ${c.gray(`스킬 ${found.skills.length}개 · 명령 ${found.commands.length}개 찾음`)}${found.plugins.length ? c.gray(` (플러그인 ${found.plugins.length}개)`) : ''}`);
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
- say(` ${c.gray('/help 로 명령 목록. Ctrl+C 로 끝냅니다.')}`);
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
- process.stdout.write(`\n${c.cyan('›')} `);
279
+ prompt();
127
280
  });
128
281
 
129
282
  for (;;) {
130
- process.stdout.write(`\n${c.cyan('›')} `);
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.magenta('✻')} ${c.gray(`생각 중… ${thinkChars}자`)}`);
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
- if (thinkingShown) { cursor.clearLine(); thinkingShown = false; }
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
- if (thinkingShown) { cursor.clearLine(); thinkingShown = false; }
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.cyan('')} ${toolLabel(ev.name, ev.args)}`);
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
- say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
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(` ${c.yellow('⚠')} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
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
- if (thinkingShown) cursor.clearLine();
436
+ clearThinking();
194
437
  say('');
195
- say(` ${c.red('✗')} ${ev.text}`);
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
- say(` ${c.red('✗')} ${err.message}`);
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
- if (session.usage.out) bits.push(`${session.usage.out.toLocaleString()}토큰`);
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('─ ' + bits.join(' · '))}`);
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(`도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · 모델 호출 ${session.usage.calls}회`)}`);
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('추론 강도가 모델 층에서 먹습니다 — /think 로 바로 조절됩니다.');
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
+ }
@@ -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')