deel-local-cli 0.5.0 → 0.8.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.
@@ -0,0 +1,193 @@
1
+ // 대화를 파일에 남겨 두고, 껐다 켜도 이어서 하게 한다.
2
+ //
3
+ // 왜 필요한가:
4
+ // 터미널을 실수로 닫거나, 컴퓨터가 재부팅되거나, 긴 작업 중 자리를 뜨는 일은 늘 있다.
5
+ // 그때 대화가 통째로 날아가면 모델이 알아낸 것·정한 것이 전부 사라진다.
6
+ // 다시 설명하는 비용이 실제 작업보다 큰 경우도 많다.
7
+ //
8
+ // 어디에 두나:
9
+ // 작업 폴더의 .deel/sessions/<id>.jsonl — 그 폴더의 일은 그 폴더에 남긴다.
10
+ // .gitignore 에 .deel/ 이 들어 있어 깃에 올라가지 않는다.
11
+ //
12
+ // 왜 jsonl 인가:
13
+ // 한 줄에 한 메시지씩 이어 붙이기만 하면 된다. 도중에 프로그램이 죽어도
14
+ // 마지막 줄까지는 성하다. 통째로 다시 쓰는 방식이면 그 순간 파일이 깨진다.
15
+ import {
16
+ existsSync, mkdirSync, readdirSync, readFileSync,
17
+ appendFileSync, writeFileSync, statSync, rmSync,
18
+ } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ export const sessionsDir = (root) => join(root, '.deel', 'sessions');
22
+
23
+ // 사람이 읽을 수 있고 정렬하면 시간순인 id.
24
+ export function newId(at = new Date()) {
25
+ const p = (n, w = 2) => String(n).padStart(w, '0');
26
+ return `${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}`
27
+ + `-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`;
28
+ }
29
+
30
+ /**
31
+ * 아직 안 쓰인 id 를 고른다.
32
+ *
33
+ * id 가 초 단위라서, 창을 두 개 거의 동시에 열면 같은 id 가 나온다.
34
+ * 그러면 서로 다른 두 대화가 한 파일에 섞여 들어가고 되살릴 때 뒤엉킨다.
35
+ * 이미 있는 이름이면 뒤에 번호를 붙여 피한다.
36
+ */
37
+ export function freeId(dir, at = new Date()) {
38
+ const base = newId(at);
39
+ if (!existsSync(join(dir, `${base}.jsonl`))) return base;
40
+ for (let n = 2; n < 1000; n++) {
41
+ const id = `${base}-${n}`;
42
+ if (!existsSync(join(dir, `${id}.jsonl`))) return id;
43
+ }
44
+ return `${base}-${process.pid}`;
45
+ }
46
+
47
+ export class Store {
48
+ constructor(root, id = null) {
49
+ this.root = root;
50
+ this.dir = sessionsDir(root);
51
+ this.auto = id == null; // 우리가 지은 이름인가 (그러면 겹칠 때 바꿔도 된다)
52
+ this.#use(id ?? freeId(this.dir));
53
+ this.opened = false;
54
+ }
55
+
56
+ #use(id) {
57
+ this.id = id;
58
+ this.file = join(this.dir, `${id}.jsonl`);
59
+ }
60
+
61
+ #open() {
62
+ if (this.opened) return;
63
+ mkdirSync(this.dir, { recursive: true });
64
+ this.opened = true;
65
+ }
66
+
67
+ /**
68
+ * 첫 줄은 머리글 — 어떤 모델·어떤 폴더에서 한 대화인지.
69
+ *
70
+ * 'wx' 로 만든다: 이미 있으면 실패한다. 이름을 확인하고 만드는 사이에
71
+ * 다른 창이 같은 이름을 채 갔을 수 있어서, 그때는 다른 이름으로 옮겨 간다.
72
+ * 사용자가 --resume 으로 이름을 준 경우에는 옮기지 않고 그 파일에 이어 쓴다.
73
+ */
74
+ begin(meta) {
75
+ this.#open();
76
+ const head = JSON.stringify({ t: 'meta', at: new Date().toISOString(), ...meta }) + '\n';
77
+ for (let tries = 0; tries < 50; tries++) {
78
+ try {
79
+ writeFileSync(this.file, head, { encoding: 'utf8', flag: 'wx' });
80
+ return this;
81
+ } catch (err) {
82
+ if (err?.code !== 'EEXIST') return this; // 못 적어도 대화는 계속돼야 한다
83
+ if (!this.auto) return this; // 이어쓰기 — 이미 있는 게 맞다
84
+ this.#use(freeId(this.dir)); // 누가 채 갔다. 옆자리로.
85
+ }
86
+ }
87
+ return this;
88
+ }
89
+
90
+ #write(obj) {
91
+ try { appendFileSync(this.file, JSON.stringify(obj) + '\n', 'utf8'); }
92
+ catch { /* 기록에 실패해도 대화는 계속돼야 한다 */ }
93
+ }
94
+
95
+ // 메시지 하나를 덧붙인다. 대화가 진행되는 대로 즉시 남긴다.
96
+ append(msg) {
97
+ this.#open();
98
+ this.#write({ t: 'msg', m: msg });
99
+ }
100
+
101
+ // 압축이 일어나면 이력이 통째로 바뀐다. 그때는 새로 적는다.
102
+ replace(messages, note = '압축') {
103
+ this.#open();
104
+ const meta = this.readMeta() ?? {};
105
+ const lines = [JSON.stringify({ t: 'meta', at: new Date().toISOString(), ...meta })];
106
+ lines.push(JSON.stringify({ t: 'note', at: new Date().toISOString(), note }));
107
+ for (const m of messages) lines.push(JSON.stringify({ t: 'msg', m }));
108
+ try { writeFileSync(this.file, lines.join('\n') + '\n', 'utf8'); } catch {}
109
+ }
110
+
111
+ readMeta() {
112
+ if (!existsSync(this.file)) return null;
113
+ try {
114
+ const first = readFileSync(this.file, 'utf8').split('\n', 1)[0];
115
+ const j = JSON.parse(first);
116
+ return j.t === 'meta' ? j : null;
117
+ } catch { return null; }
118
+ }
119
+
120
+ // 저장된 대화를 읽어 온다. 깨진 줄은 건너뛴다 — 도중에 죽었을 수 있다.
121
+ load() {
122
+ if (!existsSync(this.file)) return { meta: null, messages: [] };
123
+ let meta = null;
124
+ const messages = [];
125
+ for (const line of readFileSync(this.file, 'utf8').split('\n')) {
126
+ if (!line.trim()) continue;
127
+ let j;
128
+ try { j = JSON.parse(line); } catch { continue; }
129
+ if (j.t === 'meta') meta = j;
130
+ else if (j.t === 'msg' && j.m) messages.push(j.m);
131
+ }
132
+ return { meta, messages };
133
+ }
134
+ }
135
+
136
+ // 사람에게 보여줄 첫 마디. 이게 목록에서 대화를 알아보는 유일한 단서다.
137
+ function firstAsk(messages) {
138
+ const m = messages.find((x) => x.role === 'user' && typeof x.content === 'string'
139
+ && x.content.trim() && !x.content.startsWith('['));
140
+ return m ? m.content.replace(/\s+/g, ' ').trim() : '(빈 대화)';
141
+ }
142
+
143
+ /**
144
+ * 이 폴더에 남아 있는 대화 목록. 최근 것이 위로.
145
+ */
146
+ export function list(root, { limit = 20 } = {}) {
147
+ const dir = sessionsDir(root);
148
+ if (!existsSync(dir)) return [];
149
+ const out = [];
150
+ for (const name of readdirSync(dir)) {
151
+ if (!name.endsWith('.jsonl')) continue;
152
+ const id = name.slice(0, -6);
153
+ const file = join(dir, name);
154
+ let st;
155
+ try { st = statSync(file); } catch { continue; }
156
+ const { meta, messages } = new Store(root, id).load();
157
+ if (!messages.length) continue;
158
+ out.push({
159
+ id,
160
+ file,
161
+ at: st.mtime,
162
+ bytes: st.size,
163
+ model: meta?.model ?? '?',
164
+ turns: messages.filter((m) => m.role === 'user').length,
165
+ messages: messages.length,
166
+ first: firstAsk(messages),
167
+ });
168
+ }
169
+ return out.sort((a, b) => b.at - a.at).slice(0, limit);
170
+ }
171
+
172
+ export function remove(root, id) {
173
+ const f = join(sessionsDir(root), `${id}.jsonl`);
174
+ if (!existsSync(f)) return { error: `그런 대화가 없습니다: ${id}` };
175
+ rmSync(f, { force: true });
176
+ return { removed: id };
177
+ }
178
+
179
+ /** 가장 최근 대화. --continue 가 집어 오는 것. */
180
+ export function latest(root) {
181
+ return list(root, { limit: 1 })[0] ?? null;
182
+ }
183
+
184
+ /**
185
+ * 오래된 것은 정리한다. 안 그러면 폴더가 끝없이 자란다.
186
+ * 최근 keep 개는 무조건 남기고, 그보다 오래되고 days 를 넘긴 것만 지운다.
187
+ */
188
+ export function prune(root, { keep = 30, days = 30 } = {}) {
189
+ const all = list(root, { limit: 1000 });
190
+ const 자를것 = all.slice(keep).filter((s) => (Date.now() - s.at.getTime()) > days * 86400000);
191
+ for (const s of 자를것) { try { rmSync(s.file, { force: true }); } catch {} }
192
+ return 자를것.length;
193
+ }
@@ -1,6 +1,6 @@
1
1
  // 규격 차이(OpenAI 호환 / Ollama)를 여기 한 곳에서만 흡수한다.
2
2
  // 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
3
- import { req, headersFor, serverMessage } from './http.js';
3
+ import { req, headersFor, serverMessage, Aborted } from './http.js';
4
4
 
5
5
  export function endpoint(shape) {
6
6
  return shape === 'ollama' ? '/api/chat' : '/chat/completions';
@@ -88,6 +88,7 @@ export async function chat(conn, opts) {
88
88
  headers: headersFor(conn.auth, conn.key ?? ''),
89
89
  body,
90
90
  timeout: opts.timeout ?? 300000,
91
+ signal: opts.signal ?? null,
91
92
  });
92
93
  if (!r.ok) throw new Error(serverMessage(r));
93
94
  return extractMessage(conn.kind, r.json);
@@ -102,6 +103,7 @@ export async function* chatStream(conn, opts) {
102
103
  body,
103
104
  timeout: opts.timeout ?? 300000,
104
105
  stream: true,
106
+ signal: opts.signal ?? null,
105
107
  });
106
108
  if (!r.ok || !r.res?.body) throw new Error(r.error ?? `HTTP ${r.status}`);
107
109
 
@@ -111,7 +113,15 @@ export async function* chatStream(conn, opts) {
111
113
  let buf = '';
112
114
 
113
115
  while (true) {
114
- const { done, value } = await reader.read();
116
+ // 사용자가 끊었으면 흘러오는 것을 받지 않는다. 읽던 연결도 닫는다.
117
+ if (opts.signal?.aborted) {
118
+ try { await reader.cancel(); } catch {}
119
+ throw new Aborted();
120
+ }
121
+ let done;
122
+ let value;
123
+ try { ({ done, value } = await reader.read()); }
124
+ catch (err) { if (opts.signal?.aborted) throw new Aborted(); throw err; }
115
125
  if (done) break;
116
126
  buf += dec.decode(value, { stream: true });
117
127
 
@@ -1,4 +1,8 @@
1
- // HTTP 한 겹. 시간 제한과 오류 정규화만 담당한다.
1
+ // HTTP 한 겹. 시간 제한과 오류 정규화, 그리고 나가도 되는 곳인지 확인한다.
2
+ //
3
+ // 이 파일이 프로그램에서 바깥으로 나가는 유일한 문이다.
4
+ // 나가기 전에 반드시 safety/network.js 의 문지기에게 물어본다.
5
+ import { checkUrl, NetBlocked } from '../safety/network.js';
2
6
 
3
7
  export const AUTH_STYLES = [
4
8
  { id: 'bearer', label: 'Authorization: Bearer', apply: (h, k) => { h['Authorization'] = `Bearer ${k}`; } },
@@ -14,14 +18,19 @@ export function headersFor(authStyle, key, extra = {}) {
14
18
  return h;
15
19
  }
16
20
 
17
- export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false } = {}) {
21
+ export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null } = {}) {
18
22
  const started = Date.now();
19
23
  try {
24
+ checkUrl(url); // 허용된 자리가 아니면 여기서 끝난다. 본문은 만들어지지도 않는다.
25
+ // 시간 초과와 '사용자가 Ctrl+C' 를 둘 다 듣는다. 둘 중 먼저 오는 쪽이 끊는다.
26
+ const sig = signal
27
+ ? (AbortSignal.any ? AbortSignal.any([AbortSignal.timeout(timeout), signal]) : signal)
28
+ : AbortSignal.timeout(timeout);
20
29
  const res = await fetch(url, {
21
30
  method,
22
31
  headers,
23
32
  body: body === undefined ? undefined : JSON.stringify(body),
24
- signal: AbortSignal.timeout(timeout),
33
+ signal: sig,
25
34
  redirect: 'follow',
26
35
  });
27
36
  const ms = Date.now() - started;
@@ -33,10 +42,19 @@ export async function req(url, { method = 'GET', headers = {}, body, timeout = 2
33
42
  return { ok: res.ok, status: res.status, json, text, ms, headers: res.headers };
34
43
  } catch (err) {
35
44
  const ms = Date.now() - started;
45
+ // 막힌 것은 통신 실패와 다르다. 조용히 넘기면 자물쇠가 있는지도 모른다.
46
+ if (err instanceof NetBlocked) throw err;
47
+ // 사용자가 끊은 것도 실패가 아니다. 오류 화면을 띄우면 안 된다.
48
+ if (signal?.aborted) throw new Aborted();
36
49
  return { ok: false, status: 0, error: normalizeError(err), ms };
37
50
  }
38
51
  }
39
52
 
53
+ // 사용자가 Ctrl+C 로 끊었다는 뜻. 통신 오류와 구분하려고 따로 둔다.
54
+ export class Aborted extends Error {
55
+ constructor() { super('사용자가 중단했습니다'); this.name = 'Aborted'; }
56
+ }
57
+
40
58
  function normalizeError(err) {
41
59
  const m = String(err?.message ?? err);
42
60
  if (err?.name === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
@@ -0,0 +1,162 @@
1
+ // 이 PC 에 떠 있는 로컬 모델 서버를 전부 찾는다.
2
+ //
3
+ // 로컬 런타임은 하나만 쓰지 않는다. Ollama 로 작은 모델을 돌리면서
4
+ // LM Studio 로 큰 모델을 띄워 두는 식이 흔하다. 그래서 하나만 물어보지 않고
5
+ // 알려진 자리를 한꺼번에 두드려서 있는 대로 다 등록해 둔다.
6
+ //
7
+ // 두드리는 곳은 전부 이 컴퓨터(127.0.0.1) 다. 바깥으로 나가지 않는다.
8
+ import { req, headersFor } from './http.js';
9
+ import { allowTemporarily } from '../safety/network.js';
10
+
11
+ // 포트는 겹칠 수 있다(llama.cpp 와 LocalAI 가 둘 다 8080). 그래서 포트로 단정하지 않고
12
+ // 응답을 보고 정한다. 아래 이름은 '그 포트에서 흔한 것' 이라는 힌트일 뿐이다.
13
+ export const KNOWN = [
14
+ { port: 11434, hint: 'Ollama' },
15
+ { port: 1234, hint: 'LM Studio' },
16
+ { port: 8080, hint: 'llama.cpp · LocalAI' },
17
+ { port: 8000, hint: 'vLLM · SGLang' },
18
+ { port: 1337, hint: 'Jan' },
19
+ { port: 5001, hint: 'KoboldCpp' },
20
+ { port: 5000, hint: 'text-generation-webui · TabbyAPI' },
21
+ { port: 4891, hint: 'GPT4All' },
22
+ { port: 9997, hint: 'Xinference' },
23
+ { port: 11435, hint: 'Ollama (두 번째)' },
24
+ { port: 8081, hint: 'llama.cpp (두 번째)' },
25
+ { port: 3000, hint: 'Open WebUI · LiteLLM' },
26
+ { port: 4000, hint: 'LiteLLM' },
27
+ ];
28
+
29
+ const fmtSize = (bytes) => {
30
+ if (!bytes) return '';
31
+ const gb = bytes / 1024 ** 3;
32
+ return gb >= 1 ? `${gb.toFixed(1)}GB` : `${Math.round(bytes / 1024 ** 2)}MB`;
33
+ };
34
+
35
+ // 살아 있는지부터 짧게 본다. 죽은 포트에서 오래 기다리면 훑기가 하염없이 느려진다.
36
+ async function probeOllama(origin, timeout) {
37
+ const v = await req(`${origin}/api/version`, { timeout });
38
+ if (!v.ok || !v.json?.version) return null;
39
+ const tags = await req(`${origin}/api/tags`, { timeout: timeout * 2 });
40
+ return {
41
+ kind: 'ollama',
42
+ runtime: 'Ollama',
43
+ version: v.json.version,
44
+ base: origin,
45
+ auth: 'none',
46
+ models: (tags.json?.models ?? []).map((m) => ({
47
+ id: m.name ?? m.model,
48
+ note: [m.details?.parameter_size, fmtSize(m.size)].filter(Boolean).join(' · '),
49
+ })),
50
+ ms: v.ms,
51
+ };
52
+ }
53
+
54
+ async function probeOpenAI(origin, timeout, key) {
55
+ for (const base of [`${origin}/v1`, origin]) {
56
+ const r = await req(`${base}/models`, { headers: headersFor(key ? 'bearer' : 'none', key), timeout });
57
+ if (!r.ok || !r.json) {
58
+ // 규격은 맞는데 키가 없어 막힌 경우 — 서버가 있다는 사실은 알려 준다.
59
+ if (r.status === 401 || r.status === 403) {
60
+ return { kind: 'openai', runtime: null, base, auth: null, models: [], locked: true, ms: r.ms };
61
+ }
62
+ continue;
63
+ }
64
+ const list = r.json.data ?? r.json.models ?? [];
65
+ if (!Array.isArray(list)) continue;
66
+ return {
67
+ kind: 'openai',
68
+ runtime: null,
69
+ base,
70
+ auth: key ? 'bearer' : 'none',
71
+ models: list
72
+ .map((m) => (typeof m === 'string' ? { id: m } : { id: m.id ?? m.name ?? m.model, note: m.owned_by ?? '' }))
73
+ .filter((m) => m.id),
74
+ ms: r.ms,
75
+ };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // 어떤 런타임인지 티 나는 자국을 찾는다. 못 찾으면 포트 힌트로 적고 '추정' 이라고 밝힌다.
81
+ async function fingerprint(origin, hit, timeout) {
82
+ if (hit.runtime) return hit.runtime;
83
+ const props = await req(`${origin}/props`, { timeout }); // llama.cpp
84
+ if (props.ok && props.json?.default_generation_settings) return 'llama.cpp';
85
+ const lms = await req(`${origin}/api/v0/models`, { timeout }); // LM Studio
86
+ if (lms.ok && Array.isArray(lms.json?.data)) return 'LM Studio';
87
+ const ver = await req(`${origin}/version`, { timeout }); // vLLM
88
+ if (ver.ok && ver.json?.version) return 'vLLM';
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * 이 PC 를 훑는다.
94
+ * @param {object} o
95
+ * @param {string} o.host 기본 127.0.0.1
96
+ * @param {number[]} o.ports 더 볼 포트
97
+ * @param {number} o.timeout 포트 하나당 기다릴 시간(ms)
98
+ * @param {(x)=>void} o.onFind 하나 찾을 때마다 알림
99
+ */
100
+ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200, key = '', onFind } = {}) {
101
+ const list = [...KNOWN];
102
+ for (const p of ports) if (!list.some((x) => x.port === p)) list.push({ port: p, hint: '직접 지정' });
103
+
104
+ const jobs = list.map(async ({ port, hint }) => {
105
+ const origin = `http://${host}:${port}`;
106
+ // 훑는 동안만 그 자리를 연다. 끝나면 바로 닫아서 자물쇠를 원래대로 둔다.
107
+ const close = allowTemporarily(origin);
108
+ let hit;
109
+ let runtime;
110
+ try {
111
+ hit = await probeOllama(origin, timeout);
112
+ if (!hit) hit = await probeOpenAI(origin, timeout, key);
113
+ if (!hit) return null;
114
+ runtime = await fingerprint(origin, hit, timeout);
115
+ } finally { close(); }
116
+ const found = {
117
+ ...hit,
118
+ port,
119
+ host,
120
+ runtime: runtime ?? hint,
121
+ guessed: !runtime && hit.kind !== 'ollama',
122
+ hint,
123
+ };
124
+ onFind?.(found);
125
+ return found;
126
+ });
127
+
128
+ const found = (await Promise.all(jobs)).filter(Boolean);
129
+ // 모델이 많은 쪽을 위로. 잠긴 것은 아래로.
130
+ return found.sort((a, b) => (a.locked ? 1 : 0) - (b.locked ? 1 : 0) || b.models.length - a.models.length || a.port - b.port);
131
+ }
132
+
133
+ // 찾은 것을 설정 프로필 모양으로 바꾼다. 같은 자리가 이미 있으면 그걸 되쓴다.
134
+ export function toProfiles(found, existing = []) {
135
+ const out = [];
136
+ for (const f of found) {
137
+ for (const m of f.models.length ? f.models : [{ id: null }]) {
138
+ if (!m.id) continue;
139
+ const id = `${slugRuntime(f.runtime)}-${String(m.id).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase();
140
+ const prev = existing.find((p) => p.baseUrl === f.base && p.model === m.id);
141
+ out.push({
142
+ id: prev?.id ?? id,
143
+ name: `${f.runtime} · ${m.id}`,
144
+ kind: f.kind,
145
+ baseUrl: f.base,
146
+ auth: f.auth ?? 'none',
147
+ model: m.id,
148
+ apiKey: prev?.apiKey ?? '',
149
+ ctx: prev?.ctx ?? null,
150
+ streaming: prev?.streaming ?? true,
151
+ tools: prev?.tools ?? false,
152
+ json: prev?.json ?? false,
153
+ think: prev?.think ?? false,
154
+ note: m.note ?? '',
155
+ local: true,
156
+ });
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ const slugRuntime = (r) => String(r ?? 'local').split(/[\s·]+/)[0].toLowerCase().replace(/[^a-z0-9]+/g, '');
@@ -0,0 +1,147 @@
1
+ // deel scan — 이 PC 의 로컬 모델 서버를 훑어 보여 주고, 원하면 전부 등록한다.
2
+ import { c, say, rule, pad, mark, clip, width } from '../ui/ansi.js';
3
+ import { pick } from '../ui/prompt.js';
4
+ import { spin } from '../ui/spinner.js';
5
+ import { scanLocal, toProfiles, KNOWN } from './scan.js';
6
+ import { load, save, upsert } from '../config.js';
7
+
8
+ // 코딩 에이전트로 쓰려면 도구 호출이 되어야 한다. 진단 전이라 확실히는 모르니,
9
+ // 이름에 드러난 단서로 짐작해서 권한다. 틀릴 수 있다는 것을 문구에 밝힌다.
10
+ const 도구잘함 = /(qwen|llama-?3|llama3|mistral|devstral|hermes|command-?r|firefunction|granite|gpt-?oss|glm|kimi|minimax|seed-?oss)/i;
11
+ const 코딩 = /(cod(e|er|ing)|devstral|starcoder|deepseek|qwen.*cod|granite.*cod)/i;
12
+ const 작음 = /(0\.5b|1\.5b|1b|2b|3b|tiny|mini|small)/i;
13
+
14
+ function recommend(found) {
15
+ const all = [];
16
+ for (const f of found) for (const m of f.models) all.push({ ...f, model: m.id, note: m.note ?? '' });
17
+ if (!all.length) return null;
18
+
19
+ const 점수 = (x) => {
20
+ let s = 0;
21
+ if (코딩.test(x.model)) s += 3;
22
+ if (도구잘함.test(x.model)) s += 2;
23
+ if (!작음.test(x.model)) s += 1; // 너무 작으면 도구 호출을 잘 못 한다
24
+ if (x.kind === 'ollama') s += 1; // 규격이 확실히 확인된 쪽
25
+ return s;
26
+ };
27
+ const best = all.map((x) => ({ x, s: 점수(x) })).sort((a, b) => b.s - a.s)[0];
28
+ if (!best || best.s < 2) return null;
29
+
30
+ const 이유 = [];
31
+ if (코딩.test(best.x.model)) 이유.push('코딩용 모델');
32
+ if (도구잘함.test(best.x.model)) 이유.push('도구 호출을 잘하는 계열');
33
+ if (작음.test(best.x.model)) 이유.push(c.yellow('다만 작은 모델이라 도구 호출이 불안할 수 있습니다'));
34
+ 이유.push(c.gray('실제로 되는지는 deel diagnose 로 확인하세요'));
35
+ return { ...best.x, why: 이유.join(' · ') };
36
+ }
37
+
38
+ export async function runScan(flags = {}) {
39
+ const host = flags.host ? String(flags.host) : '127.0.0.1';
40
+ const ports = String(flags.ports ?? '')
41
+ .split(/[,\s]+/).map((x) => parseInt(x, 10)).filter(Boolean);
42
+ const timeout = flags.timeout ? parseInt(String(flags.timeout), 10) : 1200;
43
+
44
+ say('');
45
+ rule('로컬 모델 서버 훑기', 74);
46
+ say(` ${c.gray(`${host} 의 알려진 자리 ${KNOWN.length + ports.length}곳을 두드립니다. 바깥으로는 나가지 않습니다.`)}`);
47
+ say('');
48
+
49
+ const s = spin('찾는 중…');
50
+ const found = await scanLocal({ host, ports, timeout, key: flags.key ? String(flags.key) : '' });
51
+ s.stop(` ${found.length ? mark.ok : mark.warn} ${found.length}곳 찾음`);
52
+ say('');
53
+
54
+ if (!found.length) {
55
+ say(` ${c.gray('떠 있는 로컬 서버가 없습니다.')}`);
56
+ say('');
57
+ say(` ${c.gray('확인해 볼 것')}`);
58
+ say(` ${c.gray('· Ollama 를 켰나요?')} ${c.cyan('ollama serve')}`);
59
+ say(` ${c.gray('· LM Studio 의 로컬 서버를 켰나요?')} ${c.gray('(Developer → Start Server)')}`);
60
+ say(` ${c.gray('· 다른 포트를 쓴다면')} ${c.cyan('deel scan --ports 9000,9100')}`);
61
+ say('');
62
+ return 0;
63
+ }
64
+
65
+ // ── 찾은 것 표로 ─────────────────────────────────────────────────────
66
+ const wRun = Math.max(10, ...found.map((f) => width(f.runtime)));
67
+ for (const f of found) {
68
+ const 자리 = `${f.host}:${f.port}`;
69
+ const 규격 = f.kind === 'ollama' ? 'Ollama 규격' : 'OpenAI 호환';
70
+ const 표시 = f.guessed ? c.gray(' (추정)') : '';
71
+ say(` ${c.hcyan('◆')} ${c.bold(pad(f.runtime, wRun))}${표시} ${c.gray(pad(자리, 22))}${c.gray(pad(규격, 14))}${c.gray(f.ms + 'ms')}`);
72
+ if (f.locked) {
73
+ say(` ${c.yellow('키가 필요합니다')} ${c.gray('— deel scan --key <키> 로 다시 훑어 보세요')}`);
74
+ continue;
75
+ }
76
+ if (!f.models.length) {
77
+ say(` ${c.gray('모델이 하나도 없습니다 — 런타임에서 먼저 모델을 받아 두세요')}`);
78
+ continue;
79
+ }
80
+ for (const m of f.models.slice(0, 12)) {
81
+ say(` ${c.gray('·')} ${pad(clip(m.id, 40), 42)}${c.gray(m.note ?? '')}`);
82
+ }
83
+ if (f.models.length > 12) say(` ${c.gray(`… 그 밖에 ${f.models.length - 12}개`)}`);
84
+ }
85
+ say('');
86
+
87
+ const 모델수 = found.reduce((n, f) => n + f.models.length, 0);
88
+ say(` ${c.gray('합계')} 서버 ${c.bold(String(found.length))}곳 · 모델 ${c.bold(String(모델수))}개`);
89
+ say('');
90
+
91
+ // ── 추천 ─────────────────────────────────────────────────────────────
92
+ // 도구 호출이 되는 모델이라야 코딩 에이전트로 쓸 수 있다. 이름으로 짐작해 권한다.
93
+ const 추천 = recommend(found);
94
+ if (추천) {
95
+ say(` ${c.hgreen('추천')} ${c.bold(추천.runtime)} ${c.gray('·')} ${c.bold(추천.model)}`);
96
+ say(` ${c.gray(추천.why)}`);
97
+ say('');
98
+ }
99
+
100
+ // ── 등록 ─────────────────────────────────────────────────────────────
101
+ if (!flags.save && !flags.pick) {
102
+ say(` ${c.gray('전부 등록하려면')} ${c.cyan('deel scan --save')}`);
103
+ say(` ${c.gray('골라서 등록하려면')} ${c.cyan('deel scan --pick')}`);
104
+ say(` ${c.gray('등록 뒤에는 대화 중')} ${c.cyan('/model')} ${c.gray('로 바꿔 씁니다.')}`);
105
+ say('');
106
+ return 0;
107
+ }
108
+
109
+ const cfg = load();
110
+ let profiles = toProfiles(found, cfg.profiles);
111
+
112
+ // 골라 담기 — 사용자가 쓸 것만 등록한다.
113
+ if (flags.pick && profiles.length > 1) {
114
+ const items = profiles.map((p) => ({
115
+ label: `${pad(clip(p.name, 44), 46)}${c.gray(p.note ?? '')}`,
116
+ note: 추천 && p.model === 추천.model && p.baseUrl.includes(String(추천.port)) ? '추천' : '',
117
+ }));
118
+ const i = await pick('어느 것을 쓰시겠습니까', items, {
119
+ def: Math.max(0, profiles.findIndex((p) => 추천 && p.model === 추천.model)),
120
+ });
121
+ profiles = [profiles[i]];
122
+ cfg.active = null; // 고른 것을 지금 쓰는 것으로
123
+ }
124
+ if (!profiles.length) {
125
+ say(` ${mark.warn} 등록할 모델이 없습니다.`);
126
+ say('');
127
+ return 1;
128
+ }
129
+ let 새로 = 0;
130
+ for (const p of profiles) {
131
+ if (!cfg.profiles.some((x) => x.baseUrl === p.baseUrl && x.model === p.model)) 새로++;
132
+ upsert(cfg, p);
133
+ }
134
+ if (!cfg.active) cfg.active = profiles[0].id;
135
+ const at = save(cfg);
136
+
137
+ say(` ${mark.ok} ${profiles.length}개 등록 ${c.gray(`(새로 ${새로}개)`)}`);
138
+ say(` ${c.gray(at)}`);
139
+ say('');
140
+ say(` ${c.gray('지금 쓰는 것')} ${c.bold(cfg.profiles.find((x) => x.id === cfg.active)?.name ?? cfg.active)}`);
141
+ say(` ${c.gray('바꾸려면 대화 중')} ${c.cyan('/model')}`);
142
+ say('');
143
+ say(` ${c.gray('도구 호출·스트리밍이 되는지는 아직 확인 전입니다.')}`);
144
+ say(` ${c.gray('쓸 모델을 고른 뒤')} ${c.cyan('deel diagnose')} ${c.gray('를 한 번 돌리세요.')}`);
145
+ say('');
146
+ return 0;
147
+ }