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/README.en.md +686 -28
- package/README.md +571 -31
- package/bin/deel.js +234 -160
- package/package.json +3 -2
- package/src/agent/compact.js +131 -138
- package/src/agent/effort.js +44 -11
- package/src/agent/loop.js +574 -251
- package/src/agent/memory.js +152 -0
- package/src/agent/mention.js +164 -0
- package/src/agent/modes.js +127 -20
- package/src/agent/recall.js +209 -0
- package/src/agent/route.js +156 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +155 -11
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +246 -0
- package/src/backend/detect.js +10 -1
- package/src/backend/http.js +23 -0
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +97 -3
- package/src/commands.js +825 -50
- package/src/oneshot.js +327 -0
- package/src/repl.js +715 -414
- package/src/report.js +20 -6
- package/src/safety/guard.js +123 -7
- package/src/safety/undo.js +96 -11
- package/src/tools/encoding.js +24 -2
- package/src/tools/fsutil.js +70 -0
- package/src/tools/index.js +471 -27
- package/src/tools/webfetch.js +37 -1
- package/src/ui/ansi.js +32 -2
- package/src/ui/diff.js +255 -0
- package/src/ui/level.js +6 -2
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +169 -0
- package/src/ui/status.js +98 -24
- package/src/ui/tui.js +419 -0
- package/src/version.js +28 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP(Model Context Protocol) 서버 붙이기 — stdio 규격.
|
|
3
|
+
*
|
|
4
|
+
* 무엇인가:
|
|
5
|
+
* 도구를 **코드를 안 고치고** 밖에서 붙이는 규격이다. 사내 위키 검색기,
|
|
6
|
+
* 사내 이슈 트래커, DB 조회기 같은 것을 각 팀이 MCP 서버로 만들어 두면
|
|
7
|
+
* deel 은 그걸 그대로 도구로 쓴다. 우리가 매번 도구를 새로 만들지 않아도 된다.
|
|
8
|
+
*
|
|
9
|
+
* 왜 의존성 없이 되나:
|
|
10
|
+
* stdio 규격은 자식 프로세스의 stdin/stdout 에 **줄 단위 JSON-RPC 2.0** 을
|
|
11
|
+
* 주고받는 것이 전부다. child_process 와 JSON 이면 된다. SDK 가 필요 없다.
|
|
12
|
+
*
|
|
13
|
+
* ── 안전에 대해 ────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* MCP 서버는 **남의 프로그램**이다. 이 프로젝트가 존재하는 이유가 '미승인 SW
|
|
16
|
+
* 반입 금지' 인데, MCP 를 아무렇게나 켜면 그 선을 우리 손으로 무너뜨리는 셈이다.
|
|
17
|
+
* 그래서:
|
|
18
|
+
*
|
|
19
|
+
* 1) **기본은 꺼져 있다.** .deel/mcp.json 에 사람이 직접 적어야만 뜬다.
|
|
20
|
+
* 2) **--offline 이면 아예 안 띄운다.** 자식 프로세스가 어디로 나가는지
|
|
21
|
+
* 우리는 못 막는다. 막을 수 없는 것을 막았다고 말하지 않는다.
|
|
22
|
+
* 3) **감사기록에 남긴다.** 무엇을 띄웠고 무엇을 불렀는지.
|
|
23
|
+
* 4) **작업 범위 밖이다.** MCP 서버는 우리 scope 를 안 지킨다 —
|
|
24
|
+
* 제 마음대로 파일을 읽고 쓸 수 있다. /mcp 화면에서 그렇다고 말한다.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { VERSION } from '../version.js';
|
|
30
|
+
|
|
31
|
+
// 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
|
|
32
|
+
export const 붙기제한 = 8000;
|
|
33
|
+
// 도구 하나 부르고 이만큼 기다린다.
|
|
34
|
+
export const 부르기제한 = 60000;
|
|
35
|
+
// 한 서버에서 받을 도구 수. 스키마가 통째로 매 요청에 실리므로 무한정 받으면
|
|
36
|
+
// 컨텍스트가 조용히 줄어든다. 넘으면 **넘었다고 말하고** 자른다.
|
|
37
|
+
export const 도구최대 = 24;
|
|
38
|
+
// 한 줄(JSON 한 통)의 최대 크기. 미친 서버가 stdout 을 쏟아부어도 안 죽게.
|
|
39
|
+
const 줄최대 = 4 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
export const 설정자리 = (root) => join(root, '.deel', 'mcp.json');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 설정을 읽는다. Claude Code 의 `mcpServers` 모양을 그대로 받는다 —
|
|
45
|
+
* 이미 쓰던 설정을 복사해 붙일 수 있어야 한다.
|
|
46
|
+
*/
|
|
47
|
+
export function 설정읽기(root) {
|
|
48
|
+
const p = 설정자리(root);
|
|
49
|
+
if (!existsSync(p)) return { 서버들: [], 자리: p, 있음: false };
|
|
50
|
+
let j;
|
|
51
|
+
try { j = JSON.parse(readFileSync(p, 'utf8')); } catch (e) {
|
|
52
|
+
return { 서버들: [], 자리: p, 있음: true, 오류: `mcp.json 을 못 읽었습니다: ${e.message}` };
|
|
53
|
+
}
|
|
54
|
+
const 표 = j.mcpServers ?? j.servers ?? {};
|
|
55
|
+
const 서버들 = [];
|
|
56
|
+
for (const [이름, v] of Object.entries(표)) {
|
|
57
|
+
if (v?.disabled === true) continue;
|
|
58
|
+
// stdio 만 받는다. http/sse 규격은 바깥으로 나가는 것이라 자물쇠와 부딪힌다.
|
|
59
|
+
if (v?.type && v.type !== 'stdio') continue;
|
|
60
|
+
if (!v?.command) continue;
|
|
61
|
+
서버들.push({
|
|
62
|
+
이름,
|
|
63
|
+
command: String(v.command),
|
|
64
|
+
args: Array.isArray(v.args) ? v.args.map(String) : [],
|
|
65
|
+
env: v.env && typeof v.env === 'object' ? v.env : null,
|
|
66
|
+
cwd: v.cwd ? String(v.cwd) : root,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return { 서버들, 자리: p, 있음: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 서버 하나와의 연결.
|
|
74
|
+
*
|
|
75
|
+
* 규격은 JSON-RPC 2.0 이다. 줄 하나에 통 하나 — 그래서 줄 단위로 자르면 된다.
|
|
76
|
+
*/
|
|
77
|
+
export class MCP서버 {
|
|
78
|
+
constructor(설정) {
|
|
79
|
+
this.이름 = 설정.이름;
|
|
80
|
+
this.설정 = 설정;
|
|
81
|
+
this.kid = null;
|
|
82
|
+
this.다음번호 = 1;
|
|
83
|
+
this.기다리는것 = new Map();
|
|
84
|
+
this.찌꺼기 = '';
|
|
85
|
+
this.도구 = [];
|
|
86
|
+
this.정보 = null;
|
|
87
|
+
this.죽음 = null; // 왜 죽었나 (사람에게 보여 줄 말)
|
|
88
|
+
this.잘림 = 0; // 도구최대 를 넘어 자른 개수
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
|
|
92
|
+
|
|
93
|
+
async 붙기({ timeout = 붙기제한 } = {}) {
|
|
94
|
+
try {
|
|
95
|
+
this.kid = spawn(this.설정.command, this.설정.args, {
|
|
96
|
+
cwd: this.설정.cwd,
|
|
97
|
+
// 설정에 적힌 env 만 얹는다. 우리 환경변수를 통째로 넘기면
|
|
98
|
+
// 게이트웨이 열쇠(DEEL_*)까지 남의 프로세스로 넘어간다.
|
|
99
|
+
env: { ...깨끗한환경(), ...(this.설정.env ?? {}) },
|
|
100
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
shell: false,
|
|
103
|
+
});
|
|
104
|
+
} catch (e) {
|
|
105
|
+
this.죽음 = `띄우지 못했습니다: ${e.message}`;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.kid.on('error', (e) => this.끝냄(`오류: ${e.message}`));
|
|
110
|
+
this.kid.on('exit', (code, sig) => this.끝냄(`끝났습니다 (코드 ${code ?? sig})`));
|
|
111
|
+
this.kid.stdout.setEncoding('utf8');
|
|
112
|
+
this.kid.stdout.on('data', (d) => this.받음(d));
|
|
113
|
+
// 서버가 stderr 에 로그를 쏟는 일이 흔하다. 화면에 흘리면 대화가 뒤덮인다.
|
|
114
|
+
// 마지막 것만 들고 있다가 죽었을 때 원인으로 보여 준다.
|
|
115
|
+
this.kid.stderr.setEncoding('utf8');
|
|
116
|
+
this.kid.stderr.on('data', (d) => { this.마지막말 = String(d).trim().slice(-400); });
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const r = await this.보내고기다리기('initialize', {
|
|
120
|
+
protocolVersion: '2024-11-05',
|
|
121
|
+
capabilities: { tools: {} },
|
|
122
|
+
clientInfo: { name: 'deel', version: VERSION },
|
|
123
|
+
}, timeout);
|
|
124
|
+
this.정보 = r?.serverInfo ?? null;
|
|
125
|
+
this.알림('notifications/initialized', {});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
this.끝냄(`규격 인사에 실패했습니다: ${e.message}`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const r = await this.보내고기다리기('tools/list', {}, timeout);
|
|
133
|
+
const 다 = Array.isArray(r?.tools) ? r.tools : [];
|
|
134
|
+
this.도구 = 다.slice(0, 도구최대);
|
|
135
|
+
this.잘림 = Math.max(0, 다.length - this.도구.length);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
this.끝냄(`도구 목록을 못 받았습니다: ${e.message}`);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
받음(덩이) {
|
|
144
|
+
this.찌꺼기 += 덩이;
|
|
145
|
+
if (this.찌꺼기.length > 줄최대) {
|
|
146
|
+
this.끝냄('한 통이 너무 큽니다 — 규격에 안 맞는 서버입니다');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let i = this.찌꺼기.indexOf('\n');
|
|
150
|
+
while (i >= 0) {
|
|
151
|
+
const 줄 = this.찌꺼기.slice(0, i).trim();
|
|
152
|
+
this.찌꺼기 = this.찌꺼기.slice(i + 1);
|
|
153
|
+
if (줄) this.한통(줄);
|
|
154
|
+
i = this.찌꺼기.indexOf('\n');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
한통(줄) {
|
|
159
|
+
let j;
|
|
160
|
+
try { j = JSON.parse(줄); } catch { return; } // 규격 밖의 잡소리는 버린다
|
|
161
|
+
if (j.id == null) return; // 알림은 아직 안 쓴다
|
|
162
|
+
const 기다림 = this.기다리는것.get(j.id);
|
|
163
|
+
if (!기다림) return;
|
|
164
|
+
this.기다리는것.delete(j.id);
|
|
165
|
+
clearTimeout(기다림.타이머);
|
|
166
|
+
if (j.error) 기다림.실패(new Error(j.error.message ?? '알 수 없는 오류'));
|
|
167
|
+
else 기다림.성공(j.result);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
보내고기다리기(method, params, timeout = 부르기제한) {
|
|
171
|
+
return new Promise((성공, 실패) => {
|
|
172
|
+
if (!this.kid || this.kid.exitCode !== null) return 실패(new Error(this.죽음 ?? '연결이 없습니다'));
|
|
173
|
+
const id = this.다음번호++;
|
|
174
|
+
const 타이머 = setTimeout(() => {
|
|
175
|
+
this.기다리는것.delete(id);
|
|
176
|
+
실패(new Error(`${Math.round(timeout / 1000)}초 안에 답이 없습니다`));
|
|
177
|
+
}, timeout);
|
|
178
|
+
if (타이머.unref) 타이머.unref();
|
|
179
|
+
this.기다리는것.set(id, { 성공, 실패, 타이머 });
|
|
180
|
+
try {
|
|
181
|
+
this.kid.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
182
|
+
} catch (e) {
|
|
183
|
+
clearTimeout(타이머);
|
|
184
|
+
this.기다리는것.delete(id);
|
|
185
|
+
실패(e);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
알림(method, params) {
|
|
191
|
+
try { this.kid?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); } catch { /* 죽었으면 어차피 끝이다 */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async 부르기(도구이름, args, { timeout = 부르기제한 } = {}) {
|
|
195
|
+
const r = await this.보내고기다리기('tools/call', { name: 도구이름, arguments: args ?? {} }, timeout);
|
|
196
|
+
// 규격상 결과는 content 배열이다. 글만 뽑아 모델에게 넘긴다.
|
|
197
|
+
const 조각 = Array.isArray(r?.content) ? r.content : [];
|
|
198
|
+
const 글 = 조각
|
|
199
|
+
.map((p) => (p?.type === 'text' ? p.text : p?.type ? `[${p.type}]` : ''))
|
|
200
|
+
.filter(Boolean).join('\n');
|
|
201
|
+
return { text: 글, isError: r?.isError === true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
끝냄(왜) {
|
|
205
|
+
if (this.죽음) return;
|
|
206
|
+
this.죽음 = this.마지막말 ? `${왜} — ${this.마지막말}` : 왜;
|
|
207
|
+
for (const [, 기다림] of this.기다리는것) {
|
|
208
|
+
clearTimeout(기다림.타이머);
|
|
209
|
+
기다림.실패(new Error(this.죽음));
|
|
210
|
+
}
|
|
211
|
+
this.기다리는것.clear();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
닫기() {
|
|
215
|
+
this.끝냄('닫았습니다');
|
|
216
|
+
try {
|
|
217
|
+
this.kid?.stdin?.end();
|
|
218
|
+
this.kid?.kill();
|
|
219
|
+
// 자식이 살아 있으면 우리 프로그램이 안 끝난다.
|
|
220
|
+
this.kid?.unref?.();
|
|
221
|
+
} catch { /* 이미 죽었다 */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 우리 환경변수를 통째로 넘기지 않는다.
|
|
227
|
+
*
|
|
228
|
+
* DEEL_* 에는 게이트웨이 열쇠가 들어 있을 수 있고, 그 값이 남의 프로세스로
|
|
229
|
+
* 넘어가면 어디로 가는지 우리가 알 수 없다. 프로그램이 도는 데 꼭 필요한
|
|
230
|
+
* 것만 남긴다.
|
|
231
|
+
*/
|
|
232
|
+
function 깨끗한환경() {
|
|
233
|
+
const 남길것 = ['PATH', 'Path', 'PATHEXT', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'SystemRoot', 'windir', 'COMSPEC', 'LANG', 'LC_ALL', 'APPDATA', 'LOCALAPPDATA', 'ProgramFiles', 'ProgramData', 'NODE_PATH'];
|
|
234
|
+
const out = {};
|
|
235
|
+
for (const k of 남길것) if (process.env[k] != null) out[k] = process.env[k];
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 우리 도구 이름과 안 부딪히게 앞에 서버 이름을 붙인다. Claude Code 와 같은 꼴이다. */
|
|
240
|
+
export const 도구이름 = (서버, 도구) => `mcp__${서버}__${도구}`;
|
|
241
|
+
|
|
242
|
+
/** 붙인 이름에서 서버와 도구를 도로 뗀다. */
|
|
243
|
+
export function 이름풀기(전체) {
|
|
244
|
+
const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(String(전체 ?? ''));
|
|
245
|
+
return m ? { 서버: m[1], 도구: m[2] } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 설정에 적힌 서버를 전부 띄운다.
|
|
250
|
+
*
|
|
251
|
+
* 하나가 안 떠도 나머지는 쓴다 — 서버 하나 때문에 프로그램이 못 뜨면 안 된다.
|
|
252
|
+
* 안 뜬 것은 **안 떴다고 말한다.** 조용히 빠지면 "왜 그 도구가 없지" 를
|
|
253
|
+
* 영영 알 수 없다.
|
|
254
|
+
*/
|
|
255
|
+
export async function 다붙이기(root, { offline = false, timeout = 붙기제한, audit = null } = {}) {
|
|
256
|
+
const 설정 = 설정읽기(root);
|
|
257
|
+
if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
|
|
258
|
+
if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
|
|
259
|
+
|
|
260
|
+
// 자물쇠가 걸려 있으면 아예 안 띄운다. 자식 프로세스가 어디로 나가는지
|
|
261
|
+
// 우리는 못 막는다 — 막을 수 없는 것을 막았다고 말하지 않는다.
|
|
262
|
+
if (offline) {
|
|
263
|
+
return {
|
|
264
|
+
서버들: [],
|
|
265
|
+
못한것: 설정.서버들.map((s) => ({ 이름: s.이름, 왜: '오프라인 잠금 중에는 안 띄웁니다' })),
|
|
266
|
+
설정,
|
|
267
|
+
잠김: true,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const 붙은것 = [];
|
|
272
|
+
const 못한것 = [];
|
|
273
|
+
await Promise.all(설정.서버들.map(async (s) => {
|
|
274
|
+
const 서버 = new MCP서버(s);
|
|
275
|
+
const ok = await 서버.붙기({ timeout });
|
|
276
|
+
if (ok) {
|
|
277
|
+
붙은것.push(서버);
|
|
278
|
+
audit?.note?.('mcp', { 이름: s.이름, command: s.command, 도구: 서버.도구.length });
|
|
279
|
+
} else {
|
|
280
|
+
못한것.push({ 이름: s.이름, 왜: 서버.죽음 ?? '알 수 없는 이유' });
|
|
281
|
+
서버.닫기();
|
|
282
|
+
}
|
|
283
|
+
}));
|
|
284
|
+
붙은것.sort((a, b) => a.이름.localeCompare(b.이름));
|
|
285
|
+
return { 서버들: 붙은것, 못한것, 설정 };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 모델에게 넘길 도구 정의로 바꾼다. */
|
|
289
|
+
export function 도구정의(서버들) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const s of 서버들) {
|
|
292
|
+
for (const t of s.도구) {
|
|
293
|
+
out.push({
|
|
294
|
+
type: 'function',
|
|
295
|
+
function: {
|
|
296
|
+
name: 도구이름(s.이름, t.name),
|
|
297
|
+
description: `[${s.이름}] ${t.description ?? t.name}`,
|
|
298
|
+
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
package/src/backend/probe.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// warn 되긴 하는데 조건이 붙음
|
|
6
6
|
// skip 앞 검사가 실패해 확인 불가
|
|
7
7
|
import { req, headersFor, serverMessage } from './http.js';
|
|
8
|
+
import { probeCtx } from './ctxsize.js';
|
|
8
9
|
|
|
9
10
|
const READ_TOOL = {
|
|
10
11
|
type: 'function',
|
|
@@ -146,7 +147,7 @@ export async function probe(conn, onStep = () => {}) {
|
|
|
146
147
|
label: '시스템 메시지',
|
|
147
148
|
status: sys.ok ? (sysHit ? 'ok' : 'warn') : 'no',
|
|
148
149
|
detail: sys.ok
|
|
149
|
-
? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게
|
|
150
|
+
? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게 적용됩니다'
|
|
150
151
|
: serverMessage(sys),
|
|
151
152
|
ms: sys.ms,
|
|
152
153
|
});
|
|
@@ -330,26 +331,26 @@ export async function probe(conn, onStep = () => {}) {
|
|
|
330
331
|
facts.think = differs;
|
|
331
332
|
|
|
332
333
|
// 8. 컨텍스트 길이 — 파일을 몇 개까지 한 번에 읽힐 수 있느냐.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const k = Object.keys(info).find((x) => /context_length/.test(x));
|
|
339
|
-
if (k) { ctx = info[k]; ctxNote = '모델 정보에서 읽음'; }
|
|
340
|
-
} else {
|
|
341
|
-
const m = await req(`${base}/models/${encodeURIComponent(model)}`, { headers: H(), timeout: 15000 });
|
|
342
|
-
ctx = m.json?.context_window ?? m.json?.max_context_length ?? m.json?.context_length ?? null;
|
|
343
|
-
if (ctx) ctxNote = '모델 정보에서 읽음';
|
|
344
|
-
}
|
|
334
|
+
//
|
|
335
|
+
// 한 자리만 보지 않는다. 서버마다 이름도 자리도 다르다 (ctxsize.js 참고).
|
|
336
|
+
// 여기서 작게 잡으면 프로그램 전체가 작아진다 — 답 길이 상한까지 이 값에서 나온다.
|
|
337
|
+
const 길이 = await probeCtx({ kind: shape, base, auth, key, model });
|
|
338
|
+
const ctx = 길이.value;
|
|
345
339
|
add({
|
|
346
340
|
id: 'ctx',
|
|
347
341
|
label: '컨텍스트 길이',
|
|
348
342
|
status: ctx ? 'ok' : 'warn',
|
|
349
|
-
detail: ctx
|
|
343
|
+
detail: ctx
|
|
344
|
+
? `${ctx.toLocaleString()} 토큰 (${길이.source ?? '모델 정보'}에서 읽음)`
|
|
345
|
+
+ (길이.max && 길이.loaded && 길이.max > 길이.loaded
|
|
346
|
+
? ` · 이 모델은 ${길이.max.toLocaleString()} 까지 되는데 지금 ${길이.loaded.toLocaleString()} 로 올려 두셨습니다`
|
|
347
|
+
: '')
|
|
348
|
+
: '서버가 알려주지 않음 — /ctx 로 직접 지정하세요',
|
|
350
349
|
ms: 0,
|
|
351
350
|
});
|
|
352
351
|
facts.ctx = ctx;
|
|
352
|
+
facts.ctxMax = 길이.max ?? null;
|
|
353
|
+
facts.ctxLoaded = 길이.loaded ?? null;
|
|
353
354
|
|
|
354
355
|
return { facts, results };
|
|
355
356
|
}
|
package/src/backend/scan.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
// 찾은 것을 설정 프로필 모양으로 바꾼다. 같은 자리가 이미 있으면 그걸 되쓴다.
|