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/commands.js CHANGED
@@ -1,13 +1,20 @@
1
1
  // 슬래시 명령. 이름은 Claude Code / Codex 관례에 맞춘다.
2
2
  import { writeFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { c, say, rule, pad, bar, mark, width } from './ui/ansi.js';
4
+ import { c, say, rule, pad, bar, mark, width, clip } from './ui/ansi.js';
5
+ import { compact } from './agent/compact.js';
6
+ import { allowEndpoint } from './safety/network.js';
5
7
  import { pick } from './ui/prompt.js';
6
- import { load, save, resolveKey } from './config.js';
8
+ import { load, save, resolveKey, upsert } from './config.js';
7
9
  import { TOOLS } from './tools/index.js';
8
- import { loadCommand } from './skills/discover.js';
9
-
10
- const THINK_LEVELS = ['off', 'low', 'medium', 'high', 'max'];
10
+ import { loadCommand, discover } from './skills/discover.js';
11
+ import { install, list, remove, pack } from './plugins/manage.js';
12
+ import { spin } from './ui/spinner.js';
13
+ import { PROFILES, LEVELS as THINK_LEVELS, normalizeProfile, table as effortTable } from './agent/effort.js';
14
+ import { scanLocal, toProfiles } from './backend/scan.js';
15
+ import { list as listSessions } from './agent/store.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
+ import { LEVELS, ORDER as LEVEL_ORDER, DEFAULT as LEVEL_DEFAULT, normalize as normLevel, shows as levelShows } from './ui/level.js';
11
18
  const MODES = {
12
19
  auto: '자율 — 전부 알아서. 되돌리기가 안전망',
13
20
  confirm: '확인 — 되돌릴 수 없는 것만 물어봄',
@@ -18,29 +25,67 @@ export const COMMANDS = {
18
25
  help: { desc: '명령 목록' },
19
26
  clear: { desc: '대화 비우기' },
20
27
  context: { desc: '컨텍스트 사용량 보기' },
28
+ ctx: { desc: '컨텍스트 길이 — 모델에 맞춰 다시 재거나 직접 지정', arg: '[auto|숫자|640k]' },
21
29
  compact: { desc: '오래된 대화 줄이기' },
22
- model: { desc: '모델·연결 바꾸기' },
30
+ model: { desc: '연결·모델 바꾸기 (이름 일부 · list · models)', arg: '[이름|list|models]' },
23
31
  think: { desc: '추론 강도 (off/low/medium/high/max)', arg: '<수준>' },
24
- mode: { desc: '실행 모드 (auto/confirm/strict)', arg: '<모드>' },
32
+ mode: { desc: '승인 정책 — 얼마나 물어보나 (auto/confirm/strict)', arg: '<모드>' },
33
+ work: { desc: '작업 모드 — 무슨 일을 하는 중인가 (종합이면 저절로)', arg: '[모드]' },
34
+ // 이름이 /mode auto 와 겹쳐 보이므로 설명에서 못을 박는다.
35
+ // /mode auto 는 '얼마나 물어보나', /auto 는 '무슨 일을 하는 중인가' 다.
36
+ auto: { desc: '작업 모드 → 종합 (요청에 따라 저절로 옮겨 감. /mode 와 다름)' },
37
+ code: { desc: '작업 모드 → 코드 (고치고 만든다)' },
38
+ plan: { desc: '작업 모드 → 계획 (먼저 계획만)' },
39
+ architect:{ desc: '작업 모드 → 설계 (구조를 짠다)' },
40
+ debug: { desc: '작업 모드 → 디버그 (원인을 찾는다)' },
41
+ ask: { desc: '작업 모드 → 묻기 (설명만)' },
42
+ orchestrator: { desc: '작업 모드 → 총괄 (큰 일을 쪼개서)' },
25
43
  undo: { desc: '직전 작업 되돌리기', arg: '[턴수]' },
26
44
  tools: { desc: '쓸 수 있는 도구 보기' },
27
45
  skills: { desc: '스킬 보기·검색·골라 올리기', arg: '[검색어|all|off]' },
46
+ plugin: { desc: '플러그인 목록·설치·삭제·반입묶음', arg: '[install|remove|pack]' },
28
47
  cost: { desc: '이번 세션 사용량' },
29
48
  status: { desc: '연결 상태' },
49
+ scan: { desc: '이 PC 의 로컬 모델 서버 훑기', arg: '[save]' },
50
+ sessions:{ desc: '이 폴더의 지난 대화 목록' },
51
+ level: { desc: '사용자 수준 (쉬움/개발자)', arg: '[수준]' },
30
52
  init: { desc: 'DEEL.md 규칙 파일 만들기' },
31
53
  exit: { desc: '끝내기' },
32
54
  quit: { desc: '끝내기' },
33
55
  };
34
56
 
57
+ /**
58
+ * 이 줄이 명령이 아니라 '경로' 인가.
59
+ *
60
+ * 슬래시로 시작한다고 다 명령은 아니다. `/usr/local/bin` 이나 `/mnt/d/일감`
61
+ * 같은 것을 치면 그동안은 통째로 명령으로 먹혀서 "모르는 명령" 만 나오고
62
+ * 모델에게 닿지도 않았다. 경로를 아예 못 적는 셈이었다.
63
+ *
64
+ * 가르는 기준은 간단하다 — 명령 이름에는 슬래시가 없다.
65
+ * 플러그인 명령도 `/플러그인:이름` 이라 콜론을 쓰지 슬래시를 안 쓴다.
66
+ * 그러니 첫 낱말 안에 슬래시가 또 있으면 그건 경로다.
67
+ */
68
+ function 경로처럼보이나(line) {
69
+ const 첫낱말 = line.slice(1).split(/\s+/)[0] ?? '';
70
+ if (첫낱말.includes('/') || 첫낱말.includes('\\')) return true;
71
+ // `/tmp` 처럼 슬래시가 하나뿐이어도, 실제로 있는 자리면 경로로 본다.
72
+ if (첫낱말 && !COMMANDS[첫낱말.toLowerCase()]) {
73
+ try { if (existsSync(line.slice(1).trim())) return true; } catch { /* 못 보면 아닌 걸로 */ }
74
+ }
75
+ return false;
76
+ }
77
+
35
78
  // 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
36
79
  export async function handle(line, session, ctx) {
37
80
  if (!line.startsWith('/')) return { handled: false };
81
+ if (경로처럼보이나(line)) return { handled: false };
38
82
  const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
39
83
  const name = raw.toLowerCase();
40
84
  const arg = rest.join(' ');
41
85
 
42
86
  switch (name) {
43
- case 'help': return help(), { handled: true };
87
+ case 'help': return help(session), { handled: true };
88
+ case 'level': return showLevel(session, arg), { handled: true };
44
89
  case 'exit':
45
90
  case 'quit': return { handled: true, exit: true };
46
91
 
@@ -52,30 +97,50 @@ export async function handle(line, session, ctx) {
52
97
 
53
98
  case 'context': return showContext(session), { handled: true };
54
99
 
100
+ case 'ctx': return await ctxLength(session, arg), { handled: true };
101
+
55
102
  case 'compact': {
56
- const n = session.trim();
57
- say(n
58
- ? ` ${mark.ok} 오래된 대화 ${n}개를 줄였습니다.`
59
- : ` ${c.gray('줄일 만큼 쌓이지 않았습니다.')}`);
103
+ const s = spin('앞선 대화를 요약해 접는 중…');
104
+ const r = await compact(session);
105
+ if (!r.ok) {
106
+ s.stop(` ${c.gray(r.why ?? '접지 못했습니다.')}`);
107
+ say('');
108
+ return { handled: true };
109
+ }
110
+ const 줄인 = r.before - r.after;
111
+ s.stop(` ${mark.ok} 대화 ${r.folded}개를 요약으로 접었습니다.`);
112
+ say(` ${c.gray(r.before.toLocaleString())} ${c.gray('→')} ${c.white(r.after.toLocaleString())} ${c.gray('토큰')} ${c.green(`${Math.round((줄인 / Math.max(1, r.before)) * 100)}% 줄어듦`)}`);
113
+ if (r.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
114
+ else if (r.summary) {
115
+ say('');
116
+ for (const line of r.summary.split('\n').slice(0, 14)) say(` ${c.gray(clip(line, 76))}`);
117
+ }
60
118
  say('');
61
119
  return { handled: true };
62
120
  }
63
121
 
64
- case 'model': return await switchModel(session, ctx), { handled: true };
122
+ case 'model': return await switchModel(session, ctx, arg), { handled: true };
65
123
 
66
124
  case 'think': {
125
+ const asProfile = normalizeProfile(arg);
126
+ if (asProfile) {
127
+ session.effort = asProfile;
128
+ session.effortSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
129
+ say(` ${mark.ok} 배분 ${c.bold(PROFILES[asProfile].name)} ${c.gray('— ' + PROFILES[asProfile].desc)}`);
130
+ showThink(session);
131
+ return { handled: true };
132
+ }
67
133
  if (!THINK_LEVELS.includes(arg)) {
68
- say(` ${c.gray('지금')} ${c.bold(session.think)} ${c.gray('고를 수 있는 값:')} ${THINK_LEVELS.join(' · ')}`);
69
- say(` ${c.gray('예')} /think high`);
70
- say('');
134
+ showThink(session);
71
135
  return { handled: true };
72
136
  }
73
137
  session.think = arg;
138
+ session.thinkSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
74
139
  say(` ${mark.ok} 추론 강도 ${c.bold(arg)}`);
75
140
  if (!session.conn.think && arg !== 'off') {
76
- say(` ${c.yellow('이 연결은 모델 층 조절이 먹습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
141
+ say(` ${c.yellow('이 연결은 모델 층 조절이 적용되지 않습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
77
142
  }
78
- say('');
143
+ showThink(session);
79
144
  return { handled: true };
80
145
  }
81
146
 
@@ -146,6 +211,84 @@ export async function handle(line, session, ctx) {
146
211
  return { handled: true };
147
212
  }
148
213
 
214
+ case 'work':
215
+ case 'auto':
216
+ case 'code': case 'plan': case 'architect':
217
+ case 'debug': case 'ask': case 'orchestrator': {
218
+ const 원하는 = name === 'work' ? normWork(arg) : name;
219
+ if (name === 'work' && !원하는) { showWork(session); return { handled: true }; }
220
+ const 골라진 = normWork(원하는);
221
+ if (!골라진) {
222
+ say(` ${mark.warn} 그런 모드는 없습니다: ${c.white(arg)}`);
223
+ say(` ${c.gray('쓸 수 있는 것:')} ${WORK_ORDER.map((k) => c.cyan(WORK_MODES[k].name)).join(c.gray(' · '))}`);
224
+ say('');
225
+ return { handled: true };
226
+ }
227
+ session.work = 골라진;
228
+ // 사람이 직접 골랐다. 저절로 골라 둔 것이 있으면 지운다 —
229
+ // 안 지우면 이번 한마디는 여전히 옛 모드로 돌아 "왜 안 바뀌지" 가 된다.
230
+ session.routed = null;
231
+ const w = getWork(골라진);
232
+ say('');
233
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`);
234
+ say(` ${c.gray('도구')} ${canWrite(골라진) ? c.yellow('읽기 + 파일 바꾸기') : c.green('읽기만 — 파일을 못 바꿉니다')}`);
235
+ say(` ${c.gray('생각')} ${c.white(w.think ?? session.think)}${c.gray('·')}${c.magenta(w.effort)} ${c.gray('최대 ' + w.steps + '걸음')}`);
236
+ if (골라진 === 'auto') {
237
+ say(` ${c.gray('앞으로는 한마디마다 알맞은 모드로 저절로 옮겨 갑니다.')}`);
238
+ } else {
239
+ say(` ${c.gray('직접 고르셨으므로 저절로 바뀌지 않습니다. 다시 맡기려면')} ${c.cyan('/work 종합')}`);
240
+ }
241
+ say('');
242
+ return { handled: true };
243
+ }
244
+
245
+ case 'scan': {
246
+ const s2 = spin('이 PC 의 로컬 서버를 찾는 중…');
247
+ const found = await scanLocal({ timeout: 1500 });
248
+ s2.stop(` ${found.length ? mark.ok : mark.warn} ${found.length}곳 찾음`);
249
+ if (!found.length) {
250
+ say(` ${c.gray('떠 있는 로컬 서버가 없습니다. 다른 포트라면')} ${c.cyan('deel scan --ports ...')}`);
251
+ say('');
252
+ return { handled: true };
253
+ }
254
+ for (const f of found) {
255
+ say(` ${c.hcyan('◆')} ${c.bold(pad(f.runtime, 12))}${c.gray(pad(`${f.host}:${f.port}`, 22))}${c.gray(`모델 ${f.models.length}개`)}`);
256
+ for (const m of f.models.slice(0, 6)) say(` ${c.gray('·')} ${clip(m.id, 44)}`);
257
+ if (f.models.length > 6) say(` ${c.gray(`… 그 밖에 ${f.models.length - 6}개`)}`);
258
+ }
259
+ say('');
260
+ if (arg === 'save') {
261
+ const cfg2 = load();
262
+ const ps = toProfiles(found, cfg2.profiles);
263
+ for (const x of ps) upsert(cfg2, x);
264
+ save(cfg2);
265
+ say(` ${mark.ok} ${ps.length}개 등록했습니다. ${c.cyan('/model')} 로 고르세요.`);
266
+ } else {
267
+ say(` ${c.gray('등록하려면')} ${c.cyan('/scan save')} ${c.gray('고르려면')} ${c.cyan('/model')}`);
268
+ }
269
+ say('');
270
+ return { handled: true };
271
+ }
272
+
273
+ case 'sessions': {
274
+ const rows = listSessions(session.root, { limit: 12 });
275
+ rule('이 폴더의 지난 대화', 70);
276
+ if (!rows.length) {
277
+ say(` ${c.gray('아직 없습니다. 지금 이 대화가 첫 번째입니다.')}`);
278
+ say('');
279
+ return { handled: true };
280
+ }
281
+ for (const [i, r] of rows.entries()) {
282
+ say(` ${i === 0 ? c.hgreen('●') : c.gray('·')} ${c.bold(pad(r.id, 17))}${c.gray(pad(`${r.turns}턴`, 6, 'right'))} ${c.gray(clip(r.model, 22))}`);
283
+ say(` ${c.gray(clip(r.first, 66))}`);
284
+ }
285
+ say('');
286
+ say(` ${c.gray('이어하려면 나갔다가')} ${c.cyan('deel --continue')} ${c.gray('또는')} ${c.cyan(`deel --resume ${rows[0].id}`)}`);
287
+ say(` ${c.gray('지금 대화는 나가지 않아도 계속 저장되고 있습니다.')}`);
288
+ say('');
289
+ return { handled: true };
290
+ }
291
+
149
292
  case 'init': {
150
293
  const p = join(session.root, 'DEEL.md');
151
294
  if (existsSync(p)) {
@@ -162,6 +305,9 @@ export async function handle(line, session, ctx) {
162
305
 
163
306
  case 'skills': return showSkills(session, arg), { handled: true };
164
307
 
308
+ case 'plugin':
309
+ case 'plugins': return await doPlugin(session, arg), { handled: true };
310
+
165
311
  default: {
166
312
  // 이 PC 에서 찾은 슬래시 명령인지 본다. 있으면 그 내용을 모델에게 보낸다.
167
313
  const found = (session.commands ?? []).find((x) => x.name === raw)
@@ -185,6 +331,97 @@ export async function handle(line, session, ctx) {
185
331
  }
186
332
  }
187
333
 
334
+ async function doPlugin(session, arg) {
335
+ const [sub = '', ...rest] = arg.trim().split(/\s+/);
336
+ const param = rest.join(' ').trim();
337
+
338
+ // 설치·삭제 뒤에는 다시 훑어 이번 대화에 바로 반영한다.
339
+ const rescan = () => {
340
+ const f = discover(session.root);
341
+ session.skills = f.skills;
342
+ session.commands = f.commands;
343
+ session.plugins = f.plugins;
344
+ return f;
345
+ };
346
+
347
+ if (sub === 'install' || sub === 'add') {
348
+ if (!param) {
349
+ say(` ${c.gray('예')} /plugin install affaan-m/ECC`);
350
+ say(` ${c.gray(' ')} /plugin install owner/repo#가지이름`);
351
+ say('');
352
+ return;
353
+ }
354
+ say('');
355
+ const s = spin(`${param} 받는 중...`);
356
+ const r = await install(param, { onStep: (m) => {} });
357
+ if (r.error) {
358
+ s.stop(` ${mark.no} ${c.red(r.error.split('\n')[0])}`);
359
+ for (const line of r.error.split('\n').slice(1)) say(` ${c.gray(line.trim())}`);
360
+ say(` ${c.gray('오프라인이면 zip 을 ~/.deel/plugins/ 에 직접 풀면 됩니다.')}`);
361
+ say('');
362
+ return;
363
+ }
364
+ s.stop(` ${mark.ok} ${c.bold(r.name)}${r.version ? ' ' + c.gray(r.version) : ''} ${c.gray(`(${r.license ?? '라이선스 미상'})`)}`);
365
+ say(` ${c.gray('스킬')} ${r.skills}개 ${c.gray('명령')} ${r.commands}개 ${c.gray(`· ${r.how} 로 받음`)}`);
366
+ if (r.hooks) say(` ${mark.warn} ${c.yellow(`실행 스크립트 ${r.hooks}개는 쓰지 않습니다`)} ${c.gray('(hook 미지원 · 반입 심사 대상)')}`);
367
+ const f = rescan();
368
+ say(` ${c.gray(`이제 스킬 ${f.skills.length}개 · 명령 ${f.commands.length}개`)}`);
369
+ say('');
370
+ return;
371
+ }
372
+
373
+ if (sub === 'remove' || sub === 'rm' || sub === 'uninstall') {
374
+ if (!param) { say(` ${c.gray('예')} /plugin remove ecc`); say(''); return; }
375
+ const r = remove(param);
376
+ if (r.error) { say(` ${mark.no} ${c.red(r.error)}`); say(''); return; }
377
+ const f = rescan();
378
+ say(` ${mark.ok} ${c.bold(r.removed)} 지웠습니다. ${c.gray(`이제 스킬 ${f.skills.length}개`)}`);
379
+ say('');
380
+ return;
381
+ }
382
+
383
+ if (sub === 'pack') {
384
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
385
+ const out = param || join(session.root, `deel-plugins-${stamp}.zip`);
386
+ const r = pack(out, { only: null });
387
+ if (r.error) { say(` ${mark.no} ${c.red(r.error)}`); say(''); return; }
388
+ say('');
389
+ say(` ${mark.ok} ${c.cyan(r.out)}`);
390
+ say(` ${c.gray('플러그인')} ${r.plugins.length}개 ${c.gray('· 파일')} ${r.files}개 ${c.gray('·')} ${(r.bytes / 1024).toFixed(0)}KB`);
391
+ if (r.skipped) say(` ${c.gray(`실행 스크립트 ${r.skipped}개는 뺐습니다`)}`);
392
+ say('');
393
+ rule('담긴 것', 74);
394
+ for (const p of r.plugins) {
395
+ say(` ${c.cyan(pad(p.name, 22))} ${c.gray(pad(p.version || '-', 9))} ${c.gray(pad(p.license || '미상', 14))} ${c.gray(`스킬 ${p.skills} · 명령 ${p.commands}`)}`);
396
+ }
397
+ say('');
398
+ say(` ${c.gray('오프라인 기기의')} ~/.deel/plugins/ ${c.gray('에 풀면 바로 인식됩니다. 설치 명령은 필요 없습니다.')}`);
399
+ say(` ${c.gray('묶음 안에 라이선스 표가 담긴 사용안내.txt 가 함께 들어 있습니다.')}`);
400
+ say('');
401
+ return;
402
+ }
403
+
404
+ // 인자 없으면 목록
405
+ const items = list();
406
+ if (!items.length) {
407
+ say('');
408
+ say(` ${c.gray('설치된 플러그인이 없습니다.')}`);
409
+ say(` ${c.gray('받기')} /plugin install owner/repo`);
410
+ say(` ${c.gray('직접')} ~/.deel/plugins/ 에 폴더를 풀어 넣어도 됩니다`);
411
+ say('');
412
+ return;
413
+ }
414
+ say('');
415
+ rule('플러그인', 74);
416
+ for (const p of items) {
417
+ say(` ${c.cyan(pad(p.name, 22))} ${c.gray(pad(p.version || '-', 9))} ${c.gray(pad(p.license || '미상', 14))} ${c.gray(`스킬 ${p.skills} · 명령 ${p.commands}`)}`);
418
+ say(` ${c.gray(p.from)}`);
419
+ }
420
+ say('');
421
+ say(` ${c.gray('/plugin install <owner/repo> /plugin remove <이름> /plugin pack [파일]')}`);
422
+ say('');
423
+ }
424
+
188
425
  function showSkills(session, arg) {
189
426
  const all = session.skills ?? [];
190
427
  if (!all.length) {
@@ -199,7 +436,7 @@ function showSkills(session, arg) {
199
436
  if (q === 'all') {
200
437
  all.forEach((s) => { s.enabled = true; });
201
438
  session.maxSkillsListed = Math.min(all.length, 200);
202
- say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 먹습니다 — /context 로 확인하세요.')}`);
439
+ say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 차지합니다 — /context 로 확인하세요.')}`);
203
440
  say('');
204
441
  return;
205
442
  }
@@ -248,18 +485,88 @@ function showSkills(session, arg) {
248
485
  say('');
249
486
  }
250
487
 
251
- function help() {
488
+ function help(session) {
489
+ const level = session?.level ?? LEVEL_DEFAULT;
490
+ const 쉬움 = !levelShows(level, '__전부__'); // 개발자면 show 가 null 이라 전부 참
252
491
  say('');
253
- rule('명령', 70);
492
+ rule(쉬움 ? '명령 (자주 쓰는 것)' : '명령', 70);
493
+ let 감춘수 = 0;
254
494
  for (const [n, m] of Object.entries(COMMANDS)) {
255
495
  if (n === 'quit') continue;
496
+ if (!levelShows(level, n)) { 감춘수++; continue; }
256
497
  say(` ${c.cyan(pad('/' + n + (m.arg ? ' ' + m.arg : ''), 22))} ${c.gray(m.desc)}`);
257
498
  }
258
499
  say('');
500
+ // 감춘 것은 '못 쓰는 것' 이 아니다. 그 말을 분명히 해 둔다.
501
+ if (감춘수) {
502
+ say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 — 직접 입력하면 그대로 실행됩니다.`)}`);
503
+ say(` ${c.gray('전부 보려면')} ${c.cyan('/level 개발자')}`);
504
+ say('');
505
+ }
259
506
  say(` ${c.gray('그 밖의 입력은 모델에게 보냅니다. 빈 줄에서 Ctrl+C 로 끝냅니다.')}`);
260
507
  say('');
261
508
  }
262
509
 
510
+ // /level — 수준 보기·바꾸기. 설정 파일에 남겨 다음에도 그대로 쓴다.
511
+ function showLevel(session, arg) {
512
+ if (!arg) {
513
+ rule('사용자 수준', 70);
514
+ for (const k of LEVEL_ORDER) {
515
+ const lv = LEVELS[k];
516
+ const 지금 = k === (normLevel(session.level) ?? LEVEL_DEFAULT);
517
+ say(` ${지금 ? c.hgreen('●') : c.gray('·')} ${지금 ? c.bold(c.white(pad(lv.name, 10))) : c.gray(pad(lv.name, 10))}${c.gray(lv.hint)}`);
518
+ }
519
+ say('');
520
+ say(` ${c.gray('안전 장치는 두 수준이 똑같습니다 — 되돌리기·작업 범위·위험 명령 차단.')}`);
521
+ say(` ${c.gray('수준은 무엇을 보여줄지만 정합니다.')}`);
522
+ say('');
523
+ say(` ${c.gray('바꾸려면')} ${c.cyan('/level 쉬움')} ${c.gray('또는')} ${c.cyan('/level 개발자')}`);
524
+ say('');
525
+ return;
526
+ }
527
+ const 골라진 = normLevel(arg);
528
+ if (!골라진) {
529
+ say(` ${mark.warn} 그런 수준은 없습니다: ${c.white(arg)} ${c.gray('(쉬움 · 개발자)')}`);
530
+ say('');
531
+ return;
532
+ }
533
+ session.level = 골라진;
534
+ try {
535
+ const cfg = load();
536
+ cfg.level = 골라진;
537
+ save(cfg);
538
+ } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
539
+ const lv = LEVELS[골라진];
540
+ say('');
541
+ say(` ${mark.ok} ${c.bold(lv.name)} ${c.gray('— ' + lv.hint)}`);
542
+ say(` ${c.gray('안전 장치는 그대로입니다.')}`);
543
+ say('');
544
+ }
545
+
546
+ // 추론 강도는 값 하나가 아니라 '단계별 배분' 이다. 그 배분을 눈에 보이게 그린다.
547
+ function showThink(session) {
548
+ const b = session.breakdown();
549
+ const t = effortTable(session.think, session.effort, { ctx: b.total, used: b.used, max: session.conn.maxTokens ?? null });
550
+ say('');
551
+ rule('추론 강도', 70);
552
+ say(` 기준 ${c.bold(session.think)} 배분 ${c.bold(t.name)} ${c.gray(t.desc)}`);
553
+ say(` ${c.gray('상한은 이 모델 기준으로 계산합니다 — 컨텍스트')} ${c.white(t.ctx.toLocaleString())}${c.gray(', 지금 찬 양')} ${c.white(t.used.toLocaleString())}`);
554
+ say('');
555
+ say(` ${c.gray(pad('단계', 12) + pad('강도', 10) + pad('출력상한', 10) + '언제')}`);
556
+ for (const r of t.rows) {
557
+ const 화살 = r.moved > 0 ? c.yellow('↑') : r.moved < 0 ? c.cyan('↓') : c.gray('·');
558
+ say(` ${pad(r.label, 12)}${화살} ${pad(r.level, 8)}${pad(r.cap.toLocaleString(), 10, 'right')} ${c.gray(r.why)}`);
559
+ }
560
+ say('');
561
+ say(` ${c.gray('강도')} ${THINK_LEVELS.join(' · ')} ${c.gray('예')} ${c.cyan('/think high')}`);
562
+ say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('예')} ${c.cyan('/think save')}`);
563
+ if (!session.conn.think && session.think !== 'off') {
564
+ say('');
565
+ say(` ${c.yellow('이 연결은 모델 층 강도 조절이 적용되지 않습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
566
+ }
567
+ say('');
568
+ }
569
+
263
570
  function showContext(session) {
264
571
  const b = session.breakdown();
265
572
  say('');
@@ -280,32 +587,333 @@ function showContext(session) {
280
587
  say('');
281
588
  }
282
589
 
283
- async function switchModel(session, ctx) {
590
+ /**
591
+ * 컨텍스트 길이를 보고·다시 재고·직접 지정한다.
592
+ *
593
+ * /ctx 지금 값과 어디서 나온 값인지
594
+ * /ctx auto 서버에 다시 물어 모델에 맞춘다
595
+ * /ctx 655360 직접 지정 (640k · 128k · 1m 도 받는다 — k 는 1024)
596
+ *
597
+ * 왜 필요한가: 이 숫자 하나가 프로그램 전체 크기를 정한다. 서버가 안 알려주면
598
+ * 32,768 로 깔고 앉는데, 요즘 로컬 모델은 262,144 · 655,360 이 흔하다.
599
+ * 그 상태로 쓰면 모델이 가진 것의 5% 만 쓰는 셈이다.
600
+ *
601
+ * 고른 값은 프로필에 남긴다 — 다음에 켤 때도 그대로여야 한다.
602
+ */
603
+ async function ctxLength(session, arg = '') {
604
+ const { probeCtx, parseSize, fmtSize } = await import('./backend/ctxsize.js');
605
+ const 말 = String(arg ?? '').trim().toLowerCase();
606
+ const 지금 = session.conn.ctx ?? 0;
607
+
608
+ const 남기기 = (값, 어디서) => {
609
+ session.conn.ctx = 값;
610
+ const cfg = load();
611
+ const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
612
+ if (prof) { prof.ctx = 값; save(cfg); }
613
+ const b = session.breakdown();
614
+ say(` ${mark.ok} 컨텍스트 ${c.bold(값.toLocaleString())} 토큰 ${c.gray(`(${fmtSize(값)}) — ${어디서}`)}`);
615
+ say(` ${c.gray('지금 찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음')} ${c.white(b.left.toLocaleString())}`);
616
+ if (prof) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
617
+ say('');
618
+ };
619
+
620
+ // 0) 답 길이 상한 — 컨텍스트와는 다른 축이다.
621
+ //
622
+ // 컨텍스트가 655k 여도 '한 번에 뱉을 수 있는 답' 은 대개 훨씬 작다(기본 16,384).
623
+ // 모델 한계보다 큰 max_tokens 를 보내면 아예 거부하는 게이트웨이가 있어서 그렇게 뒀다.
624
+ // 큰 모델을 쓰면 여기서 올릴 수 있다.
625
+ if (/^(out|답|출력)\b/.test(말)) {
626
+ const 값 = parseSize(말.replace(/^(out|답|출력)\s*/, ''));
627
+ const cfg = load();
628
+ const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
629
+ if (!값) {
630
+ say(` ${c.gray('한 번에 받을 답 길이 상한')} ${c.white((session.conn.maxTokens ?? 16384).toLocaleString())} ${c.gray('토큰')}`);
631
+ say(` ${c.gray('올리려면')} ${c.cyan('/ctx out 32k')} ${c.gray('— 모델이 못 내는 값을 넣으면 서버가 거절합니다.')}`);
632
+ say('');
633
+ return;
634
+ }
635
+ session.conn.maxTokens = 값;
636
+ if (prof) { prof.maxTokens = 값; try { save(cfg); } catch {} }
637
+ say(` ${mark.ok} 답 길이 상한 ${c.bold(값.toLocaleString())} 토큰 ${c.gray('— 컨텍스트와는 다른 축입니다')}`);
638
+ say('');
639
+ return;
640
+ }
641
+
642
+ // 1) 직접 지정
643
+ if (말 && 말 !== 'auto' && 말 !== '자동' && 말 !== '다시') {
644
+ const 값 = parseSize(말);
645
+ if (!값) {
646
+ say(` ${mark.no} 숫자를 못 읽었습니다: ${c.white(arg)}`);
647
+ say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/ctx 655360')} ${c.cyan('/ctx 640k')} ${c.cyan('/ctx 128k')} ${c.cyan('/ctx auto')}`);
648
+ say(` ${c.gray('k 는 1024 입니다. 655,360 은 640k 이지 655k 가 아닙니다 — 헷갈리면 그냥 숫자로 쓰세요.')}`);
649
+ say('');
650
+ return;
651
+ }
652
+ 남기기(값, '직접 지정');
653
+ say(` ${c.gray('서버가 실제로 올려 둔 길이보다 크게 잡으면 긴 대화에서 거절당합니다.')}`);
654
+ say(` ${c.gray('서버 쪽에서 올린 다음 맞추는 게 안전합니다 —')} ${c.cyan('/ctx auto')} ${c.gray('로 다시 잽니다.')}`);
655
+ say('');
656
+ return;
657
+ }
658
+
659
+ // 2) 다시 재기
660
+ if (말) {
661
+ const s = spin('모델에 걸린 길이를 서버에 묻는 중…');
662
+ let r;
663
+ try { r = await probeCtx(session.conn); }
664
+ catch (err) { s.stop(` ${mark.no} 못 물어봤습니다 — ${c.gray(String(err?.message ?? err))}`); say(''); return; }
665
+ s.stop('');
666
+ if (!r.value) {
667
+ say(` ${mark.warn} 서버가 컨텍스트 길이를 안 알려줍니다.`);
668
+ for (const t of r.tried) say(` ${c.gray(pad(t.label, 20))} ${c.gray(t.ok ? '응답함(값 없음)' : `${t.status || '연결 실패'}`)}`);
669
+ say(` ${c.gray('직접 넣어 주세요 —')} ${c.cyan('/ctx 655360')}`);
670
+ say('');
671
+ return;
672
+ }
673
+ 남기기(r.value, `${r.source ?? '서버'}에서 읽음`);
674
+ if (r.max && r.loaded && r.max > r.loaded) {
675
+ say(` ${c.yellow('이 모델은')} ${c.bold(r.max.toLocaleString())} ${c.yellow('까지 되는데 지금')} ${c.bold(r.loaded.toLocaleString())} ${c.yellow('로 올려 두셨습니다.')}`);
676
+ say(` ${c.gray('서버(LM Studio 등)에서 컨텍스트를 더 올려 다시 올린 뒤')} ${c.cyan('/ctx auto')} ${c.gray('를 하시면 그만큼 씁니다.')}`);
677
+ say('');
678
+ }
679
+ return;
680
+ }
681
+
682
+ // 3) 그냥 보기
683
+ const b = session.breakdown();
684
+ say('');
685
+ rule('컨텍스트 길이', 70);
686
+ say(` ${c.bold(session.conn.model)}`);
687
+ say(` ${c.gray('지금 잡은 길이')} ${c.white(지금.toLocaleString())} ${c.gray('토큰 (' + fmtSize(지금) + ')')}`);
688
+ say(` ${c.gray('찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음 ' + b.left.toLocaleString())}`);
689
+ say('');
690
+ say(` ${c.gray('이 값 하나가 프로그램 전체 크기를 정합니다 — 한 번에 읽힐 수 있는 파일 수,')}`);
691
+ say(` ${c.gray('대화가 접히는 시점, 한 번에 쓸 수 있는 답 길이가 모두 여기서 나옵니다.')}`);
692
+ say('');
693
+ say(` ${c.cyan('/ctx auto')} ${c.gray('서버에 다시 물어 모델에 맞춥니다')}`);
694
+ say(` ${c.cyan('/ctx 655360')} ${c.gray('직접 지정 (640k · 128k · 1m 도 됩니다 — k 는 1024)')}`);
695
+ say(` ${c.cyan('/ctx out 32k')} ${c.gray('한 번에 받을 답 길이 상한 (지금 ' + (session.conn.maxTokens ?? 16384).toLocaleString() + ') — 다른 축입니다')}`);
696
+ say('');
697
+ }
698
+
699
+ /** 지금 연결을 이 프로필로 갈아끼운다. 대화는 그대로 둔다. */
700
+ function 연결적용(session, p) {
701
+ Object.assign(session.conn, {
702
+ kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
703
+ ctx: p.ctx, maxTokens: p.maxTokens ?? null,
704
+ streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
705
+ });
706
+ // 자물쇠도 같이 옮긴다. 이걸 빼먹으면 옛 주소가 열린 채로 남고 새 주소는 막혀
707
+ // 다음 한마디에서 바로 "허용되지 않은 주소" 가 난다.
708
+ allowEndpoint(p.baseUrl);
709
+ }
710
+
711
+ /**
712
+ * 지금 붙어 있는 서버가 내주는 모델 목록.
713
+ *
714
+ * 저장된 프로필에는 등록할 때 고른 모델 하나만 있다. 그런데 서버 한 대가
715
+ * 모델을 여럿 내주는 경우가 대부분이다 — 특히 프록시나 게이트웨이가 그렇다.
716
+ * 그래서 서버에 직접 물어본다. 자리를 새로 여는 게 아니라 이미 열린 자리다.
717
+ */
718
+ async function 서버모델들(conn) {
719
+ const { req, headersFor } = await import('./backend/http.js');
720
+ if (conn.kind === 'ollama') {
721
+ const r = await req(`${conn.base.replace(/\/v1\/?$/, '')}/api/tags`, { timeout: 4000 });
722
+ return (r.json?.models ?? []).map((m) => m.name ?? m.model).filter(Boolean);
723
+ }
724
+ const r = await req(`${conn.base.replace(/\/$/, '')}/models`, {
725
+ headers: headersFor(conn.auth ?? 'none', conn.key), timeout: 4000,
726
+ });
727
+ if (!r.ok) return null;
728
+ const list = r.json?.data ?? r.json?.models ?? [];
729
+ return Array.isArray(list)
730
+ ? list.map((m) => (typeof m === 'string' ? m : m.id ?? m.name ?? m.model)).filter(Boolean)
731
+ : null;
732
+ }
733
+
734
+ /**
735
+ * /model — 연결·모델 바꾸기.
736
+ *
737
+ * /model 골라 바꾸기 (연결 목록 + '이 서버의 다른 모델')
738
+ * /model <이름> 바로 바꾸기. 연결 이름이든 모델 이름이든 일부만 쳐도 된다
739
+ * /model list 무엇이 등록돼 있는지만 보기
740
+ * /model models 지금 서버가 내주는 모델을 물어보기
741
+ */
742
+ async function switchModel(session, ctx, arg = '') {
284
743
  const cfg = load();
285
- if (cfg.profiles.length <= 1 && !cfg.profiles.length) {
286
- say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')}`);
744
+ if (!cfg.profiles.length) {
745
+ say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')}`);
746
+ say('');
747
+ return;
748
+ }
749
+ const 말 = arg.trim();
750
+
751
+ if (말 === 'list' || 말 === '목록') return 연결목록(cfg, session);
752
+ if (말 === 'models' || 말 === '모델') return await 서버모델고르기(session, ctx, cfg);
753
+
754
+ // 이름으로 바로 바꾸기 — 메뉴를 안 거친다.
755
+ if (말) {
756
+ const 찾은 = 이름으로찾기(cfg.profiles, 말);
757
+ if (찾은.length === 1) return 골라적용(session, cfg, 찾은[0]);
758
+ if (찾은.length > 1) {
759
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 것이 여럿입니다.')}`);
760
+ for (const p of 찾은.slice(0, 12)) say(` ${c.cyan(p.name)} ${c.gray(p.model)}`);
761
+ say('');
762
+ return;
763
+ }
764
+ // 등록된 것에 없으면, 지금 서버가 내주는 모델 중에 있는지 본다.
765
+ const 있는것 = await 서버모델들(session.conn);
766
+ const 맞는것 = (있는것 ?? []).filter((m) => m.toLowerCase().includes(말.toLowerCase()));
767
+ if (맞는것.length === 1) return await 모델만바꾸기(session, cfg, 맞는것[0]);
768
+ if (맞는것.length > 1) {
769
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 모델이 여럿입니다.')}`);
770
+ for (const m of 맞는것.slice(0, 12)) say(` ${c.cyan(m)}`);
771
+ say('');
772
+ return;
773
+ }
774
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결도 모델도 없습니다.')}`);
775
+ say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/model list')}${c.gray(', 서버에 물어보려면')} ${c.cyan('/model models')}`);
287
776
  say('');
288
777
  return;
289
778
  }
779
+
780
+ // 인자 없이 — 골라 바꾸기. 물어볼 수 없는 자리면 목록만 보여준다.
781
+ if (!ctx?.ask) return 연결목록(cfg, session);
782
+
290
783
  const items = cfg.profiles.map((p) => ({
291
- label: `${pad(p.name, 18)} ${c.gray(p.model)}`,
784
+ label: `${pad(p.name, 22)} ${c.gray(p.model)}`,
292
785
  note: p.id === cfg.active ? '지금' : '',
293
786
  }));
294
- const i = await pick('연결 고르기', items, {
295
- def: cfg.profiles.findIndex((p) => p.id === cfg.active),
787
+ items.push({ label: c.cyan(' 서버의 다른 모델 고르기'), note: '서버에 물어봅니다' });
788
+
789
+ const i = await pick('연결·모델 고르기', items, {
790
+ def: Math.max(0, cfg.profiles.findIndex((p) => p.id === cfg.active)),
296
791
  ask: ctx?.ask,
297
792
  });
298
- const p = cfg.profiles[i];
793
+ if (i === cfg.profiles.length) return await 서버모델고르기(session, ctx, cfg);
794
+ return 골라적용(session, cfg, cfg.profiles[i]);
795
+ }
796
+
797
+ function 이름으로찾기(profiles, 말) {
798
+ const q = 말.toLowerCase();
799
+ const 정확 = profiles.filter((p) => p.name.toLowerCase() === q || p.model.toLowerCase() === q || p.id.toLowerCase() === q);
800
+ if (정확.length) return 정확;
801
+ return profiles.filter((p) => `${p.name} ${p.model} ${p.id}`.toLowerCase().includes(q));
802
+ }
803
+
804
+ function 골라적용(session, cfg, p) {
299
805
  cfg.active = p.id;
300
- save(cfg);
301
- Object.assign(session.conn, {
302
- kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
303
- ctx: p.ctx, streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
304
- });
806
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ }
807
+ 연결적용(session, p);
305
808
  say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
306
809
  say('');
307
810
  }
308
811
 
812
+ /**
813
+ * 같은 서버에서 모델만 바꾼다.
814
+ *
815
+ * 등록된 연결이 아니어도 된다 — 서버가 내준다면 쓸 수 있어야 한다.
816
+ * 다음에도 쓰도록 프로필로 남겨 둔다. 그래야 /model 목록에서 다시 보인다.
817
+ */
818
+ async function 모델만바꾸기(session, cfg, 모델) {
819
+ const 지금 = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
820
+ const 이미 = cfg.profiles.find((p) => p.baseUrl === session.conn.base && p.model === 모델);
821
+ const p = 이미 ?? {
822
+ ...지금,
823
+ id: `${(지금?.id ?? 'conn').replace(/-[^-]*$/, '')}-${String(모델).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase(),
824
+ name: `${(지금?.name ?? '연결').split(' · ')[0]} · ${모델}`,
825
+ baseUrl: session.conn.base,
826
+ model: 모델,
827
+ // 컨텍스트 길이는 물려받지 않는다. 모델마다 다르다 —
828
+ // 32k 짜리에서 655k 짜리로 옮겼는데 32k 로 깔고 앉으면 새 모델의 5% 만 쓴다.
829
+ // 반대로 큰 데서 작은 데로 옮기면 긴 대화에서 서버가 거절한다. 아래에서 다시 잰다.
830
+ ctx: null,
831
+ };
832
+ if (!이미) { try { save(upsert(cfg, p)); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ } }
833
+ else { cfg.active = p.id; try { save(cfg); } catch {} }
834
+ 연결적용(session, p);
835
+ say(` ${mark.ok} 모델을 ${c.bold(모델)} 로 바꿨습니다. ${c.gray('서버는 그대로입니다.')}`);
836
+ await 길이맞추기(session, cfg, p);
837
+ say('');
838
+ }
839
+
840
+ /**
841
+ * 바뀐 모델에 맞춰 컨텍스트 길이를 다시 잰다.
842
+ *
843
+ * 모델을 바꾸는 순간은 이미 서버와 이야기하는 중이라 한 번 더 물어봐도 티가 안 난다.
844
+ * 여기서 안 재면 새 모델을 옛 모델의 길이로 쓰게 된다 — 화면에는 아무 표시도 안 나고
845
+ * 그냥 조용히 작아진다. 그런 고장이 가장 늦게 발견된다.
846
+ */
847
+ async function 길이맞추기(session, cfg, prof) {
848
+ const { probeCtx, fmtSize, 기본값 } = await import('./backend/ctxsize.js');
849
+ let r = null;
850
+ try { r = await probeCtx(session.conn, { timeout: 8000 }); } catch { /* 못 물어보면 아래에서 기본값 */ }
851
+ const 값 = r?.value ?? prof.ctx ?? 기본값;
852
+ session.conn.ctx = 값;
853
+ if (prof) {
854
+ prof.ctx = 값;
855
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
856
+ }
857
+ say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + (r?.source ? r.source + '에서 읽음' : '서버가 안 알려줘 기본값'))}`);
858
+ if (r?.max && r?.loaded && r.max > r.loaded) {
859
+ say(` ${c.yellow('이 모델은 ' + r.max.toLocaleString() + ' 까지 됩니다.')} ${c.gray('서버에서 더 올린 뒤')} ${c.cyan('/ctx auto')}`);
860
+ } else if (!r?.value) {
861
+ say(` ${c.gray('맞지 않으면')} ${c.cyan('/ctx 655360')} ${c.gray('처럼 직접 지정하세요.')}`);
862
+ }
863
+ }
864
+
865
+ function 연결목록(cfg, session) {
866
+ rule('등록된 연결', 70);
867
+ for (const p of cfg.profiles) {
868
+ const 지금 = p.id === cfg.active;
869
+ say(` ${지금 ? c.hgreen('●') : c.gray('·')} ${지금 ? c.bold(c.white(pad(p.name, 26))) : pad(p.name, 26)}${c.gray(p.model)}`);
870
+ say(` ${c.gray(p.baseUrl)}`);
871
+ }
872
+ say('');
873
+ say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}${c.gray(' — 연결 이름이든 모델 이름이든 됩니다.')}`);
874
+ say(` ${c.gray('이 서버가 내주는 다른 모델을 보려면')} ${c.cyan('/model models')}`);
875
+ say('');
876
+ }
877
+
878
+ async function 서버모델고르기(session, ctx, cfg) {
879
+ const s = spin('서버에 모델 목록을 물어보는 중…');
880
+ let 있는것;
881
+ try { 있는것 = await 서버모델들(session.conn); } catch { 있는것 = null; }
882
+ s.stop('');
883
+ if (!있는것 || !있는것.length) {
884
+ say(` ${mark.warn} 이 서버는 모델 목록을 내주지 않습니다.`);
885
+ say(` ${c.gray('목록이 없는 게이트웨이도 있습니다. 그때는')} ${c.cyan('deel setup')} ${c.gray('에서 모델 이름을 직접 넣으세요.')}`);
886
+ say('');
887
+ return;
888
+ }
889
+ // 물어볼 수 없는 자리면 목록만 보여주고 끝낸다.
890
+ //
891
+ // 여기서 그냥 pick 을 부르면 표준입력을 붙잡고 영영 안 끝난다.
892
+ // 파이프로 넣거나 검사에서 돌릴 때가 그렇다 — 멈춘 것처럼 보이고 끊는 수밖에 없다.
893
+ if (!ctx?.ask) {
894
+ rule(`이 서버의 모델 (${있는것.length}개)`, 70);
895
+ for (const m of 있는것) say(` ${m === session.conn.model ? c.hgreen('●') : c.gray('·')} ${m === session.conn.model ? c.bold(m) : m}`);
896
+ say('');
897
+ say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}`);
898
+ say('');
899
+ return;
900
+ }
901
+
902
+ const i = await pick(`이 서버의 모델 (${있는것.length}개)`, 있는것.map((m) => ({
903
+ label: m, note: m === session.conn.model ? '지금' : '',
904
+ })), {
905
+ def: Math.max(0, 있는것.indexOf(session.conn.model)),
906
+ ask: ctx.ask,
907
+ });
908
+ const 고른것 = 있는것[i];
909
+ if (고른것 === session.conn.model) {
910
+ say(` ${c.gray('그대로 둡니다.')}`);
911
+ say('');
912
+ return;
913
+ }
914
+ await 모델만바꾸기(session, cfg, 고른것);
915
+ }
916
+
309
917
  const INIT_TEMPLATE = `# DEEL.md
310
918
 
311
919
  이 폴더에서 일할 때 지킬 규칙을 적습니다. deel 가 매번 읽습니다.
@@ -325,3 +933,20 @@ const INIT_TEMPLATE = `# DEEL.md
325
933
  - 고치기 전에 관련 파일을 먼저 읽는다
326
934
  - (프로젝트에 맞는 규칙을 적으세요)
327
935
  `;
936
+
937
+ // /work 를 인자 없이 부르면 지금 모드와 고를 수 있는 것들을 보여 준다.
938
+ function showWork(session) {
939
+ rule('작업 모드', 70);
940
+ for (const k of WORK_ORDER) {
941
+ const w = WORK_MODES[k];
942
+ const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
943
+ const 표 = 지금 ? c.hgreen('●') : c.gray('·');
944
+ const 이름 = 지금 ? c.bold(c.white(pad(w.name, 8))) : c.gray(pad(w.name, 8));
945
+ say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(w.hint)}`);
946
+ say(` ${canWrite(k) ? c.gray('파일 바꿈') : c.green('읽기만')}${c.gray(' · 생각 ' + (w.think ?? '그대로') + '·' + w.effort + ' · 최대 ' + w.steps + '걸음')}`);
947
+ }
948
+ say('');
949
+ say(` ${c.gray('바꾸려면')} ${c.cyan('/plan')} ${c.cyan('/code')} ${c.cyan('/debug')} ${c.gray('… 또는')} ${c.cyan('Shift+Tab')} ${c.gray('으로 차례로')}`);
950
+ say(` ${c.gray('이건 승인 정책(')}${c.cyan('/mode')}${c.gray(')과 다른 축입니다 — 무엇을 하느냐 / 얼마나 물어보냐.')}`);
951
+ say('');
952
+ }