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
package/src/commands.js
CHANGED
|
@@ -13,8 +13,10 @@ 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
|
+
import { diffLines, renderDiff, shortStat } from './ui/diff.js';
|
|
19
|
+
import { readTextFull } from './tools/fsutil.js';
|
|
18
20
|
const MODES = {
|
|
19
21
|
auto: '자율 — 전부 알아서. 되돌리기가 안전망',
|
|
20
22
|
confirm: '확인 — 되돌릴 수 없는 것만 물어봄',
|
|
@@ -25,11 +27,16 @@ export const COMMANDS = {
|
|
|
25
27
|
help: { desc: '명령 목록' },
|
|
26
28
|
clear: { desc: '대화 비우기' },
|
|
27
29
|
context: { desc: '컨텍스트 사용량 보기' },
|
|
30
|
+
ctx: { desc: '컨텍스트 길이 — 모델에 맞춰 다시 재거나 직접 지정', arg: '[auto|숫자|640k]' },
|
|
31
|
+
out: { desc: '한 번에 받을 답 길이 상한 — 큰 파일이 잘리면 여기를 올린다', arg: '[숫자|32k|auto]' },
|
|
28
32
|
compact: { desc: '오래된 대화 줄이기' },
|
|
29
|
-
model: { desc: '
|
|
33
|
+
model: { desc: '연결·모델 바꾸기 (이름 일부 · list · models)', arg: '[이름|list|models]' },
|
|
30
34
|
think: { desc: '추론 강도 (off/low/medium/high/max)', arg: '<수준>' },
|
|
31
35
|
mode: { desc: '승인 정책 — 얼마나 물어보나 (auto/confirm/strict)', arg: '<모드>' },
|
|
32
|
-
work: { desc: '작업 모드 — 무슨 일을 하는 중인가', arg: '[모드]' },
|
|
36
|
+
work: { desc: '작업 모드 — 무슨 일을 하는 중인가 (종합이면 저절로)', arg: '[모드]' },
|
|
37
|
+
// 이름이 /mode auto 와 겹쳐 보이므로 설명에서 못을 박는다.
|
|
38
|
+
// /mode auto 는 '얼마나 물어보나', /auto 는 '무슨 일을 하는 중인가' 다.
|
|
39
|
+
auto: { desc: '작업 모드 → 종합 (요청에 따라 저절로 옮겨 감. /mode 와 다름)' },
|
|
33
40
|
code: { desc: '작업 모드 → 코드 (고치고 만든다)' },
|
|
34
41
|
plan: { desc: '작업 모드 → 계획 (먼저 계획만)' },
|
|
35
42
|
architect:{ desc: '작업 모드 → 설계 (구조를 짠다)' },
|
|
@@ -37,6 +44,7 @@ export const COMMANDS = {
|
|
|
37
44
|
ask: { desc: '작업 모드 → 묻기 (설명만)' },
|
|
38
45
|
orchestrator: { desc: '작업 모드 → 총괄 (큰 일을 쪼개서)' },
|
|
39
46
|
undo: { desc: '직전 작업 되돌리기', arg: '[턴수]' },
|
|
47
|
+
diff: { desc: '이번 대화에서 바뀐 파일 · 바뀐 자리 보기', arg: '[파일]' },
|
|
40
48
|
tools: { desc: '쓸 수 있는 도구 보기' },
|
|
41
49
|
skills: { desc: '스킬 보기·검색·골라 올리기', arg: '[검색어|all|off]' },
|
|
42
50
|
plugin: { desc: '플러그인 목록·설치·삭제·반입묶음', arg: '[install|remove|pack]' },
|
|
@@ -44,15 +52,40 @@ export const COMMANDS = {
|
|
|
44
52
|
status: { desc: '연결 상태' },
|
|
45
53
|
scan: { desc: '이 PC 의 로컬 모델 서버 훑기', arg: '[save]' },
|
|
46
54
|
sessions:{ desc: '이 폴더의 지난 대화 목록' },
|
|
55
|
+
recall: { desc: '지난 대화에서 찾기 — 목록 말고 내용으로', arg: '<찾을 말>' },
|
|
56
|
+
mcp: { desc: '밖에서 붙인 도구(MCP) 서버 보기' },
|
|
57
|
+
memory: { desc: '대화가 끝나도 남는 기억 — 보기·지우기', arg: '[지우기 <번호>|비우기|<적을 말>]' },
|
|
47
58
|
level: { desc: '사용자 수준 (쉬움/개발자)', arg: '[수준]' },
|
|
48
59
|
init: { desc: 'DEEL.md 규칙 파일 만들기' },
|
|
49
60
|
exit: { desc: '끝내기' },
|
|
50
61
|
quit: { desc: '끝내기' },
|
|
51
62
|
};
|
|
52
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 이 줄이 명령이 아니라 '경로' 인가.
|
|
66
|
+
*
|
|
67
|
+
* 슬래시로 시작한다고 다 명령은 아니다. `/usr/local/bin` 이나 `/mnt/d/일감`
|
|
68
|
+
* 같은 것을 치면 그동안은 통째로 명령으로 먹혀서 "모르는 명령" 만 나오고
|
|
69
|
+
* 모델에게 닿지도 않았다. 경로를 아예 못 적는 셈이었다.
|
|
70
|
+
*
|
|
71
|
+
* 가르는 기준은 간단하다 — 명령 이름에는 슬래시가 없다.
|
|
72
|
+
* 플러그인 명령도 `/플러그인:이름` 이라 콜론을 쓰지 슬래시를 안 쓴다.
|
|
73
|
+
* 그러니 첫 낱말 안에 슬래시가 또 있으면 그건 경로다.
|
|
74
|
+
*/
|
|
75
|
+
function 경로처럼보이나(line) {
|
|
76
|
+
const 첫낱말 = line.slice(1).split(/\s+/)[0] ?? '';
|
|
77
|
+
if (첫낱말.includes('/') || 첫낱말.includes('\\')) return true;
|
|
78
|
+
// `/tmp` 처럼 슬래시가 하나뿐이어도, 실제로 있는 자리면 경로로 본다.
|
|
79
|
+
if (첫낱말 && !COMMANDS[첫낱말.toLowerCase()]) {
|
|
80
|
+
try { if (existsSync(line.slice(1).trim())) return true; } catch { /* 못 보면 아닌 걸로 */ }
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
// 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
|
|
54
86
|
export async function handle(line, session, ctx) {
|
|
55
87
|
if (!line.startsWith('/')) return { handled: false };
|
|
88
|
+
if (경로처럼보이나(line)) return { handled: false };
|
|
56
89
|
const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
|
|
57
90
|
const name = raw.toLowerCase();
|
|
58
91
|
const arg = rest.join(' ');
|
|
@@ -71,6 +104,12 @@ export async function handle(line, session, ctx) {
|
|
|
71
104
|
|
|
72
105
|
case 'context': return showContext(session), { handled: true };
|
|
73
106
|
|
|
107
|
+
case 'ctx': return await ctxLength(session, arg), { handled: true };
|
|
108
|
+
|
|
109
|
+
// 한 번에 받을 답 길이. 컨텍스트(/ctx)와는 다른 축이라 명령을 따로 둔다 —
|
|
110
|
+
// /ctx out 안에 숨겨 두니 아무도 못 찾았고, 정작 큰 파일이 안 만들어지는 원인이었다.
|
|
111
|
+
case 'out': case '출력': return await 출력상한(session, arg), { handled: true };
|
|
112
|
+
|
|
74
113
|
case 'compact': {
|
|
75
114
|
const s = spin('앞선 대화를 요약해 접는 중…');
|
|
76
115
|
const r = await compact(session);
|
|
@@ -91,26 +130,70 @@ export async function handle(line, session, ctx) {
|
|
|
91
130
|
return { handled: true };
|
|
92
131
|
}
|
|
93
132
|
|
|
94
|
-
case 'model': return await switchModel(session, ctx), { handled: true };
|
|
133
|
+
case 'model': return await switchModel(session, ctx, arg), { handled: true };
|
|
95
134
|
|
|
135
|
+
/*
|
|
136
|
+
* /think — '얼마나 생각하나' 하나만 정한다.
|
|
137
|
+
*
|
|
138
|
+
* 전에는 한 명령이 두 축을 맡았다. `/think high` 는 강도(5단계)를 정하고
|
|
139
|
+
* `/think save` 는 배분(3가지)을 정했다 — 같은 이름으로 다른 것을 정하니
|
|
140
|
+
* 화면을 봐도 지금 무엇이 무엇인지 읽히지 않았다. 게다가 부를 때마다
|
|
141
|
+
* 단계표가 통째로 펼쳐졌고, 그 표의 '출력상한' 칸 세 줄은 늘 같은 값이었다.
|
|
142
|
+
*
|
|
143
|
+
* 그래서 갈랐다.
|
|
144
|
+
* /think high 강도
|
|
145
|
+
* /think 배분 절약 단계별 배분
|
|
146
|
+
* /think 자세히 단계표
|
|
147
|
+
* /out 출력 상한 (아예 다른 축이라 명령을 따로 뺐다)
|
|
148
|
+
*
|
|
149
|
+
* 옛 이름(/think save)도 그대로 받는다. 쓰던 사람의 손버릇을 깨지 않는다.
|
|
150
|
+
*/
|
|
96
151
|
case 'think': {
|
|
97
|
-
const
|
|
152
|
+
const 말 = String(arg ?? '').trim();
|
|
153
|
+
// 낱말 끝을 \b 로 잡으면 안 된다. \b 는 \w(=[A-Za-z0-9_])를 기준으로 하는데
|
|
154
|
+
// 한글은 \w 가 아니다 — '배분 절약' 의 '분' 과 공백은 **둘 다 비낱말**이라
|
|
155
|
+
// 그 사이에 경계가 없다. 그래서 `/^배분\b/` 는 영영 안 맞았고,
|
|
156
|
+
// /think 배분 … 은 통째로 죽은 명령이었다(영어 prof 만 먹혔다).
|
|
157
|
+
// 공백이나 줄 끝으로 직접 끊는다.
|
|
158
|
+
const 배분말 = /^(배분|profile|prof)(\s|$)/.test(말) ? 말.replace(/^(배분|profile|prof)\s*/, '') : null;
|
|
159
|
+
|
|
160
|
+
if (배분말 !== null) {
|
|
161
|
+
const p = normalizeProfile(배분말);
|
|
162
|
+
if (!p) {
|
|
163
|
+
say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')}`);
|
|
164
|
+
say(` ${c.gray('예')} ${c.cyan('/think 배분 절약')}`);
|
|
165
|
+
say('');
|
|
166
|
+
return { handled: true };
|
|
167
|
+
}
|
|
168
|
+
session.effort = p;
|
|
169
|
+
session.effortSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
|
|
170
|
+
say(` ${mark.ok} 배분 ${c.bold(PROFILES[p].name)} ${c.gray('— ' + PROFILES[p].desc)}`);
|
|
171
|
+
showThink(session);
|
|
172
|
+
return { handled: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (/^(자세히|detail|-v)$/.test(말)) { showThink(session, { 자세히: true }); return { handled: true }; }
|
|
176
|
+
|
|
177
|
+
// 옛 이름 — /think save 처럼 배분 이름을 바로 친 경우.
|
|
178
|
+
const asProfile = normalizeProfile(말);
|
|
98
179
|
if (asProfile) {
|
|
99
180
|
session.effort = asProfile;
|
|
100
|
-
session.effortSet = true;
|
|
181
|
+
session.effortSet = true;
|
|
101
182
|
say(` ${mark.ok} 배분 ${c.bold(PROFILES[asProfile].name)} ${c.gray('— ' + PROFILES[asProfile].desc)}`);
|
|
183
|
+
say(` ${c.gray('이제')} ${c.cyan('/think 배분 ' + PROFILES[asProfile].name)} ${c.gray('로도 됩니다 — 강도와 헷갈리지 않게 갈랐습니다.')}`);
|
|
102
184
|
showThink(session);
|
|
103
185
|
return { handled: true };
|
|
104
186
|
}
|
|
105
|
-
|
|
187
|
+
|
|
188
|
+
if (!THINK_LEVELS.includes(말)) {
|
|
106
189
|
showThink(session);
|
|
107
190
|
return { handled: true };
|
|
108
191
|
}
|
|
109
|
-
session.think =
|
|
192
|
+
session.think = 말;
|
|
110
193
|
session.thinkSet = true; // 사용자가 직접 정했다 — 작업 모드보다 우선한다
|
|
111
|
-
say(` ${mark.ok} 추론 강도 ${c.bold(
|
|
112
|
-
if (!session.conn.think &&
|
|
113
|
-
say(` ${c.yellow('이 연결은 모델 층 조절이
|
|
194
|
+
say(` ${mark.ok} 추론 강도 ${c.bold(말)}`);
|
|
195
|
+
if (!session.conn.think && 말 !== 'off') {
|
|
196
|
+
say(` ${c.yellow('이 연결은 모델 층 조절이 적용되지 않습니다.')} ${c.gray('루프 층(도구 호출 상한)으로만 조절됩니다.')}`);
|
|
114
197
|
}
|
|
115
198
|
showThink(session);
|
|
116
199
|
return { handled: true };
|
|
@@ -143,6 +226,8 @@ export async function handle(line, session, ctx) {
|
|
|
143
226
|
return { handled: true };
|
|
144
227
|
}
|
|
145
228
|
|
|
229
|
+
case 'diff': return 바뀐것보기(session, ctx, arg), { handled: true };
|
|
230
|
+
|
|
146
231
|
case 'tools': {
|
|
147
232
|
rule('도구', 70);
|
|
148
233
|
for (const [n, t] of Object.entries(TOOLS)) {
|
|
@@ -184,23 +269,39 @@ export async function handle(line, session, ctx) {
|
|
|
184
269
|
}
|
|
185
270
|
|
|
186
271
|
case 'work':
|
|
272
|
+
case 'auto':
|
|
187
273
|
case 'code': case 'plan': case 'architect':
|
|
188
274
|
case 'debug': case 'ask': case 'orchestrator': {
|
|
189
|
-
|
|
190
|
-
if (name === 'work' &&
|
|
191
|
-
|
|
275
|
+
// 인자 없이 /work 만 치면 지금 모드와 고를 수 있는 것을 보여 준다.
|
|
276
|
+
if (name === 'work' && !arg.trim()) { showWork(session); return { handled: true }; }
|
|
277
|
+
|
|
278
|
+
const 골라진 = normWork(name === 'work' ? arg : name);
|
|
192
279
|
if (!골라진) {
|
|
280
|
+
// 오타에는 목록만 보여 주면 안 된다.
|
|
281
|
+
//
|
|
282
|
+
// 예전에는 /work 오타 가 조용히 목록으로 빠졌다. 그러면 사람은 자기가
|
|
283
|
+
// 친 말이 틀렸다는 걸 모른 채 '왜 안 바뀌지' 만 하게 된다. 못 알아들었다는
|
|
284
|
+
// 말을 먼저 하고, 그 다음에 쓸 수 있는 것을 보여 준다.
|
|
193
285
|
say(` ${mark.warn} 그런 모드는 없습니다: ${c.white(arg)}`);
|
|
194
286
|
say(` ${c.gray('쓸 수 있는 것:')} ${WORK_ORDER.map((k) => c.cyan(WORK_MODES[k].name)).join(c.gray(' · '))}`);
|
|
195
287
|
say('');
|
|
288
|
+
showWork(session);
|
|
196
289
|
return { handled: true };
|
|
197
290
|
}
|
|
198
291
|
session.work = 골라진;
|
|
292
|
+
// 사람이 직접 골랐다. 저절로 골라 둔 것이 있으면 지운다 —
|
|
293
|
+
// 안 지우면 이번 한마디는 여전히 옛 모드로 돌아 "왜 안 바뀌지" 가 된다.
|
|
294
|
+
session.routed = null;
|
|
199
295
|
const w = getWork(골라진);
|
|
200
296
|
say('');
|
|
201
297
|
say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`);
|
|
202
298
|
say(` ${c.gray('도구')} ${canWrite(골라진) ? c.yellow('읽기 + 파일 바꾸기') : c.green('읽기만 — 파일을 못 바꿉니다')}`);
|
|
203
299
|
say(` ${c.gray('생각')} ${c.white(w.think ?? session.think)}${c.gray('·')}${c.magenta(w.effort)} ${c.gray('최대 ' + w.steps + '걸음')}`);
|
|
300
|
+
if (골라진 === 'auto') {
|
|
301
|
+
say(` ${c.gray('앞으로는 한마디마다 알맞은 모드로 저절로 옮겨 갑니다.')}`);
|
|
302
|
+
} else {
|
|
303
|
+
say(` ${c.gray('직접 고르셨으므로 저절로 바뀌지 않습니다. 다시 맡기려면')} ${c.cyan('/work 종합')}`);
|
|
304
|
+
}
|
|
204
305
|
say('');
|
|
205
306
|
return { handled: true };
|
|
206
307
|
}
|
|
@@ -233,6 +334,167 @@ export async function handle(line, session, ctx) {
|
|
|
233
334
|
return { handled: true };
|
|
234
335
|
}
|
|
235
336
|
|
|
337
|
+
/*
|
|
338
|
+
* /recall — 지난 대화에서 **내용으로** 찾는다.
|
|
339
|
+
*
|
|
340
|
+
* /sessions 는 목록만 보여 준다. 언제 무슨 모델로 몇 턴 했는지는 알겠는데
|
|
341
|
+
* "저번에 그 인코딩 문제 어떻게 풀었더라" 는 못 찾는다. 기록이 있는데
|
|
342
|
+
* 못 찾으면 없는 것과 같다.
|
|
343
|
+
*/
|
|
344
|
+
case 'recall': {
|
|
345
|
+
const 말 = String(arg ?? '').trim();
|
|
346
|
+
if (!말) {
|
|
347
|
+
say(` ${c.gray('찾을 말을 적어 주세요 —')} ${c.cyan('/recall CP949 인코딩')}`);
|
|
348
|
+
say(` ${c.gray('낱말 두세 개가 가장 잘 맞습니다. 조사는 붙어 있어도 됩니다.')}`);
|
|
349
|
+
say('');
|
|
350
|
+
return { handled: true };
|
|
351
|
+
}
|
|
352
|
+
const { 찾기 } = await import('./agent/recall.js');
|
|
353
|
+
const r = 찾기(session.root, 말, { limit: 10 });
|
|
354
|
+
|
|
355
|
+
rule(`지난 대화에서 "${clip(말, 30)}"`, 70);
|
|
356
|
+
if (!r.낱말.length) {
|
|
357
|
+
say(` ${mark.no} 찾을 낱말이 없습니다 ${c.gray('— 두 글자 이상으로 적어 주세요.')}`);
|
|
358
|
+
say('');
|
|
359
|
+
return { handled: true };
|
|
360
|
+
}
|
|
361
|
+
if (!r.맞은것.length) {
|
|
362
|
+
// 못 찾은 것과 안 찾아본 것을 구분해서 말한다.
|
|
363
|
+
say(r.예산초과
|
|
364
|
+
? ` ${mark.warn} 지난 대화 ${r.전체파일}개 중 ${r.본파일}개까지만 뒤졌습니다 ${c.gray('(양이 많아 멈췄습니다)')}`
|
|
365
|
+
: ` ${c.gray(`지난 대화 ${r.본파일}개를 다 뒤졌지만 없습니다.`)}`);
|
|
366
|
+
say(` ${c.gray('찾은 낱말:')} ${c.white(r.낱말.join(' · '))}`);
|
|
367
|
+
say('');
|
|
368
|
+
return { handled: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const h of r.맞은것) {
|
|
372
|
+
const 날 = h.언제 instanceof Date ? h.언제.toISOString().slice(0, 16).replace('T', ' ') : '';
|
|
373
|
+
const 색 = h.role === 'user' ? c.hcyan : h.role === 'assistant' ? c.white : c.gray;
|
|
374
|
+
say(` ${c.gray(pad(날, 17))} ${색(pad(h.누구, 7))} ${c.gray(h.세션)}`);
|
|
375
|
+
say(` ${강조(h.토막, r.낱말)}`);
|
|
376
|
+
}
|
|
377
|
+
say('');
|
|
378
|
+
say(` ${c.gray(`${r.전체맞음}건 중 ${r.맞은것.length}건 · 대화 ${r.본파일}개를 뒤졌습니다`)}`
|
|
379
|
+
+ (r.예산초과 ? ` ${c.yellow(`(${r.전체파일}개 중 ${r.본파일}개까지만)`)}` : ''));
|
|
380
|
+
say(` ${c.gray('그 대화를 이어하려면')} ${c.cyan('deel --resume ' + r.맞은것[0].세션)}`);
|
|
381
|
+
say('');
|
|
382
|
+
return { handled: true };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/*
|
|
386
|
+
* /mcp — 밖에서 붙인 도구 서버.
|
|
387
|
+
*
|
|
388
|
+
* 여기서 제일 중요한 줄은 도구 목록이 아니라 **경고**다. MCP 서버는 남의
|
|
389
|
+
* 프로그램이고 우리 작업 범위를 안 지킨다. 화면 어디에도 그 말이 없으면
|
|
390
|
+
* 사람은 우리 도구와 똑같이 안전한 줄 안다.
|
|
391
|
+
*/
|
|
392
|
+
/*
|
|
393
|
+
* /memory — 대화가 끝나도 남는 것.
|
|
394
|
+
*
|
|
395
|
+
* 사람이 반드시 **볼 수 있고 지울 수 있어야** 한다. 모델이 잘못 적은 줄은
|
|
396
|
+
* 매 요청마다 실려 나가면서 계속 틀리게 만든다. 틀린 기억은 없느니만 못하다.
|
|
397
|
+
*/
|
|
398
|
+
case 'memory': {
|
|
399
|
+
const M = await import('./agent/memory.js');
|
|
400
|
+
const 말 = String(arg ?? '').trim();
|
|
401
|
+
|
|
402
|
+
if (/^(비우기|clear|지우기전부)$/.test(말)) {
|
|
403
|
+
M.비우기(session.root);
|
|
404
|
+
session.memory = M.프롬프트토막(session.root);
|
|
405
|
+
say(` ${mark.ok} 기억을 비웠습니다.`);
|
|
406
|
+
say('');
|
|
407
|
+
return { handled: true };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const 지움 = /^(지우기|잊어|forget|rm)\s+(\d+)$/.exec(말);
|
|
411
|
+
if (지움) {
|
|
412
|
+
const r = M.지우기(session.root, 지움[2]);
|
|
413
|
+
if (!r.ok) say(` ${mark.no} ${r.why}`);
|
|
414
|
+
else {
|
|
415
|
+
session.memory = M.프롬프트토막(session.root);
|
|
416
|
+
say(` ${mark.ok} 잊었습니다: ${c.gray(clip(r.뺀것, 60))}`);
|
|
417
|
+
}
|
|
418
|
+
say('');
|
|
419
|
+
return { handled: true };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 그 밖의 말은 '이걸 기억해라' 로 본다. 모델을 안 거치고 바로 적는 길이다.
|
|
423
|
+
if (말) {
|
|
424
|
+
const r = M.더하기(session.root, 말);
|
|
425
|
+
if (!r.ok) say(` ${mark.warn} ${r.why}`);
|
|
426
|
+
else {
|
|
427
|
+
session.memory = M.프롬프트토막(session.root);
|
|
428
|
+
say(` ${mark.ok} 기억했습니다 ${c.gray(`(${r.줄수}줄)`)}`);
|
|
429
|
+
if (r.넘침) say(` ${c.gray('자리가 차서 오래된 것을 뺐습니다.')}`);
|
|
430
|
+
}
|
|
431
|
+
say('');
|
|
432
|
+
return { handled: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const 기억 = M.읽기(session.root);
|
|
436
|
+
rule('기억 — 대화가 끝나도 남는 것', 70);
|
|
437
|
+
if (!기억.줄들.length) {
|
|
438
|
+
say(` ${c.gray('아직 없습니다.')}`);
|
|
439
|
+
say('');
|
|
440
|
+
say(` ${c.gray('모델이 스스로 적기도 하고, 직접 적으셔도 됩니다 —')}`);
|
|
441
|
+
say(` ${c.cyan('/memory 사내 문서는 CP949 로 읽고 CP949 로 되돌려 쓴다')}`);
|
|
442
|
+
say('');
|
|
443
|
+
return { handled: true };
|
|
444
|
+
}
|
|
445
|
+
for (const [i, l] of 기억.줄들.entries()) {
|
|
446
|
+
say(` ${c.gray(String(i + 1).padStart(2))} ${c.white(clip(l, 82))}`);
|
|
447
|
+
}
|
|
448
|
+
const 글자 = 기억.줄들.join('\n').length;
|
|
449
|
+
say('');
|
|
450
|
+
say(` ${c.gray(`${기억.줄들.length}줄 · 약 ${Math.round(글자 / 3).toLocaleString()}토큰이 매 요청마다 함께 나갑니다`)}`);
|
|
451
|
+
say(` ${c.gray('파일')} ${c.white(기억.자리)} ${c.gray('— 직접 고치셔도 됩니다')}`);
|
|
452
|
+
say(` ${c.cyan('/memory 지우기 3')} ${c.cyan('/memory 비우기')}`);
|
|
453
|
+
say('');
|
|
454
|
+
return { handled: true };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
case 'mcp': {
|
|
458
|
+
const { 설정읽기, 설정자리, 도구최대 } = await import('./backend/mcp.js');
|
|
459
|
+
const 붙은것 = session.mcp ?? [];
|
|
460
|
+
rule('밖에서 붙인 도구 (MCP)', 70);
|
|
461
|
+
|
|
462
|
+
if (!붙은것.length) {
|
|
463
|
+
const 설정 = 설정읽기(session.root);
|
|
464
|
+
if (설정.오류) say(` ${mark.no} ${설정.오류}`);
|
|
465
|
+
else if (!설정.있음) {
|
|
466
|
+
say(` ${c.gray('붙인 것이 없습니다.')} ${c.gray('설정 파일이 없습니다 —')} ${c.white(설정자리(session.root))}`);
|
|
467
|
+
} else if (!설정.서버들.length) {
|
|
468
|
+
say(` ${c.gray('설정은 있는데 띄울 서버가 없습니다.')} ${c.gray(설정자리(session.root))}`);
|
|
469
|
+
} else {
|
|
470
|
+
say(` ${mark.warn} 설정에 ${설정.서버들.length}대가 적혀 있는데 하나도 안 붙었습니다.`);
|
|
471
|
+
say(` ${c.gray('오프라인 잠금 중이거나, 켤 때 못 떴습니다. 머리말의 경고를 보세요.')}`);
|
|
472
|
+
}
|
|
473
|
+
say('');
|
|
474
|
+
say(` ${c.gray('붙이려면')} ${c.white('.deel/mcp.json')} ${c.gray('에 이렇게 적습니다 —')}`);
|
|
475
|
+
say(` ${c.gray('{ "mcpServers": { "사내위키": { "command": "node", "args": ["wiki-mcp.js"] } } }')}`);
|
|
476
|
+
say('');
|
|
477
|
+
say(` ${c.yellow('※')} ${c.gray('MCP 서버는 남의 프로그램입니다. 사내 반입 심사를 따로 받으셔야 합니다.')}`);
|
|
478
|
+
say('');
|
|
479
|
+
return { handled: true };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const s of 붙은것) {
|
|
483
|
+
const 상태 = s.살아있나() ? c.green('●') : c.red('○');
|
|
484
|
+
const 이름들 = s.도구.map((t) => t.name);
|
|
485
|
+
say(` ${상태} ${c.bold(s.이름)} ${c.gray(s.정보?.name ? `${s.정보.name} ${s.정보.version ?? ''}` : s.설정.command)}`);
|
|
486
|
+
say(` ${c.gray('도구 ' + s.도구.length + '개')} ${c.gray(clip(이름들.join(' · '), 60))}`);
|
|
487
|
+
if (s.잘림) say(` ${mark.warn} ${c.gray(`${s.잘림}개는 뺐습니다 — 한 서버에 ${도구최대}개까지만 받습니다(컨텍스트가 줄어듭니다)`)}`);
|
|
488
|
+
if (!s.살아있나()) say(` ${c.red(s.죽음 ?? '죽었습니다')}`);
|
|
489
|
+
}
|
|
490
|
+
say('');
|
|
491
|
+
say(` ${c.gray('모델에게는')} ${c.white('mcp__<서버>__<도구>')} ${c.gray('라는 이름으로 보입니다.')}`);
|
|
492
|
+
say(` ${c.yellow('※')} ${c.gray('이 도구들은 남의 프로그램이 돌립니다 —')} ${c.white('작업 범위(' + session.root + ') 를 안 지킵니다.')}`);
|
|
493
|
+
say(` ${c.gray(' 무엇을 불렀는지는')} ${c.white('.deel/audit.jsonl')} ${c.gray('에 남습니다.')}`);
|
|
494
|
+
say('');
|
|
495
|
+
return { handled: true };
|
|
496
|
+
}
|
|
497
|
+
|
|
236
498
|
case 'sessions': {
|
|
237
499
|
const rows = listSessions(session.root, { limit: 12 });
|
|
238
500
|
rule('이 폴더의 지난 대화', 70);
|
|
@@ -395,11 +657,15 @@ function showSkills(session, arg) {
|
|
|
395
657
|
return;
|
|
396
658
|
}
|
|
397
659
|
|
|
660
|
+
// 스킬은 남의 폴더·플러그인에서 온다. 앞머리(frontmatter)가 빠진 파일이 섞이면
|
|
661
|
+
// 이름이나 설명이 없다. 그걸 그대로 만지면 목록 하나 보려다 대화가 끝난다.
|
|
662
|
+
const 낮게 = (v) => String(v ?? '').toLowerCase();
|
|
663
|
+
|
|
398
664
|
const q = arg.trim();
|
|
399
665
|
if (q === 'all') {
|
|
400
666
|
all.forEach((s) => { s.enabled = true; });
|
|
401
667
|
session.maxSkillsListed = Math.min(all.length, 200);
|
|
402
|
-
say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이
|
|
668
|
+
say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 차지합니다 — /context 로 확인하세요.')}`);
|
|
403
669
|
say('');
|
|
404
670
|
return;
|
|
405
671
|
}
|
|
@@ -413,7 +679,7 @@ function showSkills(session, arg) {
|
|
|
413
679
|
const term = q.slice(3).trim().toLowerCase();
|
|
414
680
|
let n = 0;
|
|
415
681
|
for (const s of all) {
|
|
416
|
-
s.enabled = s.name
|
|
682
|
+
s.enabled = 낮게(s.name).includes(term) || 낮게(s.description).includes(term);
|
|
417
683
|
if (s.enabled) n++;
|
|
418
684
|
}
|
|
419
685
|
say(` ${mark.ok} "${term}" 에 걸리는 ${n}개만 올립니다.`);
|
|
@@ -422,14 +688,17 @@ function showSkills(session, arg) {
|
|
|
422
688
|
}
|
|
423
689
|
|
|
424
690
|
const hits = q
|
|
425
|
-
? all.filter((s) => s.name
|
|
691
|
+
? all.filter((s) => 낮게(s.name).includes(q.toLowerCase()) || 낮게(s.description).includes(q.toLowerCase()))
|
|
426
692
|
: session.listedSkills();
|
|
427
693
|
|
|
428
694
|
say('');
|
|
429
695
|
rule(q ? `스킬 검색: ${q}` : '지금 올라간 스킬', 74);
|
|
430
696
|
for (const s of hits.slice(0, 30)) {
|
|
431
697
|
const tag = s.enabled ? c.green('●') : c.gray('○');
|
|
432
|
-
|
|
698
|
+
// 설명이 없을 수 있다. 스킬은 남의 폴더·플러그인에서 오는 것이라
|
|
699
|
+
// 앞머리(frontmatter)가 빠진 파일이 섞인다. 여기서 터지면 목록 하나 보려다
|
|
700
|
+
// 대화가 통째로 끝난다 — 화면 그리기는 무슨 일이 있어도 안 죽어야 한다.
|
|
701
|
+
say(` ${tag} ${c.cyan(pad(s.name, 32))} ${c.gray(String(s.description ?? '').slice(0, 60))}`);
|
|
433
702
|
}
|
|
434
703
|
if (hits.length > 30) say(` ${c.gray(`… 그 밖에 ${hits.length - 30}개`)}`);
|
|
435
704
|
say('');
|
|
@@ -462,7 +731,7 @@ function help(session) {
|
|
|
462
731
|
say('');
|
|
463
732
|
// 감춘 것은 '못 쓰는 것' 이 아니다. 그 말을 분명히 해 둔다.
|
|
464
733
|
if (감춘수) {
|
|
465
|
-
say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 —
|
|
734
|
+
say(` ${c.gray(`이 밖에 ${감춘수}개가 더 있습니다 — 직접 입력하면 그대로 실행됩니다.`)}`);
|
|
466
735
|
say(` ${c.gray('전부 보려면')} ${c.cyan('/level 개발자')}`);
|
|
467
736
|
say('');
|
|
468
737
|
}
|
|
@@ -506,26 +775,81 @@ function showLevel(session, arg) {
|
|
|
506
775
|
say('');
|
|
507
776
|
}
|
|
508
777
|
|
|
778
|
+
/**
|
|
779
|
+
* 찾은 낱말에 색을 입힌다.
|
|
780
|
+
*
|
|
781
|
+
* 토막만 보여 주면 **왜 이게 걸렸는지** 안 보인다. 특히 조사를 떼고 찾기
|
|
782
|
+
* 때문에("인코딩을" 로 "인코딩" 을 찾는다) 눈으로는 안 맞는 것처럼 보이는
|
|
783
|
+
* 경우가 있다. 맞은 자리를 칠해 주면 그 의심이 사라진다.
|
|
784
|
+
*/
|
|
785
|
+
function 강조(글, 낱말들) {
|
|
786
|
+
let 조각 = [{ 글, 맞음: false }];
|
|
787
|
+
for (const w of 낱말들) {
|
|
788
|
+
const 다음 = [];
|
|
789
|
+
for (const p of 조각) {
|
|
790
|
+
if (p.맞음) { 다음.push(p); continue; }
|
|
791
|
+
const 낮은 = p.글.toLowerCase();
|
|
792
|
+
let i = 0;
|
|
793
|
+
let 자리 = 낮은.indexOf(w);
|
|
794
|
+
while (자리 >= 0) {
|
|
795
|
+
if (자리 > i) 다음.push({ 글: p.글.slice(i, 자리), 맞음: false });
|
|
796
|
+
다음.push({ 글: p.글.slice(자리, 자리 + w.length), 맞음: true });
|
|
797
|
+
i = 자리 + w.length;
|
|
798
|
+
자리 = 낮은.indexOf(w, i);
|
|
799
|
+
}
|
|
800
|
+
if (i < p.글.length) 다음.push({ 글: p.글.slice(i), 맞음: false });
|
|
801
|
+
}
|
|
802
|
+
조각 = 다음;
|
|
803
|
+
}
|
|
804
|
+
return 조각.map((p) => (p.맞음 ? c.hyellow(p.글) : c.gray(p.글))).join('');
|
|
805
|
+
}
|
|
806
|
+
|
|
509
807
|
// 추론 강도는 값 하나가 아니라 '단계별 배분' 이다. 그 배분을 눈에 보이게 그린다.
|
|
510
|
-
|
|
808
|
+
/**
|
|
809
|
+
* 지금 추론 강도가 어떻게 되어 있는지.
|
|
810
|
+
*
|
|
811
|
+
* 기본은 **한 줄**이다. 사람이 알고 싶은 것은 '지금 얼마나 생각하나' 이지
|
|
812
|
+
* 단계별 표가 아니다. 표는 /think 자세히 로 뺐다.
|
|
813
|
+
*
|
|
814
|
+
* 쉬움 수준에서는 배분 이야기를 아예 안 꺼낸다 — 고를 일이 없는 사람에게
|
|
815
|
+
* 고르는 법을 보여 주면 그것부터 걱정하게 된다.
|
|
816
|
+
*/
|
|
817
|
+
function showThink(session, { 자세히 = false } = {}) {
|
|
511
818
|
const b = session.breakdown();
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
819
|
+
const 아는상한 = session.conn.maxTokens ?? session.conn.maxOut ?? null;
|
|
820
|
+
const t = effortTable(session.think, session.effort, { ctx: b.total, used: b.used, max: 아는상한 });
|
|
821
|
+
const 개발자 = session.level === '개발자';
|
|
822
|
+
const 단계 = t.rows.map((r) => `${r.label} ${r.level}`).join(c.gray(' · '));
|
|
823
|
+
|
|
517
824
|
say('');
|
|
518
|
-
say(` ${c.gray(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
825
|
+
say(` ${c.gray('추론 강도')} ${c.bold(session.think)} ${c.gray(`(${단계})`)}`);
|
|
826
|
+
if (개발자 || 자세히) say(` ${c.gray('배분')} ${c.bold(t.name)} ${c.gray(t.desc)}`);
|
|
827
|
+
|
|
828
|
+
if (자세히) {
|
|
829
|
+
say('');
|
|
830
|
+
say(` ${c.gray(pad('단계', 12) + pad('강도', 10) + pad('출력상한', 10) + '언제')}`);
|
|
831
|
+
for (const r of t.rows) {
|
|
832
|
+
const 화살 = r.moved > 0 ? c.yellow('↑') : r.moved < 0 ? c.cyan('↓') : c.gray('·');
|
|
833
|
+
say(` ${pad(r.label, 12)}${화살} ${pad(r.level, 8)}${pad(r.cap.toLocaleString(), 10, 'right')} ${c.gray(r.why)}`);
|
|
834
|
+
}
|
|
835
|
+
say('');
|
|
836
|
+
// 상한이 어디서 왔는지 밝힌다. 세 줄이 같은 값일 때 그게 고장인지 아닌지
|
|
837
|
+
// 이 한 줄로 갈린다 — 아는 상한이 낮으면 셋이 같아지는 것이 맞다.
|
|
838
|
+
say(` ${c.gray('출력 상한은')} ${c.white((아는상한 ?? 16384).toLocaleString())} ${c.gray(
|
|
839
|
+
session.conn.maxTokens ? '(직접 정하신 값)' : session.conn.maxOut ? '(서버에서 알아낸 값)' : '(모르는 값이라 기본값)',
|
|
840
|
+
)}${c.gray(' 안에서 나눕니다 —')} ${c.cyan('/out')}`);
|
|
841
|
+
say(` ${c.gray('컨텍스트')} ${c.white(t.ctx.toLocaleString())}${c.gray(' · 지금 찬 양 ')}${c.white(t.used.toLocaleString())}`);
|
|
842
|
+
say('');
|
|
843
|
+
say(` ${c.gray('강도')} ${THINK_LEVELS.join(' · ')} ${c.gray('예')} ${c.cyan('/think high')}`);
|
|
844
|
+
say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('예')} ${c.cyan('/think 배분 절약')}`);
|
|
845
|
+
} else if (개발자) {
|
|
846
|
+
say(` ${c.gray('강도')} ${c.cyan('/think high')} ${c.gray('배분')} ${c.cyan('/think 배분 절약')} ${c.gray('자세히')} ${c.cyan('/think 자세히')}`);
|
|
847
|
+
} else {
|
|
848
|
+
say(` ${c.gray('더 세게')} ${c.cyan('/think high')} ${c.gray('더 빠르게')} ${c.cyan('/think low')}`);
|
|
522
849
|
}
|
|
523
|
-
|
|
524
|
-
say(` ${c.gray('강도')} ${THINK_LEVELS.join(' · ')} ${c.gray('예')} ${c.cyan('/think high')}`);
|
|
525
|
-
say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('예')} ${c.cyan('/think save')}`);
|
|
850
|
+
|
|
526
851
|
if (!session.conn.think && session.think !== 'off') {
|
|
527
|
-
say('');
|
|
528
|
-
say(` ${c.yellow('이 연결은 모델 층 강도 조절이 안 먹습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
|
|
852
|
+
say(` ${c.yellow('이 연결은 모델 층 강도 조절이 적용되지 않습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
|
|
529
853
|
}
|
|
530
854
|
say('');
|
|
531
855
|
}
|
|
@@ -550,35 +874,486 @@ function showContext(session) {
|
|
|
550
874
|
say('');
|
|
551
875
|
}
|
|
552
876
|
|
|
553
|
-
|
|
877
|
+
/**
|
|
878
|
+
* 이번 대화에서 무엇이 바뀌었는지 보여 준다.
|
|
879
|
+
*
|
|
880
|
+
* /diff 바뀐 파일 목록과 늘고 준 줄 수
|
|
881
|
+
* /diff src/a.js 그 파일이 처음과 지금 사이에 어떻게 달라졌는지
|
|
882
|
+
*
|
|
883
|
+
* 파일 하나를 볼 때는 이번 대화의 '맨 처음' 모습과 견준다. 세 번 고쳤어도
|
|
884
|
+
* 사람이 알고 싶은 것은 '내가 시키기 전과 지금이 뭐가 다른가' 이지
|
|
885
|
+
* 마지막 한 번이 아니다. 되돌리기 이력이 그 맨 처음 모습을 들고 있다.
|
|
886
|
+
*/
|
|
887
|
+
function 바뀐것보기(session, ctx, arg = '') {
|
|
888
|
+
const 말 = String(arg ?? '').trim();
|
|
889
|
+
say('');
|
|
890
|
+
|
|
891
|
+
if (!말) {
|
|
892
|
+
const 목록 = [...session.changes.entries()];
|
|
893
|
+
if (!목록.length) {
|
|
894
|
+
say(` ${c.gray('이번 대화에서 바뀐 파일이 없습니다.')}`);
|
|
895
|
+
say(` ${c.gray('파일을 고치고 나면 여기에 무엇이 얼마나 바뀌었는지 모입니다.')}`);
|
|
896
|
+
say('');
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
rule('이번 대화에서 바뀐 파일', 70);
|
|
900
|
+
let a = 0;
|
|
901
|
+
let r = 0;
|
|
902
|
+
for (const [p, v] of 목록) {
|
|
903
|
+
a += v.added;
|
|
904
|
+
r += v.removed;
|
|
905
|
+
const 몇번 = v.times > 1 ? c.gray(` ${v.times}번`) : '';
|
|
906
|
+
say(` ${pad(clip(ctx.scope.show(p), 46), 46)} ${c.hgreen(pad(`+${v.added}`, 6, 'right'))} ${c.hred(pad(`−${v.removed}`, 6, 'right'))}${몇번}`);
|
|
907
|
+
}
|
|
908
|
+
say(` ${c.gray('─'.repeat(60))}`);
|
|
909
|
+
say(` ${pad(`${목록.length}개 파일`, 46)} ${c.hgreen(pad(`+${a}`, 6, 'right'))} ${c.hred(pad(`−${r}`, 6, 'right'))}`);
|
|
910
|
+
say('');
|
|
911
|
+
say(` ${c.gray('한 파일을 자세히 보려면')} ${c.cyan('/diff <파일>')}${c.gray(', 되돌리려면')} ${c.cyan('/undo')}`);
|
|
912
|
+
say('');
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let abs;
|
|
917
|
+
try { abs = ctx.scope.resolve(말); }
|
|
918
|
+
catch (err) { say(` ${mark.warn} ${err.message}`); say(''); return; }
|
|
919
|
+
|
|
920
|
+
// 이번 대화에서 이 파일을 처음 건드리기 직전의 모습.
|
|
921
|
+
const 처음 = ctx.history.all().find((x) => x.path === abs);
|
|
922
|
+
if (!처음) {
|
|
923
|
+
say(` ${c.gray('이번 대화에서 안 바꾼 파일입니다:')} ${ctx.scope.show(abs)}`);
|
|
924
|
+
say(` ${c.gray('바뀐 것들을 보려면')} ${c.cyan('/diff')}`);
|
|
925
|
+
say('');
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const 옛것 = 처음.before === null ? null
|
|
930
|
+
: (처음.enc === 'b64' ? Buffer.from(처음.before, 'base64').toString('utf8') : 처음.before);
|
|
931
|
+
let 지금 = null;
|
|
932
|
+
if (existsSync(abs)) {
|
|
933
|
+
try { 지금 = readTextFull(abs).text; }
|
|
934
|
+
catch (err) { say(` ${mark.warn} 지금 내용을 못 읽습니다: ${err.message}`); say(''); return; }
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const d = diffLines(옛것, 지금);
|
|
938
|
+
rule(ctx.scope.show(abs), 70);
|
|
939
|
+
if (!d.changed) {
|
|
940
|
+
say(` ${c.gray('고쳤다가 되돌아와서, 처음과 지금이 같습니다.')}`);
|
|
941
|
+
say('');
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (d.isNew) say(` ${c.gray('이번 대화에서 새로 만든 파일입니다.')}`);
|
|
945
|
+
if (d.isGone) say(` ${c.gray('이번 대화에서 없어진 파일입니다.')}`);
|
|
946
|
+
say(` ${shortStat(d)}`);
|
|
947
|
+
say('');
|
|
948
|
+
for (const l of renderDiff(d, { maxLines: session.level === '개발자' ? 200 : 60 })) say(l);
|
|
949
|
+
say('');
|
|
950
|
+
say(` ${c.gray('되돌리려면')} ${c.cyan('/undo')}`);
|
|
951
|
+
say('');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* 한 번에 받을 답 길이 상한.
|
|
956
|
+
*
|
|
957
|
+
* /out 지금 값과 어디서 나온 값인지
|
|
958
|
+
* /out 32k 직접 지정
|
|
959
|
+
* /out auto 모른다고 두고 안전한 기본값으로 (16,384)
|
|
960
|
+
*
|
|
961
|
+
* 왜 따로 있나:
|
|
962
|
+
* 컨텍스트(/ctx)와 다른 축이다. 컨텍스트가 655k 여도 '한 번에 뱉을 수 있는 답'
|
|
963
|
+
* 은 대개 훨씬 작다. 그 두 값이 하나인 줄 알면 큰 파일이 왜 안 만들어지는지
|
|
964
|
+
* 영영 알 수 없다 — 컨텍스트는 넉넉한데 답이 잘리기 때문이다.
|
|
965
|
+
*
|
|
966
|
+
* 전에는 이 기능이 /ctx out 안에 숨어 있었고, 게다가 **먹지도 않았다**
|
|
967
|
+
* (effort.js 의 클램프가 다시 조였다). 있는데 안 먹는 것이 가장 나쁘다 —
|
|
968
|
+
* 문서에 적혀 있으니 사람이 그걸 믿고 쓴다.
|
|
969
|
+
*/
|
|
970
|
+
async function 출력상한(session, arg = '') {
|
|
971
|
+
const { parseSize } = await import('./backend/ctxsize.js');
|
|
972
|
+
const 말 = String(arg ?? '').trim().toLowerCase();
|
|
554
973
|
const cfg = load();
|
|
555
|
-
|
|
556
|
-
|
|
974
|
+
const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
|
|
975
|
+
const 알아낸것 = session.conn.maxOut ?? null;
|
|
976
|
+
|
|
977
|
+
if (말 === 'auto' || 말 === '자동') {
|
|
978
|
+
session.conn.maxTokens = null;
|
|
979
|
+
if (prof) { delete prof.maxTokens; try { save(cfg); } catch {} }
|
|
980
|
+
say(` ${mark.ok} 직접 정한 값을 지웠습니다.`);
|
|
981
|
+
say(` ${c.gray(알아낸것 ? `서버에서 알아낸 ${알아낸것.toLocaleString()} 토큰을 씁니다.` : '모르는 값이라 16,384 토큰으로 갑니다.')}`);
|
|
557
982
|
say('');
|
|
558
983
|
return;
|
|
559
984
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
985
|
+
|
|
986
|
+
if (말) {
|
|
987
|
+
const 값 = parseSize(말);
|
|
988
|
+
if (!값) {
|
|
989
|
+
say(` ${mark.no} 숫자를 못 읽었습니다: ${c.white(arg)}`);
|
|
990
|
+
say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/out 32k')} ${c.cyan('/out 65536')} ${c.cyan('/out auto')}`);
|
|
991
|
+
say('');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
session.conn.maxTokens = 값;
|
|
995
|
+
if (prof) { prof.maxTokens = 값; try { save(cfg); } catch {} }
|
|
996
|
+
say(` ${mark.ok} 답 길이 상한 ${c.bold(값.toLocaleString())} 토큰`);
|
|
997
|
+
say(` ${c.gray('모델이 못 내는 값을 넣으면 서버가 거절합니다. 거절당하면')} ${c.cyan('/out auto')} ${c.gray('로 되돌리세요.')}`);
|
|
998
|
+
say('');
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const 쓰는값 = session.conn.maxTokens ?? 알아낸것 ?? 16384;
|
|
1003
|
+
const 어디서 = session.conn.maxTokens ? '직접 정하신 값'
|
|
1004
|
+
: 알아낸것 ? '서버에서 알아낸 값'
|
|
1005
|
+
: '모르는 값이라 안전한 기본값';
|
|
1006
|
+
say('');
|
|
1007
|
+
rule('한 번에 받을 답 길이', 70);
|
|
1008
|
+
say(` ${c.gray('지금 상한')} ${c.white(쓰는값.toLocaleString())} ${c.gray(`토큰 — ${어디서}`)}`);
|
|
1009
|
+
say(` ${c.gray('컨텍스트')} ${c.white((session.conn.ctx ?? 0).toLocaleString())} ${c.gray('토큰')} ${c.gray('(다른 축입니다 — /ctx)')}`);
|
|
1010
|
+
say('');
|
|
1011
|
+
say(` ${c.gray('이 값이 한 번에 만들 수 있는 파일 크기를 정합니다.')}`);
|
|
1012
|
+
say(` ${c.gray('1,000줄짜리 HTML 이 대략 12,000~18,000 토큰입니다.')}`);
|
|
1013
|
+
say(` ${c.gray('여기서 잘려도 받은 데까지는 파일에 쓰고 이어 붙입니다 — 다만 몇 번 더 오갑니다.')}`);
|
|
1014
|
+
say('');
|
|
1015
|
+
say(` ${c.cyan('/out 32k')} ${c.gray('직접 지정 (k 는 1024)')}`);
|
|
1016
|
+
say(` ${c.cyan('/out auto')} ${c.gray('직접 정한 값을 지우고 알아낸 값/기본값으로')}`);
|
|
1017
|
+
say('');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* 컨텍스트 길이를 보고·다시 재고·직접 지정한다.
|
|
1022
|
+
*
|
|
1023
|
+
* /ctx 지금 값과 어디서 나온 값인지
|
|
1024
|
+
* /ctx auto 서버에 다시 물어 모델에 맞춘다
|
|
1025
|
+
* /ctx 655360 직접 지정 (640k · 128k · 1m 도 받는다 — k 는 1024)
|
|
1026
|
+
*
|
|
1027
|
+
* 왜 필요한가: 이 숫자 하나가 프로그램 전체 크기를 정한다. 서버가 안 알려주면
|
|
1028
|
+
* 32,768 로 깔고 앉는데, 요즘 로컬 모델은 262,144 · 655,360 이 흔하다.
|
|
1029
|
+
* 그 상태로 쓰면 모델이 가진 것의 5% 만 쓰는 셈이다.
|
|
1030
|
+
*
|
|
1031
|
+
* 고른 값은 프로필에 남긴다 — 다음에 켤 때도 그대로여야 한다.
|
|
1032
|
+
*/
|
|
1033
|
+
async function ctxLength(session, arg = '') {
|
|
1034
|
+
const { probeCtx, parseSize, fmtSize } = await import('./backend/ctxsize.js');
|
|
1035
|
+
const 말 = String(arg ?? '').trim().toLowerCase();
|
|
1036
|
+
const 지금 = session.conn.ctx ?? 0;
|
|
1037
|
+
|
|
1038
|
+
const 남기기 = (값, 어디서) => {
|
|
1039
|
+
session.conn.ctx = 값;
|
|
1040
|
+
const cfg = load();
|
|
1041
|
+
const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
|
|
1042
|
+
if (prof) { prof.ctx = 값; save(cfg); }
|
|
1043
|
+
const b = session.breakdown();
|
|
1044
|
+
say(` ${mark.ok} 컨텍스트 ${c.bold(값.toLocaleString())} 토큰 ${c.gray(`(${fmtSize(값)}) — ${어디서}`)}`);
|
|
1045
|
+
say(` ${c.gray('지금 찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음')} ${c.white(b.left.toLocaleString())}`);
|
|
1046
|
+
if (prof) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
|
|
1047
|
+
say('');
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// 0) 답 길이 상한 — 이제 /out 이 본자리다. 여기서는 그리로 넘긴다.
|
|
1051
|
+
// \b 가 아니라 공백·줄 끝으로 끊는다. 한글은 \w 가 아니라서
|
|
1052
|
+
// `/^답\b/` 는 '답 32k' 에도 '답' 에도 안 맞는다(위 '배분' 과 같은 함정).
|
|
1053
|
+
if (/^(out|답|출력)(\s|$)/.test(말)) return await 출력상한(session, 말.replace(/^(out|답|출력)\s*/, ''));
|
|
1054
|
+
|
|
1055
|
+
// 1) 직접 지정
|
|
1056
|
+
if (말 && !['auto', '자동', '다시', '자세히', 'detail', '-v'].includes(말)) {
|
|
1057
|
+
const 값 = parseSize(말);
|
|
1058
|
+
if (!값) {
|
|
1059
|
+
say(` ${mark.no} 숫자를 못 읽었습니다: ${c.white(arg)}`);
|
|
1060
|
+
say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/ctx 655360')} ${c.cyan('/ctx 640k')} ${c.cyan('/ctx 128k')} ${c.cyan('/ctx auto')}`);
|
|
1061
|
+
say(` ${c.gray('k 는 1024 입니다. 655,360 은 640k 이지 655k 가 아닙니다 — 헷갈리면 그냥 숫자로 쓰세요.')}`);
|
|
1062
|
+
say('');
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
남기기(값, '직접 지정');
|
|
1066
|
+
say(` ${c.gray('서버가 실제로 올려 둔 길이보다 크게 잡으면 긴 대화에서 거절당합니다.')}`);
|
|
1067
|
+
say(` ${c.gray('서버 쪽에서 올린 다음 맞추는 게 안전합니다 —')} ${c.cyan('/ctx auto')} ${c.gray('로 다시 잽니다.')}`);
|
|
1068
|
+
say('');
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// 2) 다시 재기 (auto · 다시 · 자세히)
|
|
1073
|
+
if (말) {
|
|
1074
|
+
const 자세히 = /자세히|detail|-v/.test(말);
|
|
1075
|
+
const s = spin('모델에 걸린 길이를 서버에 묻는 중…');
|
|
1076
|
+
let r;
|
|
1077
|
+
try { r = await probeCtx(session.conn); }
|
|
1078
|
+
catch (err) { s.stop(` ${mark.no} 못 물어봤습니다 — ${c.gray(String(err?.message ?? err))}`); say(''); return; }
|
|
1079
|
+
s.stop('');
|
|
1080
|
+
|
|
1081
|
+
// 어디를 두드렸고 무엇이 나왔는지. 값이 이상할 때 사람이 원인을 짚을 수 있어야 한다.
|
|
1082
|
+
if (자세히) {
|
|
1083
|
+
say('');
|
|
1084
|
+
say(` ${c.gray('두드린 자리')}`);
|
|
1085
|
+
for (const t of r.tried) {
|
|
1086
|
+
const 표 = t.ok ? c.green('응답함') : c.gray(`${t.status || '연결 실패'}`);
|
|
1087
|
+
say(` ${c.gray(pad(t.label, 20))} ${표} ${c.gray(clip(t.url, 60))}`);
|
|
1088
|
+
}
|
|
1089
|
+
say('');
|
|
1090
|
+
say(` ${c.gray('읽어 낸 값')}`);
|
|
1091
|
+
say(` ${c.gray(pad('모델 최대', 20))} ${r.max ? c.white(r.max.toLocaleString()) : c.gray('못 찾음')}`
|
|
1092
|
+
+ (r.maxKey ? c.gray(` ← ${r.maxKey}`) : ''));
|
|
1093
|
+
say(` ${c.gray(pad('지금 올린 길이', 20))} ${r.loaded ? c.white(r.loaded.toLocaleString()) : c.gray('못 찾음')}`
|
|
1094
|
+
+ (r.loadedKey ? c.gray(` ← ${r.loadedKey}`) : ''));
|
|
1095
|
+
say(` ${c.gray(pad('답 길이 상한', 20))} ${r.out ? c.white(r.out.toLocaleString()) : c.gray('못 찾음')}`
|
|
1096
|
+
+ (r.outSource ? c.gray(` ← ${r.outSource}`) : ''));
|
|
1097
|
+
say('');
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// 답 길이 상한도 같이 알아냈으면 받아 둔다. 사람이 정한 값은 안 덮는다.
|
|
1101
|
+
if (r.out) session.conn.maxOut = r.out;
|
|
1102
|
+
|
|
1103
|
+
if (!r.value) {
|
|
1104
|
+
// 못 알아낸 것을 아는 척하지 않는다. 조용히 32,768 로 깔고 앉으면
|
|
1105
|
+
// 655k 모델을 5% 만 쓰거나, 8k 서버에 128k 를 보내 조용히 잘린다.
|
|
1106
|
+
say(` ${mark.warn} 서버가 컨텍스트 길이를 안 알려줍니다. ${c.gray(r.why ?? '')}`);
|
|
1107
|
+
if (!자세히) for (const t of r.tried) say(` ${c.gray(pad(t.label, 20))} ${c.gray(t.ok ? '응답함(값 없음)' : `${t.status || '연결 실패'}`)}`);
|
|
1108
|
+
say(` ${c.gray(`지금은 ${(session.conn.ctx ?? 0).toLocaleString()} 으로 잡혀 있습니다 — 이건 알아낸 값이 아니라 기본값입니다.`)}`);
|
|
1109
|
+
say(` ${c.gray('직접 넣어 주세요 —')} ${c.cyan('/ctx 655360')} ${c.gray('어디를 두드렸는지 보려면')} ${c.cyan('/ctx 자세히')}`);
|
|
1110
|
+
say('');
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
남기기(r.value, `${r.source ?? '서버'}에서 읽음`);
|
|
1114
|
+
if (r.max && r.loaded && r.max > r.loaded) {
|
|
1115
|
+
say(` ${c.yellow('이 모델은')} ${c.bold(r.max.toLocaleString())} ${c.yellow('까지 되는데 지금')} ${c.bold(r.loaded.toLocaleString())} ${c.yellow('로 올려 두셨습니다.')}`);
|
|
1116
|
+
say(` ${c.gray('서버(LM Studio 등)에서 컨텍스트를 더 올려 다시 올린 뒤')} ${c.cyan('/ctx auto')} ${c.gray('를 하시면 그만큼 씁니다.')}`);
|
|
1117
|
+
say('');
|
|
1118
|
+
}
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// 3) 그냥 보기
|
|
1123
|
+
const b = session.breakdown();
|
|
1124
|
+
say('');
|
|
1125
|
+
rule('컨텍스트 길이', 70);
|
|
1126
|
+
say(` ${c.bold(session.conn.model)}`);
|
|
1127
|
+
say(` ${c.gray('지금 잡은 길이')} ${c.white(지금.toLocaleString())} ${c.gray('토큰 (' + fmtSize(지금) + ')')}`);
|
|
1128
|
+
say(` ${c.gray('찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음 ' + b.left.toLocaleString())}`);
|
|
1129
|
+
say('');
|
|
1130
|
+
say(` ${c.gray('이 값 하나가 프로그램 전체 크기를 정합니다 — 한 번에 읽힐 수 있는 파일 수,')}`);
|
|
1131
|
+
say(` ${c.gray('대화가 접히는 시점, 한 번에 쓸 수 있는 답 길이가 모두 여기서 나옵니다.')}`);
|
|
1132
|
+
say('');
|
|
1133
|
+
say(` ${c.cyan('/ctx auto')} ${c.gray('서버에 다시 물어 모델에 맞춥니다')}`);
|
|
1134
|
+
say(` ${c.cyan('/ctx 655360')} ${c.gray('직접 지정 (640k · 128k · 1m 도 됩니다 — k 는 1024)')}`);
|
|
1135
|
+
say(` ${c.cyan('/out 32k')} ${c.gray('한 번에 받을 답 길이 상한 (지금 ' + (session.conn.maxTokens ?? session.conn.maxOut ?? 16384).toLocaleString() + ') — 다른 축입니다')}`);
|
|
1136
|
+
say('');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** 지금 연결을 이 프로필로 갈아끼운다. 대화는 그대로 둔다. */
|
|
1140
|
+
function 연결적용(session, p) {
|
|
571
1141
|
Object.assign(session.conn, {
|
|
572
1142
|
kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
|
|
573
|
-
ctx: p.ctx,
|
|
1143
|
+
ctx: p.ctx, maxTokens: p.maxTokens ?? null,
|
|
1144
|
+
streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
|
|
574
1145
|
});
|
|
575
1146
|
// 자물쇠도 같이 옮긴다. 이걸 빼먹으면 옛 주소가 열린 채로 남고 새 주소는 막혀
|
|
576
1147
|
// 다음 한마디에서 바로 "허용되지 않은 주소" 가 난다.
|
|
577
1148
|
allowEndpoint(p.baseUrl);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* 지금 붙어 있는 서버가 내주는 모델 목록.
|
|
1153
|
+
*
|
|
1154
|
+
* 저장된 프로필에는 등록할 때 고른 모델 하나만 있다. 그런데 서버 한 대가
|
|
1155
|
+
* 모델을 여럿 내주는 경우가 대부분이다 — 특히 프록시나 게이트웨이가 그렇다.
|
|
1156
|
+
* 그래서 서버에 직접 물어본다. 자리를 새로 여는 게 아니라 이미 열린 자리다.
|
|
1157
|
+
*/
|
|
1158
|
+
async function 서버모델들(conn) {
|
|
1159
|
+
const { req, headersFor } = await import('./backend/http.js');
|
|
1160
|
+
if (conn.kind === 'ollama') {
|
|
1161
|
+
const r = await req(`${conn.base.replace(/\/v1\/?$/, '')}/api/tags`, { timeout: 4000 });
|
|
1162
|
+
return (r.json?.models ?? []).map((m) => m.name ?? m.model).filter(Boolean);
|
|
1163
|
+
}
|
|
1164
|
+
const r = await req(`${conn.base.replace(/\/$/, '')}/models`, {
|
|
1165
|
+
headers: headersFor(conn.auth ?? 'none', conn.key), timeout: 4000,
|
|
1166
|
+
});
|
|
1167
|
+
if (!r.ok) return null;
|
|
1168
|
+
const list = r.json?.data ?? r.json?.models ?? [];
|
|
1169
|
+
return Array.isArray(list)
|
|
1170
|
+
? list.map((m) => (typeof m === 'string' ? m : m.id ?? m.name ?? m.model)).filter(Boolean)
|
|
1171
|
+
: null;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* /model — 연결·모델 바꾸기.
|
|
1176
|
+
*
|
|
1177
|
+
* /model 골라 바꾸기 (연결 목록 + '이 서버의 다른 모델')
|
|
1178
|
+
* /model <이름> 바로 바꾸기. 연결 이름이든 모델 이름이든 일부만 쳐도 된다
|
|
1179
|
+
* /model list 무엇이 등록돼 있는지만 보기
|
|
1180
|
+
* /model models 지금 서버가 내주는 모델을 물어보기
|
|
1181
|
+
*/
|
|
1182
|
+
async function switchModel(session, ctx, arg = '') {
|
|
1183
|
+
const cfg = load();
|
|
1184
|
+
if (!cfg.profiles.length) {
|
|
1185
|
+
say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')}`);
|
|
1186
|
+
say('');
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const 말 = arg.trim();
|
|
1190
|
+
|
|
1191
|
+
if (말 === 'list' || 말 === '목록') return 연결목록(cfg, session);
|
|
1192
|
+
if (말 === 'models' || 말 === '모델') return await 서버모델고르기(session, ctx, cfg);
|
|
1193
|
+
|
|
1194
|
+
// 이름으로 바로 바꾸기 — 메뉴를 안 거친다.
|
|
1195
|
+
if (말) {
|
|
1196
|
+
const 찾은 = 이름으로찾기(cfg.profiles, 말);
|
|
1197
|
+
if (찾은.length === 1) return 골라적용(session, cfg, 찾은[0]);
|
|
1198
|
+
if (찾은.length > 1) {
|
|
1199
|
+
say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 것이 여럿입니다.')}`);
|
|
1200
|
+
for (const p of 찾은.slice(0, 12)) say(` ${c.cyan(p.name)} ${c.gray(p.model)}`);
|
|
1201
|
+
say('');
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
// 등록된 것에 없으면, 지금 서버가 내주는 모델 중에 있는지 본다.
|
|
1205
|
+
const 있는것 = await 서버모델들(session.conn);
|
|
1206
|
+
const 맞는것 = (있는것 ?? []).filter((m) => m.toLowerCase().includes(말.toLowerCase()));
|
|
1207
|
+
if (맞는것.length === 1) return await 모델만바꾸기(session, cfg, 맞는것[0]);
|
|
1208
|
+
if (맞는것.length > 1) {
|
|
1209
|
+
say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 모델이 여럿입니다.')}`);
|
|
1210
|
+
for (const m of 맞는것.slice(0, 12)) say(` ${c.cyan(m)}`);
|
|
1211
|
+
say('');
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결도 모델도 없습니다.')}`);
|
|
1215
|
+
say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/model list')}${c.gray(', 서버에 물어보려면')} ${c.cyan('/model models')}`);
|
|
1216
|
+
say('');
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// 인자 없이 — 골라 바꾸기. 물어볼 수 없는 자리면 목록만 보여준다.
|
|
1221
|
+
if (!ctx?.ask) return 연결목록(cfg, session);
|
|
1222
|
+
|
|
1223
|
+
const items = cfg.profiles.map((p) => ({
|
|
1224
|
+
label: `${pad(p.name, 22)} ${c.gray(p.model)}`,
|
|
1225
|
+
note: p.id === cfg.active ? '지금' : '',
|
|
1226
|
+
}));
|
|
1227
|
+
items.push({ label: c.cyan('이 서버의 다른 모델 고르기'), note: '서버에 물어봅니다' });
|
|
1228
|
+
|
|
1229
|
+
const i = await pick('연결·모델 고르기', items, {
|
|
1230
|
+
def: Math.max(0, cfg.profiles.findIndex((p) => p.id === cfg.active)),
|
|
1231
|
+
ask: ctx?.ask,
|
|
1232
|
+
});
|
|
1233
|
+
if (i === cfg.profiles.length) return await 서버모델고르기(session, ctx, cfg);
|
|
1234
|
+
return 골라적용(session, cfg, cfg.profiles[i]);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function 이름으로찾기(profiles, 말) {
|
|
1238
|
+
const q = 말.toLowerCase();
|
|
1239
|
+
const 정확 = profiles.filter((p) => p.name.toLowerCase() === q || p.model.toLowerCase() === q || p.id.toLowerCase() === q);
|
|
1240
|
+
if (정확.length) return 정확;
|
|
1241
|
+
return profiles.filter((p) => `${p.name} ${p.model} ${p.id}`.toLowerCase().includes(q));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function 골라적용(session, cfg, p) {
|
|
1245
|
+
cfg.active = p.id;
|
|
1246
|
+
try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ }
|
|
1247
|
+
연결적용(session, p);
|
|
578
1248
|
say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
|
|
579
1249
|
say('');
|
|
580
1250
|
}
|
|
581
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* 같은 서버에서 모델만 바꾼다.
|
|
1254
|
+
*
|
|
1255
|
+
* 등록된 연결이 아니어도 된다 — 서버가 내준다면 쓸 수 있어야 한다.
|
|
1256
|
+
* 다음에도 쓰도록 프로필로 남겨 둔다. 그래야 /model 목록에서 다시 보인다.
|
|
1257
|
+
*/
|
|
1258
|
+
async function 모델만바꾸기(session, cfg, 모델) {
|
|
1259
|
+
const 지금 = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
|
|
1260
|
+
const 이미 = cfg.profiles.find((p) => p.baseUrl === session.conn.base && p.model === 모델);
|
|
1261
|
+
const p = 이미 ?? {
|
|
1262
|
+
...지금,
|
|
1263
|
+
id: `${(지금?.id ?? 'conn').replace(/-[^-]*$/, '')}-${String(모델).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase(),
|
|
1264
|
+
name: `${(지금?.name ?? '연결').split(' · ')[0]} · ${모델}`,
|
|
1265
|
+
baseUrl: session.conn.base,
|
|
1266
|
+
model: 모델,
|
|
1267
|
+
// 컨텍스트 길이는 물려받지 않는다. 모델마다 다르다 —
|
|
1268
|
+
// 32k 짜리에서 655k 짜리로 옮겼는데 32k 로 깔고 앉으면 새 모델의 5% 만 쓴다.
|
|
1269
|
+
// 반대로 큰 데서 작은 데로 옮기면 긴 대화에서 서버가 거절한다. 아래에서 다시 잰다.
|
|
1270
|
+
ctx: null,
|
|
1271
|
+
};
|
|
1272
|
+
if (!이미) { try { save(upsert(cfg, p)); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ } }
|
|
1273
|
+
else { cfg.active = p.id; try { save(cfg); } catch {} }
|
|
1274
|
+
연결적용(session, p);
|
|
1275
|
+
say(` ${mark.ok} 모델을 ${c.bold(모델)} 로 바꿨습니다. ${c.gray('서버는 그대로입니다.')}`);
|
|
1276
|
+
await 길이맞추기(session, cfg, p);
|
|
1277
|
+
say('');
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* 바뀐 모델에 맞춰 컨텍스트 길이를 다시 잰다.
|
|
1282
|
+
*
|
|
1283
|
+
* 모델을 바꾸는 순간은 이미 서버와 이야기하는 중이라 한 번 더 물어봐도 티가 안 난다.
|
|
1284
|
+
* 여기서 안 재면 새 모델을 옛 모델의 길이로 쓰게 된다 — 화면에는 아무 표시도 안 나고
|
|
1285
|
+
* 그냥 조용히 작아진다. 그런 고장이 가장 늦게 발견된다.
|
|
1286
|
+
*/
|
|
1287
|
+
async function 길이맞추기(session, cfg, prof) {
|
|
1288
|
+
const { probeCtx, fmtSize, 기본값 } = await import('./backend/ctxsize.js');
|
|
1289
|
+
let r = null;
|
|
1290
|
+
try { r = await probeCtx(session.conn, { timeout: 8000 }); } catch { /* 못 물어보면 아래에서 기본값 */ }
|
|
1291
|
+
const 값 = r?.value ?? prof.ctx ?? 기본값;
|
|
1292
|
+
session.conn.ctx = 값;
|
|
1293
|
+
if (prof) {
|
|
1294
|
+
prof.ctx = 값;
|
|
1295
|
+
try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
|
|
1296
|
+
}
|
|
1297
|
+
say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + (r?.source ? r.source + '에서 읽음' : '서버가 안 알려줘 기본값'))}`);
|
|
1298
|
+
if (r?.max && r?.loaded && r.max > r.loaded) {
|
|
1299
|
+
say(` ${c.yellow('이 모델은 ' + r.max.toLocaleString() + ' 까지 됩니다.')} ${c.gray('서버에서 더 올린 뒤')} ${c.cyan('/ctx auto')}`);
|
|
1300
|
+
} else if (!r?.value) {
|
|
1301
|
+
say(` ${c.gray('맞지 않으면')} ${c.cyan('/ctx 655360')} ${c.gray('처럼 직접 지정하세요.')}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function 연결목록(cfg, session) {
|
|
1306
|
+
rule('등록된 연결', 70);
|
|
1307
|
+
for (const p of cfg.profiles) {
|
|
1308
|
+
const 지금 = p.id === cfg.active;
|
|
1309
|
+
say(` ${지금 ? c.hgreen('●') : c.gray('·')} ${지금 ? c.bold(c.white(pad(p.name, 26))) : pad(p.name, 26)}${c.gray(p.model)}`);
|
|
1310
|
+
say(` ${c.gray(p.baseUrl)}`);
|
|
1311
|
+
}
|
|
1312
|
+
say('');
|
|
1313
|
+
say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}${c.gray(' — 연결 이름이든 모델 이름이든 됩니다.')}`);
|
|
1314
|
+
say(` ${c.gray('이 서버가 내주는 다른 모델을 보려면')} ${c.cyan('/model models')}`);
|
|
1315
|
+
say('');
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
async function 서버모델고르기(session, ctx, cfg) {
|
|
1319
|
+
const s = spin('서버에 모델 목록을 물어보는 중…');
|
|
1320
|
+
let 있는것;
|
|
1321
|
+
try { 있는것 = await 서버모델들(session.conn); } catch { 있는것 = null; }
|
|
1322
|
+
s.stop('');
|
|
1323
|
+
if (!있는것 || !있는것.length) {
|
|
1324
|
+
say(` ${mark.warn} 이 서버는 모델 목록을 내주지 않습니다.`);
|
|
1325
|
+
say(` ${c.gray('목록이 없는 게이트웨이도 있습니다. 그때는')} ${c.cyan('deel setup')} ${c.gray('에서 모델 이름을 직접 넣으세요.')}`);
|
|
1326
|
+
say('');
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
// 물어볼 수 없는 자리면 목록만 보여주고 끝낸다.
|
|
1330
|
+
//
|
|
1331
|
+
// 여기서 그냥 pick 을 부르면 표준입력을 붙잡고 영영 안 끝난다.
|
|
1332
|
+
// 파이프로 넣거나 검사에서 돌릴 때가 그렇다 — 멈춘 것처럼 보이고 끊는 수밖에 없다.
|
|
1333
|
+
if (!ctx?.ask) {
|
|
1334
|
+
rule(`이 서버의 모델 (${있는것.length}개)`, 70);
|
|
1335
|
+
for (const m of 있는것) say(` ${m === session.conn.model ? c.hgreen('●') : c.gray('·')} ${m === session.conn.model ? c.bold(m) : m}`);
|
|
1336
|
+
say('');
|
|
1337
|
+
say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}`);
|
|
1338
|
+
say('');
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const i = await pick(`이 서버의 모델 (${있는것.length}개)`, 있는것.map((m) => ({
|
|
1343
|
+
label: m, note: m === session.conn.model ? '지금' : '',
|
|
1344
|
+
})), {
|
|
1345
|
+
def: Math.max(0, 있는것.indexOf(session.conn.model)),
|
|
1346
|
+
ask: ctx.ask,
|
|
1347
|
+
});
|
|
1348
|
+
const 고른것 = 있는것[i];
|
|
1349
|
+
if (고른것 === session.conn.model) {
|
|
1350
|
+
say(` ${c.gray('그대로 둡니다.')}`);
|
|
1351
|
+
say('');
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
await 모델만바꾸기(session, cfg, 고른것);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
582
1357
|
const INIT_TEMPLATE = `# DEEL.md
|
|
583
1358
|
|
|
584
1359
|
이 폴더에서 일할 때 지킬 규칙을 적습니다. deel 가 매번 읽습니다.
|
|
@@ -604,7 +1379,7 @@ function showWork(session) {
|
|
|
604
1379
|
rule('작업 모드', 70);
|
|
605
1380
|
for (const k of WORK_ORDER) {
|
|
606
1381
|
const w = WORK_MODES[k];
|
|
607
|
-
const 지금 = k === (normWork(session.work) ??
|
|
1382
|
+
const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
|
|
608
1383
|
const 표 = 지금 ? c.hgreen('●') : c.gray('·');
|
|
609
1384
|
const 이름 = 지금 ? c.bold(c.white(pad(w.name, 8))) : c.gray(pad(w.name, 8));
|
|
610
1385
|
say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(w.hint)}`);
|