deel-local-cli 1.5.7 → 1.6.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 (57) hide show
  1. package/README.en.md +24 -13
  2. package/README.md +23 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +113 -15
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +82 -3
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +282 -39
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
package/src/oneshot.js CHANGED
@@ -11,13 +11,16 @@
11
11
  // 그래서 '묻는 자리' 를 전부 없앤 길을 따로 낸다. 에이전트 루프는 그대로 쓴다 —
12
12
  // 여기서 루프를 다시 짜면 두 벌이 되고, 언젠가 한쪽만 고쳐진다.
13
13
  import { c, mark, clip } from './ui/ansi.js';
14
+ import { 규칙모으기 } from './safety/policy.js';
14
15
  import { run } from './agent/loop.js';
15
16
  import { Session } from './agent/session.js';
16
17
  import { makeScope } from './safety/guard.js';
17
18
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
18
19
  import { History } from './safety/undo.js';
19
20
  import { Audit } from './safety/audit.js';
20
- import { activeProfile, load, resolveKey } from './config.js';
21
+ import { activeProfile, load, resolveKey, 잠금소식 } from './config.js';
22
+ import { 말 as 옮긴말 } from './i18n/index.js';
23
+ import { 알림채움 } from './backend/retry.js';
21
24
  import { discover } from './skills/discover.js';
22
25
  import { allowEndpoint, setOffline } from './safety/network.js';
23
26
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
@@ -123,7 +126,7 @@ export async function runOnce(opts = {}) {
123
126
  삐끗(` ${c.red('✗')} ${message}`);
124
127
  return 내놓기({
125
128
  ok: false, reason, code: EXIT.error, text: '', why: message,
126
- tools: 0, steps: 0, usage: { in: 0, out: 0, calls: 0, ms: 0 }, ms: 0,
129
+ tools: 0, steps: 0, usage: { in: 0, out: 0, calls: 0, ms: 0, retries: 0 }, ms: 0,
127
130
  });
128
131
  };
129
132
 
@@ -140,6 +143,11 @@ export async function runOnce(opts = {}) {
140
143
  const prof = activeProfile(cfg);
141
144
  if (!prof) return 못함('no-config', '저장된 연결이 없습니다. deel setup 을 먼저 실행하세요.');
142
145
 
146
+ // 열쇠를 방금 잠갔으면 알린다. 여기서는 stdout 을 안 쓴다 —
147
+ // 배치로 부르는 쪽이 stdout 을 JSON 으로 읽고 있을 수 있다.
148
+ const 잠금 = 잠금소식();
149
+ if (잠금) { try { process.stderr.write(` ${잠금}\n`); } catch { /* 못 써도 그만 */ } }
150
+
143
151
  const root = opts.root ? String(opts.root) : process.cwd();
144
152
  const conn = {
145
153
  kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
@@ -149,12 +157,14 @@ export async function runOnce(opts = {}) {
149
157
  maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
150
158
  streaming: prof.streaming ?? false,
151
159
  tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
160
+ vision: prof.vision ?? false,
152
161
  };
153
162
 
154
163
  // 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다 —
155
164
  // 오히려 배치는 아무도 안 보는 자리라 더 단단해야 한다.
156
165
  allowEndpoint(conn.base);
157
- if (opts.offline ?? prof.offline) setOffline(true);
166
+ // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
167
+ if (opts.offline ?? prof.offline ?? cfg?.offline) setOffline(true);
158
168
 
159
169
  const session = new Session(conn, {
160
170
  root,
@@ -185,6 +195,10 @@ export async function runOnce(opts = {}) {
185
195
  // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
186
196
  // /model 로 갈아타면 conn 이 통째로 바뀌므로 그때마다 다시 읽는다.
187
197
  get 모델컨텍스트() { return conn.ctx ?? null; },
198
+ // 이 모델이 그림을 볼 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
199
+ get 눈있나() { return !!conn.vision; },
200
+ // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
201
+ 규칙들: 규칙모으기(cfg),
188
202
  history: new History(root),
189
203
  audit: new Audit(root),
190
204
  seen: new Set(),
@@ -337,10 +351,20 @@ export async function runOnce(opts = {}) {
337
351
  곁(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
338
352
  break;
339
353
 
354
+ // 서버가 잠깐 막아서 기다렸다 다시 부른 자리 (backend/retry.js).
355
+ // 배치 기록에 남아야 "그날 밤 왜 12분이 걸렸나" 를 나중에 읽을 수 있다.
356
+ case 'backoff':
357
+ 곁(` ${c.yellow('↻')} ${c.gray(옮긴말('loop.backoff', 알림채움(ev)))}`);
358
+ break;
359
+
340
360
  case 'folded':
341
361
  곁(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 (${ev.아낀토큰.toLocaleString()} 토큰을 비움)`)}`);
342
362
  break;
343
363
 
364
+ case 'images_folded':
365
+ 곁(` ${c.cyan('◲')} ${c.gray(`오래된 그림 ${(ev.뺀것들 ?? []).reduce((a3, x) => a3 + x.장수, 0)}장을 뺐습니다`)}`);
366
+ break;
367
+
344
368
  case 'compacted':
345
369
  곁(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} → ${ev.after.toLocaleString()} 토큰)`)}`);
346
370
  break;
@@ -405,6 +429,7 @@ export async function runOnce(opts = {}) {
405
429
  usage: {
406
430
  in: session.usage.in, out: session.usage.out,
407
431
  calls: session.usage.calls, ms: session.usage.ms,
432
+ retries: session.usage.retries ?? 0,
408
433
  },
409
434
  model: conn.model,
410
435
  ms: Date.now() - t0,
package/src/pack/sbom.js CHANGED
@@ -24,6 +24,7 @@
24
24
  // 검사에서 확인한다 (test/sbom.test.js) — Audit 이 실제로 남긴 줄에 여기
25
25
  // 적어 둔 칸이 다 있는지 본다.
26
26
  import { randomUUID } from 'node:crypto';
27
+ import { 보관방식 } from '../safety/keystore.js';
27
28
 
28
29
  /** 우리가 내놓는 SBOM 규격. 스캐너가 이 숫자를 보고 읽는 법을 정한다. */
29
30
  export const CDX판 = '1.5';
@@ -102,6 +103,7 @@ export function 통신명세(a) {
102
103
  어디로: 'deel setup 에서 정한 주소 딱 한 곳',
103
104
  무엇이: '오간 말과, 모델이 읽은 파일 내용',
104
105
  막는법: 'src/safety/network.js 가 요청마다 허용 목록을 확인합니다. 목록에 없으면 요청 자체를 만들지 않습니다.',
106
+ 프록시: 'HTTPS_PROXY(또는 설정의 proxy)가 있으면 그 프록시를 거칩니다(CONNECT 터널). 목적지 허용 목록은 그대로이고, 이 컴퓨터 안 주소는 프록시를 안 탑니다.',
105
107
  소스: 자리('net'),
106
108
  },
107
109
  {
@@ -146,6 +148,28 @@ export function 통신명세(a) {
146
148
  };
147
149
  }
148
150
 
151
+ /**
152
+ * 열쇠를 어디에 어떻게 두나.
153
+ *
154
+ * 심사에서 거의 언제나 나오는 질문이고, 전에는 이 문서에 답이 없었다.
155
+ * "홈 폴더에 평문" 이면 그 자리에서 끝나는 질문이라 더 그렇다.
156
+ * 지금 이 PC 에서 실제로 되는 방식을 그대로 적는다 — 되는 척은 안 한다.
157
+ */
158
+ export function 열쇠명세() {
159
+ return {
160
+ 어디: '~/.deel/config.json (또는 DEEL_HOME) 의 profiles[].apiKey',
161
+ 이PC에서: 보관방식(),
162
+ 방식: [
163
+ { 운영체제: '윈도우', 방법: 'DPAPI ProtectedData (CurrentUser) — 이 PC 의 이 계정만 풉니다. 파일을 복사해 가도 못 풉니다.' },
164
+ { 운영체제: '맥', 방법: '로그인 키체인 (security add-generic-password)' },
165
+ { 운영체제: '그 밖', 방법: '파일 권한 0600. 잠금장치가 없으므로 그렇게 적습니다.' },
166
+ ],
167
+ 넘길때: '열쇠는 명령줄에 안 올립니다 — 언제나 stdin 으로 넘깁니다. 같은 PC 의 다른 사용자가 프로세스 목록으로 못 봅니다.',
168
+ 안쓰려면: 'DEEL_API_KEY (또는 DEEL_KEY_<프로필>) 환경변수를 쓰면 파일에 아예 안 남습니다. 환경변수가 파일보다 우선합니다.',
169
+ 끄려면: 'DEEL_KEYSTORE=off — 잠금장치를 안 씁니다(파일 권한만).',
170
+ };
171
+ }
172
+
149
173
  /**
150
174
  * 감사기록 사양.
151
175
  *
@@ -201,6 +225,7 @@ export function 심사명세(a, { at = new Date() } = {}) {
201
225
  : '외부 모듈이 있습니다 — 위 목록을 확인하세요.',
202
226
  },
203
227
  통신: 통신명세(a),
228
+ 열쇠보관: 열쇠명세(),
204
229
  감사기록: 감사명세(),
205
230
  파일: a.files.map((f) => ({ 경로: f.path, 바이트: f.bytes, sha256: f.sha })),
206
231
  };
@@ -45,8 +45,10 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
45
45
  // 앞에 점이나 글자가 붙은 것은 뺀다. 그러지 않으면 정규식의 .exec( 까지
46
46
  // '외부 명령 실행' 으로 세어 심사서가 거짓말을 한다.
47
47
  const PROBES = [
48
- { id: 'net', label: '네트워크 요청', re: /(?<![.\w])fetch\s*\(/g,
49
- note: '사용자가 setup 에서 넣은 주소로만 나갑니다' },
48
+ // fetch 말고도 http.js 프록시 터널(httpRequest·httpsRequest·tlsConnect)이 있다.
49
+ // 그것을 세면 심사서가 "네트워크 요청 N건" 을 거짓으로 적는다.
50
+ { id: 'net', label: '네트워크 요청', re: /(?<![.\w])fetch\s*\(|(?:globalThis|window|self)\s*\.\s*fetch\s*\(|\bnew\s+WebSocket\s*\(|(?<![.\w])(?:httpRequest|httpsRequest|tlsConnect)\s*\(/g,
51
+ note: '사용자가 setup 에서 넣은 주소로만 나갑니다 (HTTPS_PROXY 가 있으면 그 프록시를 거쳐서)' },
50
52
  { id: 'exec', label: '외부 명령 실행', re: /(?<![.\w])(execFile|execFileSync|execSync|spawn|spawnSync|exec)\s*\(/g,
51
53
  note: '사용자·모델이 지시한 명령, 그리고 플러그인 받을 때의 git' },
52
54
  { id: 'listen', label: '포트 열기', re: /(?<![.\w])(createServer)\s*\(/g,
@@ -83,7 +85,8 @@ const MODULE_NAME = /^[@\w./:-]+$/;
83
85
 
84
86
  export function importSpecs(text) {
85
87
  const out = [];
86
- const re = /(?:^|[\s;{(])(?:import[^'"()]*from\s*|import\s*|require\s*\(\s*)(['"])([^'"]+)\1/g;
88
+ // 동적으로 부르는 import 도 문이다 — from 이 없어서 못 잡던 것을 잡는다.
89
+ const re = /(?:^|[\s;{(])(?:import[^'"()]*from\s*|import\s*\(\s*|import\s*|require\s*\(\s*)(['"])([^'"]+)\1/g;
87
90
  for (const m of text.matchAll(re)) {
88
91
  const spec = m[2];
89
92
  if (MODULE_NAME.test(spec)) out.push(spec);
@@ -171,6 +174,9 @@ export function reviewSheet(a, at) {
171
174
  L.push(' · 그 한 자리만 허용 목록에 오릅니다. 모델을 바꾸면 앞의 자리는 닫힙니다.');
172
175
  L.push(' · src/safety/network.js 가 요청마다 확인하고, 목록에 없으면 요청 자체를');
173
176
  L.push(' 만들지 않습니다.');
177
+ L.push(' · HTTPS_PROXY(또는 설정의 proxy)가 있으면 그 프록시를 **거쳐** 갑니다 (CONNECT');
178
+ L.push(' 터널). 목적지는 그대로 그 한 자리이고, 거치는 프록시는 켤 때 화면과 /status 에');
179
+ L.push(' 적힙니다. 이 컴퓨터 안(127.*, localhost)으로 가는 요청은 프록시를 안 탑니다.');
174
180
  L.push('');
175
181
  L.push(' [B] 웹 읽기 (WebFetch 도구) — 받아 오기만 하는 길');
176
182
  L.push(' · GET 만 씁니다. 본문을 실어 보내지 않습니다 (소스·대화가 나갈 수 없음).');
@@ -11,7 +11,19 @@ import {
11
11
  } from 'node:fs';
12
12
  import { untargz, stripTop } from '../pack/tar.js';
13
13
  import { makeZip } from '../pack/zip.js';
14
- import { allowTemporarily, checkUrl, isOffline } from '../safety/network.js';
14
+ import { allowTemporarily, isOffline, NetBlocked } from '../safety/network.js';
15
+ import { 원시요청, 몸읽기 } from '../backend/http.js';
16
+
17
+ /**
18
+ * 플러그인을 받다 되돌림(redirect)이 오면 따라가도 되는 곳인가. 던지면 안 따라간다.
19
+ * github 가 제 다른 집으로 보내는 것만 따라간다 — https 이고, github.com · githubusercontent.com 뿐.
20
+ */
21
+ export function 플러그인되돌림(다음) {
22
+ if (다음.protocol !== 'https:') throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
23
+ if (!/(^|\.)github\.com$|(^|\.)githubusercontent\.com$/i.test(다음.hostname)) {
24
+ throw new Error(`github 밖(${다음.hostname})으로 되돌립니다 — 따라가지 않습니다`);
25
+ }
26
+ }
15
27
  import { copyDir } from '../tools/fsutil.js';
16
28
 
17
29
  export const pluginsDir = (home = homedir()) => join(home, '.deel', 'plugins');
@@ -63,12 +75,27 @@ async function fetchInto(spec, dest, onStep) {
63
75
  const url = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz/refs/heads/${branch}`;
64
76
  // 사용자가 이 명령을 친 동안만 github 를 연다. 끝나면 바로 닫는다.
65
77
  const close = allowTemporarily(url);
66
- let res;
67
- try { checkUrl(url); res = await fetch(url, { signal: AbortSignal.timeout(120000) }); }
68
- catch (err) { continue; }
69
- finally { close(); }
70
- if (!res.ok) continue;
71
- const gz = Buffer.from(await res.arrayBuffer());
78
+ let gz;
79
+ // (backend/http.js)으로 나간다 프록시를 거치고, 되돌림은 홉마다 문지기를 지난다.
80
+ // github 는 받기 주소를 제 다른 집(githubusercontent 등)으로 되돌리므로 홉은 열어 준다 —
81
+ // 밖의 집이면 안 따라가고, 왜 못 받았는지를 말한다 (조용히 '주소를 확인하세요' 로 끝내지 않는다).
82
+ // 상한까지만 받는다. 플러그인 묶음이 64MB 를 넘을 일은 없고, 넘는 것을 다 받아 줄 이유도 없다.
83
+ const 열어둔 = [];
84
+ try {
85
+ const res = await 원시요청(url, {
86
+ timeout: 120000, stream: true,
87
+ 되돌림: (다음) => { 플러그인되돌림(다음); 열어둔.push(allowTemporarily(다음.origin)); },
88
+ });
89
+ if (!res.ok) { await res.버리기?.(); continue; }
90
+ gz = await 몸읽기(res.res.body, 64 * 1024 * 1024);
91
+ } catch (err) {
92
+ if (err instanceof NetBlocked || /되돌립니다/.test(String(err?.message))) return { error: `받지 못했습니다 — ${err.message}` };
93
+ continue;
94
+ } finally {
95
+ close();
96
+ for (const 닫기 of 열어둔) 닫기();
97
+ }
98
+ if (!gz) return { error: '받은 묶음이 너무 큽니다 (64MB 넘음) — 플러그인 저장소가 맞는지 확인하세요' };
72
99
  const files = stripTop(untargz(gz));
73
100
  if (!files.length) return { error: '받은 묶음이 비어 있습니다' };
74
101
  rmSync(dest, { recursive: true, force: true });
package/src/repl.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
+ import { 규칙모으기 } from './safety/policy.js';
4
+ import { 주소가리기 } from './safety/secrets.js';
3
5
  import { homedir } from 'node:os';
4
6
  import { resolve, basename } from 'node:path';
5
7
  import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
@@ -7,6 +9,7 @@ import { headerLines } from './ui/status.js';
7
9
  import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
8
10
  import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
9
11
  import { 언어잡기, 말 as 옮긴말 } from './i18n/index.js';
12
+ import { 알림채움 } from './backend/retry.js';
10
13
  import { 화면고르기 } from './ui/screen.js';
11
14
  import { STAGES } from './agent/effort.js';
12
15
  import { handle, COMMANDS, 미리보기끄기, 적용하기 as 그림적용 } from './commands.js';
@@ -19,7 +22,7 @@ import { 언어서버있나 } from './tools/index.js';
19
22
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
20
23
  import { History } from './safety/undo.js';
21
24
  import { Audit } from './safety/audit.js';
22
- import { activeProfile, load, resolveKey, save as saveCfg, homeDir } from './config.js';
25
+ import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식 } from './config.js';
23
26
  import { discover } from './skills/discover.js';
24
27
  import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
25
28
  import { Store, latest, prune } from './agent/store.js';
@@ -36,6 +39,7 @@ import { 접어쓰기 } from './ui/wrap.js';
36
39
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
37
40
  import { renderDiff, shortStat } from './ui/diff.js';
38
41
  import { expand as expandMentions } from './agent/mention.js';
42
+ import { 크기말 } from './backend/vision.js';
39
43
  import { 다붙이기 } from './backend/mcp.js';
40
44
  import { 읽기 as 기억읽기 } from './agent/memory.js';
41
45
  import { 갈래고르기 } from './ui/working.js';
@@ -150,6 +154,14 @@ export async function chatLoop(opts = {}) {
150
154
  return 1;
151
155
  }
152
156
 
157
+ // 평문으로 있던 열쇠를 방금 잠갔으면 그렇다고 한 줄. 조용히 바꾸면
158
+ // 나중에 설정 파일을 열어 본 사람이 제 열쇠가 사라진 줄 안다.
159
+ const 잠금 = 잠금소식();
160
+ if (잠금) {
161
+ 바로쓰기('');
162
+ 바로쓰기(` ${mark.ok} ${잠금}`);
163
+ }
164
+
153
165
  /*
154
166
  * 여기서부터 화면에 나가는 것은 전부 `화면` 을 거친다.
155
167
  *
@@ -178,11 +190,14 @@ export async function chatLoop(opts = {}) {
178
190
  maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
179
191
  streaming: prof.streaming ?? false,
180
192
  tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
193
+ // 그림을 볼 수 있는 모델인지. 못 보면 바이트를 아예 안 싣는다 (backend/vision.js).
194
+ vision: prof.vision ?? false,
181
195
  };
182
196
 
183
197
  // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
184
198
  allowEndpoint(conn.base);
185
- if (opts.offline ?? prof.offline) setOffline(true);
199
+ // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
200
+ if (opts.offline ?? prof.offline ?? cfg?.offline) setOffline(true);
186
201
 
187
202
  /*
188
203
  * ── 끝났을 때 알리기 ─────────────────────────────────────────────────
@@ -259,7 +274,7 @@ export async function chatLoop(opts = {}) {
259
274
  }
260
275
  }
261
276
  if (!store) store = new Store(root);
262
- store.begin({ model: conn.model, base: conn.base, root });
277
+ store.begin({ model: conn.model, base: 주소가리기(conn.base), root });
263
278
  try { prune(root); } catch {}
264
279
 
265
280
  /*
@@ -361,6 +376,15 @@ export async function chatLoop(opts = {}) {
361
376
  * 잘못 읽힌다. 백틱은 그럴 일이 없다.
362
377
  */
363
378
  let 이어쓰기줄들 = null;
379
+ /*
380
+ * 쌓인 줄을 이미 화면에 찍어 두었나.
381
+ *
382
+ * 줄 끝 백틱으로 이어 쓸 때는 줄쌓기() 가 치는 족족 찍는다. 붙여넣기는
383
+ * 일부러 안 찍는다(스무 줄이 주르륵 지나가지 않게). 보낼 때 무엇을 다시
384
+ * 찍을지가 그것으로 갈린다 — 이걸 안 가르면 같은 줄이 두 번 보이거나
385
+ * 붙여넣은 앞부분이 통째로 안 보인다.
386
+ */
387
+ let 이어쓴것 = false;
364
388
 
365
389
  /*
366
390
  * ── 상자 안에서 줄을 바꾼다 ────────────────────────────────────────────
@@ -412,6 +436,7 @@ export async function chatLoop(opts = {}) {
412
436
  /** 줄을 쌓아 두고 "다음 줄" 이라고 알린다. 백틱 표시와 Alt+Enter 가 같이 쓴다. */
413
437
  const 줄쌓기 = (줄) => {
414
438
  const 첫줄 = 이어쓰기줄들 === null;
439
+ 이어쓴것 = true; // 아래에서 바로 찍는다
415
440
  (이어쓰기줄들 ??= []).push(줄);
416
441
  화면.입력지움();
417
442
  say(`${첫줄 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(줄)}`);
@@ -449,13 +474,29 @@ export async function chatLoop(opts = {}) {
449
474
  // 올렸을 때 답만 있고 무엇을 물었는지가 없다.
450
475
  const 이었나 = 이어쓰기줄들 !== null;
451
476
  보낼것 = 이었나 ? [...이어쓰기줄들, l].join('\n') : l;
452
- 이어쓰기줄들 = null;
477
+ 이어쓰기줄들 = null; 이어쓴것 = false;
453
478
  화면.입력지움();
454
- // 여러 줄이면 줄마다 찍는다. 한 번에 던지면 둘째 줄부터 왼쪽 끝에
455
- // 붙어서, 사람이 보낸 글인지 프로그램이 뱉은 글인지 알 수 없다.
456
- if (l.trim() || 이었나) {
457
- for (const [i, 한줄] of l.split('\n').entries()) {
458
- say(`${i === 0 && !이었나 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(한줄)}`);
479
+ /*
480
+ * ── 보낸 것을 **보낸 그대로** 남긴다 ──────────────────────────────
481
+ *
482
+ * 여기서 `l` 찍고 있었다. 그런데 `l` 은 **마지막 줄**뿐이다 —
483
+ * 앞줄들은 이어쓰기줄들 쌓여 있다. 백틱으로 이어 쓸 때는
484
+ * 줄쌓기() 가 치는 족족 화면에 찍어 두었으니 그걸로 맞았는데,
485
+ * 붙여넣기는 일부러 안 찍는다(스무 줄이 주르륵 지나가지 않게).
486
+ *
487
+ * 그래서 붙여넣고 Enter 를 치면 **보낸 것의 마지막 조각만** 화면에
488
+ * 남았다. 사람은 앞부분이 안 갔다고 생각하고 다시 붙여넣는다.
489
+ *
490
+ * 찍을 것은 보낼것 이다. 화면에 남는 것과 모델에게 가는 것이 같아야
491
+ * 나중에 스크롤을 올려서 무엇을 시켰는지 알아볼 수 있다.
492
+ *
493
+ * 백틱으로 이어 쓴 경우에는 앞줄이 이미 찍혀 있으므로 마지막 줄만
494
+ * 이어 찍는다 — 안 그러면 같은 줄이 두 번 보인다.
495
+ */
496
+ const 찍을것 = 이어쓴것 ? l : 보낼것;
497
+ if (찍을것.trim() || 이었나) {
498
+ for (const [i, 한줄] of String(찍을것).split('\n').entries()) {
499
+ say(`${i === 0 && !이어쓴것 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(한줄)}`);
459
500
  }
460
501
  }
461
502
  } else if (l.trim() || 이어쓰기줄들 !== null) {
@@ -483,7 +524,7 @@ export async function chatLoop(opts = {}) {
483
524
  }
484
525
  if (이어쓰기줄들 !== null) {
485
526
  보낼것 = [...이어쓰기줄들, l].join('\n');
486
- 이어쓰기줄들 = null;
527
+ 이어쓰기줄들 = null; 이어쓴것 = false;
487
528
  }
488
529
  화면.대기갱신('', queue.length + 1);
489
530
  }
@@ -706,7 +747,7 @@ export async function chatLoop(opts = {}) {
706
747
  * y 를 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
707
748
  */
708
749
  묻는중 = 상자쓰나 ? 앞 : null;
709
- 이어쓰기줄들 = null; // 되묻는 사이에 걸쳐 있던 미완성 이어쓰기는 버린다
750
+ 이어쓰기줄들 = null; 이어쓴것 = false; // 되묻는 사이에 걸쳐 있던 미완성 이어쓰기는 버린다
710
751
  try {
711
752
  const a = await nextLine();
712
753
  if (a === null) return o.def ?? '';
@@ -760,6 +801,10 @@ export async function chatLoop(opts = {}) {
760
801
  // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
761
802
  // /model 로 갈아타면 conn 이 통째로 바뀌므로 그때마다 다시 읽는다.
762
803
  get 모델컨텍스트() { return conn.ctx ?? null; },
804
+ // 이 모델이 그림을 볼 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
805
+ get 눈있나() { return !!conn.vision; },
806
+ // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
807
+ 규칙들: 규칙모으기(cfg),
763
808
  history: new History(root),
764
809
  audit: new Audit(root),
765
810
  seen: new Set(),
@@ -948,7 +993,7 @@ export async function chatLoop(opts = {}) {
948
993
  * 대화 갈래. 연결·도구·되돌리기는 같이 쓰고 오간 말만 여러 벌 갖는다.
949
994
  * 갈래마다 저장 파일을 따로 열어서, 나중에 `/sessions` 로 각각 찾아갈 수 있다.
950
995
  */
951
- ctx.갈래 = new Threads(session, ctx, () => new Store(root).begin({ model: conn.model, base: conn.base, root }), store);
996
+ ctx.갈래 = new Threads(session, ctx, () => new Store(root).begin({ model: conn.model, base: 주소가리기(conn.base), root }), store);
952
997
 
953
998
  // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
954
999
  //
@@ -1087,7 +1132,7 @@ export async function chatLoop(opts = {}) {
1087
1132
  * 더는 '/' 로 시작하지 않아 명령으로 안 읽히고 모델에게 넘어간다.
1088
1133
  */
1089
1134
  if (이어쓰기줄들 !== null) {
1090
- 이어쓰기줄들 = null;
1135
+ 이어쓰기줄들 = null; 이어쓴것 = false;
1091
1136
  say('');
1092
1137
  say(` ${c.gray('이어쓰던 것을 버렸습니다.')}`);
1093
1138
  }
@@ -1173,15 +1218,19 @@ export async function chatLoop(opts = {}) {
1173
1218
  // 붙인 것은 화면에 반드시 알린다. 사람이 안 보낸 줄 아는 글이 대화에
1174
1219
  // 들어가 있으면 안 된다 — 컨텍스트가 왜 줄었는지도 모르게 된다.
1175
1220
  let 보낼글 = toSend;
1221
+ let 보낼그림 = null;
1176
1222
  if (toSend.includes('@')) {
1177
1223
  const 예산 = Math.min(20000, Math.floor((session.conn.ctx ?? CTX_DEFAULT) * 0.25));
1178
1224
  const r = expandMentions(toSend, {
1179
1225
  scope: ctx.scope, budget: 예산, seen: ctx.seen,
1180
1226
  onRead: (p, t) => session.noteRead(p, t),
1227
+ 눈있나: !!session.conn.vision,
1181
1228
  });
1182
1229
  보낼글 = r.text;
1230
+ 보낼그림 = r.그림들?.length ? r.그림들 : null;
1183
1231
  for (const a of r.attached) {
1184
- say(` ${c.blue('◧')} ${c.gray('붙임')} ${c.white(a.show)}${a.full ? '' : c.gray(' (앞부분만)')}`);
1232
+ const 꼬리 = a.그림 ? c.gray(` (그림 · ${크기말(a.bytes)})`) : (a.full ? '' : c.gray(' (앞부분만)'));
1233
+ say(` ${c.blue('◧')} ${c.gray('붙임')} ${c.white(a.show)}${꼬리}`);
1185
1234
  }
1186
1235
  for (const b of r.blocked) {
1187
1236
  say(` ${mark.warn} ${c.gray(`${b.path} 는 작업 범위 밖이라 안 붙였습니다.`)}`);
@@ -1287,7 +1336,23 @@ export async function chatLoop(opts = {}) {
1287
1336
  // '겪을수록 나아진다' 가 이번 대화 안에서도 참이 된다.
1288
1337
  ctx.카드다시?.();
1289
1338
  try {
1290
- for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
1339
+ /*
1340
+ * 일하는 중에 친 말을 걸음마다 건네준다 (agent/loop.js 의 끼어들기).
1341
+ *
1342
+ * **슬래시 명령은 안 건넨다.** /mode 나 /undo 는 설정과 파일을 건드리는
1343
+ * 것이라, 도는 턴 한가운데서 먹으면 무엇이 어느 설정으로 돌았는지가
1344
+ * 흐려진다. 그건 큐에 그대로 두고 턴이 끝난 뒤에 평소대로 처리한다.
1345
+ *
1346
+ * 큐에서 **빼서** 넘긴다 — 넘기고 큐에도 남기면 같은 말이 두 번 나간다.
1347
+ */
1348
+ const 끼어들기 = () => {
1349
+ const i = queue.findIndex((x) => !String(x ?? '').trimStart().startsWith('/'));
1350
+ if (i < 0) return null;
1351
+ const [것] = queue.splice(i, 1);
1352
+ 화면.대기갱신('', queue.length);
1353
+ return String(것).trim() || null;
1354
+ };
1355
+ for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal, 그림들: 보낼그림, 끼어들기 })) {
1291
1356
  /*
1292
1357
  * 하위 작업 안쪽에서 온 것이면 한 단 들여 그린다.
1293
1358
  *
@@ -1296,6 +1361,15 @@ export async function chatLoop(opts = {}) {
1296
1361
  */
1297
1362
  화면.들여쓰기(ev.depth ?? 0);
1298
1363
  switch (ev.type) {
1364
+ /*
1365
+ * 도중에 낀 말. **화면에 그대로 보여 준다.**
1366
+ *
1367
+ * 안 보여 주면 사람은 제가 친 말이 먹혔는지 모른 채 기다린다.
1368
+ * 그러면 한 번 더 치게 되고, 같은 말이 두 번 나간다.
1369
+ */
1370
+ case 'steer':
1371
+ say(` ${c.hcyan('❯')} ${c.white(ev.text)} ${c.gray('(도중에 낀 말 — 다음 걸음부터 반영됩니다)')}`);
1372
+ break;
1299
1373
  // 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
1300
1374
  case 'stage':
1301
1375
  stage = ev;
@@ -1311,6 +1385,14 @@ export async function chatLoop(opts = {}) {
1311
1385
  : ` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
1312
1386
  break;
1313
1387
 
1388
+ // 서버가 잠깐 막아서 기다렸다 다시 부르는 자리 (backend/retry.js).
1389
+ // 상태 코드·몇 초·몇 번째를 그대로 적는다 — 왜 느린지가 여기서만 읽힌다.
1390
+ // 쉬움 수준에서도 숫자를 뺀 채 내지 않는다. 429 는 설명이 필요한 숫자가 아니다.
1391
+ case 'backoff':
1392
+ clearThinking();
1393
+ say(` ${c.yellow('↻')} ${c.gray(옮긴말('loop.backoff', 알림채움(ev)))}`);
1394
+ break;
1395
+
1314
1396
  case 'waiting':
1315
1397
  화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
1316
1398
  // 지워야 할 줄이 화면에 있다고 표시해 둔다.
@@ -1475,6 +1557,15 @@ export async function chatLoop(opts = {}) {
1475
1557
  say(` ${c.red('✗')} ${c.white(f.보인이름 ?? '(경로 없음)')} ${c.gray(`— ${clip(String(f.error), 60)}`)}`);
1476
1558
  }
1477
1559
  }
1560
+ } else if (ev.result?.바뀐것들?.length) {
1561
+ /*
1562
+ * 폴더를 옮겼다. 옮겨진 파일을 **하나씩** 적어 둔다.
1563
+ *
1564
+ * 「이 폴더가 바뀌었다」 로 적으면 나중에 /commit 이 그 폴더를
1565
+ * 통째로 담고, 같은 폴더에 있던 남의 변경까지 실린다.
1566
+ * 줄 수는 0 이다 — 옮긴 것이지 고친 것이 아니다.
1567
+ */
1568
+ for (const f of ev.result.바뀐것들) session.noteChange(f, { added: 0, removed: 0 });
1478
1569
  }
1479
1570
  }
1480
1571
  flush(); // 도구가 하나 끝날 때마다 적어 둔다
@@ -1575,6 +1666,13 @@ export async function chatLoop(opts = {}) {
1575
1666
  break;
1576
1667
  }
1577
1668
 
1669
+ case 'images_folded': {
1670
+ clearThinking();
1671
+ const 장수 = (ev.뺀것들 ?? []).reduce((a2, x) => a2 + x.장수, 0);
1672
+ say(` ${c.cyan('◲')} ${c.gray(`오래된 그림 ${장수}장을 뺐습니다 — 사람이 쓴 말은 그대로입니다.`)}`);
1673
+ break;
1674
+ }
1675
+
1578
1676
  case 'compacted': {
1579
1677
  접기멈춤();
1580
1678
  const 줄인 = ev.before - ev.after;
package/src/report.js CHANGED
@@ -1,5 +1,23 @@
1
1
  // 진단 결과를 화면에 표로 그리고, "이걸로 돌릴 수 있는지" 판정한다.
2
2
  import { c, say, rule, pad, width, mark } from './ui/ansi.js';
3
+ import { 주소가리기 } from './safety/secrets.js';
4
+ import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
5
+ import { 보관방식 } from './safety/keystore.js';
6
+ import { load, activeProfile } from './config.js';
7
+
8
+ // 열쇠를 어디에 두고 있나. 심사 담당자가 꼭 묻는 줄이라 보고서에 박아 둔다.
9
+ function 열쇠줄() {
10
+ try { return 보관방식(activeProfile(load())?.apiKey ?? null); } catch { return 보관방식(); }
11
+ }
12
+
13
+ // 이 주소로 갈 때 거칠 프록시를 한 줄로. 안 거치면 null — 그때는 줄 자체를 안 만든다.
14
+ // 적어 놨는데 못 쓰는 것(socks5 등)이면 그 까닭을 적는다 — 진단 보고서에서 제일 먼저 볼 줄이다.
15
+ function 프록시줄(base) {
16
+ const p = 프록시고르기(base);
17
+ if (p) return `${p.url} (${p.출처})`;
18
+ const 탈 = 프록시설정().탈;
19
+ return 탈 ? `못 씀 — ${탈}` : null;
20
+ }
3
21
 
4
22
  const ICON = { ok: mark.ok, no: mark.no, warn: mark.warn, skip: c.gray('·') };
5
23
  const WORD = { ok: c.green('됨'), no: c.red('안됨'), warn: c.yellow('조건부'), skip: c.gray('확인불가') };
@@ -17,10 +35,14 @@ export function renderHeader(facts) {
17
35
  const kindName = facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환';
18
36
  const rows = [
19
37
  ['규격', kindName],
20
- ['주소', facts.base],
38
+ ['주소', 주소가리기(facts.base)],
21
39
  ['인증', facts.auth === 'none' ? '없음' : facts.auth],
22
40
  ['모델', facts.model],
23
41
  ];
42
+ // 프록시를 거치면 그 줄도 적는다. "연결 실패" 가 프록시 탓인지 게이트웨이 탓인지는
43
+ // 이 한 줄이 있어야 갈린다 — 진단 보고서를 받는 사내 담당자가 제일 먼저 보는 자리다.
44
+ const 프록시 = 프록시줄(facts.base);
45
+ if (프록시) rows.push(['프록시', 프록시]);
24
46
  for (const [k, v] of rows) say(` ${c.gray(pad(k, 8))} ${v}`);
25
47
  say('');
26
48
  rule('검사', 74);
@@ -103,8 +125,10 @@ export function plainReport(facts, results, v) {
103
125
  `생성 시각 ${at}`,
104
126
  '',
105
127
  `규격 ${facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환'}`,
106
- `주소 ${facts.base}`,
128
+ `주소 ${주소가리기(facts.base)}`,
129
+ ...(프록시줄(facts.base) ? [`프록시 ${프록시줄(facts.base)}`] : []),
107
130
  `인증 ${facts.auth === 'none' ? '없음' : facts.auth}`,
131
+ `열쇠 보관 ${열쇠줄()}`,
108
132
  `모델 ${facts.model}`,
109
133
  `컨텍스트 ${facts.ctx ? facts.ctx.toLocaleString() + ' 토큰' : '미상'}`,
110
134
  '',