deel-local-cli 1.2.0 → 1.4.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 +481 -12
- package/README.md +460 -12
- package/bin/deel.js +73 -4
- package/package.json +7 -3
- package/src/acp/jsonrpc.js +230 -0
- package/src/acp/map.js +219 -0
- package/src/acp/serve.js +556 -0
- package/src/agent/card.js +110 -0
- package/src/agent/compact.js +10 -2
- package/src/agent/evidence.js +186 -0
- package/src/agent/grade.js +20 -0
- package/src/agent/loop.js +134 -13
- package/src/agent/models.js +169 -0
- package/src/agent/modes.js +47 -1
- package/src/agent/pins.js +140 -0
- package/src/agent/project.js +10 -4
- package/src/agent/session.js +214 -10
- package/src/agent/store.js +31 -0
- package/src/commands.js +586 -52
- package/src/i18n/en.js +244 -0
- package/src/i18n/index.js +126 -0
- package/src/i18n/ko.js +231 -0
- package/src/lsp/client.js +459 -0
- package/src/lsp/diag.js +112 -0
- package/src/lsp/rpc.js +84 -0
- package/src/lsp/servers.js +218 -0
- package/src/oneshot.js +19 -0
- package/src/pack/sbom.js +218 -0
- package/src/pack/selfpack.js +19 -3
- package/src/repl.js +193 -8
- package/src/safety/secrets.js +205 -0
- package/src/safety/undo.js +10 -3
- package/src/tools/desc.en.js +219 -0
- package/src/tools/index.js +148 -5
- package/src/tools/lsp.js +327 -0
- package/src/tools/task.js +30 -2
- package/src/ui/ansi.js +36 -0
- package/src/ui/approve.js +25 -21
- package/src/ui/inputbox.js +37 -7
- package/src/ui/intro.js +174 -0
- package/src/ui/level.js +11 -5
- package/src/ui/notify.js +101 -0
- package/src/ui/status.js +177 -34
package/src/agent/compact.js
CHANGED
|
@@ -84,6 +84,12 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN }
|
|
|
84
84
|
|
|
85
85
|
const 접을것 = 자리.slice(0, Math.max(0, 자리.length - keep)).filter((x) => x.글.length >= min);
|
|
86
86
|
let 아낀토큰 = 0;
|
|
87
|
+
// 무엇을 버렸는지 이름으로 남긴다.
|
|
88
|
+
//
|
|
89
|
+
// 조용히 버리면 사람이 모른다 — "아까 그 파일 내용 어디 갔지" 를 스스로 답할
|
|
90
|
+
// 길이 없다. 접힌 자리에 표시가 남긴 하지만 그건 모델이 보는 쪽이고,
|
|
91
|
+
// 사람 화면에는 여기서 돌려주는 목록으로 알려 준다.
|
|
92
|
+
const 접은것들 = [];
|
|
87
93
|
|
|
88
94
|
for (const { i, 글 } of 접을것) {
|
|
89
95
|
const m = ms[i];
|
|
@@ -96,10 +102,12 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN }
|
|
|
96
102
|
content: `${접힘표} ${아는것.name}${곳 ? `(${곳})` : ''} — ${줄수}줄. `
|
|
97
103
|
+ '자리를 비우려고 내용을 접었습니다. 필요하면 다시 읽으세요.',
|
|
98
104
|
};
|
|
99
|
-
|
|
105
|
+
const 아낀것 = 전 - estimateTokens(ms[i].content);
|
|
106
|
+
아낀토큰 += 아낀것;
|
|
107
|
+
접은것들.push({ 도구: 아는것.name, 곳, 줄수, 토큰: Math.max(0, 아낀것) });
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰) };
|
|
110
|
+
return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들 };
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
const 요약지시 = `지금까지의 대화를 다음 형식으로 요약하세요. 이어서 일할 사람이 이것만 보고도 계속할 수 있어야 합니다.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// 증거 — 「다 됐습니다」 대신 검토할 수 있는 것을 내놓는다.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 만드나 ───────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 2026년 조사로는 개발자의 **96%가 AI 가 쓴 코드를 온전히 믿지 않는데, 매번
|
|
6
|
+
// 확인하는 사람은 48%** 다. 38%는 "사람 코드보다 리뷰가 더 힘들다"고 답한다.
|
|
7
|
+
//
|
|
8
|
+
// 왜 더 힘든가. 사람이 낸 코드는 "왜 이렇게 했나" 를 물으면 답이 온다. 에이전트가
|
|
9
|
+
// 낸 코드는 **"끝났습니다" 한 줄**과 함께 온다. 그 한 줄은 검토할 수가 없다.
|
|
10
|
+
// 무엇을 근거로 끝났다고 하는지가 그 안에 없기 때문이다.
|
|
11
|
+
//
|
|
12
|
+
// 그래서 이 파일이 하는 일은 하나다 — **말을 증거로 바꾼다.**
|
|
13
|
+
//
|
|
14
|
+
// 무엇을 바꿨나 파일과 줄 수
|
|
15
|
+
// 무엇을 돌렸나 명령과 그 결과
|
|
16
|
+
// 무엇이 증명됐나 고친 **뒤에** 돌아서 **성공한** 것이 있는가
|
|
17
|
+
//
|
|
18
|
+
// ── 제일 중요한 칸 ──────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// 바꾼 것을 늘어놓는 일은 /diff 도 한다. 여기서만 하는 것은 **증명 안 된 것**을
|
|
21
|
+
// 증명 안 됐다고 말하는 것이다.
|
|
22
|
+
//
|
|
23
|
+
// 고쳐 놓고 아무것도 안 돌렸으면 아무것도 증명되지 않았다. 검사가 빨간데
|
|
24
|
+
// 돌리기는 했다고 초록으로 치면 그건 **빨간 검사를 증거로 내미는** 셈이다.
|
|
25
|
+
// 고치기 **전에** 돌린 검사는 고친 것을 증명하지 못한다 — "아까 돌렸으니 됐다" 가
|
|
26
|
+
// 제일 흔한 자기기만이다. 셋 다 여기서 걸러진다.
|
|
27
|
+
//
|
|
28
|
+
// ── 안 하는 것 ──────────────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// 프롬프트에 안 싣는다. 이건 사람이 보는 것이지 모델에게 먹이는 것이 아니다.
|
|
31
|
+
// 실으면 컨텍스트만 먹고, 모델이 제가 만든 증거를 다시 읽는 이상한 고리가 생긴다.
|
|
32
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** 이 명령들은 '확인했다' 로 친다. 돌렸다고 다 증거가 되는 것은 아니다. */
|
|
36
|
+
const 확인하는명령 = /(^|[\s|;&])(npm|pnpm|yarn|bun)\s+(test|run\s+(test|check|lint|build|verify|typecheck))|(^|[\s|;&])(pytest|jest|vitest|mocha|go\s+test|cargo\s+test|mvn\s+test|gradle\s+test|dotnet\s+test|tsc|eslint|ruff|mypy)\b/i;
|
|
37
|
+
|
|
38
|
+
/** 우리 쪽 확인 도구. Bash 를 안 거치고 확인하는 길이다. */
|
|
39
|
+
const 확인하는도구 = new Set(['Verify', 'Test']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 이번 대화의 증거를 모은다.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} session
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {object} o.audit safety/audit.js 의 Audit
|
|
47
|
+
* @param {number} o.최근 감사기록을 몇 줄까지 볼까
|
|
48
|
+
* @param {Date} o.바꾼때 파일을 언제 고쳤다고 볼까 (검사가 고정할 수 있게)
|
|
49
|
+
*/
|
|
50
|
+
export function 증거모으기(session, { audit = null, 최근 = 400, 바꾼때 = null } = {}) {
|
|
51
|
+
/*
|
|
52
|
+
* 감사기록은 폴더에 하나뿐이고 계속 덧붙는다. 그대로 읽으면 **지난주에 돌린
|
|
53
|
+
* 검사**가 오늘 고친 파일의 증거로 붙는다 — 증거를 모으는 도구가 거짓 증거를
|
|
54
|
+
* 만드는 셈이다. 이번 세션 것만 본다.
|
|
55
|
+
*/
|
|
56
|
+
const 이번세션 = audit?.session ?? null;
|
|
57
|
+
const 기록 = (() => {
|
|
58
|
+
try {
|
|
59
|
+
const 전부 = audit?.recent?.(최근) ?? [];
|
|
60
|
+
return 이번세션 ? 전부.filter((r) => r?.session === 이번세션) : 전부;
|
|
61
|
+
} catch { return []; }
|
|
62
|
+
})();
|
|
63
|
+
|
|
64
|
+
// 돌린 것 — Bash 와 확인 도구만. 파일을 읽은 것까지 증거라고 하면 목록이
|
|
65
|
+
// 길어지기만 하고, 읽은 것은 아무것도 증명하지 않는다.
|
|
66
|
+
const 돌린것 = [];
|
|
67
|
+
for (const r of 기록) {
|
|
68
|
+
if (r?.kind !== 'tool') continue;
|
|
69
|
+
const 도구 = String(r.tool ?? '');
|
|
70
|
+
if (도구 !== 'Bash' && !확인하는도구.has(도구)) continue;
|
|
71
|
+
돌린것.push({
|
|
72
|
+
도구,
|
|
73
|
+
무엇: String(r.target ?? ''),
|
|
74
|
+
됐나: r.ok !== false,
|
|
75
|
+
남긴말: r.ok === false ? `실패 — ${String(r.note ?? '').slice(0, 120)}` : String(r.note ?? '').slice(0, 120),
|
|
76
|
+
때: r.at ?? null,
|
|
77
|
+
확인인가: 확인하는도구.has(도구) || 확인하는명령.test(String(r.target ?? '')),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const 확인시도 = 돌린것.filter((x) => x.확인인가);
|
|
82
|
+
const 언제고쳤나 = 바꾼때 ? new Date(바꾼때).getTime() : null;
|
|
83
|
+
|
|
84
|
+
const 바꾼것 = [];
|
|
85
|
+
const 증명안된것 = [];
|
|
86
|
+
for (const [파일, d] of session?.changes ?? new Map()) {
|
|
87
|
+
// 고친 **뒤에** 돌린 확인만 본다.
|
|
88
|
+
const 쓸것 = 확인시도.filter((x) => {
|
|
89
|
+
if (언제고쳤나 == null || !x.때) return true; // 시각을 모르면 순서를 안 따진다
|
|
90
|
+
return new Date(x.때).getTime() >= 언제고쳤나;
|
|
91
|
+
});
|
|
92
|
+
/*
|
|
93
|
+
* **마지막** 확인이 증거다. 성공한 것 중 마지막이 아니다.
|
|
94
|
+
*
|
|
95
|
+
* 빌드가 통과하고 그 뒤에 검사가 깨졌는데 앞의 초록을 들고 와서
|
|
96
|
+
* "확인됐습니다" 하면 그게 거짓 증거다. 사람이 보는 순서로 생각하면
|
|
97
|
+
* 답이 분명하다 — 마지막에 돌린 것이 빨간데 확인됐을 리가 없다.
|
|
98
|
+
*/
|
|
99
|
+
const 마지막 = 쓸것.length ? 쓸것[쓸것.length - 1] : null;
|
|
100
|
+
const 증명 = 마지막?.됐나 ? 마지막.무엇 : null;
|
|
101
|
+
const 한줄 = { 파일, 더한줄: d.added ?? 0, 뺀줄: d.removed ?? 0, 고친횟수: d.times ?? 0, 증명 };
|
|
102
|
+
바꾼것.push(한줄);
|
|
103
|
+
if (!증명) {
|
|
104
|
+
증명안된것.push({
|
|
105
|
+
파일,
|
|
106
|
+
왜: 왜못믿나(돌린것, 마지막, 언제고쳤나),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
바꾼것,
|
|
113
|
+
돌린것,
|
|
114
|
+
증명안된것,
|
|
115
|
+
// 화면이 한눈에 쓸 수 있게 미리 세어 둔다.
|
|
116
|
+
셈: {
|
|
117
|
+
파일: 바꾼것.length,
|
|
118
|
+
더한줄: 바꾼것.reduce((a, x) => a + x.더한줄, 0),
|
|
119
|
+
뺀줄: 바꾼것.reduce((a, x) => a + x.뺀줄, 0),
|
|
120
|
+
돌린것: 돌린것.length,
|
|
121
|
+
실패한것: 돌린것.filter((x) => !x.됐나).length,
|
|
122
|
+
증명안됨: 증명안된것.length,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function 왜못믿나(돌린것, 마지막, 언제고쳤나) {
|
|
128
|
+
if (!돌린것.length) return '고친 뒤에 아무것도 안 돌렸습니다 — 무엇도 확인되지 않았습니다.';
|
|
129
|
+
const 확인시도 = 돌린것.filter((x) => x.확인인가);
|
|
130
|
+
if (!확인시도.length) return '명령은 돌렸지만 검사·빌드는 안 돌렸습니다.';
|
|
131
|
+
if (마지막 && !마지막.됐나) {
|
|
132
|
+
return `마지막으로 돌린 \`${마지막.무엇}\` 이 실패했습니다 — 그 앞이 통과했어도 지금 상태를 증명하지 못합니다.`;
|
|
133
|
+
}
|
|
134
|
+
if (!마지막 && 언제고쳤나 != null) return '검사가 이 파일을 고치기 전에 돌았습니다 — 그 뒤 바뀐 것은 확인되지 않았습니다.';
|
|
135
|
+
return '확인되지 않았습니다.';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 사람이 읽고 남길 수 있는 글. 마크다운. */
|
|
139
|
+
export function 증거글(증거, { 제목 = '작업 증거' } = {}) {
|
|
140
|
+
const e = 증거 ?? {};
|
|
141
|
+
const 줄 = [`# ${제목}`, ''];
|
|
142
|
+
|
|
143
|
+
줄.push('## 바꾼 것', '');
|
|
144
|
+
if (!e.바꾼것?.length) 줄.push('바꾼 파일이 없습니다.', '');
|
|
145
|
+
else {
|
|
146
|
+
줄.push('| 파일 | 더함 | 뺌 | 확인한 것 |', '|---|---|---|---|');
|
|
147
|
+
for (const x of e.바꾼것) {
|
|
148
|
+
줄.push(`| ${x.파일} | +${x.더한줄} | -${x.뺀줄} | ${x.증명 ? `\`${x.증명}\`` : '**없음**'} |`);
|
|
149
|
+
}
|
|
150
|
+
줄.push('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
줄.push('## 돌린 것', '');
|
|
154
|
+
if (!e.돌린것?.length) 줄.push('돌린 명령이 없습니다.', '');
|
|
155
|
+
else {
|
|
156
|
+
for (const x of e.돌린것) {
|
|
157
|
+
줄.push(`- ${x.됐나 ? '✓' : '✗'} \`${x.무엇}\`${x.남긴말 ? ` — ${x.남긴말}` : ''}`);
|
|
158
|
+
}
|
|
159
|
+
줄.push('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 이 절이 이 글의 요점이다. 비어 있으면 비어 있다고 분명히 적는다.
|
|
163
|
+
줄.push('## 증명 안 된 것', '');
|
|
164
|
+
if (!e.증명안된것?.length) 줄.push('없습니다 — 바꾼 것마다 뒤에 돌린 확인이 있습니다.', '');
|
|
165
|
+
else {
|
|
166
|
+
for (const x of e.증명안된것) 줄.push(`- **${x.파일}** — ${x.왜}`);
|
|
167
|
+
줄.push('');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return 줄.join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 파일로 남긴다. 화면은 스크롤로 사라지고, 검토는 나중에 다른 사람이 한다.
|
|
175
|
+
*
|
|
176
|
+
* @returns {string|null} 적은 자리. 못 적었으면 null — 못 적어도 대화는 계속된다.
|
|
177
|
+
*/
|
|
178
|
+
export function 증거적기(root, 증거, 이름 = '증거') {
|
|
179
|
+
try {
|
|
180
|
+
const dir = join(root, '.deel', '증거');
|
|
181
|
+
mkdirSync(dir, { recursive: true });
|
|
182
|
+
const 자리 = join(dir, `${String(이름).replace(/[^\w가-힣.-]/g, '_')}.md`);
|
|
183
|
+
writeFileSync(자리, 증거글(증거), 'utf8');
|
|
184
|
+
return 자리;
|
|
185
|
+
} catch { return null; }
|
|
186
|
+
}
|
package/src/agent/grade.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { 언어 } from '../i18n/index.js';
|
|
1
2
|
/**
|
|
2
3
|
* 모델 급 — 지금 붙어 있는 모델이 얼마나 하는가.
|
|
3
4
|
*
|
|
@@ -194,7 +195,26 @@ export function 값(급) {
|
|
|
194
195
|
export function 급말(급) {
|
|
195
196
|
const v = 값(급);
|
|
196
197
|
if (급 === '큼') return '';
|
|
198
|
+
/*
|
|
199
|
+
* 이 글도 모델이 읽는 것이라 화면 말을 따라간다.
|
|
200
|
+
*
|
|
201
|
+
* 안 따라가면 영어로 켠 사람의 작은 모델만 한국어 지시를 받는데, 하필
|
|
202
|
+
* 그 조합이 제일 흔들리기 쉽다 — 작은 모델은 못 읽는 말이 섞이면 그 줄을
|
|
203
|
+
* 통째로 흘린다. 지시가 있는 척하면서 실제로는 없는 상태가 된다.
|
|
204
|
+
*/
|
|
205
|
+
const 영 = 언어() === 'en';
|
|
197
206
|
const 줄 = [];
|
|
207
|
+
if (영) {
|
|
208
|
+
줄.push('Sized to this model:');
|
|
209
|
+
줄.push(`- Create at most ${v.한번에쓸파일} files in one Write call. More than that, split it across calls.`);
|
|
210
|
+
줄.push(`- For a file over ${v.나눠쓰기줄} lines, Write the first part and Append the rest.`);
|
|
211
|
+
if (급 === '작음') {
|
|
212
|
+
줄.push('- One thing per step. Do not call several tools at once.');
|
|
213
|
+
줄.push('- Edit a file on the **very next step** after you Read it. Do not put anything in between.');
|
|
214
|
+
}
|
|
215
|
+
줄.push('- Call Verify before you finish. Fix what comes back and call it again.');
|
|
216
|
+
return 줄.join('\n');
|
|
217
|
+
}
|
|
198
218
|
줄.push('이 모델에 맞춘 것:');
|
|
199
219
|
줄.push(`- 파일을 한 번에 만들 때는 ${v.한번에쓸파일}개까지만. 더 있으면 나눠서 여러 번 불러라.`);
|
|
200
220
|
줄.push(`- ${v.나눠쓰기줄}줄이 넘는 파일은 Write 로 앞부분만 만들고 Append 로 이어 붙여라.`);
|
package/src/agent/loop.js
CHANGED
|
@@ -10,7 +10,9 @@ import { compact, shouldCompact, shouldFold, foldToolResults } from './compact.j
|
|
|
10
10
|
import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
|
|
11
11
|
import { Session } from './session.js';
|
|
12
12
|
import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
|
|
13
|
-
import {
|
|
13
|
+
import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
|
|
14
|
+
import { allowTemporarily, isOffline } from '../safety/network.js';
|
|
15
|
+
import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴도구 } from '../safety/secrets.js';
|
|
14
16
|
import { get as workMode } from './modes.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -57,16 +59,20 @@ export function 묶기(calls) {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 } = {}) {
|
|
60
|
-
session.push({ role: 'user', content: userText });
|
|
61
|
-
ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
|
|
62
62
|
/*
|
|
63
63
|
* 되돌리기 턴은 **부모만** 연다.
|
|
64
64
|
*
|
|
65
65
|
* 하위 작업이 제 턴을 열면 `/undo` 한 번이 하위가 만든 것만 되돌리고
|
|
66
66
|
* 부모가 만든 것은 남긴다 — 반쪽만 되돌아간 폴더가 된다. 사람 눈에는
|
|
67
67
|
* 한 번 시킨 일이니 한 번에 되돌아가야 맞다.
|
|
68
|
+
*
|
|
69
|
+
* 턴을 **사람 말을 넣기 전에** 연다. 그래야 그 말 자체가 이 턴의 첫 말로
|
|
70
|
+
* 표시되고, 되감을 때 시킨 말까지 같이 걷힌다. 시킨 말만 남으면 모델은
|
|
71
|
+
* 되돌린 일을 또 하려 든다.
|
|
68
72
|
*/
|
|
69
|
-
if (!깊이) ctx.history.nextTurn();
|
|
73
|
+
if (!깊이) session.턴시작(ctx.history.nextTurn());
|
|
74
|
+
session.push({ role: 'user', content: userText });
|
|
75
|
+
ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
|
|
70
76
|
|
|
71
77
|
/*
|
|
72
78
|
* 중단 신호를 도구도 볼 수 있게 여기 걸어 둔다.
|
|
@@ -95,7 +101,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
95
101
|
* '헛도는 작업' 을 구분하지 못한다. 여기서는 같은 자리를 반복하는 것만 본다.
|
|
96
102
|
*/
|
|
97
103
|
const 막힘 = new Map();
|
|
98
|
-
|
|
104
|
+
/*
|
|
105
|
+
* 몇 번까지 봐 줄까는 **이 모델을 겪어 본 만큼** 정한다 (agent/card.js).
|
|
106
|
+
*
|
|
107
|
+
* 되풀이가 버릇인 모델에서는 세 번까지 기다릴 이유가 없다. 되풀이한 만큼
|
|
108
|
+
* 컨텍스트가 차고 그만큼 요약이 빨리 온다 — 걸음만 태우는 것이 아니다.
|
|
109
|
+
* 겪은 것이 모자라면 여태 쓰던 3 그대로다.
|
|
110
|
+
*/
|
|
111
|
+
const 이카드 = ctx?.카드 ?? null;
|
|
112
|
+
const MAX_SAME = 이카드?.조정?.같은것한계 ?? 3;
|
|
99
113
|
let 멈출까 = null;
|
|
100
114
|
const 막힘셈 = (call, 이유) => {
|
|
101
115
|
// 파일 하나가 아니라 **그 파일** 을 센다. 이름만 세면 서로 다른 파일 세 개를
|
|
@@ -194,6 +208,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
194
208
|
work: session.effectiveWork(), // 작업 모드가 쓰는 것만 (modes.js)
|
|
195
209
|
// 밖에서 붙인 도구(MCP). 붙은 것이 없으면 아무것도 안 는다.
|
|
196
210
|
mcp: ctx.mcp ?? null,
|
|
211
|
+
// 이 자리에 언어 서버가 있을 때만 Def·Refs 를 보여 준다 (tools/lsp.js).
|
|
212
|
+
// 없는 자리에서 목록에 세워 두면 모델이 부르고, 실패를 받고, 또 부른다.
|
|
213
|
+
lsp: session.lsp === true,
|
|
197
214
|
// 창이 좁으면 도구 설명을 줄여 싣는다 (budget.js 의 설명길이).
|
|
198
215
|
// 도구를 빼는 게 아니라 설명만 줄이므로 할 수 있는 일은 안 달라진다.
|
|
199
216
|
ctx: conn.ctx ?? null,
|
|
@@ -248,7 +265,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
248
265
|
// 출력 상한을 아는 값이 둘 있다 — 사용자가 정한 것(/out)과 서버에서 알아낸 것.
|
|
249
266
|
// 사람이 정한 것이 먼저다. 둘 다 없으면 null 이고, 그때만 effort.js 의 MAX_CAP 에 선다.
|
|
250
267
|
const room = { ctx: conn.ctx ?? 0, used: session.breakdown().used, max: conn.maxTokens ?? conn.maxOut ?? null };
|
|
251
|
-
|
|
268
|
+
/*
|
|
269
|
+
* 이 모델이 인자를 자주 잘라 먹는다면 처음부터 넉넉히 준다 (agent/card.js).
|
|
270
|
+
*
|
|
271
|
+
* 지금은 잘린 **뒤에야** 상한을 올려 다시 부른다. 한 번은 반드시 버리는 셈이다.
|
|
272
|
+
* 잘리는 것이 이 모델의 버릇으로 재어졌다면 그 한 번을 매번 버릴 이유가 없다.
|
|
273
|
+
* 상한은 최댓값이지 정해진 길이가 아니라서, 짧게 답하는 턴에는 값이 안 든다.
|
|
274
|
+
*/
|
|
275
|
+
const cap = 이카드?.조정?.상한먼저올리기
|
|
276
|
+
? Math.max(tokensFor(effort, stage, room), fullCap(room))
|
|
277
|
+
: tokensFor(effort, stage, room);
|
|
252
278
|
yield { type: 'stage', stage, level, cap, step: steps };
|
|
253
279
|
|
|
254
280
|
const ask = (maxTokens, think) => ({
|
|
@@ -592,7 +618,43 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
592
618
|
}
|
|
593
619
|
|
|
594
620
|
const 자식모드 = 하위모드(call.args?.모드 ?? call.args?.mode, 모드.id);
|
|
595
|
-
|
|
621
|
+
|
|
622
|
+
/*
|
|
623
|
+
* ── 다른 모델에게 떼어 주기 ─────────────────────────────────────
|
|
624
|
+
*
|
|
625
|
+
* 안 적으면 지금 쓰는 것 그대로다. 적었으면 **사람이 설정에 적어 둔
|
|
626
|
+
* 프로필**에서만 찾는다 — 모델이 주소를 지어내면 그 자리로 나갈 뻔하고,
|
|
627
|
+
* 그건 이 프로그램이 하는 약속을 그 자리에서 깨는 것이다.
|
|
628
|
+
*
|
|
629
|
+
* 못 찾으면 조용히 지금 모델로 돌지 않는다. 시킨 쪽은 작은 모델에게
|
|
630
|
+
* 넘긴 줄 알고 창을 아끼고 있는데 실제로는 큰 모델이 다 받는 셈이라,
|
|
631
|
+
* 원인을 못 찾는 쪽으로 어긋난다. 무엇이 있는지 적어 되돌려 준다.
|
|
632
|
+
*/
|
|
633
|
+
let 자식conn = conn;
|
|
634
|
+
let 자리닫기 = null;
|
|
635
|
+
let 모델알림 = null;
|
|
636
|
+
const 부른모델 = String(call.args?.모델 ?? call.args?.model ?? '').trim();
|
|
637
|
+
if (부른모델) {
|
|
638
|
+
const 찾음 = 프로필찾기(부른모델);
|
|
639
|
+
if (!찾음.ok) {
|
|
640
|
+
const 있는것 = 목록보기().map((x) => x.id).join(' · ') || '(설정에 프로필이 없습니다)';
|
|
641
|
+
거절(call, `${찾음.why}. 쓸 수 있는 이름: ${있는것}`);
|
|
642
|
+
yield { type: 'tool', name: 'Task', args: call.args, result: { error: 찾음.why }, showLabel: true };
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const 되나 = 쓸수있나(찾음.prof);
|
|
646
|
+
if (!되나.ok) {
|
|
647
|
+
거절(call, `${부른모델} 은 지금 못 씁니다 — ${되나.why}`);
|
|
648
|
+
yield { type: 'tool', name: 'Task', args: call.args, result: { error: 되나.why }, showLabel: true };
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
자식conn = 연결만들기(찾음.prof);
|
|
652
|
+
모델알림 = 알릴말(conn, 자식conn);
|
|
653
|
+
// 그 일이 도는 동안만 연다. 끝나면 반드시 닫는다 — 아래 finally.
|
|
654
|
+
if (모델알림.다른자리) 자리닫기 = allowTemporarily(자식conn.base);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const 자식 = new Session(자식conn, {
|
|
596
658
|
root: session.root,
|
|
597
659
|
// 승인 방식은 그대로 물려준다. 하위가 승인을 우회하면 strict 가 거짓말이 된다.
|
|
598
660
|
mode: session.mode,
|
|
@@ -604,7 +666,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
604
666
|
// 부모보다 적게 준다 (budget.js). 사람이 직접 정한 값이 있으면 그 절반.
|
|
605
667
|
maxSteps: session.stepsSet
|
|
606
668
|
? Math.max(4, Math.floor(session.maxSteps / 2))
|
|
607
|
-
|
|
669
|
+
// 걸음 수는 **하위가 쓸 창**으로 잰다. 부모 창으로 재면, 작은 모델에게
|
|
670
|
+
// 떼어 준 일이 제 창보다 큰 걸음 수를 받아 중간에 창이 찬 채로 돈다.
|
|
671
|
+
: 하위걸음수(자식모드, 자식conn.ctx),
|
|
608
672
|
});
|
|
609
673
|
// 스킬·명령·기억은 부모가 켤 때 한 번 찾아 든 것이다. 하위도 같은 것을 본다.
|
|
610
674
|
자식.skills = session.skills;
|
|
@@ -622,8 +686,19 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
622
686
|
* 미리 +1 을 해 두면 두 겹째에서 1이 두 번 붙어 3이 된다.
|
|
623
687
|
* 경계선은 부모 자리에 그어야 어디부터가 떼어 낸 일인지 보인다.
|
|
624
688
|
*/
|
|
625
|
-
|
|
626
|
-
|
|
689
|
+
/*
|
|
690
|
+
* 다른 모델을 쓰면 **어디로 나가는지까지** 적는다.
|
|
691
|
+
*
|
|
692
|
+
* 조용히 여는 것이 제일 나쁘다. 상태줄의 ⌂ 는 이 세션의 연결을 보고
|
|
693
|
+
* 있어서 하위가 딴 데로 나가는 것을 모른다. 그러니 이 줄과 감사기록이
|
|
694
|
+
* 그 사실을 남기는 유일한 자리다.
|
|
695
|
+
*/
|
|
696
|
+
yield {
|
|
697
|
+
type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps,
|
|
698
|
+
모델: 모델알림?.말 ?? null, 밖으로: 모델알림?.밖으로 ?? false,
|
|
699
|
+
};
|
|
700
|
+
ctx.audit.tool('Task', { 목적, 모드: 자식모드, 모델: 모델알림?.말 ?? null },
|
|
701
|
+
{ summary: `하위 작업 시작 (${깊이 + 1}겹)${모델알림 ? ` · ${모델알림.말}` : ''}` });
|
|
627
702
|
|
|
628
703
|
let 끝 = null;
|
|
629
704
|
try {
|
|
@@ -647,6 +722,10 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
647
722
|
} catch (err) {
|
|
648
723
|
// 하위가 터져도 부모 턴은 살린다. 무엇 때문에 터졌는지는 요약에 실린다.
|
|
649
724
|
끝 = { type: 'stuck', why: String(err?.message ?? err), steps: 0, files: [] };
|
|
725
|
+
} finally {
|
|
726
|
+
// 잠깐 열어 둔 자리는 무슨 일이 있어도 닫는다. 안 닫으면 그 세션이
|
|
727
|
+
// 끝날 때까지 그 주소가 열린 채로 남는다 — '한 자리만 연다' 가 깨진다.
|
|
728
|
+
try { 자리닫기?.(); } catch { /* 닫다 터져도 이번 턴은 이어간다 */ }
|
|
650
729
|
}
|
|
651
730
|
|
|
652
731
|
/*
|
|
@@ -664,13 +743,13 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
664
743
|
session.usage.ms += 자식.usage.ms;
|
|
665
744
|
|
|
666
745
|
const 글 = 하위요약({
|
|
667
|
-
목적, 모드: 자식모드, 끝,
|
|
746
|
+
목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null,
|
|
668
747
|
글자수: 요약길이(conn.ctx),
|
|
669
748
|
보인이름: (경로) => ctx.scope?.show?.(경로) ?? 경로,
|
|
670
749
|
});
|
|
671
750
|
session.push(toolMessage(conn.kind, { callId: call.id, name: 'Task', content: 글 }));
|
|
672
751
|
ctx.audit.tool('Task', { 목적 }, { summary: 글.slice(0, 300) });
|
|
673
|
-
yield { type: 'task_done', 목적, 모드: 자식모드,
|
|
752
|
+
yield { type: 'task_done', 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null };
|
|
674
753
|
|
|
675
754
|
// 사용자가 중단했으면 부모도 여기서 멈춘다. 하위만 끊고 이어가면
|
|
676
755
|
// 무엇이 중단된 것인지 알 수 없는 화면이 된다.
|
|
@@ -760,12 +839,54 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
|
|
|
760
839
|
}
|
|
761
840
|
}
|
|
762
841
|
|
|
842
|
+
/*
|
|
843
|
+
* ── 비밀 가리기 ─────────────────────────────────────────────────
|
|
844
|
+
*
|
|
845
|
+
* 여기가 도구 결과가 대화로 들어가는 **유일한** 자리다. 여기서 막으면
|
|
846
|
+
* 모델에게도 안 가고 `.deel/sessions/*.jsonl` 에도 안 적힌다. 한 번
|
|
847
|
+
* 새면 두 벌이 되는 것을 한 곳에서 끊는다.
|
|
848
|
+
*
|
|
849
|
+
* 새는 자리는 거의 항상 명령 출력이다 — env · git remote -v · curl -v ·
|
|
850
|
+
* 검사 실패 로그. 그래서 그런 도구만 가린다.
|
|
851
|
+
*
|
|
852
|
+
* 파일에서 읽어 온 글은 **안 가린다.** 가리면 모델이 가려진 글을 보고
|
|
853
|
+
* 그대로 되돌려 써서, 진짜 열쇠가 있던 자리에 표가 적힌다 — 비밀을
|
|
854
|
+
* 지키려다 비밀을 지우는 셈이다. 대신 무엇이 들어왔는지 알린다.
|
|
855
|
+
*/
|
|
856
|
+
const 아는열쇠들 = [conn.key, process.env.DEEL_API_KEY].filter(Boolean);
|
|
857
|
+
let 비밀 = [];
|
|
858
|
+
if (가릴도구.has(call.name)) {
|
|
859
|
+
const 가린 = 가리기(실을것, { 열쇠들: 아는열쇠들 });
|
|
860
|
+
if (가린.가린것.length) {
|
|
861
|
+
비밀 = 가린.가린것;
|
|
862
|
+
실을것 = 가린.글 + 가렸다는말(가린.가린것);
|
|
863
|
+
}
|
|
864
|
+
} else if (result.content) {
|
|
865
|
+
// 안 고치고 보기만 한다. 사람이 알아야 손을 쓸 수 있다.
|
|
866
|
+
비밀 = 훑기(실을것, { 열쇠들: 아는열쇠들 });
|
|
867
|
+
}
|
|
868
|
+
if (비밀.length) {
|
|
869
|
+
ctx.audit?.write?.('secret', {
|
|
870
|
+
tool: call.name,
|
|
871
|
+
가렸나: 가릴도구.has(call.name),
|
|
872
|
+
무엇: 비밀.map((x) => `${x.종류}×${x.몇번}`).join(' '),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
763
876
|
session.push(toolMessage(conn.kind, {
|
|
764
877
|
callId: call.id,
|
|
765
878
|
name: call.name,
|
|
766
879
|
content: 실을것,
|
|
767
880
|
}));
|
|
768
|
-
yield {
|
|
881
|
+
yield {
|
|
882
|
+
type: 'tool',
|
|
883
|
+
name: call.name,
|
|
884
|
+
args: call.args,
|
|
885
|
+
result,
|
|
886
|
+
ms,
|
|
887
|
+
parallel: 함께,
|
|
888
|
+
...(비밀.length ? { 비밀: { 가렸나: 가릴도구.has(call.name), 말: 봤다는말(비밀) } } : {}),
|
|
889
|
+
};
|
|
769
890
|
}
|
|
770
891
|
}
|
|
771
892
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 한 세션 안에서 여러 모델 쓰기.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 로컬에서는 모델 하나를 골라도 늘 어딘가 아쉽다. 7B 는 계획을 잘 세우는데
|
|
7
|
+
* 파일 열 개를 고치다 보면 창이 차고, 1.5B 는 창은 넉넉한데 무엇을 할지를
|
|
8
|
+
* 못 정한다. 클라우드였으면 큰 것 하나로 끝날 일인데, 8GB 램에서는 둘 다
|
|
9
|
+
* 올려 두고 **나눠 쓰는 것**이 실제로 가능한 유일한 길이다.
|
|
10
|
+
* 7B q4 (약 4.4GB) + 1.5B q4 (약 1.3GB) ≒ 5.7GB — 8GB 안에 들어간다.
|
|
11
|
+
*
|
|
12
|
+
* 그래서 두 자리를 연다.
|
|
13
|
+
* Task 에 `모델` 큰 것이 계획을 쥐고, 잔일 한 덩이를 작은 것에 떼어 준다.
|
|
14
|
+
* /ask <프로필> 지금 쓰던 것을 안 바꾸고 다른 모델에게 한 번 물어본다.
|
|
15
|
+
*
|
|
16
|
+
* ── 안 하는 것 ─────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* 요약(compact)을 작은 모델에게 안 넘긴다. 요약은 '무엇을 버려도 되는가' 를
|
|
19
|
+
* 정하는 일이라 대화 전체를 제일 잘 아는 쪽이 해야 하는데, 그게 작은 모델이면
|
|
20
|
+
* 버려선 안 될 것을 버린다. 그 손해는 몇 턴 뒤에야 드러나고, 그때는 원인을
|
|
21
|
+
* 못 찾는다. 토큰 몇 푼 아끼자고 낼 값이 아니다.
|
|
22
|
+
*
|
|
23
|
+
* ── 경계선은 어떻게 되나 ───────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* 이게 이 파일에서 제일 조심한 자리다. deel 은 나갈 수 있는 자리를 **하나만**
|
|
26
|
+
* 연다(safety/network.js). 둘째 모델을 쓴다는 것은 그 자리를 하나 더 연다는
|
|
27
|
+
* 뜻이고, 그 자리가 바깥 게이트웨이면 "이 컴퓨터 안" 이 조용히 거짓이 된다.
|
|
28
|
+
*
|
|
29
|
+
* 1) 모델이 주소를 지어낼 수 없다. **사람이 설정에 적어 둔 프로필**만 쓴다.
|
|
30
|
+
* 2) 그 일이 도는 동안만 열고 끝나면 닫는다 (allowTemporarily).
|
|
31
|
+
* 3) 주소가 지금 쓰는 것과 다르면 **화면과 감사기록에 그렇게 적는다.**
|
|
32
|
+
* 조용히 열면 그게 제일 나쁘다.
|
|
33
|
+
* 4) 오프라인 잠금이면 이 컴퓨터 밖 프로필은 아예 못 쓴다.
|
|
34
|
+
*/
|
|
35
|
+
import { load, activeProfile, resolveKey } from '../config.js';
|
|
36
|
+
import { isLocalHost, isOffline } from '../safety/network.js';
|
|
37
|
+
|
|
38
|
+
// 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
|
|
39
|
+
export const CTX_DEFAULT = 32768;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 프로필 하나로 연결을 만든다.
|
|
43
|
+
*
|
|
44
|
+
* repl.js 도 이걸 쓴다. 두 군데서 따로 만들면 한쪽에만 손이 가고, 그러면
|
|
45
|
+
* `/ask` 로 부른 모델만 스트리밍이 꺼져 있다거나 하는 일이 생긴다 —
|
|
46
|
+
* 화면에는 "느리네" 로만 보이고 원인은 안 보인다.
|
|
47
|
+
*/
|
|
48
|
+
export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
|
|
49
|
+
if (!prof) return null;
|
|
50
|
+
return {
|
|
51
|
+
kind: prof.kind,
|
|
52
|
+
base: prof.baseUrl,
|
|
53
|
+
auth: prof.auth,
|
|
54
|
+
key: resolveKey(prof),
|
|
55
|
+
model: prof.model,
|
|
56
|
+
ctx: ctx ?? prof.ctx ?? CTX_DEFAULT,
|
|
57
|
+
maxTokens: maxTokens ?? prof.maxTokens ?? null,
|
|
58
|
+
streaming: prof.streaming ?? false,
|
|
59
|
+
tools: prof.tools ?? false,
|
|
60
|
+
json: prof.json ?? false,
|
|
61
|
+
think: prof.think ?? false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 설정에 있는 프로필들. */
|
|
66
|
+
export function 프로필들(cfg = load()) {
|
|
67
|
+
return Array.isArray(cfg?.profiles) ? cfg.profiles : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 이름으로 프로필 찾기.
|
|
72
|
+
*
|
|
73
|
+
* id → 이름 → 모델 이름 → 앞부분 일치 순으로 본다. 사람은 `/ask small ...`
|
|
74
|
+
* 처럼 기억나는 대로 치지, 설정에 적은 id 를 외우고 있지 않다.
|
|
75
|
+
*
|
|
76
|
+
* 앞부분 일치에서 **둘 이상 걸리면 고르지 않는다.** 아무거나 골라 주면
|
|
77
|
+
* 물어본 사람은 어느 모델이 답했는지 모른 채로 그 답을 믿게 된다.
|
|
78
|
+
*/
|
|
79
|
+
export function 프로필찾기(이름, cfg = load()) {
|
|
80
|
+
const q = String(이름 ?? '').trim().toLowerCase();
|
|
81
|
+
if (!q) return { ok: false, why: '없음', 후보: [] };
|
|
82
|
+
const 목록 = 프로필들(cfg);
|
|
83
|
+
if (!목록.length) return { ok: false, why: '설정에 프로필이 하나도 없습니다', 후보: [] };
|
|
84
|
+
|
|
85
|
+
const 딱 = 목록.find((p) => String(p.id).toLowerCase() === q)
|
|
86
|
+
?? 목록.find((p) => String(p.name ?? '').toLowerCase() === q)
|
|
87
|
+
?? 목록.find((p) => String(p.model ?? '').toLowerCase() === q);
|
|
88
|
+
if (딱) return { ok: true, prof: 딱, 후보: [딱] };
|
|
89
|
+
|
|
90
|
+
const 걸린것 = 목록.filter((p) => [p.id, p.name, p.model]
|
|
91
|
+
.some((v) => String(v ?? '').toLowerCase().includes(q)));
|
|
92
|
+
if (걸린것.length === 1) return { ok: true, prof: 걸린것[0], 후보: 걸린것 };
|
|
93
|
+
if (걸린것.length > 1) {
|
|
94
|
+
return { ok: false, why: `'${이름}' 에 여러 개가 걸립니다`, 후보: 걸린것 };
|
|
95
|
+
}
|
|
96
|
+
return { ok: false, why: `'${이름}' 이라는 프로필이 없습니다`, 후보: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 이 프로필로 나가도 되는가.
|
|
101
|
+
*
|
|
102
|
+
* 오프라인 잠금은 여기서도 지킨다. 잠갔는데 하위 작업만 밖으로 나가면,
|
|
103
|
+
* 잠금은 화면에만 있고 실제로는 안 잠긴 것이 된다.
|
|
104
|
+
*/
|
|
105
|
+
export function 쓸수있나(prof) {
|
|
106
|
+
if (!prof?.baseUrl) return { ok: false, why: '주소가 없는 프로필입니다' };
|
|
107
|
+
let 로컬 = false;
|
|
108
|
+
try { 로컬 = isLocalHost(new URL(prof.baseUrl).hostname); } catch {
|
|
109
|
+
return { ok: false, why: `주소를 읽을 수 없습니다: ${prof.baseUrl}` };
|
|
110
|
+
}
|
|
111
|
+
if (isOffline() && !로컬) {
|
|
112
|
+
return { ok: false, why: '오프라인 잠금 중입니다 — 이 컴퓨터 밖 프로필은 못 씁니다' };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, 로컬 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 두 주소가 같은 자리인가. 다르면 사람에게 알려야 한다. */
|
|
118
|
+
export function 같은자리(a, b) {
|
|
119
|
+
try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 다른 모델을 쓸 때 화면과 감사기록에 남길 한 줄.
|
|
124
|
+
*
|
|
125
|
+
* 같은 자리면 모델 이름만, 다른 자리면 **어디로 나가는지**까지 적는다.
|
|
126
|
+
* 이 줄이 없으면 사람은 제 소스가 어느 서버로 갔는지 알 방법이 없다.
|
|
127
|
+
* @returns {{말: string, 밖으로: boolean, 다른자리: boolean}}
|
|
128
|
+
*/
|
|
129
|
+
export function 알릴말(지금conn, 새conn) {
|
|
130
|
+
const 다른자리 = !같은자리(지금conn?.base, 새conn?.base);
|
|
131
|
+
let 밖으로 = false;
|
|
132
|
+
try { 밖으로 = !isLocalHost(new URL(새conn.base).hostname); } catch { 밖으로 = true; }
|
|
133
|
+
if (!다른자리) return { 말: `모델 ${새conn.model}`, 밖으로, 다른자리 };
|
|
134
|
+
let 어디 = 새conn.base;
|
|
135
|
+
try { 어디 = new URL(새conn.base).host; } catch { /* 못 읽으면 통째로 */ }
|
|
136
|
+
return {
|
|
137
|
+
말: `모델 ${새conn.model} · ${밖으로 ? '바깥' : '이 컴퓨터 안'} ${어디}`,
|
|
138
|
+
밖으로,
|
|
139
|
+
다른자리,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 지금 붙어 있는 프로필. `/ask` 가 '나 자신에게 묻기' 를 걸러낼 때 쓴다.
|
|
145
|
+
*/
|
|
146
|
+
export function 지금프로필(cfg = load()) {
|
|
147
|
+
return activeProfile(cfg);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 화면에 낼 프로필 한 줄들.
|
|
152
|
+
* @returns {{id:string, name:string, model:string, 어디:string, 로컬:boolean, 지금:boolean}[]}
|
|
153
|
+
*/
|
|
154
|
+
export function 목록보기(cfg = load()) {
|
|
155
|
+
const 지금 = 지금프로필(cfg);
|
|
156
|
+
return 프로필들(cfg).map((p) => {
|
|
157
|
+
let 어디 = String(p.baseUrl ?? '');
|
|
158
|
+
let 로컬 = false;
|
|
159
|
+
try { const u = new URL(p.baseUrl); 어디 = u.host; 로컬 = isLocalHost(u.hostname); } catch { /* 못 읽으면 통째로 */ }
|
|
160
|
+
return {
|
|
161
|
+
id: p.id,
|
|
162
|
+
name: p.name ?? p.id,
|
|
163
|
+
model: p.model ?? '',
|
|
164
|
+
어디,
|
|
165
|
+
로컬,
|
|
166
|
+
지금: 지금?.id === p.id,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|