deel-local-cli 0.8.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/commands.js CHANGED
@@ -13,8 +13,10 @@ import { spin } from './ui/spinner.js';
13
13
  import { PROFILES, LEVELS as THINK_LEVELS, normalizeProfile, table as effortTable } from './agent/effort.js';
14
14
  import { scanLocal, toProfiles } from './backend/scan.js';
15
15
  import { list as listSessions } from './agent/store.js';
16
- import { MODES as WORK_MODES, ORDER as WORK_ORDER, normalize as normWork, get as getWork, canWrite } from './agent/modes.js';
16
+ import { MODES as WORK_MODES, ORDER as WORK_ORDER, DEFAULT as WORK_DEFAULT, normalize as normWork, get as getWork, canWrite } from './agent/modes.js';
17
17
  import { LEVELS, ORDER as LEVEL_ORDER, DEFAULT as LEVEL_DEFAULT, normalize as normLevel, shows as levelShows } from './ui/level.js';
18
+ import { diffLines, renderDiff, shortStat } from './ui/diff.js';
19
+ import { readTextFull } from './tools/fsutil.js';
18
20
  const MODES = {
19
21
  auto: '자율 — 전부 알아서. 되돌리기가 안전망',
20
22
  confirm: '확인 — 되돌릴 수 없는 것만 물어봄',
@@ -25,11 +27,16 @@ export const COMMANDS = {
25
27
  help: { desc: '명령 목록' },
26
28
  clear: { desc: '대화 비우기' },
27
29
  context: { desc: '컨텍스트 사용량 보기' },
30
+ ctx: { desc: '컨텍스트 길이 — 모델에 맞춰 다시 재거나 직접 지정', arg: '[auto|숫자|640k]' },
31
+ out: { desc: '한 번에 받을 답 길이 상한 — 큰 파일이 잘리면 여기를 올린다', arg: '[숫자|32k|auto]' },
28
32
  compact: { desc: '오래된 대화 줄이기' },
29
- model: { desc: '모델·연결 바꾸기' },
33
+ model: { desc: '연결·모델 바꾸기 (이름 일부 · list · models)', arg: '[이름|list|models]' },
30
34
  think: { desc: '추론 강도 (off/low/medium/high/max)', arg: '<수준>' },
31
35
  mode: { desc: '승인 정책 — 얼마나 물어보나 (auto/confirm/strict)', arg: '<모드>' },
32
- work: { desc: '작업 모드 — 무슨 일을 하는 중인가', arg: '[모드]' },
36
+ work: { desc: '작업 모드 — 무슨 일을 하는 중인가 (종합이면 저절로)', arg: '[모드]' },
37
+ // 이름이 /mode auto 와 겹쳐 보이므로 설명에서 못을 박는다.
38
+ // /mode auto 는 '얼마나 물어보나', /auto 는 '무슨 일을 하는 중인가' 다.
39
+ auto: { desc: '작업 모드 → 종합 (요청에 따라 저절로 옮겨 감. /mode 와 다름)' },
33
40
  code: { desc: '작업 모드 → 코드 (고치고 만든다)' },
34
41
  plan: { desc: '작업 모드 → 계획 (먼저 계획만)' },
35
42
  architect:{ desc: '작업 모드 → 설계 (구조를 짠다)' },
@@ -37,6 +44,7 @@ export const COMMANDS = {
37
44
  ask: { desc: '작업 모드 → 묻기 (설명만)' },
38
45
  orchestrator: { desc: '작업 모드 → 총괄 (큰 일을 쪼개서)' },
39
46
  undo: { desc: '직전 작업 되돌리기', arg: '[턴수]' },
47
+ diff: { desc: '이번 대화에서 바뀐 파일 · 바뀐 자리 보기', arg: '[파일]' },
40
48
  tools: { desc: '쓸 수 있는 도구 보기' },
41
49
  skills: { desc: '스킬 보기·검색·골라 올리기', arg: '[검색어|all|off]' },
42
50
  plugin: { desc: '플러그인 목록·설치·삭제·반입묶음', arg: '[install|remove|pack]' },
@@ -44,15 +52,40 @@ export const COMMANDS = {
44
52
  status: { desc: '연결 상태' },
45
53
  scan: { desc: '이 PC 의 로컬 모델 서버 훑기', arg: '[save]' },
46
54
  sessions:{ desc: '이 폴더의 지난 대화 목록' },
55
+ recall: { desc: '지난 대화에서 찾기 — 목록 말고 내용으로', arg: '<찾을 말>' },
56
+ mcp: { desc: '밖에서 붙인 도구(MCP) 서버 보기' },
57
+ memory: { desc: '대화가 끝나도 남는 기억 — 보기·지우기', arg: '[지우기 <번호>|비우기|<적을 말>]' },
47
58
  level: { desc: '사용자 수준 (쉬움/개발자)', arg: '[수준]' },
48
59
  init: { desc: 'DEEL.md 규칙 파일 만들기' },
49
60
  exit: { desc: '끝내기' },
50
61
  quit: { desc: '끝내기' },
51
62
  };
52
63
 
64
+ /**
65
+ * 이 줄이 명령이 아니라 '경로' 인가.
66
+ *
67
+ * 슬래시로 시작한다고 다 명령은 아니다. `/usr/local/bin` 이나 `/mnt/d/일감`
68
+ * 같은 것을 치면 그동안은 통째로 명령으로 먹혀서 "모르는 명령" 만 나오고
69
+ * 모델에게 닿지도 않았다. 경로를 아예 못 적는 셈이었다.
70
+ *
71
+ * 가르는 기준은 간단하다 — 명령 이름에는 슬래시가 없다.
72
+ * 플러그인 명령도 `/플러그인:이름` 이라 콜론을 쓰지 슬래시를 안 쓴다.
73
+ * 그러니 첫 낱말 안에 슬래시가 또 있으면 그건 경로다.
74
+ */
75
+ function 경로처럼보이나(line) {
76
+ const 첫낱말 = line.slice(1).split(/\s+/)[0] ?? '';
77
+ if (첫낱말.includes('/') || 첫낱말.includes('\\')) return true;
78
+ // `/tmp` 처럼 슬래시가 하나뿐이어도, 실제로 있는 자리면 경로로 본다.
79
+ if (첫낱말 && !COMMANDS[첫낱말.toLowerCase()]) {
80
+ try { if (existsSync(line.slice(1).trim())) return true; } catch { /* 못 보면 아닌 걸로 */ }
81
+ }
82
+ return false;
83
+ }
84
+
53
85
  // 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
54
86
  export async function handle(line, session, ctx) {
55
87
  if (!line.startsWith('/')) return { handled: false };
88
+ if (경로처럼보이나(line)) return { handled: false };
56
89
  const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
57
90
  const name = raw.toLowerCase();
58
91
  const arg = rest.join(' ');
@@ -71,6 +104,12 @@ export async function handle(line, session, ctx) {
71
104
 
72
105
  case 'context': return showContext(session), { handled: true };
73
106
 
107
+ case 'ctx': return await ctxLength(session, arg), { handled: true };
108
+
109
+ // 한 번에 받을 답 길이. 컨텍스트(/ctx)와는 다른 축이라 명령을 따로 둔다 —
110
+ // /ctx out 안에 숨겨 두니 아무도 못 찾았고, 정작 큰 파일이 안 만들어지는 원인이었다.
111
+ case 'out': case '출력': return await 출력상한(session, arg), { handled: true };
112
+
74
113
  case 'compact': {
75
114
  const s = spin('앞선 대화를 요약해 접는 중…');
76
115
  const r = await compact(session);
@@ -91,26 +130,70 @@ export async function handle(line, session, ctx) {
91
130
  return { handled: true };
92
131
  }
93
132
 
94
- case 'model': return await switchModel(session, ctx), { handled: true };
133
+ case 'model': return await switchModel(session, ctx, arg), { handled: true };
95
134
 
135
+ /*
136
+ * /think — '얼마나 생각하나' 하나만 정한다.
137
+ *
138
+ * 전에는 한 명령이 두 축을 맡았다. `/think high` 는 강도(5단계)를 정하고
139
+ * `/think save` 는 배분(3가지)을 정했다 — 같은 이름으로 다른 것을 정하니
140
+ * 화면을 봐도 지금 무엇이 무엇인지 읽히지 않았다. 게다가 부를 때마다
141
+ * 단계표가 통째로 펼쳐졌고, 그 표의 '출력상한' 칸 세 줄은 늘 같은 값이었다.
142
+ *
143
+ * 그래서 갈랐다.
144
+ * /think high 강도
145
+ * /think 배분 절약 단계별 배분
146
+ * /think 자세히 단계표
147
+ * /out 출력 상한 (아예 다른 축이라 명령을 따로 뺐다)
148
+ *
149
+ * 옛 이름(/think save)도 그대로 받는다. 쓰던 사람의 손버릇을 깨지 않는다.
150
+ */
96
151
  case 'think': {
97
- const asProfile = normalizeProfile(arg);
152
+ const = String(arg ?? '').trim();
153
+ // 낱말 끝을 \b 로 잡으면 안 된다. \b 는 \w(=[A-Za-z0-9_])를 기준으로 하는데
154
+ // 한글은 \w 가 아니다 — '배분 절약' 의 '분' 과 공백은 **둘 다 비낱말**이라
155
+ // 그 사이에 경계가 없다. 그래서 `/^배분\b/` 는 영영 안 맞았고,
156
+ // /think 배분 … 은 통째로 죽은 명령이었다(영어 prof 만 먹혔다).
157
+ // 공백이나 줄 끝으로 직접 끊는다.
158
+ const 배분말 = /^(배분|profile|prof)(\s|$)/.test(말) ? 말.replace(/^(배분|profile|prof)\s*/, '') : null;
159
+
160
+ if (배분말 !== null) {
161
+ const p = normalizeProfile(배분말);
162
+ if (!p) {
163
+ say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')}`);
164
+ say(` ${c.gray('예')} ${c.cyan('/think 배분 절약')}`);
165
+ say('');
166
+ return { handled: true };
167
+ }
168
+ session.effort = p;
169
+ session.effortSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
170
+ say(` ${mark.ok} 배분 ${c.bold(PROFILES[p].name)} ${c.gray('— ' + PROFILES[p].desc)}`);
171
+ showThink(session);
172
+ return { handled: true };
173
+ }
174
+
175
+ if (/^(자세히|detail|-v)$/.test(말)) { showThink(session, { 자세히: true }); return { handled: true }; }
176
+
177
+ // 옛 이름 — /think save 처럼 배분 이름을 바로 친 경우.
178
+ const asProfile = normalizeProfile(말);
98
179
  if (asProfile) {
99
180
  session.effort = asProfile;
100
- session.effortSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
181
+ session.effortSet = true;
101
182
  say(` ${mark.ok} 배분 ${c.bold(PROFILES[asProfile].name)} ${c.gray('— ' + PROFILES[asProfile].desc)}`);
183
+ say(` ${c.gray('이제')} ${c.cyan('/think 배분 ' + PROFILES[asProfile].name)} ${c.gray('로도 됩니다 — 강도와 헷갈리지 않게 갈랐습니다.')}`);
102
184
  showThink(session);
103
185
  return { handled: true };
104
186
  }
105
- if (!THINK_LEVELS.includes(arg)) {
187
+
188
+ if (!THINK_LEVELS.includes(말)) {
106
189
  showThink(session);
107
190
  return { handled: true };
108
191
  }
109
- session.think = arg;
192
+ session.think = 말;
110
193
  session.thinkSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
111
- say(` ${mark.ok} 추론 강도 ${c.bold(arg)}`);
112
- if (!session.conn.think && arg !== 'off') {
113
- say(` ${c.yellow('이 연결은 모델 층 조절이 먹습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
194
+ say(` ${mark.ok} 추론 강도 ${c.bold()}`);
195
+ if (!session.conn.think && !== 'off') {
196
+ say(` ${c.yellow('이 연결은 모델 층 조절이 적용되지 않습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
114
197
  }
115
198
  showThink(session);
116
199
  return { handled: true };
@@ -143,6 +226,8 @@ export async function handle(line, session, ctx) {
143
226
  return { handled: true };
144
227
  }
145
228
 
229
+ case 'diff': return 바뀐것보기(session, ctx, arg), { handled: true };
230
+
146
231
  case 'tools': {
147
232
  rule('도구', 70);
148
233
  for (const [n, t] of Object.entries(TOOLS)) {
@@ -184,23 +269,39 @@ export async function handle(line, session, ctx) {
184
269
  }
185
270
 
186
271
  case 'work':
272
+ case 'auto':
187
273
  case 'code': case 'plan': case 'architect':
188
274
  case 'debug': case 'ask': case 'orchestrator': {
189
- const 원하는 = name === 'work' ? normWork(arg) : name;
190
- if (name === 'work' && !원하는) { showWork(session); return { handled: true }; }
191
- const 골라진 = normWork(원하는);
275
+ // 인자 없이 /work 치면 지금 모드와 고를 수 있는 것을 보여 준다.
276
+ if (name === 'work' && !arg.trim()) { showWork(session); return { handled: true }; }
277
+
278
+ const 골라진 = normWork(name === 'work' ? arg : name);
192
279
  if (!골라진) {
280
+ // 오타에는 목록만 보여 주면 안 된다.
281
+ //
282
+ // 예전에는 /work 오타 가 조용히 목록으로 빠졌다. 그러면 사람은 자기가
283
+ // 친 말이 틀렸다는 걸 모른 채 '왜 안 바뀌지' 만 하게 된다. 못 알아들었다는
284
+ // 말을 먼저 하고, 그 다음에 쓸 수 있는 것을 보여 준다.
193
285
  say(` ${mark.warn} 그런 모드는 없습니다: ${c.white(arg)}`);
194
286
  say(` ${c.gray('쓸 수 있는 것:')} ${WORK_ORDER.map((k) => c.cyan(WORK_MODES[k].name)).join(c.gray(' · '))}`);
195
287
  say('');
288
+ showWork(session);
196
289
  return { handled: true };
197
290
  }
198
291
  session.work = 골라진;
292
+ // 사람이 직접 골랐다. 저절로 골라 둔 것이 있으면 지운다 —
293
+ // 안 지우면 이번 한마디는 여전히 옛 모드로 돌아 "왜 안 바뀌지" 가 된다.
294
+ session.routed = null;
199
295
  const w = getWork(골라진);
200
296
  say('');
201
297
  say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`);
202
298
  say(` ${c.gray('도구')} ${canWrite(골라진) ? c.yellow('읽기 + 파일 바꾸기') : c.green('읽기만 — 파일을 못 바꿉니다')}`);
203
299
  say(` ${c.gray('생각')} ${c.white(w.think ?? session.think)}${c.gray('·')}${c.magenta(w.effort)} ${c.gray('최대 ' + w.steps + '걸음')}`);
300
+ if (골라진 === 'auto') {
301
+ say(` ${c.gray('앞으로는 한마디마다 알맞은 모드로 저절로 옮겨 갑니다.')}`);
302
+ } else {
303
+ say(` ${c.gray('직접 고르셨으므로 저절로 바뀌지 않습니다. 다시 맡기려면')} ${c.cyan('/work 종합')}`);
304
+ }
204
305
  say('');
205
306
  return { handled: true };
206
307
  }
@@ -233,6 +334,167 @@ export async function handle(line, session, ctx) {
233
334
  return { handled: true };
234
335
  }
235
336
 
337
+ /*
338
+ * /recall — 지난 대화에서 **내용으로** 찾는다.
339
+ *
340
+ * /sessions 는 목록만 보여 준다. 언제 무슨 모델로 몇 턴 했는지는 알겠는데
341
+ * "저번에 그 인코딩 문제 어떻게 풀었더라" 는 못 찾는다. 기록이 있는데
342
+ * 못 찾으면 없는 것과 같다.
343
+ */
344
+ case 'recall': {
345
+ const 말 = String(arg ?? '').trim();
346
+ if (!말) {
347
+ say(` ${c.gray('찾을 말을 적어 주세요 —')} ${c.cyan('/recall CP949 인코딩')}`);
348
+ say(` ${c.gray('낱말 두세 개가 가장 잘 맞습니다. 조사는 붙어 있어도 됩니다.')}`);
349
+ say('');
350
+ return { handled: true };
351
+ }
352
+ const { 찾기 } = await import('./agent/recall.js');
353
+ const r = 찾기(session.root, 말, { limit: 10 });
354
+
355
+ rule(`지난 대화에서 "${clip(말, 30)}"`, 70);
356
+ if (!r.낱말.length) {
357
+ say(` ${mark.no} 찾을 낱말이 없습니다 ${c.gray('— 두 글자 이상으로 적어 주세요.')}`);
358
+ say('');
359
+ return { handled: true };
360
+ }
361
+ if (!r.맞은것.length) {
362
+ // 못 찾은 것과 안 찾아본 것을 구분해서 말한다.
363
+ say(r.예산초과
364
+ ? ` ${mark.warn} 지난 대화 ${r.전체파일}개 중 ${r.본파일}개까지만 뒤졌습니다 ${c.gray('(양이 많아 멈췄습니다)')}`
365
+ : ` ${c.gray(`지난 대화 ${r.본파일}개를 다 뒤졌지만 없습니다.`)}`);
366
+ say(` ${c.gray('찾은 낱말:')} ${c.white(r.낱말.join(' · '))}`);
367
+ say('');
368
+ return { handled: true };
369
+ }
370
+
371
+ for (const h of r.맞은것) {
372
+ const 날 = h.언제 instanceof Date ? h.언제.toISOString().slice(0, 16).replace('T', ' ') : '';
373
+ const 색 = h.role === 'user' ? c.hcyan : h.role === 'assistant' ? c.white : c.gray;
374
+ say(` ${c.gray(pad(날, 17))} ${색(pad(h.누구, 7))} ${c.gray(h.세션)}`);
375
+ say(` ${강조(h.토막, r.낱말)}`);
376
+ }
377
+ say('');
378
+ say(` ${c.gray(`${r.전체맞음}건 중 ${r.맞은것.length}건 · 대화 ${r.본파일}개를 뒤졌습니다`)}`
379
+ + (r.예산초과 ? ` ${c.yellow(`(${r.전체파일}개 중 ${r.본파일}개까지만)`)}` : ''));
380
+ say(` ${c.gray('그 대화를 이어하려면')} ${c.cyan('deel --resume ' + r.맞은것[0].세션)}`);
381
+ say('');
382
+ return { handled: true };
383
+ }
384
+
385
+ /*
386
+ * /mcp — 밖에서 붙인 도구 서버.
387
+ *
388
+ * 여기서 제일 중요한 줄은 도구 목록이 아니라 **경고**다. MCP 서버는 남의
389
+ * 프로그램이고 우리 작업 범위를 안 지킨다. 화면 어디에도 그 말이 없으면
390
+ * 사람은 우리 도구와 똑같이 안전한 줄 안다.
391
+ */
392
+ /*
393
+ * /memory — 대화가 끝나도 남는 것.
394
+ *
395
+ * 사람이 반드시 **볼 수 있고 지울 수 있어야** 한다. 모델이 잘못 적은 줄은
396
+ * 매 요청마다 실려 나가면서 계속 틀리게 만든다. 틀린 기억은 없느니만 못하다.
397
+ */
398
+ case 'memory': {
399
+ const M = await import('./agent/memory.js');
400
+ const 말 = String(arg ?? '').trim();
401
+
402
+ if (/^(비우기|clear|지우기전부)$/.test(말)) {
403
+ M.비우기(session.root);
404
+ session.memory = M.프롬프트토막(session.root);
405
+ say(` ${mark.ok} 기억을 비웠습니다.`);
406
+ say('');
407
+ return { handled: true };
408
+ }
409
+
410
+ const 지움 = /^(지우기|잊어|forget|rm)\s+(\d+)$/.exec(말);
411
+ if (지움) {
412
+ const r = M.지우기(session.root, 지움[2]);
413
+ if (!r.ok) say(` ${mark.no} ${r.why}`);
414
+ else {
415
+ session.memory = M.프롬프트토막(session.root);
416
+ say(` ${mark.ok} 잊었습니다: ${c.gray(clip(r.뺀것, 60))}`);
417
+ }
418
+ say('');
419
+ return { handled: true };
420
+ }
421
+
422
+ // 그 밖의 말은 '이걸 기억해라' 로 본다. 모델을 안 거치고 바로 적는 길이다.
423
+ if (말) {
424
+ const r = M.더하기(session.root, 말);
425
+ if (!r.ok) say(` ${mark.warn} ${r.why}`);
426
+ else {
427
+ session.memory = M.프롬프트토막(session.root);
428
+ say(` ${mark.ok} 기억했습니다 ${c.gray(`(${r.줄수}줄)`)}`);
429
+ if (r.넘침) say(` ${c.gray('자리가 차서 오래된 것을 뺐습니다.')}`);
430
+ }
431
+ say('');
432
+ return { handled: true };
433
+ }
434
+
435
+ const 기억 = M.읽기(session.root);
436
+ rule('기억 — 대화가 끝나도 남는 것', 70);
437
+ if (!기억.줄들.length) {
438
+ say(` ${c.gray('아직 없습니다.')}`);
439
+ say('');
440
+ say(` ${c.gray('모델이 스스로 적기도 하고, 직접 적으셔도 됩니다 —')}`);
441
+ say(` ${c.cyan('/memory 사내 문서는 CP949 로 읽고 CP949 로 되돌려 쓴다')}`);
442
+ say('');
443
+ return { handled: true };
444
+ }
445
+ for (const [i, l] of 기억.줄들.entries()) {
446
+ say(` ${c.gray(String(i + 1).padStart(2))} ${c.white(clip(l, 82))}`);
447
+ }
448
+ const 글자 = 기억.줄들.join('\n').length;
449
+ say('');
450
+ say(` ${c.gray(`${기억.줄들.length}줄 · 약 ${Math.round(글자 / 3).toLocaleString()}토큰이 매 요청마다 함께 나갑니다`)}`);
451
+ say(` ${c.gray('파일')} ${c.white(기억.자리)} ${c.gray('— 직접 고치셔도 됩니다')}`);
452
+ say(` ${c.cyan('/memory 지우기 3')} ${c.cyan('/memory 비우기')}`);
453
+ say('');
454
+ return { handled: true };
455
+ }
456
+
457
+ case 'mcp': {
458
+ const { 설정읽기, 설정자리, 도구최대 } = await import('./backend/mcp.js');
459
+ const 붙은것 = session.mcp ?? [];
460
+ rule('밖에서 붙인 도구 (MCP)', 70);
461
+
462
+ if (!붙은것.length) {
463
+ const 설정 = 설정읽기(session.root);
464
+ if (설정.오류) say(` ${mark.no} ${설정.오류}`);
465
+ else if (!설정.있음) {
466
+ say(` ${c.gray('붙인 것이 없습니다.')} ${c.gray('설정 파일이 없습니다 —')} ${c.white(설정자리(session.root))}`);
467
+ } else if (!설정.서버들.length) {
468
+ say(` ${c.gray('설정은 있는데 띄울 서버가 없습니다.')} ${c.gray(설정자리(session.root))}`);
469
+ } else {
470
+ say(` ${mark.warn} 설정에 ${설정.서버들.length}대가 적혀 있는데 하나도 안 붙었습니다.`);
471
+ say(` ${c.gray('오프라인 잠금 중이거나, 켤 때 못 떴습니다. 머리말의 경고를 보세요.')}`);
472
+ }
473
+ say('');
474
+ say(` ${c.gray('붙이려면')} ${c.white('.deel/mcp.json')} ${c.gray('에 이렇게 적습니다 —')}`);
475
+ say(` ${c.gray('{ "mcpServers": { "사내위키": { "command": "node", "args": ["wiki-mcp.js"] } } }')}`);
476
+ say('');
477
+ say(` ${c.yellow('※')} ${c.gray('MCP 서버는 남의 프로그램입니다. 사내 반입 심사를 따로 받으셔야 합니다.')}`);
478
+ say('');
479
+ return { handled: true };
480
+ }
481
+
482
+ for (const s of 붙은것) {
483
+ const 상태 = s.살아있나() ? c.green('●') : c.red('○');
484
+ const 이름들 = s.도구.map((t) => t.name);
485
+ say(` ${상태} ${c.bold(s.이름)} ${c.gray(s.정보?.name ? `${s.정보.name} ${s.정보.version ?? ''}` : s.설정.command)}`);
486
+ say(` ${c.gray('도구 ' + s.도구.length + '개')} ${c.gray(clip(이름들.join(' · '), 60))}`);
487
+ if (s.잘림) say(` ${mark.warn} ${c.gray(`${s.잘림}개는 뺐습니다 — 한 서버에 ${도구최대}개까지만 받습니다(컨텍스트가 줄어듭니다)`)}`);
488
+ if (!s.살아있나()) say(` ${c.red(s.죽음 ?? '죽었습니다')}`);
489
+ }
490
+ say('');
491
+ say(` ${c.gray('모델에게는')} ${c.white('mcp__<서버>__<도구>')} ${c.gray('라는 이름으로 보입니다.')}`);
492
+ say(` ${c.yellow('※')} ${c.gray('이 도구들은 남의 프로그램이 돌립니다 —')} ${c.white('작업 범위(' + session.root + ') 를 안 지킵니다.')}`);
493
+ say(` ${c.gray(' 무엇을 불렀는지는')} ${c.white('.deel/audit.jsonl')} ${c.gray('에 남습니다.')}`);
494
+ say('');
495
+ return { handled: true };
496
+ }
497
+
236
498
  case 'sessions': {
237
499
  const rows = listSessions(session.root, { limit: 12 });
238
500
  rule('이 폴더의 지난 대화', 70);
@@ -395,11 +657,15 @@ function showSkills(session, arg) {
395
657
  return;
396
658
  }
397
659
 
660
+ // 스킬은 남의 폴더·플러그인에서 온다. 앞머리(frontmatter)가 빠진 파일이 섞이면
661
+ // 이름이나 설명이 없다. 그걸 그대로 만지면 목록 하나 보려다 대화가 끝난다.
662
+ const 낮게 = (v) => String(v ?? '').toLowerCase();
663
+
398
664
  const q = arg.trim();
399
665
  if (q === 'all') {
400
666
  all.forEach((s) => { s.enabled = true; });
401
667
  session.maxSkillsListed = Math.min(all.length, 200);
402
- say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 먹습니다 — /context 로 확인하세요.')}`);
668
+ say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 차지합니다 — /context 로 확인하세요.')}`);
403
669
  say('');
404
670
  return;
405
671
  }
@@ -413,7 +679,7 @@ function showSkills(session, arg) {
413
679
  const term = q.slice(3).trim().toLowerCase();
414
680
  let n = 0;
415
681
  for (const s of all) {
416
- s.enabled = s.name.toLowerCase().includes(term) || s.description.toLowerCase().includes(term);
682
+ s.enabled = 낮게(s.name).includes(term) || 낮게(s.description).includes(term);
417
683
  if (s.enabled) n++;
418
684
  }
419
685
  say(` ${mark.ok} "${term}" 에 걸리는 ${n}개만 올립니다.`);
@@ -422,14 +688,17 @@ function showSkills(session, arg) {
422
688
  }
423
689
 
424
690
  const hits = q
425
- ? all.filter((s) => s.name.toLowerCase().includes(q.toLowerCase()) || s.description.toLowerCase().includes(q.toLowerCase()))
691
+ ? all.filter((s) => 낮게(s.name).includes(q.toLowerCase()) || 낮게(s.description).includes(q.toLowerCase()))
426
692
  : session.listedSkills();
427
693
 
428
694
  say('');
429
695
  rule(q ? `스킬 검색: ${q}` : '지금 올라간 스킬', 74);
430
696
  for (const s of hits.slice(0, 30)) {
431
697
  const tag = s.enabled ? c.green('●') : c.gray('○');
432
- say(` ${tag} ${c.cyan(pad(s.name, 32))} ${c.gray(s.description.slice(0, 60))}`);
698
+ // 설명이 없을 있다. 스킬은 남의 폴더·플러그인에서 오는 것이라
699
+ // 앞머리(frontmatter)가 빠진 파일이 섞인다. 여기서 터지면 목록 하나 보려다
700
+ // 대화가 통째로 끝난다 — 화면 그리기는 무슨 일이 있어도 안 죽어야 한다.
701
+ say(` ${tag} ${c.cyan(pad(s.name, 32))} ${c.gray(String(s.description ?? '').slice(0, 60))}`);
433
702
  }
434
703
  if (hits.length > 30) say(` ${c.gray(`… 그 밖에 ${hits.length - 30}개`)}`);
435
704
  say('');
@@ -462,7 +731,7 @@ function help(session) {
462
731
  say('');
463
732
  // 감춘 것은 '못 쓰는 것' 이 아니다. 그 말을 분명히 해 둔다.
464
733
  if (감춘수) {
465
- say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 — 넣으면 그대로 먹습니다.`)}`);
734
+ say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 — 직접 입력하면 그대로 실행됩니다.`)}`);
466
735
  say(` ${c.gray('전부 보려면')} ${c.cyan('/level 개발자')}`);
467
736
  say('');
468
737
  }
@@ -506,26 +775,81 @@ function showLevel(session, arg) {
506
775
  say('');
507
776
  }
508
777
 
778
+ /**
779
+ * 찾은 낱말에 색을 입힌다.
780
+ *
781
+ * 토막만 보여 주면 **왜 이게 걸렸는지** 안 보인다. 특히 조사를 떼고 찾기
782
+ * 때문에("인코딩을" 로 "인코딩" 을 찾는다) 눈으로는 안 맞는 것처럼 보이는
783
+ * 경우가 있다. 맞은 자리를 칠해 주면 그 의심이 사라진다.
784
+ */
785
+ function 강조(글, 낱말들) {
786
+ let 조각 = [{ 글, 맞음: false }];
787
+ for (const w of 낱말들) {
788
+ const 다음 = [];
789
+ for (const p of 조각) {
790
+ if (p.맞음) { 다음.push(p); continue; }
791
+ const 낮은 = p.글.toLowerCase();
792
+ let i = 0;
793
+ let 자리 = 낮은.indexOf(w);
794
+ while (자리 >= 0) {
795
+ if (자리 > i) 다음.push({ 글: p.글.slice(i, 자리), 맞음: false });
796
+ 다음.push({ 글: p.글.slice(자리, 자리 + w.length), 맞음: true });
797
+ i = 자리 + w.length;
798
+ 자리 = 낮은.indexOf(w, i);
799
+ }
800
+ if (i < p.글.length) 다음.push({ 글: p.글.slice(i), 맞음: false });
801
+ }
802
+ 조각 = 다음;
803
+ }
804
+ return 조각.map((p) => (p.맞음 ? c.hyellow(p.글) : c.gray(p.글))).join('');
805
+ }
806
+
509
807
  // 추론 강도는 값 하나가 아니라 '단계별 배분' 이다. 그 배분을 눈에 보이게 그린다.
510
- function showThink(session) {
808
+ /**
809
+ * 지금 추론 강도가 어떻게 되어 있는지.
810
+ *
811
+ * 기본은 **한 줄**이다. 사람이 알고 싶은 것은 '지금 얼마나 생각하나' 이지
812
+ * 단계별 표가 아니다. 표는 /think 자세히 로 뺐다.
813
+ *
814
+ * 쉬움 수준에서는 배분 이야기를 아예 안 꺼낸다 — 고를 일이 없는 사람에게
815
+ * 고르는 법을 보여 주면 그것부터 걱정하게 된다.
816
+ */
817
+ function showThink(session, { 자세히 = false } = {}) {
511
818
  const b = session.breakdown();
512
- const t = effortTable(session.think, session.effort, { ctx: b.total, used: b.used, max: session.conn.maxTokens ?? null });
513
- say('');
514
- rule('추론 강도', 70);
515
- say(` 기준 ${c.bold(session.think)} 배분 ${c.bold(t.name)} ${c.gray(t.desc)}`);
516
- say(` ${c.gray('상한은 이 모델 기준으로 계산합니다 — 컨텍스트')} ${c.white(t.ctx.toLocaleString())}${c.gray(', 지금 찬 양')} ${c.white(t.used.toLocaleString())}`);
819
+ const 아는상한 = session.conn.maxTokens ?? session.conn.maxOut ?? null;
820
+ const t = effortTable(session.think, session.effort, { ctx: b.total, used: b.used, max: 아는상한 });
821
+ const 개발자 = session.level === '개발자';
822
+ const 단계 = t.rows.map((r) => `${r.label} ${r.level}`).join(c.gray(' · '));
823
+
517
824
  say('');
518
- say(` ${c.gray(pad('단계', 12) + pad('강도', 10) + pad('출력상한', 10) + '언제')}`);
519
- for (const r of t.rows) {
520
- const 화살 = r.moved > 0 ? c.yellow('↑') : r.moved < 0 ? c.cyan('↓') : c.gray('·');
521
- say(` ${pad(r.label, 12)}${화살} ${pad(r.level, 8)}${pad(r.cap.toLocaleString(), 10, 'right')} ${c.gray(r.why)}`);
825
+ say(` ${c.gray('추론 강도')} ${c.bold(session.think)} ${c.gray(`(${단계})`)}`);
826
+ if (개발자 || 자세히) say(` ${c.gray('배분')} ${c.bold(t.name)} ${c.gray(t.desc)}`);
827
+
828
+ if (자세히) {
829
+ say('');
830
+ say(` ${c.gray(pad('단계', 12) + pad('강도', 10) + pad('출력상한', 10) + '언제')}`);
831
+ for (const r of t.rows) {
832
+ const 화살 = r.moved > 0 ? c.yellow('↑') : r.moved < 0 ? c.cyan('↓') : c.gray('·');
833
+ say(` ${pad(r.label, 12)}${화살} ${pad(r.level, 8)}${pad(r.cap.toLocaleString(), 10, 'right')} ${c.gray(r.why)}`);
834
+ }
835
+ say('');
836
+ // 상한이 어디서 왔는지 밝힌다. 세 줄이 같은 값일 때 그게 고장인지 아닌지
837
+ // 이 한 줄로 갈린다 — 아는 상한이 낮으면 셋이 같아지는 것이 맞다.
838
+ say(` ${c.gray('출력 상한은')} ${c.white((아는상한 ?? 16384).toLocaleString())} ${c.gray(
839
+ session.conn.maxTokens ? '(직접 정하신 값)' : session.conn.maxOut ? '(서버에서 알아낸 값)' : '(모르는 값이라 기본값)',
840
+ )}${c.gray(' 안에서 나눕니다 —')} ${c.cyan('/out')}`);
841
+ say(` ${c.gray('컨텍스트')} ${c.white(t.ctx.toLocaleString())}${c.gray(' · 지금 찬 양 ')}${c.white(t.used.toLocaleString())}`);
842
+ say('');
843
+ say(` ${c.gray('강도')} ${THINK_LEVELS.join(' · ')} ${c.gray('예')} ${c.cyan('/think high')}`);
844
+ say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('예')} ${c.cyan('/think 배분 절약')}`);
845
+ } else if (개발자) {
846
+ say(` ${c.gray('강도')} ${c.cyan('/think high')} ${c.gray('배분')} ${c.cyan('/think 배분 절약')} ${c.gray('자세히')} ${c.cyan('/think 자세히')}`);
847
+ } else {
848
+ say(` ${c.gray('더 세게')} ${c.cyan('/think high')} ${c.gray('더 빠르게')} ${c.cyan('/think low')}`);
522
849
  }
523
- say('');
524
- say(` ${c.gray('강도')} ${THINK_LEVELS.join(' · ')} ${c.gray('예')} ${c.cyan('/think high')}`);
525
- say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('예')} ${c.cyan('/think save')}`);
850
+
526
851
  if (!session.conn.think && session.think !== 'off') {
527
- say('');
528
- say(` ${c.yellow('이 연결은 모델 층 강도 조절이 안 먹습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
852
+ say(` ${c.yellow('이 연결은 모델 층 강도 조절이 적용되지 않습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
529
853
  }
530
854
  say('');
531
855
  }
@@ -550,35 +874,486 @@ function showContext(session) {
550
874
  say('');
551
875
  }
552
876
 
553
- async function switchModel(session, ctx) {
877
+ /**
878
+ * 이번 대화에서 무엇이 바뀌었는지 보여 준다.
879
+ *
880
+ * /diff 바뀐 파일 목록과 늘고 준 줄 수
881
+ * /diff src/a.js 그 파일이 처음과 지금 사이에 어떻게 달라졌는지
882
+ *
883
+ * 파일 하나를 볼 때는 이번 대화의 '맨 처음' 모습과 견준다. 세 번 고쳤어도
884
+ * 사람이 알고 싶은 것은 '내가 시키기 전과 지금이 뭐가 다른가' 이지
885
+ * 마지막 한 번이 아니다. 되돌리기 이력이 그 맨 처음 모습을 들고 있다.
886
+ */
887
+ function 바뀐것보기(session, ctx, arg = '') {
888
+ const 말 = String(arg ?? '').trim();
889
+ say('');
890
+
891
+ if (!말) {
892
+ const 목록 = [...session.changes.entries()];
893
+ if (!목록.length) {
894
+ say(` ${c.gray('이번 대화에서 바뀐 파일이 없습니다.')}`);
895
+ say(` ${c.gray('파일을 고치고 나면 여기에 무엇이 얼마나 바뀌었는지 모입니다.')}`);
896
+ say('');
897
+ return;
898
+ }
899
+ rule('이번 대화에서 바뀐 파일', 70);
900
+ let a = 0;
901
+ let r = 0;
902
+ for (const [p, v] of 목록) {
903
+ a += v.added;
904
+ r += v.removed;
905
+ const 몇번 = v.times > 1 ? c.gray(` ${v.times}번`) : '';
906
+ say(` ${pad(clip(ctx.scope.show(p), 46), 46)} ${c.hgreen(pad(`+${v.added}`, 6, 'right'))} ${c.hred(pad(`−${v.removed}`, 6, 'right'))}${몇번}`);
907
+ }
908
+ say(` ${c.gray('─'.repeat(60))}`);
909
+ say(` ${pad(`${목록.length}개 파일`, 46)} ${c.hgreen(pad(`+${a}`, 6, 'right'))} ${c.hred(pad(`−${r}`, 6, 'right'))}`);
910
+ say('');
911
+ say(` ${c.gray('한 파일을 자세히 보려면')} ${c.cyan('/diff <파일>')}${c.gray(', 되돌리려면')} ${c.cyan('/undo')}`);
912
+ say('');
913
+ return;
914
+ }
915
+
916
+ let abs;
917
+ try { abs = ctx.scope.resolve(말); }
918
+ catch (err) { say(` ${mark.warn} ${err.message}`); say(''); return; }
919
+
920
+ // 이번 대화에서 이 파일을 처음 건드리기 직전의 모습.
921
+ const 처음 = ctx.history.all().find((x) => x.path === abs);
922
+ if (!처음) {
923
+ say(` ${c.gray('이번 대화에서 안 바꾼 파일입니다:')} ${ctx.scope.show(abs)}`);
924
+ say(` ${c.gray('바뀐 것들을 보려면')} ${c.cyan('/diff')}`);
925
+ say('');
926
+ return;
927
+ }
928
+
929
+ const 옛것 = 처음.before === null ? null
930
+ : (처음.enc === 'b64' ? Buffer.from(처음.before, 'base64').toString('utf8') : 처음.before);
931
+ let 지금 = null;
932
+ if (existsSync(abs)) {
933
+ try { 지금 = readTextFull(abs).text; }
934
+ catch (err) { say(` ${mark.warn} 지금 내용을 못 읽습니다: ${err.message}`); say(''); return; }
935
+ }
936
+
937
+ const d = diffLines(옛것, 지금);
938
+ rule(ctx.scope.show(abs), 70);
939
+ if (!d.changed) {
940
+ say(` ${c.gray('고쳤다가 되돌아와서, 처음과 지금이 같습니다.')}`);
941
+ say('');
942
+ return;
943
+ }
944
+ if (d.isNew) say(` ${c.gray('이번 대화에서 새로 만든 파일입니다.')}`);
945
+ if (d.isGone) say(` ${c.gray('이번 대화에서 없어진 파일입니다.')}`);
946
+ say(` ${shortStat(d)}`);
947
+ say('');
948
+ for (const l of renderDiff(d, { maxLines: session.level === '개발자' ? 200 : 60 })) say(l);
949
+ say('');
950
+ say(` ${c.gray('되돌리려면')} ${c.cyan('/undo')}`);
951
+ say('');
952
+ }
953
+
954
+ /**
955
+ * 한 번에 받을 답 길이 상한.
956
+ *
957
+ * /out 지금 값과 어디서 나온 값인지
958
+ * /out 32k 직접 지정
959
+ * /out auto 모른다고 두고 안전한 기본값으로 (16,384)
960
+ *
961
+ * 왜 따로 있나:
962
+ * 컨텍스트(/ctx)와 다른 축이다. 컨텍스트가 655k 여도 '한 번에 뱉을 수 있는 답'
963
+ * 은 대개 훨씬 작다. 그 두 값이 하나인 줄 알면 큰 파일이 왜 안 만들어지는지
964
+ * 영영 알 수 없다 — 컨텍스트는 넉넉한데 답이 잘리기 때문이다.
965
+ *
966
+ * 전에는 이 기능이 /ctx out 안에 숨어 있었고, 게다가 **먹지도 않았다**
967
+ * (effort.js 의 클램프가 다시 조였다). 있는데 안 먹는 것이 가장 나쁘다 —
968
+ * 문서에 적혀 있으니 사람이 그걸 믿고 쓴다.
969
+ */
970
+ async function 출력상한(session, arg = '') {
971
+ const { parseSize } = await import('./backend/ctxsize.js');
972
+ const 말 = String(arg ?? '').trim().toLowerCase();
554
973
  const cfg = load();
555
- if (cfg.profiles.length <= 1 && !cfg.profiles.length) {
556
- say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')}`);
974
+ const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
975
+ const 알아낸것 = session.conn.maxOut ?? null;
976
+
977
+ if (말 === 'auto' || 말 === '자동') {
978
+ session.conn.maxTokens = null;
979
+ if (prof) { delete prof.maxTokens; try { save(cfg); } catch {} }
980
+ say(` ${mark.ok} 직접 정한 값을 지웠습니다.`);
981
+ say(` ${c.gray(알아낸것 ? `서버에서 알아낸 ${알아낸것.toLocaleString()} 토큰을 씁니다.` : '모르는 값이라 16,384 토큰으로 갑니다.')}`);
557
982
  say('');
558
983
  return;
559
984
  }
560
- const items = cfg.profiles.map((p) => ({
561
- label: `${pad(p.name, 18)} ${c.gray(p.model)}`,
562
- note: p.id === cfg.active ? '지금' : '',
563
- }));
564
- const i = await pick('연결 고르기', items, {
565
- def: cfg.profiles.findIndex((p) => p.id === cfg.active),
566
- ask: ctx?.ask,
567
- });
568
- const p = cfg.profiles[i];
569
- cfg.active = p.id;
570
- save(cfg);
985
+
986
+ if () {
987
+ const = parseSize(말);
988
+ if (!값) {
989
+ say(` ${mark.no} 숫자를 읽었습니다: ${c.white(arg)}`);
990
+ say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/out 32k')} ${c.cyan('/out 65536')} ${c.cyan('/out auto')}`);
991
+ say('');
992
+ return;
993
+ }
994
+ session.conn.maxTokens = 값;
995
+ if (prof) { prof.maxTokens = 값; try { save(cfg); } catch {} }
996
+ say(` ${mark.ok} 답 길이 상한 ${c.bold(값.toLocaleString())} 토큰`);
997
+ say(` ${c.gray('모델이 못 내는 값을 넣으면 서버가 거절합니다. 거절당하면')} ${c.cyan('/out auto')} ${c.gray('로 되돌리세요.')}`);
998
+ say('');
999
+ return;
1000
+ }
1001
+
1002
+ const 쓰는값 = session.conn.maxTokens ?? 알아낸것 ?? 16384;
1003
+ const 어디서 = session.conn.maxTokens ? '직접 정하신 값'
1004
+ : 알아낸것 ? '서버에서 알아낸 값'
1005
+ : '모르는 값이라 안전한 기본값';
1006
+ say('');
1007
+ rule('한 번에 받을 답 길이', 70);
1008
+ say(` ${c.gray('지금 상한')} ${c.white(쓰는값.toLocaleString())} ${c.gray(`토큰 — ${어디서}`)}`);
1009
+ say(` ${c.gray('컨텍스트')} ${c.white((session.conn.ctx ?? 0).toLocaleString())} ${c.gray('토큰')} ${c.gray('(다른 축입니다 — /ctx)')}`);
1010
+ say('');
1011
+ say(` ${c.gray('이 값이 한 번에 만들 수 있는 파일 크기를 정합니다.')}`);
1012
+ say(` ${c.gray('1,000줄짜리 HTML 이 대략 12,000~18,000 토큰입니다.')}`);
1013
+ say(` ${c.gray('여기서 잘려도 받은 데까지는 파일에 쓰고 이어 붙입니다 — 다만 몇 번 더 오갑니다.')}`);
1014
+ say('');
1015
+ say(` ${c.cyan('/out 32k')} ${c.gray('직접 지정 (k 는 1024)')}`);
1016
+ say(` ${c.cyan('/out auto')} ${c.gray('직접 정한 값을 지우고 알아낸 값/기본값으로')}`);
1017
+ say('');
1018
+ }
1019
+
1020
+ /**
1021
+ * 컨텍스트 길이를 보고·다시 재고·직접 지정한다.
1022
+ *
1023
+ * /ctx 지금 값과 어디서 나온 값인지
1024
+ * /ctx auto 서버에 다시 물어 모델에 맞춘다
1025
+ * /ctx 655360 직접 지정 (640k · 128k · 1m 도 받는다 — k 는 1024)
1026
+ *
1027
+ * 왜 필요한가: 이 숫자 하나가 프로그램 전체 크기를 정한다. 서버가 안 알려주면
1028
+ * 32,768 로 깔고 앉는데, 요즘 로컬 모델은 262,144 · 655,360 이 흔하다.
1029
+ * 그 상태로 쓰면 모델이 가진 것의 5% 만 쓰는 셈이다.
1030
+ *
1031
+ * 고른 값은 프로필에 남긴다 — 다음에 켤 때도 그대로여야 한다.
1032
+ */
1033
+ async function ctxLength(session, arg = '') {
1034
+ const { probeCtx, parseSize, fmtSize } = await import('./backend/ctxsize.js');
1035
+ const 말 = String(arg ?? '').trim().toLowerCase();
1036
+ const 지금 = session.conn.ctx ?? 0;
1037
+
1038
+ const 남기기 = (값, 어디서) => {
1039
+ session.conn.ctx = 값;
1040
+ const cfg = load();
1041
+ const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
1042
+ if (prof) { prof.ctx = 값; save(cfg); }
1043
+ const b = session.breakdown();
1044
+ say(` ${mark.ok} 컨텍스트 ${c.bold(값.toLocaleString())} 토큰 ${c.gray(`(${fmtSize(값)}) — ${어디서}`)}`);
1045
+ say(` ${c.gray('지금 찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음')} ${c.white(b.left.toLocaleString())}`);
1046
+ if (prof) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
1047
+ say('');
1048
+ };
1049
+
1050
+ // 0) 답 길이 상한 — 이제 /out 이 본자리다. 여기서는 그리로 넘긴다.
1051
+ // \b 가 아니라 공백·줄 끝으로 끊는다. 한글은 \w 가 아니라서
1052
+ // `/^답\b/` 는 '답 32k' 에도 '답' 에도 안 맞는다(위 '배분' 과 같은 함정).
1053
+ if (/^(out|답|출력)(\s|$)/.test(말)) return await 출력상한(session, 말.replace(/^(out|답|출력)\s*/, ''));
1054
+
1055
+ // 1) 직접 지정
1056
+ if (말 && !['auto', '자동', '다시', '자세히', 'detail', '-v'].includes(말)) {
1057
+ const 값 = parseSize(말);
1058
+ if (!값) {
1059
+ say(` ${mark.no} 숫자를 못 읽었습니다: ${c.white(arg)}`);
1060
+ say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/ctx 655360')} ${c.cyan('/ctx 640k')} ${c.cyan('/ctx 128k')} ${c.cyan('/ctx auto')}`);
1061
+ say(` ${c.gray('k 는 1024 입니다. 655,360 은 640k 이지 655k 가 아닙니다 — 헷갈리면 그냥 숫자로 쓰세요.')}`);
1062
+ say('');
1063
+ return;
1064
+ }
1065
+ 남기기(값, '직접 지정');
1066
+ say(` ${c.gray('서버가 실제로 올려 둔 길이보다 크게 잡으면 긴 대화에서 거절당합니다.')}`);
1067
+ say(` ${c.gray('서버 쪽에서 올린 다음 맞추는 게 안전합니다 —')} ${c.cyan('/ctx auto')} ${c.gray('로 다시 잽니다.')}`);
1068
+ say('');
1069
+ return;
1070
+ }
1071
+
1072
+ // 2) 다시 재기 (auto · 다시 · 자세히)
1073
+ if (말) {
1074
+ const 자세히 = /자세히|detail|-v/.test(말);
1075
+ const s = spin('모델에 걸린 길이를 서버에 묻는 중…');
1076
+ let r;
1077
+ try { r = await probeCtx(session.conn); }
1078
+ catch (err) { s.stop(` ${mark.no} 못 물어봤습니다 — ${c.gray(String(err?.message ?? err))}`); say(''); return; }
1079
+ s.stop('');
1080
+
1081
+ // 어디를 두드렸고 무엇이 나왔는지. 값이 이상할 때 사람이 원인을 짚을 수 있어야 한다.
1082
+ if (자세히) {
1083
+ say('');
1084
+ say(` ${c.gray('두드린 자리')}`);
1085
+ for (const t of r.tried) {
1086
+ const 표 = t.ok ? c.green('응답함') : c.gray(`${t.status || '연결 실패'}`);
1087
+ say(` ${c.gray(pad(t.label, 20))} ${표} ${c.gray(clip(t.url, 60))}`);
1088
+ }
1089
+ say('');
1090
+ say(` ${c.gray('읽어 낸 값')}`);
1091
+ say(` ${c.gray(pad('모델 최대', 20))} ${r.max ? c.white(r.max.toLocaleString()) : c.gray('못 찾음')}`
1092
+ + (r.maxKey ? c.gray(` ← ${r.maxKey}`) : ''));
1093
+ say(` ${c.gray(pad('지금 올린 길이', 20))} ${r.loaded ? c.white(r.loaded.toLocaleString()) : c.gray('못 찾음')}`
1094
+ + (r.loadedKey ? c.gray(` ← ${r.loadedKey}`) : ''));
1095
+ say(` ${c.gray(pad('답 길이 상한', 20))} ${r.out ? c.white(r.out.toLocaleString()) : c.gray('못 찾음')}`
1096
+ + (r.outSource ? c.gray(` ← ${r.outSource}`) : ''));
1097
+ say('');
1098
+ }
1099
+
1100
+ // 답 길이 상한도 같이 알아냈으면 받아 둔다. 사람이 정한 값은 안 덮는다.
1101
+ if (r.out) session.conn.maxOut = r.out;
1102
+
1103
+ if (!r.value) {
1104
+ // 못 알아낸 것을 아는 척하지 않는다. 조용히 32,768 로 깔고 앉으면
1105
+ // 655k 모델을 5% 만 쓰거나, 8k 서버에 128k 를 보내 조용히 잘린다.
1106
+ say(` ${mark.warn} 서버가 컨텍스트 길이를 안 알려줍니다. ${c.gray(r.why ?? '')}`);
1107
+ if (!자세히) for (const t of r.tried) say(` ${c.gray(pad(t.label, 20))} ${c.gray(t.ok ? '응답함(값 없음)' : `${t.status || '연결 실패'}`)}`);
1108
+ say(` ${c.gray(`지금은 ${(session.conn.ctx ?? 0).toLocaleString()} 으로 잡혀 있습니다 — 이건 알아낸 값이 아니라 기본값입니다.`)}`);
1109
+ say(` ${c.gray('직접 넣어 주세요 —')} ${c.cyan('/ctx 655360')} ${c.gray('어디를 두드렸는지 보려면')} ${c.cyan('/ctx 자세히')}`);
1110
+ say('');
1111
+ return;
1112
+ }
1113
+ 남기기(r.value, `${r.source ?? '서버'}에서 읽음`);
1114
+ if (r.max && r.loaded && r.max > r.loaded) {
1115
+ say(` ${c.yellow('이 모델은')} ${c.bold(r.max.toLocaleString())} ${c.yellow('까지 되는데 지금')} ${c.bold(r.loaded.toLocaleString())} ${c.yellow('로 올려 두셨습니다.')}`);
1116
+ say(` ${c.gray('서버(LM Studio 등)에서 컨텍스트를 더 올려 다시 올린 뒤')} ${c.cyan('/ctx auto')} ${c.gray('를 하시면 그만큼 씁니다.')}`);
1117
+ say('');
1118
+ }
1119
+ return;
1120
+ }
1121
+
1122
+ // 3) 그냥 보기
1123
+ const b = session.breakdown();
1124
+ say('');
1125
+ rule('컨텍스트 길이', 70);
1126
+ say(` ${c.bold(session.conn.model)}`);
1127
+ say(` ${c.gray('지금 잡은 길이')} ${c.white(지금.toLocaleString())} ${c.gray('토큰 (' + fmtSize(지금) + ')')}`);
1128
+ say(` ${c.gray('찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음 ' + b.left.toLocaleString())}`);
1129
+ say('');
1130
+ say(` ${c.gray('이 값 하나가 프로그램 전체 크기를 정합니다 — 한 번에 읽힐 수 있는 파일 수,')}`);
1131
+ say(` ${c.gray('대화가 접히는 시점, 한 번에 쓸 수 있는 답 길이가 모두 여기서 나옵니다.')}`);
1132
+ say('');
1133
+ say(` ${c.cyan('/ctx auto')} ${c.gray('서버에 다시 물어 모델에 맞춥니다')}`);
1134
+ say(` ${c.cyan('/ctx 655360')} ${c.gray('직접 지정 (640k · 128k · 1m 도 됩니다 — k 는 1024)')}`);
1135
+ say(` ${c.cyan('/out 32k')} ${c.gray('한 번에 받을 답 길이 상한 (지금 ' + (session.conn.maxTokens ?? session.conn.maxOut ?? 16384).toLocaleString() + ') — 다른 축입니다')}`);
1136
+ say('');
1137
+ }
1138
+
1139
+ /** 지금 연결을 이 프로필로 갈아끼운다. 대화는 그대로 둔다. */
1140
+ function 연결적용(session, p) {
571
1141
  Object.assign(session.conn, {
572
1142
  kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
573
- ctx: p.ctx, streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
1143
+ ctx: p.ctx, maxTokens: p.maxTokens ?? null,
1144
+ streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
574
1145
  });
575
1146
  // 자물쇠도 같이 옮긴다. 이걸 빼먹으면 옛 주소가 열린 채로 남고 새 주소는 막혀
576
1147
  // 다음 한마디에서 바로 "허용되지 않은 주소" 가 난다.
577
1148
  allowEndpoint(p.baseUrl);
1149
+ }
1150
+
1151
+ /**
1152
+ * 지금 붙어 있는 서버가 내주는 모델 목록.
1153
+ *
1154
+ * 저장된 프로필에는 등록할 때 고른 모델 하나만 있다. 그런데 서버 한 대가
1155
+ * 모델을 여럿 내주는 경우가 대부분이다 — 특히 프록시나 게이트웨이가 그렇다.
1156
+ * 그래서 서버에 직접 물어본다. 자리를 새로 여는 게 아니라 이미 열린 자리다.
1157
+ */
1158
+ async function 서버모델들(conn) {
1159
+ const { req, headersFor } = await import('./backend/http.js');
1160
+ if (conn.kind === 'ollama') {
1161
+ const r = await req(`${conn.base.replace(/\/v1\/?$/, '')}/api/tags`, { timeout: 4000 });
1162
+ return (r.json?.models ?? []).map((m) => m.name ?? m.model).filter(Boolean);
1163
+ }
1164
+ const r = await req(`${conn.base.replace(/\/$/, '')}/models`, {
1165
+ headers: headersFor(conn.auth ?? 'none', conn.key), timeout: 4000,
1166
+ });
1167
+ if (!r.ok) return null;
1168
+ const list = r.json?.data ?? r.json?.models ?? [];
1169
+ return Array.isArray(list)
1170
+ ? list.map((m) => (typeof m === 'string' ? m : m.id ?? m.name ?? m.model)).filter(Boolean)
1171
+ : null;
1172
+ }
1173
+
1174
+ /**
1175
+ * /model — 연결·모델 바꾸기.
1176
+ *
1177
+ * /model 골라 바꾸기 (연결 목록 + '이 서버의 다른 모델')
1178
+ * /model <이름> 바로 바꾸기. 연결 이름이든 모델 이름이든 일부만 쳐도 된다
1179
+ * /model list 무엇이 등록돼 있는지만 보기
1180
+ * /model models 지금 서버가 내주는 모델을 물어보기
1181
+ */
1182
+ async function switchModel(session, ctx, arg = '') {
1183
+ const cfg = load();
1184
+ if (!cfg.profiles.length) {
1185
+ say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')}`);
1186
+ say('');
1187
+ return;
1188
+ }
1189
+ const 말 = arg.trim();
1190
+
1191
+ if (말 === 'list' || 말 === '목록') return 연결목록(cfg, session);
1192
+ if (말 === 'models' || 말 === '모델') return await 서버모델고르기(session, ctx, cfg);
1193
+
1194
+ // 이름으로 바로 바꾸기 — 메뉴를 안 거친다.
1195
+ if (말) {
1196
+ const 찾은 = 이름으로찾기(cfg.profiles, 말);
1197
+ if (찾은.length === 1) return 골라적용(session, cfg, 찾은[0]);
1198
+ if (찾은.length > 1) {
1199
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 것이 여럿입니다.')}`);
1200
+ for (const p of 찾은.slice(0, 12)) say(` ${c.cyan(p.name)} ${c.gray(p.model)}`);
1201
+ say('');
1202
+ return;
1203
+ }
1204
+ // 등록된 것에 없으면, 지금 서버가 내주는 모델 중에 있는지 본다.
1205
+ const 있는것 = await 서버모델들(session.conn);
1206
+ const 맞는것 = (있는것 ?? []).filter((m) => m.toLowerCase().includes(말.toLowerCase()));
1207
+ if (맞는것.length === 1) return await 모델만바꾸기(session, cfg, 맞는것[0]);
1208
+ if (맞는것.length > 1) {
1209
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 모델이 여럿입니다.')}`);
1210
+ for (const m of 맞는것.slice(0, 12)) say(` ${c.cyan(m)}`);
1211
+ say('');
1212
+ return;
1213
+ }
1214
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결도 모델도 없습니다.')}`);
1215
+ say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/model list')}${c.gray(', 서버에 물어보려면')} ${c.cyan('/model models')}`);
1216
+ say('');
1217
+ return;
1218
+ }
1219
+
1220
+ // 인자 없이 — 골라 바꾸기. 물어볼 수 없는 자리면 목록만 보여준다.
1221
+ if (!ctx?.ask) return 연결목록(cfg, session);
1222
+
1223
+ const items = cfg.profiles.map((p) => ({
1224
+ label: `${pad(p.name, 22)} ${c.gray(p.model)}`,
1225
+ note: p.id === cfg.active ? '지금' : '',
1226
+ }));
1227
+ items.push({ label: c.cyan('이 서버의 다른 모델 고르기'), note: '서버에 물어봅니다' });
1228
+
1229
+ const i = await pick('연결·모델 고르기', items, {
1230
+ def: Math.max(0, cfg.profiles.findIndex((p) => p.id === cfg.active)),
1231
+ ask: ctx?.ask,
1232
+ });
1233
+ if (i === cfg.profiles.length) return await 서버모델고르기(session, ctx, cfg);
1234
+ return 골라적용(session, cfg, cfg.profiles[i]);
1235
+ }
1236
+
1237
+ function 이름으로찾기(profiles, 말) {
1238
+ const q = 말.toLowerCase();
1239
+ const 정확 = profiles.filter((p) => p.name.toLowerCase() === q || p.model.toLowerCase() === q || p.id.toLowerCase() === q);
1240
+ if (정확.length) return 정확;
1241
+ return profiles.filter((p) => `${p.name} ${p.model} ${p.id}`.toLowerCase().includes(q));
1242
+ }
1243
+
1244
+ function 골라적용(session, cfg, p) {
1245
+ cfg.active = p.id;
1246
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ }
1247
+ 연결적용(session, p);
578
1248
  say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
579
1249
  say('');
580
1250
  }
581
1251
 
1252
+ /**
1253
+ * 같은 서버에서 모델만 바꾼다.
1254
+ *
1255
+ * 등록된 연결이 아니어도 된다 — 서버가 내준다면 쓸 수 있어야 한다.
1256
+ * 다음에도 쓰도록 프로필로 남겨 둔다. 그래야 /model 목록에서 다시 보인다.
1257
+ */
1258
+ async function 모델만바꾸기(session, cfg, 모델) {
1259
+ const 지금 = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
1260
+ const 이미 = cfg.profiles.find((p) => p.baseUrl === session.conn.base && p.model === 모델);
1261
+ const p = 이미 ?? {
1262
+ ...지금,
1263
+ id: `${(지금?.id ?? 'conn').replace(/-[^-]*$/, '')}-${String(모델).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase(),
1264
+ name: `${(지금?.name ?? '연결').split(' · ')[0]} · ${모델}`,
1265
+ baseUrl: session.conn.base,
1266
+ model: 모델,
1267
+ // 컨텍스트 길이는 물려받지 않는다. 모델마다 다르다 —
1268
+ // 32k 짜리에서 655k 짜리로 옮겼는데 32k 로 깔고 앉으면 새 모델의 5% 만 쓴다.
1269
+ // 반대로 큰 데서 작은 데로 옮기면 긴 대화에서 서버가 거절한다. 아래에서 다시 잰다.
1270
+ ctx: null,
1271
+ };
1272
+ if (!이미) { try { save(upsert(cfg, p)); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ } }
1273
+ else { cfg.active = p.id; try { save(cfg); } catch {} }
1274
+ 연결적용(session, p);
1275
+ say(` ${mark.ok} 모델을 ${c.bold(모델)} 로 바꿨습니다. ${c.gray('서버는 그대로입니다.')}`);
1276
+ await 길이맞추기(session, cfg, p);
1277
+ say('');
1278
+ }
1279
+
1280
+ /**
1281
+ * 바뀐 모델에 맞춰 컨텍스트 길이를 다시 잰다.
1282
+ *
1283
+ * 모델을 바꾸는 순간은 이미 서버와 이야기하는 중이라 한 번 더 물어봐도 티가 안 난다.
1284
+ * 여기서 안 재면 새 모델을 옛 모델의 길이로 쓰게 된다 — 화면에는 아무 표시도 안 나고
1285
+ * 그냥 조용히 작아진다. 그런 고장이 가장 늦게 발견된다.
1286
+ */
1287
+ async function 길이맞추기(session, cfg, prof) {
1288
+ const { probeCtx, fmtSize, 기본값 } = await import('./backend/ctxsize.js');
1289
+ let r = null;
1290
+ try { r = await probeCtx(session.conn, { timeout: 8000 }); } catch { /* 못 물어보면 아래에서 기본값 */ }
1291
+ const 값 = r?.value ?? prof.ctx ?? 기본값;
1292
+ session.conn.ctx = 값;
1293
+ if (prof) {
1294
+ prof.ctx = 값;
1295
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
1296
+ }
1297
+ say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + (r?.source ? r.source + '에서 읽음' : '서버가 안 알려줘 기본값'))}`);
1298
+ if (r?.max && r?.loaded && r.max > r.loaded) {
1299
+ say(` ${c.yellow('이 모델은 ' + r.max.toLocaleString() + ' 까지 됩니다.')} ${c.gray('서버에서 더 올린 뒤')} ${c.cyan('/ctx auto')}`);
1300
+ } else if (!r?.value) {
1301
+ say(` ${c.gray('맞지 않으면')} ${c.cyan('/ctx 655360')} ${c.gray('처럼 직접 지정하세요.')}`);
1302
+ }
1303
+ }
1304
+
1305
+ function 연결목록(cfg, session) {
1306
+ rule('등록된 연결', 70);
1307
+ for (const p of cfg.profiles) {
1308
+ const 지금 = p.id === cfg.active;
1309
+ say(` ${지금 ? c.hgreen('●') : c.gray('·')} ${지금 ? c.bold(c.white(pad(p.name, 26))) : pad(p.name, 26)}${c.gray(p.model)}`);
1310
+ say(` ${c.gray(p.baseUrl)}`);
1311
+ }
1312
+ say('');
1313
+ say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}${c.gray(' — 연결 이름이든 모델 이름이든 됩니다.')}`);
1314
+ say(` ${c.gray('이 서버가 내주는 다른 모델을 보려면')} ${c.cyan('/model models')}`);
1315
+ say('');
1316
+ }
1317
+
1318
+ async function 서버모델고르기(session, ctx, cfg) {
1319
+ const s = spin('서버에 모델 목록을 물어보는 중…');
1320
+ let 있는것;
1321
+ try { 있는것 = await 서버모델들(session.conn); } catch { 있는것 = null; }
1322
+ s.stop('');
1323
+ if (!있는것 || !있는것.length) {
1324
+ say(` ${mark.warn} 이 서버는 모델 목록을 내주지 않습니다.`);
1325
+ say(` ${c.gray('목록이 없는 게이트웨이도 있습니다. 그때는')} ${c.cyan('deel setup')} ${c.gray('에서 모델 이름을 직접 넣으세요.')}`);
1326
+ say('');
1327
+ return;
1328
+ }
1329
+ // 물어볼 수 없는 자리면 목록만 보여주고 끝낸다.
1330
+ //
1331
+ // 여기서 그냥 pick 을 부르면 표준입력을 붙잡고 영영 안 끝난다.
1332
+ // 파이프로 넣거나 검사에서 돌릴 때가 그렇다 — 멈춘 것처럼 보이고 끊는 수밖에 없다.
1333
+ if (!ctx?.ask) {
1334
+ rule(`이 서버의 모델 (${있는것.length}개)`, 70);
1335
+ for (const m of 있는것) say(` ${m === session.conn.model ? c.hgreen('●') : c.gray('·')} ${m === session.conn.model ? c.bold(m) : m}`);
1336
+ say('');
1337
+ say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}`);
1338
+ say('');
1339
+ return;
1340
+ }
1341
+
1342
+ const i = await pick(`이 서버의 모델 (${있는것.length}개)`, 있는것.map((m) => ({
1343
+ label: m, note: m === session.conn.model ? '지금' : '',
1344
+ })), {
1345
+ def: Math.max(0, 있는것.indexOf(session.conn.model)),
1346
+ ask: ctx.ask,
1347
+ });
1348
+ const 고른것 = 있는것[i];
1349
+ if (고른것 === session.conn.model) {
1350
+ say(` ${c.gray('그대로 둡니다.')}`);
1351
+ say('');
1352
+ return;
1353
+ }
1354
+ await 모델만바꾸기(session, cfg, 고른것);
1355
+ }
1356
+
582
1357
  const INIT_TEMPLATE = `# DEEL.md
583
1358
 
584
1359
  이 폴더에서 일할 때 지킬 규칙을 적습니다. deel 가 매번 읽습니다.
@@ -604,7 +1379,7 @@ function showWork(session) {
604
1379
  rule('작업 모드', 70);
605
1380
  for (const k of WORK_ORDER) {
606
1381
  const w = WORK_MODES[k];
607
- const 지금 = k === (normWork(session.work) ?? 'code');
1382
+ const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
608
1383
  const 표 = 지금 ? c.hgreen('●') : c.gray('·');
609
1384
  const 이름 = 지금 ? c.bold(c.white(pad(w.name, 8))) : c.gray(pad(w.name, 8));
610
1385
  say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(w.hint)}`);