deel-local-cli 0.8.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.
@@ -5,6 +5,8 @@
5
5
  // 알려진 자리를 한꺼번에 두드려서 있는 대로 다 등록해 둔다.
6
6
  //
7
7
  // 두드리는 곳은 전부 이 컴퓨터(127.0.0.1) 다. 바깥으로 나가지 않는다.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { readFileSync } from 'node:fs';
8
10
  import { req, headersFor } from './http.js';
9
11
  import { allowTemporarily } from '../safety/network.js';
10
12
 
@@ -32,6 +34,62 @@ const fmtSize = (bytes) => {
32
34
  return gb >= 1 ? `${gb.toFixed(1)}GB` : `${Math.round(bytes / 1024 ** 2)}MB`;
33
35
  };
34
36
 
37
+ /**
38
+ * 이 컴퓨터에서 실제로 듣고 있는 TCP 포트.
39
+ *
40
+ * 왜 필요한가:
41
+ * 알려진 자리 13곳만 두드리면, 직접 세운 프록시나 사내 게이트웨이를 못 찾는다.
42
+ * 그런 것들은 아무 포트나 쓴다. "안 잡히는데요" 의 대부분이 이 경우다.
43
+ *
44
+ * 그래서 운영체제에게 물어본다. 이미 열려 있는 걸 아는 포트라서 두드려도
45
+ * 기다릴 일이 없다 — 죽은 포트를 찍는 것보다 오히려 빠르다.
46
+ *
47
+ * 못 물어봐도 그냥 넘어간다. 알려진 자리 훑기는 그대로 되기 때문이다.
48
+ */
49
+ export function listeningPorts({ timeout = 4000 } = {}) {
50
+ const 명령 = process.platform === 'win32'
51
+ ? { file: 'netstat.exe', args: ['-ano', '-p', 'TCP'] }
52
+ : process.platform === 'darwin'
53
+ ? { file: 'netstat', args: ['-an', '-p', 'tcp'] }
54
+ : { file: 'ss', args: ['-H', '-ltn'] };
55
+
56
+ let out = '';
57
+ try {
58
+ out = execFileSync(명령.file, 명령.args, { timeout, encoding: 'latin1', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
59
+ } catch {
60
+ // 리눅스에 ss 가 없을 수 있다. 그때는 커널이 직접 알려주는 표를 읽는다.
61
+ if (process.platform === 'linux') return procNetTcp();
62
+ return [];
63
+ }
64
+
65
+ const 포트 = new Set();
66
+ for (const line of out.split(/\r?\n/)) {
67
+ if (!/LISTEN/i.test(line)) continue;
68
+ // 주소는 `0.0.0.0:8080` `[::]:8080` `*.8080` 중 하나로 온다.
69
+ const m = line.match(/[\s[](?:[\d.]+|::[\da-f:%]*|\*)[\]]?[:.](\d{1,5})\s/i)
70
+ ?? line.match(/:(\d{1,5})\s/);
71
+ const p = Number(m?.[1]);
72
+ if (p > 0 && p < 65536) 포트.add(p);
73
+ }
74
+ return [...포트].sort((a, b) => a - b);
75
+ }
76
+
77
+ function procNetTcp() {
78
+ const 포트 = new Set();
79
+ for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
80
+ let text;
81
+ try { text = readFileSync(f, 'utf8'); } catch { continue; }
82
+ for (const line of text.split('\n').slice(1)) {
83
+ const col = line.trim().split(/\s+/);
84
+ if (col.length < 4) continue;
85
+ if (col[3] !== '0A') continue; // 0A = LISTEN
86
+ const p = parseInt(col[1].split(':')[1], 16);
87
+ if (p > 0 && p < 65536) 포트.add(p);
88
+ }
89
+ }
90
+ return [...포트].sort((a, b) => a - b);
91
+ }
92
+
35
93
  // 살아 있는지부터 짧게 본다. 죽은 포트에서 오래 기다리면 훑기가 하염없이 느려진다.
36
94
  async function probeOllama(origin, timeout) {
37
95
  const v = await req(`${origin}/api/version`, { timeout });
@@ -51,8 +109,16 @@ async function probeOllama(origin, timeout) {
51
109
  };
52
110
  }
53
111
 
112
+ // OpenAI 호환 서버가 놓일 수 있는 자리들.
113
+ //
114
+ // 대부분은 `/v1` 이다. 그런데 사내 게이트웨이나 직접 세운 프록시는 앞에 뭔가를
115
+ // 더 붙이는 경우가 많다 — 라우터 뒤에 붙이거나 여러 서비스를 한 포트에 모을 때
116
+ // 그렇게 된다. 그래서 흔한 모양 몇 가지를 더 본다.
117
+ // 죽은 포트에서는 첫 요청이 바로 실패하므로 훑는 시간에는 거의 영향이 없다.
118
+ const BASES = ['/v1', '', '/api/v1', '/openai/v1', '/api', '/llm/v1', '/proxy/v1'];
119
+
54
120
  async function probeOpenAI(origin, timeout, key) {
55
- for (const base of [`${origin}/v1`, origin]) {
121
+ for (const base of BASES.map((b) => `${origin}${b}`)) {
56
122
  const r = await req(`${base}/models`, { headers: headersFor(key ? 'bearer' : 'none', key), timeout });
57
123
  if (!r.ok || !r.json) {
58
124
  // 규격은 맞는데 키가 없어 막힌 경우 — 서버가 있다는 사실은 알려 준다.
@@ -97,10 +163,23 @@ async function fingerprint(origin, hit, timeout) {
97
163
  * @param {number} o.timeout 포트 하나당 기다릴 시간(ms)
98
164
  * @param {(x)=>void} o.onFind 하나 찾을 때마다 알림
99
165
  */
100
- export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200, key = '', onFind } = {}) {
166
+ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200, key = '', onFind, listening = true, maxListening = 80 } = {}) {
101
167
  const list = [...KNOWN];
102
168
  for (const p of ports) if (!list.some((x) => x.port === p)) list.push({ port: p, hint: '직접 지정' });
103
169
 
170
+ // 이 컴퓨터에서 실제로 듣고 있는 포트도 같이 본다.
171
+ // 직접 세운 프록시는 알려진 자리에 없기 때문이다. 이 PC 안에서만 한다 —
172
+ // 남의 컴퓨터 포트를 훑는 것은 이 도구가 할 일이 아니다.
173
+ const 잘린것 = [];
174
+ if (listening && (host === '127.0.0.1' || host === 'localhost' || host === '::1')) {
175
+ const 열린것 = listeningPorts().filter((p) => !list.some((x) => x.port === p));
176
+ const 볼것 = 열린것.slice(0, maxListening);
177
+ if (열린것.length > 볼것.length) 잘린것.push(...열린것.slice(maxListening));
178
+ // 이름표는 '무엇인지' 를 적어야 한다. 어느 런타임인지 못 알아보면
179
+ // 규격만이라도 적어 준다 — '열려 있는 자리' 는 사용자에게 아무 정보가 아니다.
180
+ for (const p of 볼것) list.push({ port: p, hint: 'OpenAI 호환 서버' });
181
+ }
182
+
104
183
  const jobs = list.map(async ({ port, hint }) => {
105
184
  const origin = `http://${host}:${port}`;
106
185
  // 훑는 동안만 그 자리를 연다. 끝나면 바로 닫아서 자물쇠를 원래대로 둔다.
@@ -108,6 +187,17 @@ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200
108
187
  let hit;
109
188
  let runtime;
110
189
  try {
190
+ // 먼저 'HTTP 로 말은 하는가' 만 짧게 본다.
191
+ //
192
+ // 이게 없으면 열려 있지만 HTTP 가 아닌 자리(파일 공유, RPC 같은)에서
193
+ // 요청마다 시간 초과를 기다리게 된다. 자리 하나에 여러 길을 보므로
194
+ // 그 기다림이 곱해져서, 훑기가 몇 초가 아니라 몇 분이 된다.
195
+ //
196
+ // 상태 0 은 '아무 답도 못 받았다' 는 뜻이다. 연결이 거절됐거나,
197
+ // 열려 있어도 HTTP 로 답하지 않는 자리다. 둘 다 여기서 접는다.
198
+ const 인사 = await req(`${origin}/`, { timeout: Math.min(timeout, 800) });
199
+ if (인사.status === 0) return null;
200
+
111
201
  hit = await probeOllama(origin, timeout);
112
202
  if (!hit) hit = await probeOpenAI(origin, timeout, key);
113
203
  if (!hit) return null;
@@ -127,7 +217,11 @@ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200
127
217
 
128
218
  const found = (await Promise.all(jobs)).filter(Boolean);
129
219
  // 모델이 많은 쪽을 위로. 잠긴 것은 아래로.
130
- return found.sort((a, b) => (a.locked ? 1 : 0) - (b.locked ? 1 : 0) || b.models.length - a.models.length || a.port - b.port);
220
+ found.sort((a, b) => (a.locked ? 1 : 0) - (b.locked ? 1 : 0) || b.models.length - a.models.length || a.port - b.port);
221
+ // 몇 자리를 안 봤는지 알려 준다. 조용히 자르면 '다 봤다' 로 읽힌다.
222
+ found.훑은자리 = list.length;
223
+ found.안본자리 = 잘린것;
224
+ return found;
131
225
  }
132
226
 
133
227
  // 찾은 것을 설정 프로필 모양으로 바꾼다. 같은 자리가 이미 있으면 그걸 되쓴다.
package/src/commands.js CHANGED
@@ -13,7 +13,7 @@ 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
18
  const MODES = {
19
19
  auto: '자율 — 전부 알아서. 되돌리기가 안전망',
@@ -25,11 +25,15 @@ export const COMMANDS = {
25
25
  help: { desc: '명령 목록' },
26
26
  clear: { desc: '대화 비우기' },
27
27
  context: { desc: '컨텍스트 사용량 보기' },
28
+ ctx: { desc: '컨텍스트 길이 — 모델에 맞춰 다시 재거나 직접 지정', arg: '[auto|숫자|640k]' },
28
29
  compact: { desc: '오래된 대화 줄이기' },
29
- model: { desc: '모델·연결 바꾸기' },
30
+ model: { desc: '연결·모델 바꾸기 (이름 일부 · list · models)', arg: '[이름|list|models]' },
30
31
  think: { desc: '추론 강도 (off/low/medium/high/max)', arg: '<수준>' },
31
32
  mode: { desc: '승인 정책 — 얼마나 물어보나 (auto/confirm/strict)', arg: '<모드>' },
32
- work: { desc: '작업 모드 — 무슨 일을 하는 중인가', arg: '[모드]' },
33
+ work: { desc: '작업 모드 — 무슨 일을 하는 중인가 (종합이면 저절로)', arg: '[모드]' },
34
+ // 이름이 /mode auto 와 겹쳐 보이므로 설명에서 못을 박는다.
35
+ // /mode auto 는 '얼마나 물어보나', /auto 는 '무슨 일을 하는 중인가' 다.
36
+ auto: { desc: '작업 모드 → 종합 (요청에 따라 저절로 옮겨 감. /mode 와 다름)' },
33
37
  code: { desc: '작업 모드 → 코드 (고치고 만든다)' },
34
38
  plan: { desc: '작업 모드 → 계획 (먼저 계획만)' },
35
39
  architect:{ desc: '작업 모드 → 설계 (구조를 짠다)' },
@@ -50,9 +54,31 @@ export const COMMANDS = {
50
54
  quit: { desc: '끝내기' },
51
55
  };
52
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
+
53
78
  // 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
54
79
  export async function handle(line, session, ctx) {
55
80
  if (!line.startsWith('/')) return { handled: false };
81
+ if (경로처럼보이나(line)) return { handled: false };
56
82
  const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
57
83
  const name = raw.toLowerCase();
58
84
  const arg = rest.join(' ');
@@ -71,6 +97,8 @@ export async function handle(line, session, ctx) {
71
97
 
72
98
  case 'context': return showContext(session), { handled: true };
73
99
 
100
+ case 'ctx': return await ctxLength(session, arg), { handled: true };
101
+
74
102
  case 'compact': {
75
103
  const s = spin('앞선 대화를 요약해 접는 중…');
76
104
  const r = await compact(session);
@@ -91,7 +119,7 @@ export async function handle(line, session, ctx) {
91
119
  return { handled: true };
92
120
  }
93
121
 
94
- case 'model': return await switchModel(session, ctx), { handled: true };
122
+ case 'model': return await switchModel(session, ctx, arg), { handled: true };
95
123
 
96
124
  case 'think': {
97
125
  const asProfile = normalizeProfile(arg);
@@ -110,7 +138,7 @@ export async function handle(line, session, ctx) {
110
138
  session.thinkSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
111
139
  say(` ${mark.ok} 추론 강도 ${c.bold(arg)}`);
112
140
  if (!session.conn.think && arg !== 'off') {
113
- say(` ${c.yellow('이 연결은 모델 층 조절이 먹습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
141
+ say(` ${c.yellow('이 연결은 모델 층 조절이 적용되지 않습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
114
142
  }
115
143
  showThink(session);
116
144
  return { handled: true };
@@ -184,6 +212,7 @@ export async function handle(line, session, ctx) {
184
212
  }
185
213
 
186
214
  case 'work':
215
+ case 'auto':
187
216
  case 'code': case 'plan': case 'architect':
188
217
  case 'debug': case 'ask': case 'orchestrator': {
189
218
  const 원하는 = name === 'work' ? normWork(arg) : name;
@@ -196,11 +225,19 @@ export async function handle(line, session, ctx) {
196
225
  return { handled: true };
197
226
  }
198
227
  session.work = 골라진;
228
+ // 사람이 직접 골랐다. 저절로 골라 둔 것이 있으면 지운다 —
229
+ // 안 지우면 이번 한마디는 여전히 옛 모드로 돌아 "왜 안 바뀌지" 가 된다.
230
+ session.routed = null;
199
231
  const w = getWork(골라진);
200
232
  say('');
201
233
  say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`);
202
234
  say(` ${c.gray('도구')} ${canWrite(골라진) ? c.yellow('읽기 + 파일 바꾸기') : c.green('읽기만 — 파일을 못 바꿉니다')}`);
203
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
+ }
204
241
  say('');
205
242
  return { handled: true };
206
243
  }
@@ -399,7 +436,7 @@ function showSkills(session, arg) {
399
436
  if (q === 'all') {
400
437
  all.forEach((s) => { s.enabled = true; });
401
438
  session.maxSkillsListed = Math.min(all.length, 200);
402
- say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 먹습니다 — /context 로 확인하세요.')}`);
439
+ say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 차지합니다 — /context 로 확인하세요.')}`);
403
440
  say('');
404
441
  return;
405
442
  }
@@ -462,7 +499,7 @@ function help(session) {
462
499
  say('');
463
500
  // 감춘 것은 '못 쓰는 것' 이 아니다. 그 말을 분명히 해 둔다.
464
501
  if (감춘수) {
465
- say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 — 넣으면 그대로 먹습니다.`)}`);
502
+ say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 — 직접 입력하면 그대로 실행됩니다.`)}`);
466
503
  say(` ${c.gray('전부 보려면')} ${c.cyan('/level 개발자')}`);
467
504
  say('');
468
505
  }
@@ -525,7 +562,7 @@ function showThink(session) {
525
562
  say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('예')} ${c.cyan('/think save')}`);
526
563
  if (!session.conn.think && session.think !== 'off') {
527
564
  say('');
528
- say(` ${c.yellow('이 연결은 모델 층 강도 조절이 먹습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
565
+ say(` ${c.yellow('이 연결은 모델 층 강도 조절이 적용되지 않습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
529
566
  }
530
567
  say('');
531
568
  }
@@ -550,35 +587,333 @@ function showContext(session) {
550
587
  say('');
551
588
  }
552
589
 
553
- 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 = '') {
554
743
  const cfg = load();
555
- if (cfg.profiles.length <= 1 && !cfg.profiles.length) {
556
- 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')}`);
557
746
  say('');
558
747
  return;
559
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')}`);
776
+ say('');
777
+ return;
778
+ }
779
+
780
+ // 인자 없이 — 골라 바꾸기. 물어볼 수 없는 자리면 목록만 보여준다.
781
+ if (!ctx?.ask) return 연결목록(cfg, session);
782
+
560
783
  const items = cfg.profiles.map((p) => ({
561
- label: `${pad(p.name, 18)} ${c.gray(p.model)}`,
784
+ label: `${pad(p.name, 22)} ${c.gray(p.model)}`,
562
785
  note: p.id === cfg.active ? '지금' : '',
563
786
  }));
564
- const i = await pick('연결 고르기', items, {
565
- 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)),
566
791
  ask: ctx?.ask,
567
792
  });
568
- 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) {
569
805
  cfg.active = p.id;
570
- save(cfg);
571
- Object.assign(session.conn, {
572
- 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,
574
- });
575
- // 자물쇠도 같이 옮긴다. 이걸 빼먹으면 옛 주소가 열린 채로 남고 새 주소는 막혀
576
- // 다음 한마디에서 바로 "허용되지 않은 주소" 가 난다.
577
- allowEndpoint(p.baseUrl);
806
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ }
807
+ 연결적용(session, p);
578
808
  say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
579
809
  say('');
580
810
  }
581
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
+
582
917
  const INIT_TEMPLATE = `# DEEL.md
583
918
 
584
919
  이 폴더에서 일할 때 지킬 규칙을 적습니다. deel 가 매번 읽습니다.
@@ -604,7 +939,7 @@ function showWork(session) {
604
939
  rule('작업 모드', 70);
605
940
  for (const k of WORK_ORDER) {
606
941
  const w = WORK_MODES[k];
607
- const 지금 = k === (normWork(session.work) ?? 'code');
942
+ const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
608
943
  const 표 = 지금 ? c.hgreen('●') : c.gray('·');
609
944
  const 이름 = 지금 ? c.bold(c.white(pad(w.name, 8))) : c.gray(pad(w.name, 8));
610
945
  say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(w.hint)}`);