deel-local-cli 1.6.3 → 1.8.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.
Files changed (53) hide show
  1. package/README.ko.md +1182 -0
  2. package/README.md +850 -636
  3. package/bin/deel.js +44 -3
  4. package/package.json +3 -3
  5. package/src/acp/map.js +1 -1
  6. package/src/acp/serve.js +25 -2
  7. package/src/agent/loop.js +41 -9
  8. package/src/agent/session.js +15 -9
  9. package/src/backend/adapter.js +277 -4
  10. package/src/backend/ctxsize.js +16 -2
  11. package/src/backend/detect.js +104 -6
  12. package/src/backend/price.js +197 -0
  13. package/src/backend/retry.js +3 -0
  14. package/src/backend/scanui.js +2 -1
  15. package/src/backend/vision.js +26 -4
  16. package/src/commands.js +141 -38
  17. package/src/completion.js +21 -2
  18. package/src/i18n/en.js +160 -1
  19. package/src/i18n/index.js +22 -0
  20. package/src/i18n/ja.js +152 -1
  21. package/src/i18n/ko.js +173 -1
  22. package/src/i18n/zh.js +152 -1
  23. package/src/oneshot.js +39 -6
  24. package/src/pack/selfpack.js +1 -1
  25. package/src/providers/anthropic.js +48 -0
  26. package/src/providers/bedrock.js +102 -0
  27. package/src/providers/custom.js +65 -0
  28. package/src/providers/gemini.js +64 -0
  29. package/src/providers/index.js +121 -0
  30. package/src/providers/openai.js +68 -0
  31. package/src/repl.js +198 -33
  32. package/src/report.js +3 -2
  33. package/src/reset.js +397 -0
  34. package/src/safety/audit.js +5 -2
  35. package/src/safety/authcmd.js +305 -0
  36. package/src/safety/keystore.js +53 -0
  37. package/src/safety/runmode.js +123 -0
  38. package/src/safety/secrets.js +61 -0
  39. package/src/safety/undo.js +3 -2
  40. package/src/setup.js +215 -12
  41. package/src/tools/desc.en.js +19 -14
  42. package/src/tools/index.js +141 -43
  43. package/src/tools/jobs.js +17 -13
  44. package/src/tools/lsp.js +5 -4
  45. package/src/tools/outline.js +7 -3
  46. package/src/tools/task.js +10 -6
  47. package/src/tools/todo.js +9 -2
  48. package/src/tools/verify.js +9 -3
  49. package/src/tools/webfetch.js +2 -1
  50. package/src/ui/export.js +1 -1
  51. package/src/ui/histline.js +67 -0
  52. package/src/ui/status.js +88 -3
  53. package/README.en.md +0 -1025
@@ -0,0 +1,121 @@
1
+ /*
2
+ * 붙일 만한 곳들 — **명단이 아니라 주소 메모다.**
3
+ *
4
+ * ── 이게 「지원 목록」이 아닌 까닭 ───────────────────────────────────────
5
+ *
6
+ * 회사마다 규격도 도메인도 다르다. 명단을 코드에 박으면 그 명단이 곧 지원
7
+ * 목록이 되고, 없는 회사는 안 되는 것처럼 보인다. 그리고 반드시 낡는다.
8
+ *
9
+ * 이 도구는 처음부터 스캐너였다(backend/detect.js). 주소만 주면 규격도 인증도
10
+ * 알아낸다 — 후보를 넓혀 보고, 첫 401 에서 안 멈추고, 401/403 과 404 를
11
+ * 구분하고, 200 인데 JSON 이 아니면 성공으로 안 친다. 그 힘은 그대로 둔다.
12
+ *
13
+ * 그래서 여기 있는 것들의 역할은 딱 하나다 —
14
+ * **아무도 못 외우는 주소를 대신 적어 두는 것.**
15
+ *
16
+ * bedrock-runtime.ap-northeast-2.amazonaws.com/v1
17
+ * generativelanguage.googleapis.com/v1beta/openai/
18
+ *
19
+ * 이걸 외워서 치라고 하면 아무도 안 쓴다. 목록에 없는 곳은 「주소를 직접
20
+ * 넣기」로 그냥 된다 — 화면에서도 그 길을 늘 위에 둔다.
21
+ *
22
+ * ── 늘리는 법 ───────────────────────────────────────────────────────────
23
+ *
24
+ * 파일 하나 더하고 아래 `제공자들` 에 줄 하나 더한다. 코드 분기가 아니라
25
+ * 데이터라서, 남이 PR 하기도 쉽고 회사가 제 것을 보태기도 쉽다.
26
+ *
27
+ * ── 열쇠를 절대 뿌리지 않는다 ───────────────────────────────────────────
28
+ *
29
+ * 「키만 붙여 넣으면 어디 것인지 알아서 찾아 주기」 는 편하지만, 그걸 여러
30
+ * 벤더에 던져 보고 200 이 오는 곳을 찾는 식으로 만들면 안 된다. 그러면
31
+ * Anthropic 열쇠가 OpenAI 서버로, 다시 Google 서버로 전송된다. 401 이 오고
32
+ * 끝이지만 **열쇠는 이미 갔다.** 「내 것이 안 나간다」 가 정체성인 도구가
33
+ * 할 짓이 아니다.
34
+ *
35
+ * 그래서 앞머리로 짐작하고 **한 곳에만** 물어본다. 모르면 뿌리지 말고 사람에게
36
+ * 묻는다. 앞머리 표가 틀려도 손해가 없다 — 틀리면 401 이 오고 그때 물어보면
37
+ * 된다. 표는 순서를 정하는 힌트지 판단이 아니다.
38
+ */
39
+ import { 제공자 as openai } from './openai.js';
40
+ import { 제공자 as anthropic } from './anthropic.js';
41
+ import { 제공자 as gemini } from './gemini.js';
42
+ import { 제공자 as bedrock } from './bedrock.js';
43
+ import { 제공자 as 직접 } from './custom.js';
44
+
45
+ /** 화면에 보일 차례. 「직접 넣기」 는 언제나 목록에 있고, 화면에서는 위에 둔다. */
46
+ export const 제공자들 = [openai, anthropic, gemini, bedrock, 직접];
47
+
48
+ /** id 로 하나 집는다. 모르면 null — 지어내지 않는다. */
49
+ export function 제공자고르기(id) {
50
+ return 제공자들.find((p) => p.id === String(id ?? '')) ?? null;
51
+ }
52
+
53
+ /**
54
+ * 이 열쇠는 어디 것인가.
55
+ *
56
+ * @returns {{제공자, 왜: string} | null} 모르면 null. **그때는 아무 데도 안 묻는다.**
57
+ *
58
+ * 확신이 서는 앞머리 하나만 본다. 겹치는 앞머리가 있으면(예: `sk-ant-` 는
59
+ * `sk-` 로도 시작한다) **긴 것이 이긴다** — 짧은 쪽이 먼저 집으면 Anthropic
60
+ * 열쇠를 OpenAI 로 보내게 된다. 그게 여기서 제일 조심할 일이다.
61
+ */
62
+ export function 어디것일까(열쇠) {
63
+ const k = String(열쇠 ?? '').trim();
64
+ if (!k) return null;
65
+ let 제일긴것 = null;
66
+ for (const p of 제공자들) {
67
+ for (const 무늬 of p.키앞머리 ?? []) {
68
+ if (!무늬.test(k)) continue;
69
+ const 길이 = 무늬.source.length;
70
+ if (!제일긴것 || 길이 > 제일긴것.길이) 제일긴것 = { 제공자: p, 길이, 무늬 };
71
+ }
72
+ }
73
+ if (!제일긴것) return null;
74
+ return { 제공자: 제일긴것.제공자, 왜: `열쇠가 ${제일긴것.무늬.source.replace(/[\^\\]/g, '')} 로 시작합니다` };
75
+ }
76
+
77
+ /**
78
+ * 이 제공자에 붙을 주소 후보들. 앞엣것이 제일 그럴듯한 것이다.
79
+ *
80
+ * 목록을 주는 것이지 정하는 것이 아니다 — 진짜로 되는지는 detect 가 물어본다.
81
+ * 표에 적힌 것을 믿고 그냥 쓰면, 그 표가 낡은 날 사람은 「연결 실패」만 본다.
82
+ */
83
+ export function 주소후보(제공자, { 리전 = null } = {}) {
84
+ if (!제공자?.주소들) return [];
85
+ return 제공자.주소들({ 리전: 리전 ?? 제공자.기본리전 ?? null }).filter(Boolean);
86
+ }
87
+
88
+ /**
89
+ * 막힌 까닭을 사람 말로.
90
+ *
91
+ * 제공자마다 처음 막히는 자리가 다르다 — Bedrock 은 모델 접근 신청, 벤더
92
+ * 직접은 잔액, 사내는 프록시. 여기서 갈라 주지 않으면 전부 「연결 실패」 한
93
+ * 마디가 되고, 사람은 주소를 의심하며 엉뚱한 데를 판다.
94
+ *
95
+ * @returns {string | null} 아는 자리만 답한다. 모르면 null 이고, 그때는
96
+ * **서버가 한 말을 그대로** 보여 주는 편이 낫다.
97
+ */
98
+ export function 막힌까닭(제공자, { status, 서버말 = '' } = {}) {
99
+ const 공통 = 공통까닭(status, String(서버말 ?? ''));
100
+ const 제것 = 제공자?.오류읽기?.({ status, 서버말: String(서버말 ?? '') }) ?? null;
101
+ return 제것 ?? 공통;
102
+ }
103
+
104
+ /*
105
+ * 어디서나 같은 뜻인 것들. 제공자 파일에 네 번 적지 않는다.
106
+ *
107
+ * 401 을 「연결 실패」로 뭉개던 것이 이번 판에서 고치는 자리다. 401 은 닿은
108
+ * 것이다 — 주소는 맞고 열쇠가 틀렸거나 만료된 것이라, 주소를 의심하면 안 된다.
109
+ */
110
+ function 공통까닭(status, 서버말) {
111
+ if (status === 401) {
112
+ return /expired|만료/i.test(서버말)
113
+ ? '열쇠가 만료됐습니다 — 새로 발급받아 다시 넣으세요.'
114
+ : '열쇠를 받아 주지 않았습니다 — 주소는 닿았습니다. 열쇠가 틀렸거나 만료됐거나, 인증 방식이 다릅니다.';
115
+ }
116
+ if (status === 403) return '닿았지만 권한이 없습니다 — 열쇠는 맞는데 이 모델·이 자원을 쓸 권한이 없습니다.';
117
+ if (status === 404) return '그 주소에 이 규격이 없습니다 — 주소를 다시 보세요 (열쇠 문제가 아닙니다).';
118
+ if (status === 429) return '너무 자주 불렀거나 한도를 다 썼습니다 — 잠시 뒤에 다시 하세요.';
119
+ if (status === 402) return '잔액·한도가 모자랍니다 — 결제 상태를 확인하세요.';
120
+ return null;
121
+ }
@@ -0,0 +1,68 @@
1
+ /*
2
+ * OpenAI 직접 (GPT · Codex).
3
+ *
4
+ * 규격이 이미 있다 — deel 의 기본 규격이 OpenAI 호환이다. 그래서 여기서 할
5
+ * 일은 주소를 대신 채우고, 막힌 까닭을 알아듣는 것뿐이다.
6
+ *
7
+ * 이 파일에는 **코드 분기가 없다.** 데이터 한 장이다. 그게 요점이다 —
8
+ * 제공자를 늘리는 일이 코드를 고치는 일이 되면, 목록에 없는 곳은 못 쓰는
9
+ * 곳이 된다 (providers/index.js 머리말).
10
+ */
11
+ export const 제공자 = {
12
+ id: 'openai',
13
+ 이름: 'OpenAI (GPT · Codex)',
14
+ 한줄: '벤더에서 바로. 열쇠 하나만 있으면 됩니다.',
15
+
16
+ // ── 축 셋 (계획표 05절) ────────────────────────────────────────────
17
+ 규격: 'openai', // conn.kind
18
+ 인증: 'bearer', // backend/http.js 의 AUTH_STYLES id
19
+ 주소들: () => ['https://api.openai.com/v1'],
20
+ 리전들: null, // 리전을 고를 일이 없다
21
+
22
+ /*
23
+ * 이 규격을 deel 이 말할 줄 아나.
24
+ *
25
+ * 다섯이 다 true 다 — 말 못 하는 것은 아예 안 싣는다는 뜻이다. 그런데도
26
+ * 칸을 남겨 둔다. 새 제공자를 붙이다가 규격이 반쯤 붙은 상태로 목록에
27
+ * 올라가는 것이 제일 나쁘기 때문이다: 목록에 보이고, 열쇠를 넣고, 연결도
28
+ * 되고, 첫 한마디에서 400 이 난다. 그 화면으로는 무엇이 잘못됐는지 알
29
+ * 길이 없다. false 로 두면 설치 화면이 「아직」 이라고 또렷하게 말하고
30
+ * 저장까지 안 간다 (setup.js).
31
+ */
32
+ 규격됐나: true,
33
+ 빈칸: ['열쇠'],
34
+ 열쇠받는곳: 'https://platform.openai.com/api-keys',
35
+
36
+ /*
37
+ * 열쇠 앞머리.
38
+ *
39
+ * `sk-proj-` 를 `sk-` 보다 먼저 두는 것이 뜻이 있는 게 아니다 — 고르는 쪽이
40
+ * **제일 긴 무늬**를 집는다(index.js 의 어디것일까). 그래야 `sk-ant-` 가
41
+ * `sk-` 에 먼저 걸려 Anthropic 열쇠가 OpenAI 로 가는 일이 안 생긴다.
42
+ */
43
+ 키앞머리: [/^sk-proj-/, /^sk-/],
44
+
45
+ /*
46
+ * 요금.
47
+ *
48
+ * **비워 둔다.** 아는 척하지 않는다.
49
+ *
50
+ * 단가는 반드시 낡는다. 옛 단가로 「$0.42」 를 찍으면 사람은 그 숫자를 믿고
51
+ * 큰 작업을 돌린다 — 0원으로 찍는 것 다음으로 나쁜 것이 낡은 값을 확인한
52
+ * 낯으로 내미는 것이다. 아는 것이 없으면 토큰만 적고 「요금 모름」 이라고
53
+ * 말한다. 쓰는 사람이 제 단가를 설정에 넣으면 그때부터 찍는다.
54
+ */
55
+ 요금: {},
56
+ 요금기준: null,
57
+ 요금표주소: 'https://openai.com/api/pricing/',
58
+
59
+ 오류읽기({ status, 서버말 }) {
60
+ if (status === 429 && /quota|billing/i.test(서버말)) {
61
+ return '이 열쇠의 한도를 다 썼거나 결제가 안 걸려 있습니다 — 사용량·결제 화면을 보세요.';
62
+ }
63
+ if (status === 404 && /model/i.test(서버말)) {
64
+ return '그 모델 이름을 이 열쇠로는 못 씁니다 — 이름이 틀렸거나, 아직 안 열린 모델입니다.';
65
+ }
66
+ return null;
67
+ },
68
+ };
package/src/repl.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
- import { 규칙모으기 } from './safety/policy.js';
3
+ import { 규칙모으기, 정책읽기 } from './safety/policy.js';
4
+ import { 받기설정 } from './safety/authcmd.js';
4
5
  import { 주소가리기 } from './safety/secrets.js';
5
6
  import { homedir } from 'node:os';
6
7
  import { resolve, basename } from 'node:path';
@@ -8,7 +9,7 @@ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
8
9
  import { headerLines } from './ui/status.js';
9
10
  import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
10
11
  import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
11
- import { 언어잡기, 말 as 옮긴말 } from './i18n/index.js';
12
+ import { 언어잡기, 말 as 옮긴말, 세말 } from './i18n/index.js';
12
13
  import { 알림채움 } from './backend/retry.js';
13
14
  import { 화면고르기 } from './ui/screen.js';
14
15
  import { STAGES } from './agent/effort.js';
@@ -24,19 +25,22 @@ import { History } from './safety/undo.js';
24
25
  import { Audit } from './safety/audit.js';
25
26
  import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식 } from './config.js';
26
27
  import { discover } from './skills/discover.js';
27
- import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
28
+ import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
29
+ import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
30
+ import { 세션요금, 돈셈, 돈말 } from './backend/price.js';
28
31
  import { Store, latest, prune } from './agent/store.js';
29
32
  import { Threads } from './agent/threads.js';
30
33
  import { 못박기 } from './agent/pins.js';
31
34
  import { 카드 } from './agent/card.js';
32
35
  import { 배움 } from './agent/evolve.js';
33
36
  import { 마크다운 } from './ui/md.js';
34
- import { askHidden } from './ui/prompt.js';
37
+ import { askHidden, confirm } from './ui/prompt.js';
35
38
  import { explain, shows as levelShows } from './ui/level.js';
36
39
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
37
40
  import { 추천, 채울글 } from './ui/complete.js';
38
41
  import { 접어쓰기 } from './ui/wrap.js';
39
42
  import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들 } from './ui/pastechip.js';
43
+ import { 이력지킴이 } from './ui/histline.js';
40
44
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
41
45
  import { renderDiff, shortStat } from './ui/diff.js';
42
46
  import { expand as expandMentions } from './agent/mention.js';
@@ -67,7 +71,7 @@ const TOOL_GLYPH = {
67
71
  function toolLabel(name, args) {
68
72
  const a = args ?? {};
69
73
  const first =
70
- a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적 ??
74
+ a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
71
75
  (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
72
76
  // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이다 — 빈 괄호를 띄우면
73
77
  // 어느 것을 보고 있는지가 화면에서 사라진다. 서너 개를 띄워 놓고 나면
@@ -76,27 +80,27 @@ function toolLabel(name, args) {
76
80
  //
77
81
  // 이름 고르기는 jobs.js 에 맡긴다. 모델이 `job` 으로 보낼 수도 있는데,
78
82
  // 여기서 `a.번호` 만 보면 도구는 제대로 도는데 화면만 빈 괄호가 된다.
79
- (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
83
+ (() => { const g = 일감인자(a); return g.번호 != null ? `${옮긴말('job.no', { n: g.번호 })}${g.끝내기 ? ` · ${옮긴말('job.stop')}` : ''}` : null; })() ??
80
84
  // 한 번에 여러 파일을 쓸 때. 빈 괄호를 띄우면 화면만 보고는 무엇을
81
85
  // 만들었는지 알 수 없다 — 첫 파일과 개수를 적는다.
82
86
  (Array.isArray(a.files) && a.files.length
83
- ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` ${a.files.length - 1}개` : ''}`
87
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` ${옮긴말('more.files', { n: a.files.length - 1 })}` : ''}`
84
88
  : null) ??
85
89
  // 한 번에 여러 군데를 고칠 때. 파일 수와 군데 수는 다르다 — 한 파일을
86
90
  // 여섯 군데 고치는 것이 보통이라 '외 5개' 라고 적으면 거짓이 된다.
87
91
  (Array.isArray(a.edits) && a.edits.length
88
- ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` ${a.edits.length - 1}군데` : ''}`
92
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` ${옮긴말('more.spots', { n: a.edits.length - 1 })}` : ''}`
89
93
  : null) ??
90
- (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
94
+ (Array.isArray(a.paths) && a.paths.length ? 세말('count', a.paths.length) : null) ??
91
95
  // 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
92
- (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
96
+ (Array.isArray(a.todos) ? 세말('hits', a.todos.length) : null) ?? '';
93
97
  const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
94
98
  const 안 = clip(String(first ?? ''), 56);
95
99
  return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
96
100
  }
97
101
 
98
102
  function toolResultLine(result, ms) {
99
- const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
103
+ const t = ms > 700 ? c.gray(` ${옮긴말('unit.sec', { n: (ms / 1000).toFixed(1) })}`) : '';
100
104
  if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
101
105
 
102
106
  /*
@@ -108,16 +112,16 @@ function toolResultLine(result, ms) {
108
112
  */
109
113
  if (typeof result?.탈 === 'number') {
110
114
  const 조각 = [];
111
- if (result.탈) 조각.push(c.red(`탈 ${result.탈}개`));
112
- if (result.확인됨) 조각.push(c.green(`확인 ${result.확인됨}개`));
113
- if (result.못확인) 조각.push(c.yellow(`못 확인 ${result.못확인}개`));
115
+ if (result.탈) 조각.push(c.red(옮긴말('sum.broken', { n: result.탈 })));
116
+ if (result.확인됨) 조각.push(c.green(옮긴말('sum.verified', { n: result.확인됨 })));
117
+ if (result.못확인) 조각.push(c.yellow(옮긴말('sum.unverified', { n: result.못확인 })));
114
118
  return `${result.탈 ? c.red('└') : c.gray('└')} `
115
- + `${조각.join(c.gray(' · ')) || c.gray('확인할 것이 없었습니다')}${t}`;
119
+ + `${조각.join(c.gray(' · ')) || c.gray(옮긴말('sum.nothingToCheck'))}${t}`;
116
120
  }
117
121
 
118
122
  // 고친 자리는 몇 줄이 늘고 줄었는지를 요약 옆에 붙인다.
119
123
  const 셈 = result?.diff ? ` ${shortStat(result.diff)}` : '';
120
- return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${셈}${t}`;
124
+ return `${c.gray('└')} ${c.gray(clip(result?.summary ?? 옮긴말('sum.done'), 80))}${셈}${t}`;
121
125
  }
122
126
 
123
127
  // 수준별로 몇 줄까지 펼칠지. 초보에게 60줄을 쏟으면 아무것도 안 읽는다.
@@ -182,6 +186,14 @@ export async function chatLoop(opts = {}) {
182
186
  const conn = {
183
187
  kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
184
188
  key: resolveKey(prof), model: prof.model,
189
+ /*
190
+ * 열쇠를 갖고 있는 대신 **받아 오는** 설정 (safety/authcmd.js).
191
+ *
192
+ * 여기서는 설정만 싣는다. 명령은 요청 직전에 부른다 — 켤 때 한 번
193
+ * 받아 두면 한 시간짜리 토큰이 세 시간짜리 대화 한복판에서 죽고,
194
+ * 그 401 은 화면에서 「열쇠가 틀렸다」 와 구별이 안 된다.
195
+ */
196
+ 열쇠받기: 받기설정(prof, { 정책값: 정책읽기().값 }),
185
197
  // 컨텍스트 길이. 순서가 곧 우선순위다 —
186
198
  // deel --ctx 655360 > 프로필에 저장된 값 > 기본값
187
199
  // 기본값으로 떨어졌다는 것은 '아직 못 쟀다' 는 뜻이다. 아래에서 그렇다고 말해 준다.
@@ -195,10 +207,29 @@ export async function chatLoop(opts = {}) {
195
207
  vision: prof.vision ?? false,
196
208
  };
197
209
 
210
+ /*
211
+ * ── 주소와 허가를 뗀다 ───────────────────────────────────────────────
212
+ *
213
+ * 여태는 baseUrl 만 보고 그 자리를 열었다. 그러면 설정 파일 한 줄만 고쳐도
214
+ * 바깥으로 나간다 — 화면에 ↗ 가 뜨긴 하지만 그건 알리는 것이지 막는 것이
215
+ * 아니다. 「완전 로컬」 이 정체성인데 코드가 지키는 것은 그 절반이었다.
216
+ *
217
+ * 이제 **둘 다 있어야** 나간다: 주소(baseUrl)와 허가(profile.online).
218
+ * 주소만 바꿔서는 못 나간다 — 실수로는 안 되고, 마음먹어야 된다.
219
+ *
220
+ * 허가를 물어야 하는 자리면 여기서 안 연다. 머리말을 보여 준 **뒤에** 묻고,
221
+ * 그때 연다 — 어디로 나가는지를 보고 답하게 하는 것이 요점이다.
222
+ */
223
+ const 바깥연결 = 바깥인가(conn.base);
224
+ const 실행모드 = 지금모드({
225
+ online: opts.online === true,
226
+ // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
227
+ offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
228
+ });
229
+ const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
198
230
  // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
199
- allowEndpoint(conn.base);
200
- // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
201
- if (opts.offline ?? prof.offline ?? cfg?.offline) setOffline(true);
231
+ if (!나감.물어볼까) allowEndpoint(conn.base);
232
+ if (실행모드.허가무시) setOffline(true);
202
233
 
203
234
  /*
204
235
  * ── 끝났을 때 알리기 ─────────────────────────────────────────────────
@@ -236,6 +267,55 @@ export async function chatLoop(opts = {}) {
236
267
  effort: opts.effort ?? 'save',
237
268
  maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
238
269
  });
270
+ // 지금 어느 실행 모드인가. 화면이 첫 줄에 이걸 그린다(ui/status.js).
271
+ // session 에 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
272
+ session.실행모드 = 실행모드;
273
+ /*
274
+ * 요금표는 사람이 설정에 적은 것만 쓴다 (backend/price.js).
275
+ *
276
+ * 여기서 한 번만 읽는다. 상태줄은 글자를 칠 때마다 다시 그려지는데,
277
+ * 그때마다 설정 파일을 열면 디스크를 초당 수십 번 두드리게 된다.
278
+ */
279
+ session.요금표 = cfg?.요금 ?? null;
280
+ session.제공자 = prof?.제공자 ?? null;
281
+
282
+ /*
283
+ * ── 열쇠를 받아 오는 연결 (safety/authcmd.js) ───────────────────────
284
+ *
285
+ * 두 자리를 여기서 잇는다.
286
+ *
287
+ * 묻기 — 우리가 남이 적어 준 명령을 띄우려는 참이다. 한 번은 물어야
288
+ * 한다. 다만 **한 판에 한 번만** 묻는다 (authcmd 가 기억한다) —
289
+ * 한 시간짜리 토큰으로 세 시간 일하면 세 번 받아 오는데, 세 번
290
+ * 다 물으면 사람은 손이 가는 대로 누른다. 관리 정책이 준
291
+ * 명령은 아예 안 묻는다.
292
+ * 표시 — 사내 로그인은 브라우저를 열고 사람을 기다린다. 그동안 화면이
293
+ * 조용하면 사람은 멈춘 줄 안다. 무엇을 기다리는지 적는다.
294
+ */
295
+ if (conn.열쇠받기) {
296
+ session.열쇠물어보기 = async (설정) => {
297
+ say('');
298
+ say(` ${mark.warn} ${c.gray('이 연결은 열쇠를 받아 옵니다. 아래 명령을 띄웁니다:')}`);
299
+ say(` ${c.white(clip(설정.명령, 88))}`);
300
+ say(` ${c.gray('받은 열쇠는 이 판이 도는 동안 메모리에만 있고, 파일로 안 적습니다.')}`);
301
+ const 예 = await confirm(' 띄울까요?', true);
302
+ say('');
303
+ return 예;
304
+ };
305
+ session.onAuth = (것) => {
306
+ if (것?.type === '시작') return 화면.돌리기(옮긴말('auth.waiting'));
307
+ if (것?.type === '끝') return 화면.돌림멈춤('');
308
+ 화면.돌림멈춤('');
309
+ if (것?.ok) {
310
+ const 초 = Math.max(0, Math.round((것.만료 - Date.now()) / 1000));
311
+ return say(` ${mark.ok} ${c.gray(옮긴말('auth.got', { 분: Math.round(초 / 60) }))}`);
312
+ }
313
+ // 못 받았어도 대화를 안 끊는다. 원래 열쇠로 가 보고, 그것도 안 되면
314
+ // 서버가 한 말이 화면에 나온다. 여기서 끊으면 무엇이 문제인지 못 본다.
315
+ say(` ${mark.warn} ${c.gray(옮긴말('auth.failed', { 왜: 것?.왜 ?? '?' }))}`);
316
+ if (것?.보인것) say(` ${c.gray(clip(String(것.보인것), 88))}`);
317
+ };
318
+ }
239
319
 
240
320
  // ── 대화 이어하기 ─────────────────────────────────────────────────────
241
321
  // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
@@ -449,6 +529,11 @@ export async function chatLoop(opts = {}) {
449
529
  // 붙여넣는 중인가 (bracketed paste). 키 처리와 'line' 이 같이 본다.
450
530
  let 붙여넣는중 = false;
451
531
 
532
+ // 위/아래로 이력을 뒤지는 동안 치던 글을 붙잡아 둔다. 왜 우리가 갖는지는
533
+ // ui/histline.js 에 — 한 줄로 줄이면, readline 의 화살표 동작이 Node 판마다
534
+ // 다르고 옛 판에서는 치던 글이 그냥 없어지기 때문이다.
535
+ const 이력지킴 = 이력지킴이();
536
+
452
537
  /*
453
538
  * ── 붙여넣은 덩이를 접어 둔다 ────────────────────────────────────────
454
539
  *
@@ -475,6 +560,9 @@ export async function chatLoop(opts = {}) {
475
560
  };
476
561
 
477
562
  rl.on('line', (원래줄) => {
563
+ // 줄이 나갔으니 붙잡아 둔 「치던 글」도 놓는다. 안 놓으면 다음 줄을 치다가
564
+ // 위/아래를 눌렀을 때 아까 보낸 글이 되살아난다.
565
+ 이력지킴.비우기();
478
566
  // 상자 안에서 바꾼 줄을 여기서 진짜 줄바꿈으로 되돌린다. 아래 코드는
479
567
  // 전부 평범한 \n 만 본다 — 줄표가 이 아래로는 한 글자도 안 새어 나간다.
480
568
  const l = 펴기(원래줄);
@@ -787,6 +875,24 @@ export async function chatLoop(opts = {}) {
787
875
  });
788
876
  return;
789
877
  }
878
+ /*
879
+ * 위/아래로 이력을 뒤져도 치던 글을 안 잃는다.
880
+ *
881
+ * readline 이 이 키를 처리한 **뒤에** 자리를 봐야 하므로 한 틱 미룬다.
882
+ * 아래 그리기보다 먼저 잡아 두는 자리이니, 되돌린 글은 이어서 도는
883
+ * 그리기가 그대로 화면에 올린다.
884
+ */
885
+ if (key && (key.name === 'up' || key.name === 'down')) {
886
+ setImmediate(() => {
887
+ const 되돌릴 = 이력지킴.되돌릴것(key.name, rl.historyIndex ?? -1);
888
+ if (되돌릴 === null || rl.line === 되돌릴) return;
889
+ rl.line = 되돌릴;
890
+ rl.cursor = 되돌릴.length;
891
+ if (입력기다림 && 묻는중 === null) {
892
+ 화면.입력갱신(session, 펴기(rl.line), rl.cursor, 지금추천(펴기(rl.line)));
893
+ }
894
+ });
895
+ }
790
896
  /*
791
897
  * 여러 키가 한꺼번에 들어와도 **한 번만** 그린다.
792
898
  *
@@ -798,6 +904,9 @@ export async function chatLoop(opts = {}) {
798
904
  그릴예정 = true;
799
905
  setImmediate(() => {
800
906
  그릴예정 = false;
907
+ // 이력을 안 뒤지는 동안의 줄을 적어 둔다 — 화살표로 돌아왔을 때
908
+ // 돌려줄 것이 이것이다. readline 이 다 처리한 뒤라야 제 값이 보인다.
909
+ 이력지킴.적기(rl.line ?? '', rl.historyIndex ?? -1);
801
910
  if (입력기다림) {
802
911
  화면.입력갱신(session, 펴기(rl.line), rl.cursor ?? 0, 지금추천(펴기(rl.line)));
803
912
  } else {
@@ -874,12 +983,12 @@ export async function chatLoop(opts = {}) {
874
983
  for (const f of files) {
875
984
  const 이름 = ctx?.scope ? ctx.scope.show(f.path) : f.path;
876
985
  if (f.missing) {
877
- say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray('— 만들어지지 않았습니다')}`);
986
+ say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray(옮긴말('run.notMade'))}`);
878
987
  continue;
879
988
  }
880
989
  if (f.dir) continue;
881
990
  const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
882
- say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${f.lines.toLocaleString()} · ${kb}`)}`);
991
+ say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${세말('lines', f.lines)} · ${kb}`)}`);
883
992
  }
884
993
  };
885
994
 
@@ -1109,7 +1218,7 @@ export async function chatLoop(opts = {}) {
1109
1218
  if (opts.ctx == null) {
1110
1219
  // 이 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
1111
1220
  // 무슨 일이 일어나는 중인지 알려 준다 — 멈춘 것처럼 보이면 안 된다.
1112
- 화면.돌리기('모델에 걸린 컨텍스트 길이를 확인하는 중…');
1221
+ 화면.돌리기(옮긴말('run.ctxProbe'));
1113
1222
  let r = null;
1114
1223
  try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
1115
1224
  화면.돌림멈춤('');
@@ -1159,10 +1268,8 @@ export async function chatLoop(opts = {}) {
1159
1268
  * 꼴이 되는데, 그건 안 보여 주느니만 못하다.
1160
1269
  */
1161
1270
  {
1162
- let 바깥 = false;
1163
- try { 바깥 = !isLocalHost(new URL(conn.base).hostname); } catch { 바깥 = false; }
1164
1271
  say('');
1165
- await 인트로({ 바깥, 곁말: 기본곁말(바깥), 쓰기: (t) => process.stdout.write(t) });
1272
+ await 인트로({ 바깥: 바깥연결, 곁말: 기본곁말(바깥연결), 쓰기: (t) => process.stdout.write(t) });
1166
1273
  }
1167
1274
 
1168
1275
  // ── 머리말 ────────────────────────────────────────────────────────────
@@ -1198,14 +1305,54 @@ export async function chatLoop(opts = {}) {
1198
1305
  // 안 뜬 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
1199
1306
  for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
1200
1307
  if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
1201
- if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
1308
+ if (!conn.streaming) warn.push(옮긴말('run.noStream'));
1202
1309
  warn.push(...길이경고);
1203
1310
  // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
1204
1311
  for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
1205
1312
  for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
1206
1313
  // ESC 를 앞에 둔다 — 멈추는 길로는 이것이 먼저다. Ctrl+C 는 두 번 누르면
1207
1314
  // 끝나 버려서, 급히 멈추려던 사람이 대화를 통째로 닫는 일이 실제로 있었다.
1208
- say(` ${c.gray('/help 명령 목록')} ${c.gray('ESC 중단')} ${c.gray('Ctrl+C 중단·끝내기')}`);
1315
+ say(` ${c.gray(옮긴말('run.hintHelp'))} ${c.gray(옮긴말('run.hintEsc'))} ${c.gray(옮긴말('run.hintQuit'))}`);
1316
+
1317
+ /*
1318
+ * ── 바깥으로 나가도 되는지 한 번 묻는다 ─────────────────────────────
1319
+ *
1320
+ * 여기서 묻는 까닭은 **머리말 다음**이어야 하기 때문이다. 바로 위 화면에
1321
+ * `보냄 바깥 api.anthropic.com` 이 찍혀 있다. 그것을 보고 답하는 것과,
1322
+ * 켜자마자 "나가도 될까요?" 를 받는 것은 전혀 다른 물음이다.
1323
+ *
1324
+ * 한 번만 묻는다. 답하면 그 프로필에 적어 두고 다음부터 안 묻는다 — 기본값을
1325
+ * 뒤집으면서 쓰던 사람을 안 깨뜨리는 길이 이것이다. 막는 것이 목적이 아니라
1326
+ * **모르고 나가는 것**을 없애는 것이 목적이다.
1327
+ */
1328
+ if (나감.물어볼까) {
1329
+ const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
1330
+ say('');
1331
+ say(` ${c.yellow('↗')} ${c.bold('이 연결은 이 컴퓨터 밖으로 나갑니다.')}`);
1332
+ say(` ${c.gray('보낼 곳')} ${c.white(주소가리기(어디))}`);
1333
+ say(` ${c.gray('보내는 것 — 시킨 말, 그리고 모델이 읽은 파일의 내용입니다.')}`);
1334
+ say(` ${c.gray('로컬 모델(127.0.0.1)이나 사내망 주소였다면 이 물음이 안 나옵니다.')}`);
1335
+ say('');
1336
+ const 답 = String(await ask(`나가도 될까요? ${c.gray('⏎ 허락하고 기억 · n 그만')}`, { def: 'y' }))
1337
+ .trim().toLowerCase();
1338
+ if (['n', 'no', 'ㄴ', '아니', '아니요', '아니오', '취소', '그만'].includes(답)) {
1339
+ say('');
1340
+ say(` ${c.gray('나가지 않았습니다. 아무것도 안 보냈습니다.')}`);
1341
+ say(` ${c.gray('이 컴퓨터 안의 모델을 붙이려면')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')} ${c.gray('를 쓰세요.')}`);
1342
+ rl.close();
1343
+ return 0;
1344
+ }
1345
+ prof.online = true;
1346
+ try {
1347
+ saveCfg(cfg);
1348
+ say(` ${c.gray('허락을 적어 뒀습니다 — 이 프로필은 다음부터 안 묻습니다.')}`
1349
+ + ` ${c.gray('되돌리려면 설정에서')} ${c.white('online')} ${c.gray('을 지우면 됩니다.')}`);
1350
+ } catch (e) {
1351
+ // 못 적어도 이번 판은 나간다. 사람이 방금 허락했다 — 그걸 무르는 것이 더 놀랍다.
1352
+ say(` ${mark.warn} ${c.gray(`허락을 저장하지 못했습니다 (${e.message}). 이번에만 나갑니다.`)}`);
1353
+ }
1354
+ allowEndpoint(conn.base);
1355
+ }
1209
1356
 
1210
1357
  /*
1211
1358
  * 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
@@ -1506,7 +1653,7 @@ export async function chatLoop(opts = {}) {
1506
1653
  break;
1507
1654
 
1508
1655
  case 'waiting':
1509
- 화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
1656
+ 화면.기다림(c.gray(꼬리표(stage) + 옮긴말('run.thinking')));
1510
1657
  // 지워야 할 줄이 화면에 있다고 표시해 둔다.
1511
1658
  //
1512
1659
  // 전에는 이 표시를 안 세웠다. \r 로 커서만 앞으로 보내 놓고 지우지는
@@ -1517,7 +1664,7 @@ export async function chatLoop(opts = {}) {
1517
1664
 
1518
1665
  case 'thinking':
1519
1666
  thinkChars += ev.text.length;
1520
- 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
1667
+ 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(옮긴말('run.thinkingChars', { n: thinkChars.toLocaleString() }))}`);
1521
1668
  if (process.stdout.isTTY) thinkingShown = true;
1522
1669
  break;
1523
1670
 
@@ -1963,11 +2110,22 @@ export async function chatLoop(opts = {}) {
1963
2110
 
1964
2111
  // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
1965
2112
  const secs = ((Date.now() - started) / 1000).toFixed(1);
1966
- const bits = [`${secs}초`];
1967
- if (tools) bits.push(`도구 ${tools}회`);
2113
+ const bits = [옮긴말('unit.sec', { n: secs })];
2114
+ if (tools) bits.push(`${옮긴말('scr.tools')} ${옮긴말('unit.calls', { n: tools })}`);
1968
2115
  const dIn = session.usage.in - before.in;
1969
2116
  const dOut = session.usage.out - before.out;
1970
2117
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
2118
+ /*
2119
+ * 이번 턴에 나간 돈.
2120
+ *
2121
+ * 누적은 상태줄에 있다. 그런데 누적만 보면 **어느 한마디가 비쌌는지**를
2122
+ * 알 수가 없다. 긴 파일을 한 번 붙였을 때 그 한 턴이 앞의 스무 턴보다
2123
+ * 비싼 일이 흔하고, 사람이 버릇을 고칠 수 있는 건 그걸 봤을 때다.
2124
+ *
2125
+ * 요금을 모르면 안 적는다 (backend/price.js — 지어낸 요금은 안 찍는다).
2126
+ */
2127
+ const 이번돈 = 돈셈({ in: dIn, out: dOut }, 세션요금(session));
2128
+ if (이번돈 && 이번돈.달러 > 0) bits.push(돈말(이번돈.달러));
1971
2129
  say('');
1972
2130
  say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
1973
2131
 
@@ -2101,7 +2259,12 @@ export async function chatLoop(opts = {}) {
2101
2259
  // 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
2102
2260
  // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
2103
2261
  say('');
2104
- say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
2262
+ say(` ${c.gray(옮긴말('run.bye'))} ${c.gray(옮긴말('run.byeStats', {
2263
+ n: session.usage.calls,
2264
+ 초: (session.usage.ms / 1000).toFixed(1),
2265
+ 입력: session.usage.in.toLocaleString(),
2266
+ 출력: session.usage.out.toLocaleString(),
2267
+ }))}`);
2105
2268
  say('');
2106
2269
  화면.close();
2107
2270
  return 0;
@@ -2116,6 +2279,8 @@ export async function chatLoop(opts = {}) {
2116
2279
  */
2117
2280
  function stageTag(ev, level) {
2118
2281
  if (!ev || level === '쉬움') return '';
2119
- const label = STAGES[ev.stage]?.label ?? ev.stage;
2282
+ // 아는 단계만 표를 거친다. 모르는 것에 말() 을 태우면 못 찾은 열쇠가
2283
+ // 그대로 화면에 찍혀 `stage.xyz 생각 중…` 이 된다.
2284
+ const label = STAGES[ev.stage] ? 옮긴말(`stage.${ev.stage}`) : ev.stage;
2120
2285
  return `${label}·${ev.level}`;
2121
2286
  }
package/src/report.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { c, say, rule, pad, width, mark } from './ui/ansi.js';
3
3
  import { 주소가리기 } from './safety/secrets.js';
4
4
  import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
5
+ import { 규격이름 } from './backend/adapter.js';
5
6
  import { 보관방식 } from './safety/keystore.js';
6
7
  import { load, activeProfile } from './config.js';
7
8
 
@@ -32,7 +33,7 @@ export function renderLine(r) {
32
33
  export function renderHeader(facts) {
33
34
  say('');
34
35
  rule('연결', 74);
35
- const kindName = facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환';
36
+ const kindName = 규격이름(facts.shape);
36
37
  const rows = [
37
38
  ['규격', kindName],
38
39
  ['주소', 주소가리기(facts.base)],
@@ -124,7 +125,7 @@ export function plainReport(facts, results, v) {
124
125
  'deel 연결 진단 보고서',
125
126
  `생성 시각 ${at}`,
126
127
  '',
127
- `규격 ${facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환'}`,
128
+ `규격 ${규격이름(facts.shape)}`,
128
129
  `주소 ${주소가리기(facts.base)}`,
129
130
  ...(프록시줄(facts.base) ? [`프록시 ${프록시줄(facts.base)}`] : []),
130
131
  `인증 ${facts.auth === 'none' ? '없음' : facts.auth}`,