deel-local-cli 1.6.2 → 1.7.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 +115 -23
- package/README.md +114 -24
- package/bin/deel.js +21 -3
- package/package.json +2 -2
- package/src/acp/serve.js +25 -2
- package/src/agent/loop.js +68 -5
- package/src/backend/adapter.js +211 -4
- package/src/backend/ctxsize.js +16 -2
- package/src/backend/detect.js +104 -6
- package/src/backend/price.js +197 -0
- package/src/backend/scanui.js +2 -1
- package/src/backend/vision.js +26 -4
- package/src/commands.js +80 -6
- package/src/completion.js +1 -0
- package/src/i18n/en.js +6 -0
- package/src/i18n/ja.js +6 -0
- package/src/i18n/ko.js +6 -0
- package/src/i18n/zh.js +6 -0
- package/src/oneshot.js +28 -4
- package/src/providers/anthropic.js +48 -0
- package/src/providers/bedrock.js +102 -0
- package/src/providers/custom.js +65 -0
- package/src/providers/gemini.js +64 -0
- package/src/providers/index.js +121 -0
- package/src/providers/openai.js +68 -0
- package/src/repl.js +118 -7
- package/src/report.js +3 -2
- package/src/safety/runmode.js +123 -0
- package/src/safety/secrets.js +61 -0
- package/src/setup.js +215 -12
- package/src/tools/index.js +46 -2
- package/src/tools/todo.js +28 -0
- package/src/ui/histline.js +67 -0
- package/src/ui/status.js +88 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 실행 모드 — 지금 이 deel 이 어디까지 나갈 수 있나.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 나누나 ───────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 여태 자물쇠는 `--offline` 하나였고, **기본은 열림**이었다. 설정 파일의
|
|
7
|
+
* baseUrl 한 줄만 바꾸면 그대로 바깥으로 나갔다. 화면에 ↗ 가 뜨긴 했지만
|
|
8
|
+
* 그건 알리는 것이지 막는 것이 아니다.
|
|
9
|
+
*
|
|
10
|
+
* 「완전 로컬」 이 이 도구의 정체성인데, 코드가 지키는 것은 그 절반이었다.
|
|
11
|
+
* 여기서 기본값을 뒤집는다 — **말하지 않으면 안 나간다.**
|
|
12
|
+
*
|
|
13
|
+
* ── 셋으로 나눈다 ───────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* ⌂ 이 안 (기본) 사람이 대화창에서 쓸 때.
|
|
16
|
+
* 로컬·사내망은 그냥 간다. 바깥은 한 번 묻고 기억한다.
|
|
17
|
+
* ↗ 바깥 --online 스크립트 · CI · run -p. 물어볼 사람이 없는 자리.
|
|
18
|
+
* 묻지 않는다. 그래도 어디로 가는지는 화면에 남긴다.
|
|
19
|
+
* ⛊ 봉인 --offline 심사 · 사내 규정 · 비행기.
|
|
20
|
+
* 기억해 둔 허가까지 무시한다. 제일 센 상태.
|
|
21
|
+
*
|
|
22
|
+
* 이름만 다른 모드는 자물쇠가 아니라 장식이다. 셋이 각각 다른 일을 한다.
|
|
23
|
+
*
|
|
24
|
+
* ── 봉인은 「인터넷 없음」이 아니다 ─────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* 사내망(10.x · 172.16~31.x · 192.168.x)은 봉인에서도 「이 안」이다. 지키려는
|
|
27
|
+
* 경계는 **회사 밖으로 안 나간다**이지 랜선을 뽑는 것이 아니다. 사람들이 제일
|
|
28
|
+
* 자주 오해하는 자리라 화면 글에도 그대로 적는다 (safety/network.js 의
|
|
29
|
+
* isLocalHost 가 그 판단을 갖고 있다 — 여기서 두 벌로 만들지 않는다).
|
|
30
|
+
*
|
|
31
|
+
* ── 표 하나로 둔다 ──────────────────────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* if 를 세 번 쓰지 않는다. 넷째 모드(사내 정책·감사)를 붙일 때 줄 하나만
|
|
34
|
+
* 더하면 되게 열어 둔다.
|
|
35
|
+
*/
|
|
36
|
+
import { isLocalHost } from './network.js';
|
|
37
|
+
|
|
38
|
+
export const 모드들 = [
|
|
39
|
+
{
|
|
40
|
+
id: '이안',
|
|
41
|
+
깃발: null,
|
|
42
|
+
글자: '⌂',
|
|
43
|
+
이름: '이 안',
|
|
44
|
+
영: 'default',
|
|
45
|
+
한줄: '로컬·사내망은 그냥 갑니다. 바깥은 한 번 물어보고 기억합니다.',
|
|
46
|
+
물어보나: true,
|
|
47
|
+
허가무시: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: '바깥',
|
|
51
|
+
깃발: 'online',
|
|
52
|
+
글자: '↗',
|
|
53
|
+
이름: '바깥',
|
|
54
|
+
영: 'online',
|
|
55
|
+
한줄: '묻지 않고 나갑니다. 스크립트·CI 처럼 물어볼 사람이 없는 자리입니다.',
|
|
56
|
+
물어보나: false,
|
|
57
|
+
허가무시: false,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: '봉인',
|
|
61
|
+
깃발: 'offline',
|
|
62
|
+
글자: '⛊',
|
|
63
|
+
이름: '봉인',
|
|
64
|
+
영: 'offline',
|
|
65
|
+
한줄: '기억해 둔 허가까지 무시하고 막습니다. 사내망은 그대로 갑니다.',
|
|
66
|
+
물어보나: false,
|
|
67
|
+
허가무시: true,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const 기본 = 모드들[0];
|
|
72
|
+
|
|
73
|
+
/** id 나 깃발 이름으로 모드 하나를 집는다. 모르면 기본이다. */
|
|
74
|
+
export function 모드고르기(무엇) {
|
|
75
|
+
const s = String(무엇 ?? '');
|
|
76
|
+
return 모드들.find((m) => m.id === s || m.깃발 === s || m.영 === s) ?? 기본;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 지금 어느 모드인가.
|
|
81
|
+
*
|
|
82
|
+
* 봉인이 언제나 이긴다. 「막아 달라」 와 「나가게 해 달라」 가 같이 오면
|
|
83
|
+
* 막는 쪽을 따르는 것이 자물쇠의 기본이다 — 반대로 하면 `--offline` 을
|
|
84
|
+
* 붙였는데 나가는 일이 생기고, 그건 자물쇠가 없느니만 못하다.
|
|
85
|
+
*/
|
|
86
|
+
export function 지금모드({ online = false, offline = false } = {}) {
|
|
87
|
+
if (offline) return 모드고르기('offline');
|
|
88
|
+
if (online) return 모드고르기('online');
|
|
89
|
+
return 기본;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 이 주소가 바깥인가.
|
|
94
|
+
*
|
|
95
|
+
* 못 읽는 주소는 바깥으로 친다. 모르면 조이는 쪽이다 — 읽을 수 없는 주소를
|
|
96
|
+
* 「이 안」 으로 봐 주면, 이상하게 생긴 주소 하나로 자물쇠가 풀린다.
|
|
97
|
+
*/
|
|
98
|
+
export function 바깥인가(baseUrl) {
|
|
99
|
+
try { return !isLocalHost(new URL(String(baseUrl)).hostname); } catch { return true; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 지금 나가도 되나.
|
|
104
|
+
*
|
|
105
|
+
* @param 모드 지금모드() 가 준 것
|
|
106
|
+
* @param 바깥 바깥인가(conn.base)
|
|
107
|
+
* @param 허가 이 프로필에 「바깥으로 나가도 된다」 가 붙어 있나 (profile.online)
|
|
108
|
+
* @returns {{되나: boolean, 물어볼까?: boolean, 왜?: string}}
|
|
109
|
+
*
|
|
110
|
+
* 주소와 허가를 **뗀 것**이 요점이다. 주소만 바꿔서는 못 나가고, 두 군데를
|
|
111
|
+
* 다 고쳐야 나간다 — 실수로는 안 되고, 마음먹어야 된다.
|
|
112
|
+
*/
|
|
113
|
+
export function 나갈수있나(모드, { 바깥 = false, 허가 = false } = {}) {
|
|
114
|
+
if (!바깥) return { 되나: true, 왜: '이안' };
|
|
115
|
+
if (모드.허가무시) return { 되나: false, 왜: '봉인' };
|
|
116
|
+
if (모드.물어보나 && !허가) return { 되나: false, 물어볼까: true, 왜: '허가없음' };
|
|
117
|
+
return { 되나: true, 왜: 허가 ? '허가있음' : '모드가열음' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 화면에 붙일 한 조각 — `⌂ 이 안` · `↗ 바깥`. 색은 부르는 쪽이 칠한다. */
|
|
121
|
+
export function 모드글(모드) {
|
|
122
|
+
return `${모드.글자} ${모드.이름}`;
|
|
123
|
+
}
|
package/src/safety/secrets.js
CHANGED
|
@@ -235,3 +235,64 @@ export function 주소가리기(주소) {
|
|
|
235
235
|
* 되돌려 쓸 수 있으므로 절대 안 가린다 — 위 머리말 참고.
|
|
236
236
|
*/
|
|
237
237
|
export const 가릴도구 = new Set(['Bash', 'Verify', 'Jobs', 'WebFetch']);
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 이번 도구 결과를 가릴까.
|
|
241
|
+
*
|
|
242
|
+
* ── 바깥으로 나가면 저울이 뒤집힌다 ─────────────────────────────────────
|
|
243
|
+
*
|
|
244
|
+
* 위 머리말이 「파일 내용은 안 가린다」 로 정한 것은, 로컬 모델만 쓰던 때의
|
|
245
|
+
* 저울이다. 가려서 잃는 것(사람의 열쇠가 지워짐)이, 얻는 것(어차피 이 컴퓨터
|
|
246
|
+
* 밖으로 안 나가는 글을 가림)보다 컸다.
|
|
247
|
+
*
|
|
248
|
+
* 바깥으로 나가면 얻는 쪽이 훨씬 무거워진다. Read 한 번이면 `.env` 가 통째로
|
|
249
|
+
* 남의 서버 로그에 남고, 그건 되돌릴 수가 없다.
|
|
250
|
+
*
|
|
251
|
+
* 그리고 잃는 쪽은 이제 다른 자리에서 막는다 — Write·Append·Edit 이 가린
|
|
252
|
+
* 표를 파일에 못 쓴다(tools/index.js 의 가린표되돌리나). 그 자물쇠가 먼저
|
|
253
|
+
* 생겼기 때문에 여기서 가릴 수 있게 된 것이다. 순서가 중요하다.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} 도구이름
|
|
256
|
+
* @param {{바깥?: boolean}} o 바깥 = 이 연결이 이 컴퓨터 밖으로 나가나
|
|
257
|
+
*/
|
|
258
|
+
export function 가릴까(도구이름, { 바깥 = false } = {}) {
|
|
259
|
+
return 가릴도구.has(도구이름) || 바깥 === true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/*
|
|
263
|
+
* ── 가린 표가 파일로 되돌아가는 것을 막는다 ─────────────────────────────
|
|
264
|
+
*
|
|
265
|
+
* 위 머리말이 걱정한 바로 그 일이, 파일을 안 가려도 일어날 수 있다.
|
|
266
|
+
*
|
|
267
|
+
* 1. 모델이 `cat .env` 를 부른다 → Bash 라서 우리가 가린다
|
|
268
|
+
* 2. 모델은 «가림:환경변수» 를 진짜 값으로 알고
|
|
269
|
+
* 3. Write 로 `.env` 를 다시 쓴다 → **진짜 열쇠가 있던 자리에 표가 적힌다**
|
|
270
|
+
*
|
|
271
|
+
* 가렸다는말() 로 "이건 진짜 값이 아니다" 라고 일러 주고는 있지만, 그건 부탁이지
|
|
272
|
+
* 자물쇠가 아니다. 못 알아들은 모델 하나면 사람의 열쇠가 없어진다. 되돌리기가
|
|
273
|
+
* 있어도 사람은 무엇이 없어졌는지조차 모른다 — 파일은 멀쩡해 보인다.
|
|
274
|
+
*
|
|
275
|
+
* 그래서 쓰는 자리에서 막는다. 이 표는 **우리가 만든 글자**라, 파일에 들어갈
|
|
276
|
+
* 이유가 처음부터 없다.
|
|
277
|
+
*/
|
|
278
|
+
|
|
279
|
+
/** 가린 자리에 남는 표. 종류가 붙은 것과 안 붙은 것(주소가리기) 둘 다. */
|
|
280
|
+
export const 표무늬 = /«가림(?::[^»]*)?»/g;
|
|
281
|
+
|
|
282
|
+
/** 이 글에 가린 표가 몇 군데 있나. */
|
|
283
|
+
export function 표몇군데(글) {
|
|
284
|
+
return (String(글 ?? '').match(표무늬) ?? []).length;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 파일에 쓰려는 글에 표가 섞였을 때 할 말.
|
|
289
|
+
*
|
|
290
|
+
* 무엇을 해야 하는지까지 적는다. "안 됩니다" 만 하면 모델은 같은 것을 다시
|
|
291
|
+
* 보내거나, 표를 지우고 빈 값으로 써서 결국 열쇠를 없앤다.
|
|
292
|
+
*/
|
|
293
|
+
export function 표막는말(보인이름, 몇) {
|
|
294
|
+
return `쓰려는 내용에 deel 이 가린 표(«가림:…»)가 ${몇}군데 있습니다: ${보인이름}\n`
|
|
295
|
+
+ ' 그건 진짜 값이 아니라 비밀을 가린 자리입니다. 그대로 쓰면 그 자리의 진짜 값이 없어집니다.\n'
|
|
296
|
+
+ ' 이 파일을 고쳐야 한다면 Edit 으로 **가린 자리를 건드리지 않는 부분만** 바꾸세요.'
|
|
297
|
+
+ ' 그 값 자체가 필요하면 사용자에게 물어보세요.';
|
|
298
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// 첫 실행 마법사 + 진단 실행.
|
|
2
2
|
import { c, say, rule, mark } from './ui/ansi.js';
|
|
3
3
|
import { 주소가리기 } from './safety/secrets.js';
|
|
4
|
-
import { ask, pick } from './ui/prompt.js';
|
|
4
|
+
import { ask, pick, confirm } from './ui/prompt.js';
|
|
5
5
|
import { spin } from './ui/spinner.js';
|
|
6
6
|
import { detect } from './backend/detect.js';
|
|
7
|
+
import { 규격이름 } from './backend/adapter.js';
|
|
7
8
|
import { probe } from './backend/probe.js';
|
|
8
9
|
import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
|
|
9
10
|
import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
|
|
10
11
|
import { 보관방식 } from './safety/keystore.js';
|
|
11
12
|
import { 애저풀기, 애저base } from './backend/azure.js';
|
|
12
13
|
import { allowEndpoint } from './safety/network.js';
|
|
14
|
+
import { 바깥인가 } from './safety/runmode.js';
|
|
15
|
+
import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭 } from './providers/index.js';
|
|
13
16
|
import { writeFileSync } from 'node:fs';
|
|
14
17
|
|
|
15
18
|
export function banner() {
|
|
@@ -19,7 +22,11 @@ export function banner() {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
// 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
|
|
22
|
-
|
|
25
|
+
//
|
|
26
|
+
// 조용히 = 후보를 여럿 훑는 중이라, 실패해도 아직 화면에 안 적는다.
|
|
27
|
+
// 세 개를 줄줄이 시도하면서 실패 안내를 세 번 찍으면, 마지막에 성공해도
|
|
28
|
+
// 사람 눈에는 「뭔가 잔뜩 실패했다」 로 남는다.
|
|
29
|
+
async function connect(url, key, { 조용히 = false, 제공자: 어디 = null } = {}) {
|
|
23
30
|
const s = spin(`${url} 확인 중...`);
|
|
24
31
|
/*
|
|
25
32
|
* 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
|
|
@@ -31,19 +38,54 @@ async function connect(url, key) {
|
|
|
31
38
|
* 그 호스트 하나뿐이라 넓어지는 것이 아니다.
|
|
32
39
|
*/
|
|
33
40
|
allowEndpoint(/^https?:\/\//i.test(url) ? url : [`http://${url}`, `https://${url}`]);
|
|
34
|
-
|
|
41
|
+
/*
|
|
42
|
+
* 자물쇠가 막으면 **던져서 끝나지 않게** 받아 둔다.
|
|
43
|
+
*
|
|
44
|
+
* 봉인(offline)이 켜져 있으면 checkUrl 이 예외를 던진다. 예전에는 그것이
|
|
45
|
+
* 여기를 그냥 지나쳐 `deel setup` 전체를 죽였다 — 화면에는 스택 자국이
|
|
46
|
+
* 뜨고, 사람은 자기가 켜 둔 봉인 때문이라는 것을 알 길이 없다.
|
|
47
|
+
*
|
|
48
|
+
* 못 붙은 것으로 치고 그 말을 아래 「연결 실패」 자리에 그대로 싣는다.
|
|
49
|
+
*/
|
|
50
|
+
let found;
|
|
51
|
+
try { found = await detect(url, key); }
|
|
52
|
+
catch (err) { found = { kind: null, tried: [url], status: 0, why: String(err?.message ?? err) }; }
|
|
35
53
|
if (!found.kind) {
|
|
54
|
+
if (조용히) { s.stop(''); return null; }
|
|
36
55
|
s.stop(` ${mark.no} ${c.red('연결 실패')}`);
|
|
37
56
|
say('');
|
|
57
|
+
/*
|
|
58
|
+
* 무엇이 막았는지를 먼저 말한다 (providers/index.js 의 막힌까닭).
|
|
59
|
+
*
|
|
60
|
+
* 여태는 401 도 404 도 403 도 전부 「연결 실패」 한 마디였다. 그런데 이
|
|
61
|
+
* 셋은 고칠 자리가 완전히 다르다 — 401 은 닿은 것이라 주소를 의심하면
|
|
62
|
+
* 안 되고, 404 는 주소를 봐야 하고, Bedrock 의 AccessDenied 는 콘솔에서
|
|
63
|
+
* 신청 버튼 한 번 누르면 되는 일이다. 뭉쳐 놓으면 사람은 셋 다 주소
|
|
64
|
+
* 문제로 알고 엉뚱한 데를 판다.
|
|
65
|
+
*
|
|
66
|
+
* 모르는 것은 지어내지 않는다 — 그때는 아래 「확인할 것」 이 그대로 남는다.
|
|
67
|
+
*/
|
|
68
|
+
const 까닭 = 막힌까닭(어디, { status: found.status ?? 0, 서버말: found.why ?? '' });
|
|
69
|
+
/*
|
|
70
|
+
* 옮길 말이 없으면 **받은 말을 그대로** 보여 준다.
|
|
71
|
+
*
|
|
72
|
+
* 사람 말로 옮기는 표는 HTTP 상태코드가 있을 때만 쓸모가 있다. 닿지도
|
|
73
|
+
* 못했을 때(status 0)가 오히려 원인이 또렷한 경우가 많다 — 봉인에 막힘 ·
|
|
74
|
+
* 포트 닫힘 · 인증서 · 프록시. 그 한 줄을 버리면 화면에는 「연결 실패」
|
|
75
|
+
* 네 글자만 남고, 정작 답이 적힌 문장을 우리가 지운 셈이 된다.
|
|
76
|
+
*/
|
|
77
|
+
if (까닭) { say(` ${mark.warn} ${c.yellow(까닭)}`); say(''); }
|
|
78
|
+
else if (found.why) { say(` ${mark.warn} ${c.yellow(found.why)}`); say(''); }
|
|
38
79
|
say(` 시도한 주소:`);
|
|
39
80
|
for (const t of found.tried) say(` ${c.gray(주소가리기(t.includes('?') ? t : `${t}/models`))}`);
|
|
40
81
|
say('');
|
|
41
82
|
say(` ${c.gray('확인할 것 — 주소·포트가 맞는지, 프록시가 필요한지,')}`);
|
|
42
83
|
say(` ${c.gray('사내 인증서라면 NODE_EXTRA_CA_CERTS 환경변수가 필요합니다.')}`);
|
|
84
|
+
if (어디?.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(어디.열쇠받는곳)}`);
|
|
43
85
|
say('');
|
|
44
86
|
return null;
|
|
45
87
|
}
|
|
46
|
-
const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() :
|
|
88
|
+
const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() : 규격이름(found.kind);
|
|
47
89
|
s.stop(` ${mark.ok} ${c.green('연결됨')} ${c.gray(`${kindName} · ${found.ms}ms`)}`);
|
|
48
90
|
// 물음표 뒤에 열쇠를 싣는 앞단이 있다 (safety/secrets.js 의 주소가리기).
|
|
49
91
|
say(` ${c.gray('주소')} ${주소가리기(found.base)}`);
|
|
@@ -80,22 +122,143 @@ export async function runProbe(conn, { out = null } = {}) {
|
|
|
80
122
|
return { facts, results, v };
|
|
81
123
|
}
|
|
82
124
|
|
|
125
|
+
/*
|
|
126
|
+
* ── 어디에 붙일까 ───────────────────────────────────────────────────────
|
|
127
|
+
*
|
|
128
|
+
* 세 갈래가 다 같은 문으로 들어간다. 다른 것은 **사람이 채울 빈칸 개수**뿐이다.
|
|
129
|
+
*
|
|
130
|
+
* 열쇠만 있을 때 빈칸 1개 — 앞머리로 어디 것인지 알아본다
|
|
131
|
+
* 아는 곳에서 고르기 빈칸 1~2개 — 주소를 대신 채워 준다
|
|
132
|
+
* 주소를 직접 넣기 빈칸 2개 — 규격·인증은 스캐너가 알아낸다
|
|
133
|
+
*
|
|
134
|
+
* 「직접 넣기」 를 목록 위쪽에 둔다. 목록이 지원 명단처럼 보이면, 거기 없는
|
|
135
|
+
* 회사는 안 되는 줄 알고 돌아선다 (providers/index.js 머리말).
|
|
136
|
+
*
|
|
137
|
+
* @returns {{제공자, 주소들: string[], 열쇠: string, 이름: string} | null}
|
|
138
|
+
*/
|
|
139
|
+
async function 붙일곳고르기() {
|
|
140
|
+
const 목록 = [
|
|
141
|
+
{ id: '열쇠먼저', label: '열쇠만 있습니다 — 어디 것인지 알아봐 주세요', note: '빈칸 1개' },
|
|
142
|
+
{ id: 'custom', label: '주소를 직접 넣기', note: '사내 게이트웨이 · 목록에 없는 곳' },
|
|
143
|
+
...제공자들.filter((p) => p.id !== 'custom').map((p) => ({
|
|
144
|
+
id: p.id,
|
|
145
|
+
label: p.이름,
|
|
146
|
+
note: `빈칸 ${p.빈칸.length}개 (${p.빈칸.join(' + ')})`
|
|
147
|
+
+ (p.규격됐나 === false ? ' · 규격 붙이는 중' : ''),
|
|
148
|
+
})),
|
|
149
|
+
];
|
|
150
|
+
const i = await pick('어디에 붙일까요?', 목록, { def: 1 });
|
|
151
|
+
const 고른 = 목록[i];
|
|
152
|
+
|
|
153
|
+
// ── 열쇠 먼저 ────────────────────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// 열쇠를 여러 곳에 던져 보고 200 이 오는 데를 찾지 **않는다.** 그러면 남의
|
|
156
|
+
// 서버에 내 열쇠가 남는다. 앞머리로 짐작해 한 곳만 묻고, 모르면 물어본다.
|
|
157
|
+
let 제공자 = null;
|
|
158
|
+
let 열쇠 = '';
|
|
159
|
+
if (고른.id === '열쇠먼저') {
|
|
160
|
+
열쇠 = (await ask('API 키', { mask: true })).trim();
|
|
161
|
+
if (!열쇠) { say(` ${mark.no} 열쇠가 비었습니다.`); return null; }
|
|
162
|
+
const 짚은것 = 어디것일까(열쇠);
|
|
163
|
+
if (짚은것) {
|
|
164
|
+
제공자 = 짚은것.제공자;
|
|
165
|
+
say(` ${mark.ok} ${c.bold(제공자.이름)} 열쇠로 보입니다. ${c.gray(`(${짚은것.왜})`)}`);
|
|
166
|
+
say(` ${c.gray('여기 말고 다른 데는 안 물어봅니다 — 열쇠를 여기저기 던지지 않습니다.')}`);
|
|
167
|
+
} else {
|
|
168
|
+
say(` ${mark.warn} ${c.yellow('어디 열쇠인지 모르겠습니다.')}`);
|
|
169
|
+
say(` ${c.gray('짐작으로 여기저기 보내지 않습니다. 어디 것인지 골라 주세요.')}`);
|
|
170
|
+
const 나머지 = 제공자들.map((p) => ({ label: p.이름, note: p.한줄 }));
|
|
171
|
+
제공자 = 제공자들[await pick('어디 열쇠인가요?', 나머지, { def: 0 })];
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
제공자 = 제공자고르기(고른.id);
|
|
175
|
+
}
|
|
176
|
+
if (!제공자) return null;
|
|
177
|
+
|
|
178
|
+
/*
|
|
179
|
+
* 아직 말할 줄 모르는 규격은 여기서 멈춘다.
|
|
180
|
+
*
|
|
181
|
+
* 열쇠를 받고, 연결도 되고, 저장까지 해 놓고 첫 한마디에서 400 이 나면
|
|
182
|
+
* 그 화면으로는 무엇이 잘못됐는지 알 길이 없다. 못 하는 것은 못 한다고
|
|
183
|
+
* 지금 말하는 편이 언제나 싸다.
|
|
184
|
+
*/
|
|
185
|
+
if (제공자.규격됐나 === false) {
|
|
186
|
+
say('');
|
|
187
|
+
say(` ${mark.no} ${c.yellow(`${제공자.이름} 는 규격을 아직 다 못 붙였습니다.`)}`);
|
|
188
|
+
say(` ${c.gray('주소와 열쇠는 아는데 말을 아직 못 합니다. 붙는 대로 열립니다.')}`);
|
|
189
|
+
say(` ${c.gray('지금 쓰시려면 OpenAI 호환 창구가 있는 곳(Gemini · Bedrock)을 골라 주세요.')}`);
|
|
190
|
+
say('');
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 리전 ─────────────────────────────────────────────────────────────
|
|
195
|
+
//
|
|
196
|
+
// 리전을 고르면 그 자리에서 진짜로 물어본다. 표에 「서울에 무슨 모델이
|
|
197
|
+
// 있다」 를 박아 두지 않는다 — 그 표는 반드시 낡는다.
|
|
198
|
+
let 리전 = null;
|
|
199
|
+
if (제공자.리전들) {
|
|
200
|
+
const 것들 = [...제공자.리전들.map((r) => ({ label: `${r.id} ${r.어디}`, note: r.왜 })),
|
|
201
|
+
{ label: '직접 입력', note: '목록은 낡습니다 — 빠져나갈 구멍을 늘 둡니다' }];
|
|
202
|
+
const j = await pick('어느 리전인가요?', 것들, { def: 0 });
|
|
203
|
+
리전 = j < 제공자.리전들.length
|
|
204
|
+
? 제공자.리전들[j].id
|
|
205
|
+
: (await ask('리전 (예: ap-northeast-2)')).trim();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── 주소 ─────────────────────────────────────────────────────────────
|
|
209
|
+
let 주소들 = 주소후보(제공자, { 리전 });
|
|
210
|
+
if (!주소들.length) {
|
|
211
|
+
if (제공자.리전들 && 리전) {
|
|
212
|
+
say(` ${mark.no} ${c.yellow(`리전 이름이 이상합니다: ${리전}`)} ${c.gray('(예: us-east-1 · ap-northeast-2)')}`);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const url = (await ask('주소 (Base URL)')).trim();
|
|
216
|
+
if (!url) { say(` ${mark.no} 주소가 비었습니다.`); return null; }
|
|
217
|
+
주소들 = [url];
|
|
218
|
+
} else {
|
|
219
|
+
say(` ${c.gray('주소')} ${주소들.map((u) => 주소가리기(u)).join(c.gray(' · '))}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 열쇠 ─────────────────────────────────────────────────────────────
|
|
223
|
+
if (!열쇠) {
|
|
224
|
+
if (제공자.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(제공자.열쇠받는곳)}`);
|
|
225
|
+
열쇠 = (await ask('API 키', { mask: true })).trim();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const 기본이름 = 제공자.id === 'custom' ? '사내게이트웨이' : 제공자.이름;
|
|
229
|
+
const 이름 = (await ask('이름', { def: 기본이름 })).trim() || 기본이름;
|
|
230
|
+
return { 제공자, 주소들, 열쇠, 이름 };
|
|
231
|
+
}
|
|
232
|
+
|
|
83
233
|
export async function runSetup() {
|
|
84
234
|
banner();
|
|
85
235
|
// 설정에 적어 둔 api-version 을 **붙기 전에** 읽는다. load() 가 애저정하기()를
|
|
86
236
|
// 부른다. 이걸 뒤에서 하면 사내에서 판을 고정해 둔 곳이 확인만 GA판으로 하고,
|
|
87
237
|
// 문서에 적어 둔 대로 안 도는 셈이 된다.
|
|
88
238
|
load();
|
|
89
|
-
say(` ${c.gray('모델 연결을 설정합니다.
|
|
90
|
-
|
|
239
|
+
say(` ${c.gray('모델 연결을 설정합니다.')}`);
|
|
240
|
+
// 빈칸 0개짜리 길을 제일 먼저 알려 준다. 이 PC 에 이미 모델이 떠 있는데
|
|
241
|
+
// 주소를 손으로 치게 만드는 것은 우리 잘못이다.
|
|
242
|
+
say(` ${c.gray('이 PC 에 로컬 모델이 떠 있다면')} ${c.cyan('deel scan --save')} ${c.gray('가 빈칸 없이 찾아 줍니다.')}`);
|
|
91
243
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const key = (await ask('API 키', { mask: true })).trim();
|
|
244
|
+
const 고른것 = await 붙일곳고르기();
|
|
245
|
+
if (!고른것) return 1;
|
|
246
|
+
const { 제공자: 붙일곳, 주소들, 열쇠: key, 이름: name } = 고른것;
|
|
96
247
|
|
|
97
248
|
say('');
|
|
98
|
-
|
|
249
|
+
/*
|
|
250
|
+
* 후보를 앞에서부터 두드린다. 하나라도 붙으면 그것으로 간다.
|
|
251
|
+
*
|
|
252
|
+
* Bedrock 처럼 창구가 여럿인 곳이 있고, 리전·계정마다 열린 창구가 다르다.
|
|
253
|
+
* 표를 믿고 하나만 쓰면 그 표가 낡은 날 「연결 실패」 만 남는다 —
|
|
254
|
+
* 물어보면 되는 것을 짐작으로 정하지 않는다.
|
|
255
|
+
*/
|
|
256
|
+
let found = null;
|
|
257
|
+
for (const [n, url] of 주소들.entries()) {
|
|
258
|
+
const 마지막 = n === 주소들.length - 1;
|
|
259
|
+
found = await connect(url, key, { 조용히: !마지막, 제공자: 붙일곳 });
|
|
260
|
+
if (found) break;
|
|
261
|
+
}
|
|
99
262
|
if (!found) return 1;
|
|
100
263
|
|
|
101
264
|
const model = await chooseModel(found);
|
|
@@ -116,6 +279,29 @@ export async function runSetup() {
|
|
|
116
279
|
const conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
|
|
117
280
|
const { facts } = await runProbe(conn);
|
|
118
281
|
|
|
282
|
+
/*
|
|
283
|
+
* ── 「나가도 된다」 를 여기서 명시적으로 받는다 ───────────────────────
|
|
284
|
+
*
|
|
285
|
+
* 주소와 허가를 뗀 것이 이번 자물쇠의 요점이다(safety/runmode.js). 주소는
|
|
286
|
+
* 설정 파일에 그대로 있고 손으로도 고칠 수 있지만, 허가는 **사람이 고른
|
|
287
|
+
* 자리에서만** 붙는다. 그래서 둘 다 있어야 나간다 — 설정 파일 한 줄을
|
|
288
|
+
* 고쳐서는 못 나간다.
|
|
289
|
+
*
|
|
290
|
+
* 여기서 안 받아도 대화 화면이 첫 켤 때 다시 묻는다. 그러니 이건 막는
|
|
291
|
+
* 자리가 아니라, **지금 사실을 알려 주는** 자리다. 주소를 방금 친 사람이
|
|
292
|
+
* 그 주소가 바깥이라는 것을 제일 잘 알아들을 자리이기도 하다.
|
|
293
|
+
*/
|
|
294
|
+
let 나가도되나 = false;
|
|
295
|
+
if (바깥인가(found.base)) {
|
|
296
|
+
say('');
|
|
297
|
+
say(` ${c.yellow('↗')} ${c.bold(주소가리기(found.base))} ${c.gray('는 이 컴퓨터 밖입니다.')}`);
|
|
298
|
+
say(` ${c.gray('쓰면 시킨 말과, 모델이 읽은 파일의 내용이 그리로 갑니다.')}`);
|
|
299
|
+
나가도되나 = await confirm('이 연결로 바깥에 나가도 될까요?', true);
|
|
300
|
+
if (!나가도되나) {
|
|
301
|
+
say(` ${c.gray('허락 없이 저장합니다 — 이 연결로 처음 켤 때 다시 물어봅니다.')}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
119
305
|
const id = slug(name);
|
|
120
306
|
const cfg = load();
|
|
121
307
|
upsert(cfg, {
|
|
@@ -125,6 +311,12 @@ export async function runSetup() {
|
|
|
125
311
|
auth: found.auth,
|
|
126
312
|
apiKey: key,
|
|
127
313
|
model,
|
|
314
|
+
// 어디 것인지 적어 둔다. 요금표 주소·열쇠 받는 곳처럼 「이 회사에만 해당되는
|
|
315
|
+
// 안내」를 나중에 꺼내 쓸 때 이 한 칸이 없으면 주소를 보고 다시 짐작해야 한다.
|
|
316
|
+
제공자: 붙일곳.id,
|
|
317
|
+
// 로컬·사내망이면 이 칸 자체가 없다. 있는 것만으로도 「바깥이다」 라는 뜻이라,
|
|
318
|
+
// 설정 파일을 열어 본 사람이 그것만 보고도 안다.
|
|
319
|
+
...(나가도되나 ? { online: true } : {}),
|
|
128
320
|
ctx: facts.ctx ?? null,
|
|
129
321
|
streaming: facts.streaming ?? false,
|
|
130
322
|
tools: facts.tools ?? false,
|
|
@@ -200,7 +392,18 @@ export async function showStatus() {
|
|
|
200
392
|
p.think ? c.green('추론') : c.gray('추론'),
|
|
201
393
|
].join(c.gray('·'));
|
|
202
394
|
say(` ${c.bold(p.name)} ${c.gray(p.model)} ${caps}${here}`);
|
|
203
|
-
|
|
395
|
+
/*
|
|
396
|
+
* 바깥으로 나가는 연결인지, 그리고 허락이 붙어 있는지를 같이 적는다.
|
|
397
|
+
*
|
|
398
|
+
* 주소만 보고는 모른다 — `gw.회사.com` 이 사내인지 바깥인지는 사람이
|
|
399
|
+
* 헷갈리는 자리다. 그리고 허락이 없으면 켤 때 물어본다는 사실도 여기서
|
|
400
|
+
* 미리 알려 준다. 안 그러면 "왜 갑자기 물어보지" 가 된다.
|
|
401
|
+
*/
|
|
402
|
+
const 바깥 = 바깥인가(p.baseUrl);
|
|
403
|
+
const 표 = 바깥
|
|
404
|
+
? `${c.yellow('↗ 바깥')}${p.online ? c.gray(' · 나가도 됨') : c.gray(' · 켤 때 물어봄')}`
|
|
405
|
+
: c.green('⌂ 이 안');
|
|
406
|
+
say(` ${c.gray(주소가리기(p.baseUrl))} ${표}`);
|
|
204
407
|
}
|
|
205
408
|
say('');
|
|
206
409
|
say(` ${c.gray('설정 파일')} ${configPath()}`);
|
package/src/tools/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { 도구설명EN } from './desc.en.js';
|
|
|
32
32
|
import { 그림인가, 그림읽기, 크기말, 기본한도 } from '../backend/vision.js';
|
|
33
33
|
import { 빠르게찾기, 엔진말, 안볼정규식 } from './fastgrep.js';
|
|
34
34
|
import { 지시말 } from '../i18n/index.js';
|
|
35
|
+
import { 표몇군데, 표막는말 } from '../safety/secrets.js';
|
|
35
36
|
|
|
36
37
|
/*
|
|
37
38
|
* 한 번에 돌려줄 양은 **모델에 맞춰** 정한다 (agent/budget.js).
|
|
@@ -190,6 +191,23 @@ function 그림잃나(abs, 새내용) {
|
|
|
190
191
|
* 것이 반드시 나온다. 내용을 보고 정하면 목록을 관리할 일이 없다.
|
|
191
192
|
* @returns {string|null} 막을 이유. 써도 되면 null.
|
|
192
193
|
*/
|
|
194
|
+
/*
|
|
195
|
+
* 우리가 가린 표를 파일에 되돌려 쓰려 하나.
|
|
196
|
+
*
|
|
197
|
+
* 명령 출력의 비밀은 이미 가리고 있다(safety/secrets.js). 그런데 모델은 그
|
|
198
|
+
* «가림:…» 을 **진짜 값으로 알고** 파일에 그대로 옮겨 쓸 수 있다. 그러면
|
|
199
|
+
* 진짜 열쇠가 있던 자리에 표가 적힌다 — 비밀을 지키려다 비밀을 지우는 셈이고,
|
|
200
|
+
* 파일은 멀쩡해 보여서 사람은 무엇이 없어졌는지조차 모른다.
|
|
201
|
+
*
|
|
202
|
+
* "이건 진짜 값이 아닙니다" 라고 일러 주고는 있지만 그건 부탁이지 자물쇠가
|
|
203
|
+
* 아니다. 못 알아들은 모델 하나면 그것으로 끝이다. 그림잃나() 와 같은 자리,
|
|
204
|
+
* 같은 까닭이다 — **우리가 안 보여 준 것을 모델이 지우게 두지 않는다.**
|
|
205
|
+
*/
|
|
206
|
+
function 가린표되돌리나(보인이름, 새내용) {
|
|
207
|
+
const 몇 = 표몇군데(새내용);
|
|
208
|
+
return 몇 ? 표막는말(보인이름, 몇) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
193
211
|
function 바이너리인가(abs) {
|
|
194
212
|
if (!existsSync(abs)) return null;
|
|
195
213
|
let buf;
|
|
@@ -588,10 +606,24 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
|
|
|
588
606
|
function 여러개옮기기(목록, ctx) {
|
|
589
607
|
const 된것 = [];
|
|
590
608
|
const 안된것 = [];
|
|
609
|
+
/*
|
|
610
|
+
* ── 무엇이 움직였는지 모아서 돌려준다 ──────────────────────────────
|
|
611
|
+
*
|
|
612
|
+
* 여기서 한 개짜리 결과의 `changed` 를 버리고 있었다. 그래서 스물두
|
|
613
|
+
* 개를 옮기면 화면에는 `22개 옮김` 이 뜨는데, **턴이 아는 「손댄
|
|
614
|
+
* 파일」 은 0개**였다. 그 값은 여러 군데가 본다 — 턴 끝의 만든 파일
|
|
615
|
+
* 목록, `/commit`, 그리고 헛도는지 재는 자리. 그 자리들이 전부
|
|
616
|
+
* "아무 일도 안 일어났다" 로 알고 있었다.
|
|
617
|
+
*
|
|
618
|
+
* 한 개씩 옮길 때는 멀쩡했다. 배열로 부를 때만 새던 자리다.
|
|
619
|
+
*/
|
|
620
|
+
const 바뀐것들 = [];
|
|
591
621
|
for (const 하나 of 목록) {
|
|
592
622
|
const r = 한개옮기기(하나, ctx);
|
|
593
|
-
if (r.error) 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`);
|
|
594
|
-
|
|
623
|
+
if (r.error) { 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`); continue; }
|
|
624
|
+
된것.push(r.content);
|
|
625
|
+
// 폴더를 옮겼으면 그 안의 파일 하나하나가, 아니면 옮겨 간 자리가 답이다.
|
|
626
|
+
for (const f of r.바뀐것들 ?? (r.changed ? [r.changed] : [])) 바뀐것들.push(f);
|
|
595
627
|
}
|
|
596
628
|
/*
|
|
597
629
|
* 하나도 못 옮겼으면 오류로 돌려준다. 그래야 모델이 '됐다' 고 넘어가지
|
|
@@ -605,6 +637,7 @@ function 여러개옮기기(목록, ctx) {
|
|
|
605
637
|
+ (안된것.length ? `\n${안된것.map((e) => ` ✗ ${e.split('\n')[0]}`).join('\n')}` : '')
|
|
606
638
|
+ (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
|
|
607
639
|
summary: `${된것.length}개 옮김${안된것.length ? ` · ${안된것.length}개 실패` : ''}`,
|
|
640
|
+
바뀐것들,
|
|
608
641
|
};
|
|
609
642
|
}
|
|
610
643
|
|
|
@@ -630,6 +663,9 @@ function 한파일쓰기(args, ctx) {
|
|
|
630
663
|
// 우리가 안 보여 준 그림을 모델이 지우게 두지 않는다 (그림잃나 머리말).
|
|
631
664
|
const 그림막기 = 그림잃나(abs, args.content);
|
|
632
665
|
if (그림막기) return { error: 그림막기 };
|
|
666
|
+
// 우리가 가린 비밀도 마찬가지다 (가린표되돌리나 머리말).
|
|
667
|
+
const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.content);
|
|
668
|
+
if (표막기) return { error: 표막기 };
|
|
633
669
|
ctx.history.snapshot(abs, 'Write');
|
|
634
670
|
const existed = existsSync(abs);
|
|
635
671
|
// 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
|
|
@@ -726,6 +762,10 @@ function 한군데고치기(args, ctx) {
|
|
|
726
762
|
if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
|
|
727
763
|
if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
|
|
728
764
|
if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
|
|
765
|
+
// 새로 넣을 글에만 본다. old_string 쪽은 **찾는 말**이라 표가 들어 있어도
|
|
766
|
+
// 파일이 안 바뀐다 — 못 찾고 끝날 뿐이다 (가린표되돌리나 머리말).
|
|
767
|
+
const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.new_string);
|
|
768
|
+
if (표막기) return { error: 표막기 };
|
|
729
769
|
|
|
730
770
|
const 읽음 = readTextFull(abs);
|
|
731
771
|
const text = 읽음.text;
|
|
@@ -1064,6 +1104,10 @@ export const TOOLS = {
|
|
|
1064
1104
|
const existed = existsSync(abs);
|
|
1065
1105
|
if (existed && statSync(abs).isDirectory()) return { error: `폴더입니다: ${args.file_path}` };
|
|
1066
1106
|
|
|
1107
|
+
// 이어 붙이는 조각에도 가린 표가 섞이면 안 된다 (가린표되돌리나 머리말).
|
|
1108
|
+
const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.content);
|
|
1109
|
+
if (표막기) return { error: 표막기 };
|
|
1110
|
+
|
|
1067
1111
|
// Append 는 한 턴에 여러 번 불리는 것이 정상이다. 그래도 되돌리기 이력에
|
|
1068
1112
|
// 사본이 쌓이지 않는다 — History.snapshot 이 턴마다 한 번만 뜬다(undo.js).
|
|
1069
1113
|
ctx.history.snapshot(abs, 'Append');
|
package/src/tools/todo.js
CHANGED
|
@@ -95,6 +95,34 @@ export const TODO_TOOL = {
|
|
|
95
95
|
const 이전 = ctx.todos ?? [];
|
|
96
96
|
ctx.todos = items;
|
|
97
97
|
|
|
98
|
+
/*
|
|
99
|
+
* ── 똑같은 목록을 또 보냈으면 그렇다고 말한다 ────────────────────────
|
|
100
|
+
*
|
|
101
|
+
* 「폴더 정리 해줘」 에서 이 자리가 걸렸다. 모델이 파일을 옮긴 뒤
|
|
102
|
+
* 목록을 갱신하려 했는데, 끝난 줄을 done 으로 안 바꾸고 **글자 하나
|
|
103
|
+
* 안 틀린 같은 목록**을 다시 보냈다. 그러면 여기서는 성공으로 돌려주고
|
|
104
|
+
* 「3개 중 1개 완료」 라는 앞과 똑같은 글이 나간다. 모델 쪽에서 보면
|
|
105
|
+
* 갱신이 된 것이므로, 다음 걸음에 또 같은 것을 보낸다. 그렇게 세 번이면
|
|
106
|
+
* 헛돈다고 판정돼 턴이 죽었다.
|
|
107
|
+
*
|
|
108
|
+
* 오류로 만들지는 않는다. 목록은 실제로 저장됐고, 틀린 것을 한 것도
|
|
109
|
+
* 아니다. 다만 **아무 일도 안 일어났다는 사실**과 그럴 때 무엇을 해야
|
|
110
|
+
* 하는지를 그 자리에서 알려 준다.
|
|
111
|
+
*/
|
|
112
|
+
const 같은목록 = 이전.length === items.length
|
|
113
|
+
&& items.every((x, i) => 이전[i]?.text === x.text && 이전[i]?.state === x.state);
|
|
114
|
+
if (같은목록) {
|
|
115
|
+
return {
|
|
116
|
+
content: `${render(items)}\n\n`
|
|
117
|
+
+ '이 목록은 앞에 보낸 것과 글자 하나까지 같습니다 — 바뀐 것이 없습니다.\n'
|
|
118
|
+
+ '방금 끝낸 일이 있으면 그 줄을 done 으로 바꿔서 보내고,'
|
|
119
|
+
+ ' 없으면 목록을 다시 보내지 말고 다음 일을 하세요.',
|
|
120
|
+
summary: '그대로 (바뀐 것 없음)',
|
|
121
|
+
todos: items,
|
|
122
|
+
안바뀜: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
const 끝난것 = items.filter((x) => x.state === 'done').length;
|
|
99
127
|
const 새로끝난 = items.filter((x) =>
|
|
100
128
|
x.state === 'done' && !이전.some((y) => y.text === x.text && y.state === 'done'));
|