deel-local-cli 1.5.8 → 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 +23 -13
  2. package/README.md +22 -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 +78 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +13 -2
  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 +226 -36
  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
  /*
@@ -786,6 +801,10 @@ export async function chatLoop(opts = {}) {
786
801
  // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
787
802
  // /model 로 갈아타면 conn 이 통째로 바뀌므로 그때마다 다시 읽는다.
788
803
  get 모델컨텍스트() { return conn.ctx ?? null; },
804
+ // 이 모델이 그림을 볼 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
805
+ get 눈있나() { return !!conn.vision; },
806
+ // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
807
+ 규칙들: 규칙모으기(cfg),
789
808
  history: new History(root),
790
809
  audit: new Audit(root),
791
810
  seen: new Set(),
@@ -974,7 +993,7 @@ export async function chatLoop(opts = {}) {
974
993
  * 대화 갈래. 연결·도구·되돌리기는 같이 쓰고 오간 말만 여러 벌 갖는다.
975
994
  * 갈래마다 저장 파일을 따로 열어서, 나중에 `/sessions` 로 각각 찾아갈 수 있다.
976
995
  */
977
- 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);
978
997
 
979
998
  // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
980
999
  //
@@ -1199,15 +1218,19 @@ export async function chatLoop(opts = {}) {
1199
1218
  // 붙인 것은 화면에 반드시 알린다. 사람이 안 보낸 줄 아는 글이 대화에
1200
1219
  // 들어가 있으면 안 된다 — 컨텍스트가 왜 줄었는지도 모르게 된다.
1201
1220
  let 보낼글 = toSend;
1221
+ let 보낼그림 = null;
1202
1222
  if (toSend.includes('@')) {
1203
1223
  const 예산 = Math.min(20000, Math.floor((session.conn.ctx ?? CTX_DEFAULT) * 0.25));
1204
1224
  const r = expandMentions(toSend, {
1205
1225
  scope: ctx.scope, budget: 예산, seen: ctx.seen,
1206
1226
  onRead: (p, t) => session.noteRead(p, t),
1227
+ 눈있나: !!session.conn.vision,
1207
1228
  });
1208
1229
  보낼글 = r.text;
1230
+ 보낼그림 = r.그림들?.length ? r.그림들 : null;
1209
1231
  for (const a of r.attached) {
1210
- 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)}${꼬리}`);
1211
1234
  }
1212
1235
  for (const b of r.blocked) {
1213
1236
  say(` ${mark.warn} ${c.gray(`${b.path} 는 작업 범위 밖이라 안 붙였습니다.`)}`);
@@ -1313,7 +1336,23 @@ export async function chatLoop(opts = {}) {
1313
1336
  // '겪을수록 나아진다' 가 이번 대화 안에서도 참이 된다.
1314
1337
  ctx.카드다시?.();
1315
1338
  try {
1316
- 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, 그림들: 보낼그림, 끼어들기 })) {
1317
1356
  /*
1318
1357
  * 하위 작업 안쪽에서 온 것이면 한 단 들여 그린다.
1319
1358
  *
@@ -1322,6 +1361,15 @@ export async function chatLoop(opts = {}) {
1322
1361
  */
1323
1362
  화면.들여쓰기(ev.depth ?? 0);
1324
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;
1325
1373
  // 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
1326
1374
  case 'stage':
1327
1375
  stage = ev;
@@ -1337,6 +1385,14 @@ export async function chatLoop(opts = {}) {
1337
1385
  : ` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
1338
1386
  break;
1339
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
+
1340
1396
  case 'waiting':
1341
1397
  화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
1342
1398
  // 지워야 할 줄이 화면에 있다고 표시해 둔다.
@@ -1501,6 +1557,15 @@ export async function chatLoop(opts = {}) {
1501
1557
  say(` ${c.red('✗')} ${c.white(f.보인이름 ?? '(경로 없음)')} ${c.gray(`— ${clip(String(f.error), 60)}`)}`);
1502
1558
  }
1503
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 });
1504
1569
  }
1505
1570
  }
1506
1571
  flush(); // 도구가 하나 끝날 때마다 적어 둔다
@@ -1601,6 +1666,13 @@ export async function chatLoop(opts = {}) {
1601
1666
  break;
1602
1667
  }
1603
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
+
1604
1676
  case 'compacted': {
1605
1677
  접기멈춤();
1606
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
  '',
@@ -68,6 +68,13 @@ function 진짜자리(p) {
68
68
  return p;
69
69
  }
70
70
 
71
+ /** `/c/Users/…` → `C:/Users/…`. 윈도우에서만, 드라이브 글자 한 자짜리 첫 칸만. */
72
+ export function MSYS풀기(p) {
73
+ if (process.platform !== 'win32') return p;
74
+ const m = /^\/([a-zA-Z])(\/|$)/.exec(p);
75
+ return m ? `${m[1].toUpperCase()}:${p.slice(2) || '/'}` : p;
76
+ }
77
+
71
78
  // 작업 범위 — deel 를 띄운 폴더. 그 밖은 읽기도 쓰기도 막는다.
72
79
  export function makeScope(root) {
73
80
  const base = resolve(root);
@@ -78,7 +85,10 @@ export function makeScope(root) {
78
85
  root: base,
79
86
  resolve(p) {
80
87
  if (!p || typeof p !== 'string') throw new ScopeError('경로가 비었습니다');
81
- const abs = isAbsolute(p) ? resolve(p) : resolve(base, p);
88
+ // Git Bash 절대 경로(/c/Users/…) 윈도우에서 C:\Users\… 다. 안 풀면 지금
89
+ // 드라이브 밑의 \c\Users\… 로 읽혀, 안에 있는 파일이 '범위 밖' 으로 막힌다.
90
+ const 경로 = MSYS풀기(p);
91
+ const abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
82
92
  if (밖(base, abs)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
83
93
  // 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
84
94
  //
@@ -102,7 +112,8 @@ export function makeScope(root) {
102
112
 
103
113
  // 되돌릴 수 없는 것만 막는다. 목록이 길어지면 도구가 쓸모없어진다.
104
114
  const BLOCKED = [
105
- { re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
115
+ // `/c` · `/c/` Git Bash 에서 C:\ 다 — 윈도우에서 bash 를 쓰게 되면서 닿게 된 자리.
116
+ { re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|\/[a-z]\/?|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
106
117
  { re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
107
118
  { re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
108
119
  // 윈도우 짝을 빠뜨리면 안 된다 — deel 은 윈도우에서 주로 돈다.