deel-local-cli 0.5.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,355 @@
1
+ // 게이트웨이/로컬서버가 "에이전트를 돌릴 수 있는지"를 실제 요청으로 확인한다.
2
+ // 각 검사는 { id, label, status, detail, ms } 를 돌려준다.
3
+ // ok 되는 것을 확인함
4
+ // no 안 됨 (기능에 직접 영향)
5
+ // warn 되긴 하는데 조건이 붙음
6
+ // skip 앞 검사가 실패해 확인 불가
7
+ import { req, headersFor, serverMessage } from './http.js';
8
+
9
+ const READ_TOOL = {
10
+ type: 'function',
11
+ function: {
12
+ name: 'read_file',
13
+ description: '파일 하나의 내용을 읽는다',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: { path: { type: 'string', description: '읽을 파일 경로' } },
17
+ required: ['path'],
18
+ },
19
+ },
20
+ };
21
+
22
+ // 규격별 요청 만들기 — 이 함수 하나가 openai/ollama 차이를 흡수한다.
23
+ function build(shape, { model, messages, tools, stream, json, think, maxTokens = 128 }) {
24
+ if (shape === 'ollama') {
25
+ const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
26
+ if (tools) body.tools = tools;
27
+ if (json) body.format = json;
28
+ if (think !== undefined) body.think = think;
29
+ return { path: '/api/chat', body };
30
+ }
31
+ const body = { model, messages, stream: !!stream, max_tokens: maxTokens };
32
+ if (tools) { body.tools = tools; body.tool_choice = 'auto'; }
33
+ if (json) {
34
+ body.response_format = { type: 'json_schema', json_schema: { name: 'probe', schema: json, strict: true } };
35
+ }
36
+ if (think !== undefined) body.reasoning_effort = think;
37
+ return { path: '/chat/completions', body };
38
+ }
39
+
40
+ // 응답에서 본문과 도구호출을 꺼낸다.
41
+ function extract(shape, json) {
42
+ if (shape === 'ollama') {
43
+ const m = json?.message ?? {};
44
+ return { content: m.content ?? '', toolCalls: m.tool_calls ?? [], thinking: m.thinking ?? '' };
45
+ }
46
+ const m = json?.choices?.[0]?.message ?? {};
47
+ return { content: m.content ?? '', toolCalls: m.tool_calls ?? [], thinking: m.reasoning_content ?? '' };
48
+ }
49
+
50
+ // 추론 모델일 때 기본 대화 칸에 덧붙일 설명.
51
+ function c_note(retried) {
52
+ return retried ? ' (추론 모델 — 사고를 끄고 다시 물어 확인)' : ' (추론 모델)';
53
+ }
54
+
55
+ const SKIPPED = [
56
+ ['system', '시스템 메시지'],
57
+ ['stream', '스트리밍'],
58
+ ['tools', '도구 호출'],
59
+ ['toolresult', '도구 결과 되돌리기'],
60
+ ['json', '구조적 출력'],
61
+ ['think', '추론 강도 조절'],
62
+ ['ctx', '컨텍스트 길이'],
63
+ ];
64
+
65
+ export async function probe(conn, onStep = () => {}) {
66
+ const { kind: shape, base, auth, model } = conn;
67
+ const key = conn.key ?? '';
68
+ const H = () => headersFor(auth, key);
69
+ const url = (p) => `${base}${p}`;
70
+ const results = [];
71
+ const facts = { shape, base, auth, model };
72
+
73
+ const add = (r) => { results.push(r); onStep(r); return r; };
74
+ const call = (opts) => {
75
+ const { path, body } = build(shape, { model, ...opts });
76
+ return req(url(path), {
77
+ method: 'POST',
78
+ headers: H(),
79
+ body,
80
+ timeout: opts.timeout ?? 60000,
81
+ stream: opts.stream,
82
+ });
83
+ };
84
+
85
+ // 1. 기본 대화 — 이게 안 되면 나머지는 볼 필요가 없다.
86
+ // 추론 모델은 본문이 전부 thinking 으로 가고 토큰 상한에 잘린다.
87
+ // 그걸 "안됨"으로 볼 수 없으므로, 사고를 끄고 넉넉히 한 번 더 물어본다.
88
+ const ASK = { messages: [{ role: 'user', content: '1+1은? 숫자만 답하세요.' }] };
89
+ let basic = await call({ ...ASK, maxTokens: 256 });
90
+ let got = basic.ok ? extract(shape, basic.json) : { content: '', thinking: '' };
91
+ let thinkingModel = false;
92
+ let retried = false;
93
+
94
+ if (basic.ok && !got.content && got.thinking) {
95
+ thinkingModel = true;
96
+ retried = true;
97
+ const second = await call({ ...ASK, maxTokens: 1024, think: false, timeout: 90000 });
98
+ if (second.ok) {
99
+ const e2 = extract(shape, second.json);
100
+ if (e2.content) { basic = second; got = e2; }
101
+ else {
102
+ // 사고를 못 끄는 서버 — 상한만 크게 올려 한 번 더.
103
+ const third = await call({ ...ASK, maxTokens: 2048, timeout: 120000 });
104
+ if (third.ok && extract(shape, third.json).content) { basic = third; got = extract(shape, third.json); }
105
+ }
106
+ }
107
+ }
108
+
109
+ const basicText = got.content;
110
+ const basicOk = basic.ok && !!basicText;
111
+ facts.thinkingModel = thinkingModel;
112
+ // 사고를 끌 수 있는 모델이면 이후 검사에서 꺼서 토큰과 시간을 아낀다.
113
+ const quiet = shape === 'ollama' && thinkingModel ? { think: false, maxTokens: 512 } : {};
114
+
115
+ add({
116
+ id: 'chat',
117
+ label: '기본 대화',
118
+ status: basicOk ? 'ok' : 'no',
119
+ detail: basicOk
120
+ ? `응답 "${basicText.trim().slice(0, 24)}"` + (thinkingModel ? c_note(retried) : '')
121
+ : basic.ok
122
+ ? got.thinking
123
+ ? '사고만 나오고 본문이 안 나옵니다 — 토큰 상한을 크게 올려야 합니다'
124
+ : '응답이 비어 있습니다 — 모델 이름을 확인하세요'
125
+ : serverMessage(basic),
126
+ ms: basic.ms,
127
+ });
128
+ if (!basicOk) {
129
+ for (const [id, label] of SKIPPED) {
130
+ add({ id, label, status: 'skip', detail: '기본 대화가 안 되어 확인 불가', ms: 0 });
131
+ }
132
+ return { facts, results };
133
+ }
134
+
135
+ // 2. 시스템 메시지 — 규칙과 스킬이 먹느냐가 여기 달렸다.
136
+ const sys = await call({
137
+ ...quiet,
138
+ messages: [
139
+ { role: 'system', content: '너는 무슨 질문을 받든 정확히 DEEL 한 단어만 답한다.' },
140
+ { role: 'user', content: '안녕하세요' },
141
+ ],
142
+ });
143
+ const sysHit = sys.ok && /DEEL/i.test(extract(shape, sys.json).content);
144
+ add({
145
+ id: 'system',
146
+ label: '시스템 메시지',
147
+ status: sys.ok ? (sysHit ? 'ok' : 'warn') : 'no',
148
+ detail: sys.ok
149
+ ? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게 먹습니다'
150
+ : serverMessage(sys),
151
+ ms: sys.ms,
152
+ });
153
+
154
+ // 3. 스트리밍 — 화면이 한 글자씩 흐르느냐.
155
+ const st = await call({
156
+ ...quiet,
157
+ messages: [{ role: 'user', content: '1부터 20까지 세어보세요.' }],
158
+ stream: true,
159
+ timeout: 45000,
160
+ });
161
+ let chunks = 0;
162
+ let firstMs = 0;
163
+ if (st.ok && st.res?.body) {
164
+ const t0 = Date.now();
165
+ try {
166
+ const reader = st.res.body.getReader();
167
+ const dec = new TextDecoder();
168
+ while (chunks < 400) {
169
+ const { done, value } = await reader.read();
170
+ if (done) break;
171
+ const text = dec.decode(value, { stream: true });
172
+ const hits = (text.match(/(^|\n)data:|"done"\s*:/g) ?? []).length;
173
+ if (hits && !firstMs) firstMs = Date.now() - t0;
174
+ chunks += hits || (text.trim() ? 1 : 0);
175
+ }
176
+ reader.cancel().catch(() => {});
177
+ } catch {}
178
+ }
179
+ add({
180
+ id: 'stream',
181
+ label: '스트리밍',
182
+ status: chunks > 2 ? 'ok' : st.ok ? 'warn' : 'no',
183
+ detail: chunks > 2
184
+ ? `조각 ${chunks}개, 첫 응답 ${firstMs}ms`
185
+ : st.ok ? '한 번에 옵니다 — 화면은 스피너로 대체합니다' : serverMessage(st),
186
+ ms: st.ms,
187
+ });
188
+ facts.streaming = chunks > 2;
189
+
190
+ // 4. 도구 호출 — 에이전트의 생사가 걸린 검사.
191
+ const tl = await call({
192
+ ...quiet,
193
+ messages: [{ role: 'user', content: 'config.json 파일을 읽어 주세요.' }],
194
+ tools: [READ_TOOL],
195
+ maxTokens: 512,
196
+ timeout: 90000,
197
+ });
198
+ const tcalls = tl.ok ? extract(shape, tl.json).toolCalls : [];
199
+ const gotCall = tcalls.length > 0;
200
+ const argOk = gotCall && JSON.stringify(tcalls[0]?.function?.arguments ?? '').includes('config');
201
+ add({
202
+ id: 'tools',
203
+ label: '도구 호출',
204
+ status: gotCall ? (argOk ? 'ok' : 'warn') : 'no',
205
+ detail: gotCall
206
+ ? `${tcalls[0]?.function?.name} 호출됨${argOk ? '' : ' — 인자가 부정확, 편집 신뢰성 작업이 더 필요합니다'}`
207
+ : tl.ok ? '도구를 안 부르고 글로만 답합니다' : serverMessage(tl),
208
+ ms: tl.ms,
209
+ });
210
+ facts.tools = gotCall;
211
+
212
+ // 5. 도구 결과 되돌리기 — 여러 턴이 이어지느냐. 에이전트 루프의 전제다.
213
+ if (gotCall) {
214
+ const tc = tcalls[0];
215
+ const callId = tc.id ?? 'call_1';
216
+ const assistantMsg = shape === 'ollama'
217
+ ? { role: 'assistant', content: '', tool_calls: tcalls }
218
+ : { role: 'assistant', content: null, tool_calls: [{ id: callId, type: 'function', function: tc.function }] };
219
+ const toolMsg = shape === 'ollama'
220
+ ? { role: 'tool', tool_name: tc.function?.name, content: '{"port": 7099}' }
221
+ : { role: 'tool', tool_call_id: callId, content: '{"port": 7099}' };
222
+ const rt = await call({
223
+ ...quiet,
224
+ timeout: 90000,
225
+ messages: [
226
+ { role: 'user', content: 'config.json 파일을 읽어 주세요.' },
227
+ assistantMsg,
228
+ toolMsg,
229
+ { role: 'user', content: 'port 값이 몇인가요? 숫자만 답하세요.' },
230
+ ],
231
+ tools: [READ_TOOL],
232
+ maxTokens: 256,
233
+ });
234
+ const said = rt.ok ? extract(shape, rt.json).content : '';
235
+ add({
236
+ id: 'toolresult',
237
+ label: '도구 결과 되돌리기',
238
+ status: rt.ok ? (/7099/.test(said) ? 'ok' : 'warn') : 'no',
239
+ detail: rt.ok
240
+ ? /7099/.test(said) ? '결과를 읽고 이어서 답함' : `받긴 하나 활용이 약함 ("${said.trim().slice(0, 20)}")`
241
+ : serverMessage(rt),
242
+ ms: rt.ms,
243
+ });
244
+ } else {
245
+ add({ id: 'toolresult', label: '도구 결과 되돌리기', status: 'skip', detail: '도구 호출이 안 되어 확인 불가', ms: 0 });
246
+ }
247
+
248
+ // 6. 구조적 출력 — 편집 형식을 강제할 수 있느냐.
249
+ const schema = {
250
+ type: 'object',
251
+ properties: { answer: { type: 'number' } },
252
+ required: ['answer'],
253
+ additionalProperties: false,
254
+ };
255
+ // 한 번은 흔들릴 수 있으므로 실패하면 한 번만 더 본다.
256
+ let js = null;
257
+ let parsed = null;
258
+ let raw = '';
259
+ for (let attempt = 0; attempt < 2 && !parsed; attempt++) {
260
+ js = await call({
261
+ ...quiet,
262
+ messages: [{ role: 'user', content: '3 곱하기 7은?' }],
263
+ json: schema,
264
+ maxTokens: 512,
265
+ timeout: 90000,
266
+ });
267
+ if (!js.ok) break;
268
+ raw = extract(shape, js.json).content ?? '';
269
+ try { parsed = JSON.parse(raw); } catch {}
270
+ }
271
+ const jsonOk = !!(parsed && 'answer' in parsed);
272
+ add({
273
+ id: 'json',
274
+ label: '구조적 출력',
275
+ status: jsonOk ? 'ok' : js?.ok ? 'warn' : 'no',
276
+ detail: jsonOk
277
+ ? `스키마대로 반환 (answer=${parsed.answer})`
278
+ : js?.ok
279
+ ? `스키마를 안 지킴 — 받은 값 ${JSON.stringify(raw.slice(0, 60))} · 편집 형식을 프롬프트로 강제합니다`
280
+ : serverMessage(js ?? {}),
281
+ ms: js?.ms ?? 0,
282
+ });
283
+ facts.json = jsonOk;
284
+
285
+ // 7. 추론 강도 조절 — 낮음/높음이 실제로 다른 결과를 내느냐.
286
+ // 상한에 걸리면 둘 다 같은 숫자가 나와 비교가 무의미해진다. 넉넉히 준다.
287
+ const THINK_CAP = 1500;
288
+ const seen = [];
289
+ for (const lv of ['low', 'high']) {
290
+ const r = await call({
291
+ messages: [{ role: 'user', content: '17 곱하기 23은? 계산 과정을 보이세요.' }],
292
+ think: lv,
293
+ maxTokens: THINK_CAP,
294
+ timeout: 120000,
295
+ });
296
+ if (r.ok) {
297
+ const e = extract(shape, r.json);
298
+ seen.push({
299
+ lv,
300
+ ms: r.ms,
301
+ thought: (e.thinking ?? '').length,
302
+ out: r.json?.usage?.completion_tokens ?? r.json?.eval_count ?? 0,
303
+ capped: (r.json?.done_reason ?? r.json?.choices?.[0]?.finish_reason) === 'length',
304
+ });
305
+ } else {
306
+ seen.push({ lv, ms: r.ms, err: serverMessage(r) });
307
+ }
308
+ }
309
+ const bothOk = seen.every((s) => !s.err);
310
+ const capped = bothOk && seen.every((s) => s.capped);
311
+ // 사고 길이가 눈에 띄게 다르거나, 출력량이 20% 넘게 차이나야 "먹는다"고 본다.
312
+ const thoughtGap = bothOk ? Math.abs(seen[0].thought - seen[1].thought) : 0;
313
+ const outGap = bothOk ? Math.abs(seen[0].out - seen[1].out) : 0;
314
+ const differs = bothOk && !capped &&
315
+ (thoughtGap > Math.max(80, seen[0].thought * 0.2) || outGap > Math.max(20, seen[0].out * 0.2));
316
+ const fmt = (s) => `${s.lv === 'low' ? '낮음' : '높음'} 사고 ${s.thought}자/출력 ${s.out}토큰/${s.ms}ms`;
317
+ add({
318
+ id: 'think',
319
+ label: '추론 강도 조절',
320
+ status: !bothOk ? 'no' : capped ? 'warn' : differs ? 'ok' : 'warn',
321
+ detail: !bothOk
322
+ ? `파라미터 거부됨 — ${seen.find((s) => s.err)?.err}`
323
+ : capped
324
+ ? `둘 다 토큰 상한(${THINK_CAP})에 걸려 비교 불가 — 루프 층에서 조절합니다`
325
+ : differs
326
+ ? `${fmt(seen[0])} · ${fmt(seen[1])}`
327
+ : `차이 없음 (${fmt(seen[0])} · ${fmt(seen[1])}) — 루프 층에서 조절합니다`,
328
+ ms: seen.reduce((a, s) => a + (s.ms ?? 0), 0),
329
+ });
330
+ facts.think = differs;
331
+
332
+ // 8. 컨텍스트 길이 — 파일을 몇 개까지 한 번에 읽힐 수 있느냐.
333
+ let ctx = null;
334
+ let ctxNote = '';
335
+ if (shape === 'ollama') {
336
+ const show = await req(`${base}/api/show`, { method: 'POST', headers: H(), body: { model }, timeout: 20000 });
337
+ const info = show.json?.model_info ?? {};
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
+ }
345
+ add({
346
+ id: 'ctx',
347
+ label: '컨텍스트 길이',
348
+ status: ctx ? 'ok' : 'warn',
349
+ detail: ctx ? `${ctx.toLocaleString()} 토큰 (${ctxNote})` : '서버가 알려주지 않음 — 설정에서 직접 지정합니다',
350
+ ms: 0,
351
+ });
352
+ facts.ctx = ctx;
353
+
354
+ return { facts, results };
355
+ }
@@ -0,0 +1,327 @@
1
+ // 슬래시 명령. 이름은 Claude Code / Codex 관례에 맞춘다.
2
+ import { writeFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { c, say, rule, pad, bar, mark, width } from './ui/ansi.js';
5
+ import { pick } from './ui/prompt.js';
6
+ import { load, save, resolveKey } from './config.js';
7
+ import { TOOLS } from './tools/index.js';
8
+ import { loadCommand } from './skills/discover.js';
9
+
10
+ const THINK_LEVELS = ['off', 'low', 'medium', 'high', 'max'];
11
+ const MODES = {
12
+ auto: '자율 — 전부 알아서. 되돌리기가 안전망',
13
+ confirm: '확인 — 되돌릴 수 없는 것만 물어봄',
14
+ strict: '엄격 — 파일 변경·명령 전부 물어봄',
15
+ };
16
+
17
+ export const COMMANDS = {
18
+ help: { desc: '명령 목록' },
19
+ clear: { desc: '대화 비우기' },
20
+ context: { desc: '컨텍스트 사용량 보기' },
21
+ compact: { desc: '오래된 대화 줄이기' },
22
+ model: { desc: '모델·연결 바꾸기' },
23
+ think: { desc: '추론 강도 (off/low/medium/high/max)', arg: '<수준>' },
24
+ mode: { desc: '실행 모드 (auto/confirm/strict)', arg: '<모드>' },
25
+ undo: { desc: '직전 작업 되돌리기', arg: '[턴수]' },
26
+ tools: { desc: '쓸 수 있는 도구 보기' },
27
+ skills: { desc: '스킬 보기·검색·골라 올리기', arg: '[검색어|all|off]' },
28
+ cost: { desc: '이번 세션 사용량' },
29
+ status: { desc: '연결 상태' },
30
+ init: { desc: 'DEEL.md 규칙 파일 만들기' },
31
+ exit: { desc: '끝내기' },
32
+ quit: { desc: '끝내기' },
33
+ };
34
+
35
+ // 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
36
+ export async function handle(line, session, ctx) {
37
+ if (!line.startsWith('/')) return { handled: false };
38
+ const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
39
+ const name = raw.toLowerCase();
40
+ const arg = rest.join(' ');
41
+
42
+ switch (name) {
43
+ case 'help': return help(), { handled: true };
44
+ case 'exit':
45
+ case 'quit': return { handled: true, exit: true };
46
+
47
+ case 'clear':
48
+ session.clear();
49
+ say(` ${mark.ok} 대화를 비웠습니다. 규칙과 연결은 그대로입니다.`);
50
+ say('');
51
+ return { handled: true };
52
+
53
+ case 'context': return showContext(session), { handled: true };
54
+
55
+ case 'compact': {
56
+ const n = session.trim();
57
+ say(n
58
+ ? ` ${mark.ok} 오래된 대화 ${n}개를 줄였습니다.`
59
+ : ` ${c.gray('줄일 만큼 쌓이지 않았습니다.')}`);
60
+ say('');
61
+ return { handled: true };
62
+ }
63
+
64
+ case 'model': return await switchModel(session, ctx), { handled: true };
65
+
66
+ case 'think': {
67
+ 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('');
71
+ return { handled: true };
72
+ }
73
+ session.think = arg;
74
+ say(` ${mark.ok} 추론 강도 ${c.bold(arg)}`);
75
+ if (!session.conn.think && arg !== 'off') {
76
+ say(` ${c.yellow('이 연결은 모델 층 조절이 안 먹습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
77
+ }
78
+ say('');
79
+ return { handled: true };
80
+ }
81
+
82
+ case 'mode': {
83
+ if (!MODES[arg]) {
84
+ say(` ${c.gray('지금')} ${c.bold(session.mode)}`);
85
+ for (const [k, v] of Object.entries(MODES)) say(` ${c.cyan(pad(k, 9))} ${c.gray(v)}`);
86
+ say('');
87
+ return { handled: true };
88
+ }
89
+ session.mode = arg;
90
+ say(` ${mark.ok} 실행 모드 ${c.bold(arg)} ${c.gray('— ' + MODES[arg])}`);
91
+ say('');
92
+ return { handled: true };
93
+ }
94
+
95
+ case 'undo': {
96
+ const n = Math.max(1, parseInt(arg, 10) || 1);
97
+ const r = ctx.history.undo(n);
98
+ ctx.audit.undo({ turns: r.turns, files: r.restored.length });
99
+ if (!r.restored.length) {
100
+ say(` ${c.gray('되돌릴 것이 없습니다.')}`);
101
+ } else {
102
+ say(` ${mark.ok} ${r.turns}개 턴, 파일 ${r.restored.length}개를 되돌렸습니다.`);
103
+ for (const f of r.restored) say(` ${c.gray(ctx.scope.show(f.path))} ${c.gray(f.how)}`);
104
+ }
105
+ say('');
106
+ return { handled: true };
107
+ }
108
+
109
+ case 'tools': {
110
+ rule('도구', 70);
111
+ for (const [n, t] of Object.entries(TOOLS)) {
112
+ say(` ${c.cyan(pad(n, 8))} ${c.gray(t.schema.description)}`);
113
+ }
114
+ say('');
115
+ return { handled: true };
116
+ }
117
+
118
+ case 'cost': {
119
+ const mins = ((Date.now() - session.startedAt) / 60000).toFixed(1);
120
+ rule('이번 세션', 70);
121
+ say(` ${c.gray(pad('모델 호출', 14))} ${session.usage.calls}회`);
122
+ say(` ${c.gray(pad('입력 토큰', 14))} ${session.usage.in.toLocaleString()}`);
123
+ say(` ${c.gray(pad('출력 토큰', 14))} ${session.usage.out.toLocaleString()}`);
124
+ say(` ${c.gray(pad('도구 시간', 14))} ${(session.usage.ms / 1000).toFixed(1)}초`);
125
+ say(` ${c.gray(pad('경과', 14))} ${mins}분`);
126
+ say('');
127
+ return { handled: true };
128
+ }
129
+
130
+ case 'status': {
131
+ const k = session.conn;
132
+ rule('연결', 70);
133
+ say(` ${c.gray(pad('규격', 10))} ${k.kind === 'ollama' ? 'Ollama' : 'OpenAI 호환'}`);
134
+ say(` ${c.gray(pad('주소', 10))} ${k.base}`);
135
+ say(` ${c.gray(pad('모델', 10))} ${k.model}`);
136
+ say(` ${c.gray(pad('작업 폴더', 10))} ${session.root}`);
137
+ say(` ${c.gray(pad('규칙', 10))} ${session.rules ? session.rules.name : '없음 (/init 으로 만들 수 있습니다)'}`);
138
+ const caps = [
139
+ k.tools ? c.green('도구') : c.red('도구'),
140
+ k.streaming ? c.green('스트림') : c.gray('스트림'),
141
+ k.json ? c.green('스키마') : c.gray('스키마'),
142
+ k.think ? c.green('추론') : c.gray('추론'),
143
+ ].join(c.gray(' · '));
144
+ say(` ${c.gray(pad('지원', 10))} ${caps}`);
145
+ say('');
146
+ return { handled: true };
147
+ }
148
+
149
+ case 'init': {
150
+ const p = join(session.root, 'DEEL.md');
151
+ if (existsSync(p)) {
152
+ say(` ${mark.warn} 이미 있습니다: ${c.cyan('DEEL.md')}`);
153
+ say('');
154
+ return { handled: true };
155
+ }
156
+ writeFileSync(p, INIT_TEMPLATE, 'utf8');
157
+ session.rules = { name: 'DEEL.md', text: INIT_TEMPLATE };
158
+ say(` ${mark.ok} ${c.cyan('DEEL.md')} 를 만들었습니다. 이 폴더의 규칙을 여기에 적으면 매번 읽습니다.`);
159
+ say('');
160
+ return { handled: true };
161
+ }
162
+
163
+ case 'skills': return showSkills(session, arg), { handled: true };
164
+
165
+ default: {
166
+ // 이 PC 에서 찾은 슬래시 명령인지 본다. 있으면 그 내용을 모델에게 보낸다.
167
+ const found = (session.commands ?? []).find((x) => x.name === raw)
168
+ ?? (session.commands ?? []).find((x) => x.name.toLowerCase() === name)
169
+ ?? (session.commands ?? []).find((x) => x.name.split(':').pop() === name);
170
+ if (found) {
171
+ const { text, error } = loadCommand(found, arg);
172
+ if (error) { say(` ${c.red('명령을 읽지 못했습니다')} ${error}`); say(''); return { handled: true }; }
173
+ say(` ${c.cyan('⌘')} ${found.name} ${c.gray(found.source)}`);
174
+ return { handled: false, text };
175
+ }
176
+ const near = (session.commands ?? [])
177
+ .filter((x) => x.name.includes(name) || name.includes(x.name.split(':').pop()))
178
+ .slice(0, 5).map((x) => '/' + x.name);
179
+ say(` ${c.red('모르는 명령')} /${name}`);
180
+ if (near.length) say(` ${c.gray('비슷한 것:')} ${near.join(' ')}`);
181
+ say(` ${c.gray('/help 로 목록을, /skills 로 스킬을 봅니다.')}`);
182
+ say('');
183
+ return { handled: true };
184
+ }
185
+ }
186
+ }
187
+
188
+ function showSkills(session, arg) {
189
+ const all = session.skills ?? [];
190
+ if (!all.length) {
191
+ say('');
192
+ say(` ${c.gray('이 PC 에서 찾은 스킬이 없습니다.')}`);
193
+ say(` ${c.gray('찾는 자리: ./.deel/skills ./.claude/skills ~/.claude/skills ~/.claude/plugins')}`);
194
+ say('');
195
+ return;
196
+ }
197
+
198
+ const q = arg.trim();
199
+ if (q === 'all') {
200
+ all.forEach((s) => { s.enabled = true; });
201
+ session.maxSkillsListed = Math.min(all.length, 200);
202
+ say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 먹습니다 — /context 로 확인하세요.')}`);
203
+ say('');
204
+ return;
205
+ }
206
+ if (q === 'off') {
207
+ all.forEach((s) => { s.enabled = false; });
208
+ say(` ${mark.ok} 스킬을 모두 내렸습니다.`);
209
+ say('');
210
+ return;
211
+ }
212
+ if (q.startsWith('on ')) {
213
+ const term = q.slice(3).trim().toLowerCase();
214
+ let n = 0;
215
+ for (const s of all) {
216
+ s.enabled = s.name.toLowerCase().includes(term) || s.description.toLowerCase().includes(term);
217
+ if (s.enabled) n++;
218
+ }
219
+ say(` ${mark.ok} "${term}" 에 걸리는 ${n}개만 올립니다.`);
220
+ say('');
221
+ return;
222
+ }
223
+
224
+ const hits = q
225
+ ? all.filter((s) => s.name.toLowerCase().includes(q.toLowerCase()) || s.description.toLowerCase().includes(q.toLowerCase()))
226
+ : session.listedSkills();
227
+
228
+ say('');
229
+ rule(q ? `스킬 검색: ${q}` : '지금 올라간 스킬', 74);
230
+ for (const s of hits.slice(0, 30)) {
231
+ const tag = s.enabled ? c.green('●') : c.gray('○');
232
+ say(` ${tag} ${c.cyan(pad(s.name, 32))} ${c.gray(s.description.slice(0, 60))}`);
233
+ }
234
+ if (hits.length > 30) say(` ${c.gray(`… 그 밖에 ${hits.length - 30}개`)}`);
235
+ say('');
236
+
237
+ const bySource = { project: 0, user: 0, plugin: 0 };
238
+ for (const s of all) bySource[s.source] = (bySource[s.source] ?? 0) + 1;
239
+ say(` ${c.gray('전체')} ${all.length}개 ${c.gray('(프로젝트')} ${bySource.project} ${c.gray('· 사용자')} ${bySource.user} ${c.gray('· 플러그인')} ${bySource.plugin}${c.gray(')')}`);
240
+ say(` ${c.gray('프롬프트에 올라간 것')} ${session.listedSkills().length}개 ${c.gray(`(상한 ${session.maxSkillsListed})`)}`);
241
+ if ((session.plugins ?? []).length) {
242
+ say(` ${c.gray('플러그인')} ${session.plugins.filter((p) => p.skills > 0).map((p) => p.name).slice(0, 8).join(', ')}`);
243
+ }
244
+ say('');
245
+ say(` ${c.gray('/skills <검색어> 찾아보기')}`);
246
+ say(` ${c.gray('/skills on <검색어> 걸리는 것만 올리기')}`);
247
+ say(` ${c.gray('/skills all | off 전부 올리기 | 내리기')}`);
248
+ say('');
249
+ }
250
+
251
+ function help() {
252
+ say('');
253
+ rule('명령', 70);
254
+ for (const [n, m] of Object.entries(COMMANDS)) {
255
+ if (n === 'quit') continue;
256
+ say(` ${c.cyan(pad('/' + n + (m.arg ? ' ' + m.arg : ''), 22))} ${c.gray(m.desc)}`);
257
+ }
258
+ say('');
259
+ say(` ${c.gray('그 밖의 입력은 모델에게 보냅니다. 빈 줄에서 Ctrl+C 로 끝냅니다.')}`);
260
+ say('');
261
+ }
262
+
263
+ function showContext(session) {
264
+ const b = session.breakdown();
265
+ say('');
266
+ rule('컨텍스트', 70);
267
+ say(` ${c.bold(session.conn.model)} ${c.gray('·')} ${b.total.toLocaleString()} 토큰`);
268
+ say('');
269
+ say(` ${bar(b.used, b.total, 32)} ${b.used.toLocaleString()} / ${b.total.toLocaleString()} ${c.gray(`${Math.round((b.used / b.total) * 100)}%`)}`);
270
+ say('');
271
+ for (const r of b.rows) {
272
+ if (!r.n) continue;
273
+ say(` ${c.gray(pad(r.label, 26))} ${pad(r.n.toLocaleString(), 8, 'right')}`);
274
+ }
275
+ say(` ${c.gray('─'.repeat(35))}`);
276
+ say(` ${c.gray(pad('남음', 26))} ${pad(b.left.toLocaleString(), 8, 'right')}`);
277
+ say('');
278
+ say(` ${c.gray('/compact 대화 줄이기 /clear 통째로 비우기')}`);
279
+ say(` ${c.gray('숫자는 추정입니다 — 정확한 토크나이저를 쓰지 않습니다.')}`);
280
+ say('');
281
+ }
282
+
283
+ async function switchModel(session, ctx) {
284
+ const cfg = load();
285
+ if (cfg.profiles.length <= 1 && !cfg.profiles.length) {
286
+ say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')}`);
287
+ say('');
288
+ return;
289
+ }
290
+ const items = cfg.profiles.map((p) => ({
291
+ label: `${pad(p.name, 18)} ${c.gray(p.model)}`,
292
+ note: p.id === cfg.active ? '지금' : '',
293
+ }));
294
+ const i = await pick('연결 고르기', items, {
295
+ def: cfg.profiles.findIndex((p) => p.id === cfg.active),
296
+ ask: ctx?.ask,
297
+ });
298
+ const p = cfg.profiles[i];
299
+ 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
+ });
305
+ say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
306
+ say('');
307
+ }
308
+
309
+ const INIT_TEMPLATE = `# DEEL.md
310
+
311
+ 이 폴더에서 일할 때 지킬 규칙을 적습니다. deel 가 매번 읽습니다.
312
+
313
+ ## 이 프로젝트
314
+
315
+ - 무엇을 하는 프로젝트인지 두세 줄
316
+
317
+ ## 명령
318
+
319
+ - 빌드:
320
+ - 시험:
321
+ - 실행:
322
+
323
+ ## 규칙
324
+
325
+ - 고치기 전에 관련 파일을 먼저 읽는다
326
+ - (프로젝트에 맞는 규칙을 적으세요)
327
+ `;