deel-local-cli 1.5.8 → 1.6.1

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 (59) hide show
  1. package/README.en.md +30 -14
  2. package/README.md +28 -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 +166 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +196 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +22 -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 +92 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +74 -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/convert.js +287 -0
  46. package/src/tools/docs.js +72 -1
  47. package/src/tools/fastgrep.js +217 -0
  48. package/src/tools/fsutil.js +55 -12
  49. package/src/tools/ignore.js +192 -0
  50. package/src/tools/index.js +391 -41
  51. package/src/tools/jobs.js +4 -12
  52. package/src/tools/outline.js +3 -1
  53. package/src/tools/pdf.js +1368 -0
  54. package/src/tools/shell.js +123 -0
  55. package/src/tools/verify.js +20 -4
  56. package/src/tools/webfetch.js +18 -5
  57. package/src/ui/inputbox.js +2 -2
  58. package/src/ui/screen.js +4 -1
  59. package/src/ui/status.js +22 -0
package/src/agent/loop.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
2
2
  // 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
3
3
  import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
4
+ import { 그림메시지 } from '../backend/vision.js';
5
+ import { 어떻게할까 } from '../safety/policy.js';
4
6
  import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
5
7
  import { isMutating } from '../safety/guard.js';
6
8
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
7
9
  import { 살린쓰기 } from './salvage.js';
8
10
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
9
- import { compact, shouldCompact, shouldFold, foldToolResults } from './compact.js';
11
+ import { compact, shouldCompact, shouldFold, foldToolResults, foldImages } from './compact.js';
10
12
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
11
13
  import { Session } from './session.js';
12
14
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
@@ -16,6 +18,35 @@ import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴도구 } from '
16
18
  import { get as workMode } from './modes.js';
17
19
  import { 지시말 } from '../i18n/index.js';
18
20
 
21
+ /*
22
+ * 콜백으로만 소식을 주는 부름을, 제너레이터가 중간에 내보낼 수 있는 모양으로 바꾼다.
23
+ *
24
+ * chat() 이나 compact() 는 await 하나로 끝나는 부름이라, 그 안에서 "서버가 막았다,
25
+ * 2초 기다린다" 같은 소식이 생겨도 화면으로 나올 길이 없었다. 소식을 우편함에
26
+ * 넣어 두고 받는 쪽이 하나씩 꺼내 yield 한다. 부름이 끝나면 소식()도 끝난다.
27
+ *
28
+ * const 편지 = 우편함((알려줘) => chat(conn, { ..., onBackoff: 알려줘 }));
29
+ * for await (const 소식 of 편지.소식()) yield 소식;
30
+ * const 답 = await 편지.부름;
31
+ */
32
+ function 우편함(부르기) {
33
+ const 함 = [];
34
+ let 깨우기 = null;
35
+ let 끝 = false;
36
+ const 부름 = 부르기((소식) => { 함.push(소식); 깨우기?.(); });
37
+ 부름.then(() => { 끝 = true; 깨우기?.(); }, () => { 끝 = true; 깨우기?.(); });
38
+ return {
39
+ 부름,
40
+ async *소식() {
41
+ while (!끝 || 함.length) {
42
+ if (함.length) { yield 함.shift(); continue; }
43
+ await new Promise((r) => { 깨우기 = r; });
44
+ 깨우기 = null;
45
+ }
46
+ },
47
+ };
48
+ }
49
+
19
50
  /**
20
51
  * 아무 내용도 없는 답인가.
21
52
  *
@@ -59,7 +90,7 @@ export function 묶기(calls) {
59
90
  return out;
60
91
  }
61
92
 
62
- export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 } = {}) {
93
+ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0, 그림들 = null, 끼어들기 = null } = {}) {
63
94
  /*
64
95
  * 되돌리기 턴은 **부모만** 연다.
65
96
  *
@@ -72,7 +103,10 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
72
103
  * 되돌린 일을 또 하려 든다.
73
104
  */
74
105
  if (!깊이) session.턴시작(ctx.history.nextTurn());
75
- session.push({ role: 'user', content: userText });
106
+ // @ 그림을 지목했으면 그 말과 함께 실어 보낸다 (backend/vision.js).
107
+ session.push(그림들?.length
108
+ ? 그림메시지(session.conn?.kind, { 글: userText, 그림들 })
109
+ : { role: 'user', content: userText });
76
110
  ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
77
111
 
78
112
  /*
@@ -222,6 +256,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
222
256
  // 창이 좁으면 도구 설명을 줄여 싣는다 (budget.js 의 설명길이).
223
257
  // 도구를 빼는 게 아니라 설명만 줄이므로 할 수 있는 일은 안 달라진다.
224
258
  ctx: conn.ctx ?? null,
259
+ // 그림을 볼 수 있는 모델일 때만 Read 설명에 그림 이야기를 넣는다.
260
+ // 못 보는데 넣어 두면 모델이 화면 사진을 열려 들고, 그때마다 한 걸음이 헛간다.
261
+ vision: conn.vision === true,
225
262
  });
226
263
 
227
264
  /*
@@ -283,11 +320,30 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
283
320
  * 여기까지 오지 않는다 — 막히는 것은 글로 되묻고 턴을 닫는 쪽뿐이다.
284
321
  */
285
322
  let 민적 = false;
323
+ /*
324
+ * 인사에는 안 민다.
325
+ *
326
+ * 「안녕」 한 마디에 모델이 "안녕하세요! 무엇을 도와드릴까요?" 라고 답하면
327
+ * 그건 **맞는 답**이다. 그런데 그 말이 되묻는말에 걸려서 되밀렸다 —
328
+ *
329
+ * ❯ 안녕
330
+ * ▌ 안녕하세요! 무엇을 도와드릴까요?
331
+ * ↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다
332
+ * ▌ 안녕하세요! 반갑습니다.
333
+ * ── 6.8초
334
+ *
335
+ * 인사 한 마디에 모델을 두 번 부른다. 로컬 모델에서 그 왕복이 6.8초다.
336
+ * 되밀기는 「시킨 일을 안 하고 되돌려준」 자리를 잡으라고 만든 것인데,
337
+ * 인사는 시킨 일이 없다. 시킨 것이 없으면 안 한 것도 없다.
338
+ */
339
+ const 인사인가 = /^(안녕[가-힣]*|반(가|갑)[가-힣]*|하이|ㅎㅇ+|헬로[우가-힣]*|고마[가-힣]*|감사[가-힣]*|수고[가-힣]*|hi|hello|hey|yo|thanks?|thank you|테스트|test|ok(ay)?|네|응)[\s!.~?ㅎㅋ,]*$/i
340
+ .test(String(userText ?? '').trim());
286
341
  const 되묻는말 = /무엇을\s*도와|무엇을\s*해\s*드릴|원하시는\s*(작업|것)|어떤\s*(작업|것)\s*(을|부터)|말씀해\s*주세요|알려\s*주세요|지시해\s*주세요|어떻게\s*할까요|해\s*드릴까요|진행할까요|what would you like|how can i (help|assist)|let me know (what|which|how)|shall i\b|would you like me to/i;
287
342
 
288
343
  /** 이번 답을 되밀까. 밀 이유를 돌려주고, 아니면 null. */
289
344
  const 밀어줄까 = (글) => {
290
345
  if (민적 || 깊이) return null;
346
+ if (인사인가) return null; // 시킨 것이 없으면 안 한 것도 없다
291
347
  if (손댄파일.size) return null; // 뭐라도 바꿨으면 일은 한 것이다
292
348
  if (!모드.tools.includes('Write')) return null; // 안 바꾸는 모드는 그게 맞다
293
349
  const 말 = String(글 ?? '').trim();
@@ -311,6 +367,36 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
311
367
 
312
368
  while (steps < maxSteps) {
313
369
  steps++;
370
+
371
+ /*
372
+ * ── 도중에 한 말을 여기서 받는다 (끼어들기) ──────────────────────────
373
+ *
374
+ * 일하는 동안 친 말은 여태 **턴이 끝날 때까지** 기다렸다. 로컬 모델은
375
+ * 한 턴이 몇 분씩 가는데, 그 사이에 "아, 그건 말고 저거" 라고 쳐도
376
+ * 모델은 하던 길을 끝까지 간다. 사람이 할 수 있는 것은 Ctrl+C 로
377
+ * 지금까지 한 것을 통째로 버리는 것뿐이었다.
378
+ *
379
+ * 그래서 걸음마다 **모델을 부르기 직전에** 받아 넣는다. 다음 부름에
380
+ * 그 말이 같이 실리니, 하던 일을 버리지 않고 방향만 튼다.
381
+ *
382
+ * 도구가 도는 중간에는 안 넣는다 — 도구 호출과 그 답 사이에 사람 말이
383
+ * 끼면 대화 모양이 깨져서 게이트웨이가 400 을 준다. 걸음의 머리는
384
+ * 언제나 도구 답이 다 붙은 뒤다.
385
+ */
386
+ /*
387
+ * 첫 걸음에는 안 받는다.
388
+ *
389
+ * 첫 부름 전에 큐에 남아 있는 것은 **턴이 시작되기 전에** 친 말이다.
390
+ * 그건 원래 다음 턴이 될 것이었는데, 여기서 끌어다 넣으면 사람이 따로
391
+ * 시킨 두 가지가 한 턴으로 뭉친다. 끼어들기는 「가던 길을 트는 것」이지
392
+ * 「줄 서 있는 것을 당겨오는 것」이 아니다.
393
+ */
394
+ const 끼어든말 = (깊이 || steps <= 1) ? null : 끼어들기?.();
395
+ if (끼어든말) {
396
+ session.push({ role: 'user', content: 끼어든말 });
397
+ ctx.audit.turn(`[도중에 끼어든 말] ${끼어든말}`);
398
+ yield { type: 'steer', text: 끼어든말 };
399
+ }
314
400
  /*
315
401
  * 이 모델이 실제로 어떻게 하고 있는지 센다 (agent/grade.js).
316
402
  *
@@ -351,18 +437,40 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
351
437
  // 흘러온 글을 우리도 모아 둔다. 중간에 끊기면 이것만이 남는 전부다 —
352
438
  // chatStream 은 끝까지 가야 message 를 주므로, 끊긴 순간에는 msg 가 비어 있다.
353
439
  let 흘린것 = '';
440
+ /*
441
+ * 서버가 잠깐 막아 기다렸다 다시 부른 횟수는 /cost 와 deel run --json 이 본다.
442
+ * 다만 **정말 다시 불렀을 때** 센다 — 알림을 낸 뒤 기다리다 Ctrl+C 로 끊기면
443
+ * 다시 부른 것이 아니다. 그래서 셈은 미뤄 두고, 다음 소식이 오면 그때 더한다.
444
+ */
445
+ let 미룬셈 = 0;
446
+ const 셈하기 = () => { session.usage.retries = (session.usage.retries ?? 0) + 미룬셈; 미룬셈 = 0; };
447
+ const 끝셈 = (err) => { if (err?.name === 'Aborted') 미룬셈 = 0; else 셈하기(); };
354
448
  const askModel = async function* (maxTokens, think, { 한번에 = false } = {}) {
355
449
  if (conn.streaming && !한번에) {
356
- for await (const ev of chatStream(conn, ask(maxTokens, think))) {
357
- if (ev.type === 'done') msg = ev.message;
358
- else {
359
- if (ev.type === 'content') 흘린것 += ev.text ?? '';
360
- yield ev;
450
+ try {
451
+ for await (const ev of chatStream(conn, ask(maxTokens, think))) {
452
+ 셈하기(); // 무슨 소식이든 왔다는 것은 앞의 다시 부름이 실제로 있었다는 뜻
453
+ if (ev.type === 'done') msg = ev.message;
454
+ else {
455
+ if (ev.type === 'content') 흘린것 += ev.text ?? '';
456
+ if (ev.type === 'backoff') 미룬셈 = 1;
457
+ yield ev;
458
+ }
361
459
  }
362
- }
460
+ } catch (err) { 끝셈(err); throw err; }
461
+ 셈하기();
363
462
  } else {
364
463
  yield { type: 'waiting' };
365
- msg = await chat(conn, ask(maxTokens, think));
464
+ /*
465
+ * 한 번에 받는 길은 await 하나라 알림이 흘러나오지 않는다. 그래서 부름을
466
+ * 띄워 두고, 알림이 오면 먼저 내보내고, 답이 오면 그때 끝낸다. 안 그러면
467
+ * 서버가 60초 기다리라 한 동안 화면에는 '생각 중' 만 떠 있다 — 사람은
468
+ * 모델이 느린 줄 알지만 실은 429 였다.
469
+ */
470
+ const 편지 = 우편함((onBackoff) => chat(conn, { ...ask(maxTokens, think), onBackoff }));
471
+ for await (const 알림 of 편지.소식()) { 셈하기(); 미룬셈 = 1; yield 알림; }
472
+ try { msg = await 편지.부름; } catch (err) { 끝셈(err); throw err; }
473
+ 셈하기();
366
474
  if (msg.thinking) yield { type: 'thinking', text: msg.thinking };
367
475
  if (msg.content) yield { type: 'content', text: msg.content };
368
476
  }
@@ -390,15 +498,38 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
390
498
  */
391
499
  const full = Math.max(cap, fullCap(room));
392
500
  const 낮춘생각 = level === 'off' || level === 'low' ? level : shiftLevel(level, -1);
501
+ let 마지막상한 = cap;
393
502
  if (wasCut(msg) && (full > cap || 낮춘생각 !== level)) {
394
503
  yield {
395
504
  type: 'retry',
396
505
  why: full > cap ? '대답이 상한에서 잘렸습니다' : '대답이 잘렸습니다 — 생각을 줄여 자리를 냅니다',
397
506
  from: cap, to: full, think: 낮춘생각,
398
507
  };
508
+ 마지막상한 = full;
399
509
  yield* askModel(full, 낮춘생각);
400
510
  }
401
511
 
512
+ /*
513
+ * 우리가 할 수 있는 것을 다 했는데도 잘렸다 — 여기서 입을 다물면 안 된다.
514
+ *
515
+ * 상한도 올려 봤고 생각도 줄여 봤는데 여전히 잘렸으면, 남은 일은 사람
516
+ * 몫이다. 그런데 여태 그냥 조용히 끝냈다. 화면에는 중간에서 끊긴 답만
517
+ * 남는다 — 사람 눈에는 모델이 게을러서 대충 답한 것으로 보이니 같은 것을
518
+ * 다시 시키고, 같은 자리에서 또 잘린다. 몇 분씩 가는 로컬 모델에서
519
+ * 이 왕복은 비싸다.
520
+ *
521
+ * 안 되는 것은 **까닭과 손댈 자리를 같이** 말한다 (`/out`).
522
+ * 다시 부른 뒤에 보는 것이 중요하다. 부르기 전에 판정하면, 상한을 올려
523
+ * 멀쩡히 끝난 답에까지 경고가 붙는다 — 그러면 곧 아무도 안 읽는다.
524
+ */
525
+ if (wasCut(msg)) {
526
+ yield {
527
+ type: 'capped',
528
+ cap: 마지막상한,
529
+ 정한값: conn.maxTokens ?? conn.maxOut ?? null,
530
+ };
531
+ }
532
+
402
533
  /*
403
534
  * 빈 답을 성공으로 넘기지 않는다.
404
535
  *
@@ -524,6 +655,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
524
655
  session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
525
656
  };
526
657
 
658
+ /*
659
+ * 이번 턴에 Read 로 연 그림들.
660
+ *
661
+ * 도구 결과가 다 들어간 **뒤에** 한꺼번에 붙인다. 하나 읽을 때마다 바로
662
+ * 붙이면 도구 결과 사이에 사람 말이 끼어드는데, 그러면 뒤에 오는 도구
663
+ * 결과들이 짝을 잃는다 — 게이트웨이가 통째로 400 을 준다.
664
+ */
665
+ const 붙일그림 = [];
666
+
527
667
  for (const 덩어리 of 묶기(msg.toolCalls)) {
528
668
  // 돌리는 중에 끊었다면, 남은 것은 실행하지 않고 결과 자리만 채운다.
529
669
  // 자리를 비우면 짝이 깨져 다음에 이어할 수 없다.
@@ -637,8 +777,33 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
637
777
  attempted.add(key);
638
778
  }
639
779
 
780
+ /*
781
+ * 적어 둔 규칙이 모드보다 먼저다 (safety/policy.js).
782
+ *
783
+ * 금지 — 물어보지도 않는다. 물어보는 것은 막는 것이 아니다.
784
+ * `npm test` 에 스무 번 y 를 친 손은 스물한 번째도 친다.
785
+ * 허락 — 모드가 뭐든 안 묻는다.
786
+ * 없으면 예전 그대로 모드가 정한다.
787
+ *
788
+ * 막았으면 **어디에 적힌 규칙인지까지** 말한다. 그 말이 없으면 사람은
789
+ * 제 설정을 고칠 수도, 관리자에게 무엇을 풀어 달라고 할 수도 없다.
790
+ */
791
+ const 판정 = 어떻게할까(ctx.규칙들, call.name, call.args);
792
+ if (판정.답 === 'deny') {
793
+ ctx.audit?.blocked?.('규칙으로 금지됨', `${판정.출처}: ${판정.규칙}`);
794
+ 거절(call, `${판정.출처}에 적힌 규칙 ${판정.규칙} 으로 막혀 있습니다.`
795
+ + ' 이 방법은 쓸 수 없습니다 — 다른 길을 찾거나, 왜 필요한지 사용자에게 말하세요.'
796
+ + ' 같은 것을 다시 부르지 마세요.');
797
+ if (막힘셈(call, '규칙 금지')) 멈출까 = '규칙으로 막힌 것을 계속 다시 부르고 있습니다';
798
+ yield {
799
+ type: 'tool', name: call.name, args: call.args, showLabel: true,
800
+ result: { error: `막힘 — ${판정.출처}의 ${판정.규칙}` },
801
+ };
802
+ continue;
803
+ }
804
+
640
805
  // 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
641
- const needsOk = session.mode === 'strict'
806
+ const needsOk = 판정.답 === 'allow' ? false : session.mode === 'strict'
642
807
  ? ['Write', 'Edit', 'Bash'].includes(call.name)
643
808
  : session.mode === 'confirm'
644
809
  ? (call.name === 'Bash' && isMutating(call.args?.command))
@@ -810,6 +975,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
810
975
  session.usage.out += 자식.usage.out;
811
976
  session.usage.calls += 자식.usage.calls;
812
977
  session.usage.ms += 자식.usage.ms;
978
+ session.usage.retries = (session.usage.retries ?? 0) + (자식.usage.retries ?? 0);
813
979
 
814
980
  const 글 = 하위요약({
815
981
  목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null,
@@ -950,6 +1116,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
950
1116
  name: call.name,
951
1117
  content: 실을것,
952
1118
  }));
1119
+ if (result.그림) 붙일그림.push(result.그림);
953
1120
  yield {
954
1121
  type: 'tool',
955
1122
  name: call.name,
@@ -962,6 +1129,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
962
1129
  }
963
1130
  }
964
1131
 
1132
+ // 연 그림들을 사람 말 자리로 붙인다 (backend/vision.js).
1133
+ if (붙일그림.length) {
1134
+ const 이름들 = 붙일그림.map((g) => g.show).join(' · ');
1135
+ session.push(그림메시지(conn.kind, {
1136
+ 글: 붙일그림.length === 1
1137
+ ? `방금 연 그림입니다: ${이름들}`
1138
+ : `방금 연 그림 ${붙일그림.length}장입니다: ${이름들}`,
1139
+ 그림들: 붙일그림,
1140
+ }));
1141
+ }
1142
+
965
1143
  // 도구를 돌리다 끕어졌어도 모델이 한 말은 이미 대화에 들어가 있다. 그건 남는다.
966
1144
  if (signal?.aborted) { yield { type: 'aborted', steps, kept: true }; return; }
967
1145
 
@@ -983,12 +1161,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
983
1161
  if (shouldFold(session)) {
984
1162
  const f = foldToolResults(session);
985
1163
  if (f.접은것) yield { type: 'folded', ...f };
1164
+ // 그림은 사람 말 자리에 실려서 위 접기가 못 건드린다. 따로 뺀다.
1165
+ const g = foldImages(session);
1166
+ if (g.뺀것) yield { type: 'images_folded', ...g };
986
1167
  }
987
1168
 
988
1169
  // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
989
1170
  if (shouldCompact(session)) {
990
1171
  yield { type: 'compacting' };
991
- const r = await compact(session, { auto: true, signal });
1172
+ // 요약을 부르다 서버가 막으면 알림도 화면으로 '접는 중' 뒤에 60초를 숨기지 않는다.
1173
+ const 편지 = 우편함((onBackoff) => compact(session, { auto: true, signal, onBackoff }));
1174
+ for await (const 알림 of 편지.소식()) yield 알림;
1175
+ const r = await 편지.부름;
992
1176
  if (r.aborted) { yield { type: 'aborted', steps, kept: true }; return; }
993
1177
  if (r.ok) yield { type: 'compacted', ...r };
994
1178
  else yield { type: 'compact_failed', why: r.why };
@@ -14,7 +14,9 @@
14
14
  // 그래서 규칙을 하나로 뒀다: **실제로 있는 경로일 때만 붙인다.**
15
15
  // 없으면 아무 말 없이 글자 그대로 둔다. 지목이 아니었을 테니 조용한 편이 맞다.
16
16
  import { existsSync, statSync, readdirSync } from 'node:fs';
17
- import { readTextFull } from '../tools/fsutil.js';
17
+ import { readTextFull, 내부살림 } from '../tools/fsutil.js';
18
+ import { 계보규칙읽기, 무시하나 } from '../tools/ignore.js';
19
+ import { 그림인가, 그림읽기, 크기말 } from '../backend/vision.js';
18
20
  import { estimateTokens } from './session.js';
19
21
 
20
22
  // 붙일 수 있는 최대 토큰. 부르는 쪽에서 컨텍스트에 맞춰 넘겨준다.
@@ -74,13 +76,19 @@ function 찾아보기(m, scope) {
74
76
  return { why: 'missing', path: m.path };
75
77
  }
76
78
 
77
- function 폴더내용(abs, show) {
79
+ function 폴더내용(abs, show, root = null) {
78
80
  let 목록 = [];
79
81
  try { 목록 = readdirSync(abs, { withFileTypes: true }); }
80
82
  catch (err) { return `(폴더를 못 읽었습니다: ${err.message})`; }
81
- const 줄들 = 목록.slice(0, 폴더최대).map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
82
- const = 목록.length > 폴더최대 ? `\n… 전체 ${목록.length}개 중 ${폴더최대}개까지` : '';
83
- return `${show} 폴더입니다. 안에 것:\n${줄들.join('\n')}${더}`;
83
+ // git 보는 것은 여기서도 안 늘어놓는다 — Glob · Grep 과 같은 규칙 (tools/ignore.js). 수는 적는다.
84
+ const rel = show === '.' ? '' : String(show).replace(/\\/g, '/');
85
+ const 규칙 = root ? 계보규칙읽기(root, rel) : [];
86
+ const 보임 = 규칙.length ? 목록.filter((e) => !무시하나(rel ? `${rel}/${e.name}` : e.name, e.isDirectory(), 규칙)) : 목록;
87
+ const 뺀 = 목록.length - 보임.length;
88
+ const 줄들 = 보임.slice(0, 폴더최대).map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
89
+ const 더 = 보임.length > 폴더최대 ? `\n… 전체 ${보임.length}개 중 ${폴더최대}개까지` : '';
90
+ const 건너뜀 = 뺀 ? `\n(.gitignore 로 ${뺀}개 건너뜀 — 경로를 직접 주면 Read 된다)` : '';
91
+ return `${show} 는 폴더입니다. 안에 든 것:\n${줄들.join('\n')}${더}${건너뜀}`;
84
92
  }
85
93
 
86
94
  /**
@@ -96,9 +104,9 @@ function 폴더내용(abs, show) {
96
104
  * 'Read 부터 하라' 며 되돌려 보내지 않는다. 잘린 파일은 안 적는다.
97
105
  * 안 본 데를 본 것으로 치면 그 자리를 그냥 고쳐 버린다.
98
106
  */
99
- export function expand(text, { scope = null, budget = 기본예산, seen = null, onRead = null } = {}) {
107
+ export function expand(text, { scope = null, budget = 기본예산, seen = null, onRead = null, 눈있나 = false } = {}) {
100
108
  const 원문 = String(text ?? '');
101
- const 빈답 = { text: 원문, attached: [], missing: [], blocked: [] };
109
+ const 빈답 = { text: 원문, attached: [], missing: [], blocked: [], 그림들: [] };
102
110
  if (!원문.includes('@') || !scope?.resolve) return 빈답;
103
111
 
104
112
  const 지목들 = findMentions(원문);
@@ -107,6 +115,7 @@ export function expand(text, { scope = null, budget = 기본예산, seen = null,
107
115
  const attached = [];
108
116
  const missing = [];
109
117
  const blocked = [];
118
+ const 그림들 = [];
110
119
  const 붙일것 = [];
111
120
  const 본것 = new Set();
112
121
  let 남은예산 = Math.max(200, Number(budget) || 기본예산);
@@ -115,6 +124,18 @@ export function expand(text, { scope = null, budget = 기본예산, seen = null,
115
124
  const 자리 = 찾아보기(m, scope);
116
125
  if (자리.why === 'blocked') { blocked.push({ path: 자리.path, why: 자리.note }); continue; }
117
126
  if (자리.why === 'missing') { missing.push(자리.path); continue; }
127
+
128
+ /*
129
+ * 살림 파일은 @ 로도 못 붙인다.
130
+ *
131
+ * Read 도구는 .deel/config.json 을 막고 있었는데 **여기는 안 막고 있었다.**
132
+ * 그래서 `@.deel/config.json` 한 줄이면 게이트웨이 열쇠가 대화에 실려
133
+ * 그대로 바깥으로 나갔다. 도구는 막고 @ 는 안 막으면 막은 것이 아니다.
134
+ * (검사: test/mention-secret.test.js)
135
+ */
136
+ const 살림 = 내부살림(자리.abs);
137
+ if (살림) { blocked.push({ path: 자리.path, why: 살림 }); continue; }
138
+
118
139
  if (본것.has(자리.abs)) continue; // 같은 파일을 두 번 적지 않는다
119
140
  본것.add(자리.abs);
120
141
 
@@ -124,7 +145,32 @@ export function expand(text, { scope = null, budget = 기본예산, seen = null,
124
145
 
125
146
  try {
126
147
  if (statSync(자리.abs).isDirectory()) {
127
- 몸통 = 폴더내용(자리.abs, show);
148
+ 몸통 = 폴더내용(자리.abs, show, scope.root ?? null);
149
+ } else if (그림인가(자리.abs)) {
150
+ /*
151
+ * 그림은 글로 안 붙인다. 붙이면 깨진 글자 수천 자가 대화에 실린다.
152
+ *
153
+ * 볼 수 있는 모델이면 그림 자체를 이 말과 함께 싣고(repl.js → loop.js),
154
+ * 못 보는 모델이면 **바이트를 아예 안 싣는다.** 안 보이는 모델에게
155
+ * 보내 봐야 400 이 오거나, 더 나쁘게는 서버가 조용히 무시하고 답을
156
+ * 지어낸다. 그러면 사람은 모델이 화면을 봤다고 믿는다.
157
+ */
158
+ const 것 = 눈있나 ? 그림읽기(자리.abs) : { ok: false, 왜: null, bytes: 0 };
159
+ if (것.ok) {
160
+ 그림들.push({ b64: 것.b64, mime: 것.mime, bytes: 것.bytes, show });
161
+ 몸통 = `(그림 · ${크기말(것.bytes)} — 이 말과 함께 붙였습니다)`;
162
+ attached.push({ path: 자리.abs, show, full: true, 그림: true, bytes: 것.bytes });
163
+ 붙일것.push(`
164
+
165
+ --- ${show} ---
166
+ ${몸통}
167
+ --- ${show} 끝 ---`);
168
+ continue;
169
+ }
170
+ 몸통 = 눈있나
171
+ ? `(못 붙였습니다: ${것.왜})`
172
+ : '(그림입니다. 지금 붙어 있는 모델은 그림을 못 봅니다 — 안 붙였습니다.)';
173
+ 통째로 = false;
128
174
  } else {
129
175
  // 사내 파일은 CP949 가 흔하다. 그냥 읽으면 통째로 깨진다.
130
176
  const 읽음 = readTextFull(자리.abs);
@@ -156,9 +202,9 @@ export function expand(text, { scope = null, budget = 기본예산, seen = null,
156
202
  if (남은예산 <= 0) break;
157
203
  }
158
204
 
159
- if (!붙일것.length) return { text: 원문, attached, missing, blocked };
205
+ if (!붙일것.length) return { text: 원문, attached, missing, blocked, 그림들 };
160
206
  const 머리 = attached.length === 1
161
207
  ? '아래는 사용자가 @ 로 지목한 파일입니다. 이미 읽은 것으로 치고 답하세요.'
162
208
  : `아래는 사용자가 @ 로 지목한 파일 ${attached.length}개입니다. 이미 읽은 것으로 치고 답하세요.`;
163
- return { text: `${원문}\n\n${머리}${붙일것.join('')}`, attached, missing, blocked };
209
+ return { text: `${원문}\n\n${머리}${붙일것.join('')}`, attached, missing, blocked, 그림들 };
164
210
  }
@@ -0,0 +1,192 @@
1
+ // `/review` — 이번에 바꾼 것을 모델에게 한 번 더 보게 한다.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 쓴 사람은 제가 쓴 것을 못 본다. 방금 그 코드를 왜 그렇게 썼는지 머릿속에
6
+ // 남아 있어서, 읽을 때 그 기억이 빈자리를 메워 준다. 모델도 똑같다 — 같은
7
+ // 대화 안에서 "다시 봐" 라고 하면 제가 한 말을 근거로 제 코드를 변호한다.
8
+ //
9
+ // 그래서 **새 창에서** 본다. 지금 대화는 한 줄도 안 보낸다. 보내는 것은
10
+ // 바뀐 코드와 검사표뿐이다. 무엇을 하려던 것인지 모르는 눈으로 봐야
11
+ // "이건 왜 이렇게 돼 있지" 가 나온다.
12
+ //
13
+ // ── 무엇을 안 하나 ─────────────────────────────────────────────────────
14
+ //
15
+ // **고치지 않는다.** 도구를 아예 안 준다. 찾은 것을 말로만 돌려준다.
16
+ // 리뷰가 제 손으로 고치기 시작하면 사람이 무엇을 승인한 것인지 흐려지고,
17
+ // 찾은 것 중 무엇이 진짜인지 가릴 기회가 없어진다. 고칠지 말지는 사람이
18
+ // 정한다 — 그게 리뷰의 전부다.
19
+ import { 깃, 저장소뿌리, 이번에바꾼것 } from './commit.js';
20
+ import { chat } from '../backend/adapter.js';
21
+
22
+ /** 모델에게 보여 줄 diff 길이 상한(글자). 넘으면 자르고 잘랐다고 적는다. */
23
+ const DIFF상한 = 60000;
24
+
25
+ /*
26
+ * 검사표.
27
+ *
28
+ * 고정해 둔다. 매번 다른 것을 보면 지난번에 통과한 것이 이번에 걸리고,
29
+ * 사람은 그 흔들림을 보고 리뷰를 안 믿게 된다. 그리고 여기 적힌 순서가
30
+ * 곧 중요도다 — 위에서부터 본다.
31
+ */
32
+ export const 검사표 = [
33
+ ['맞나', '이 코드가 하려는 일을 실제로 하는가. 경계값·빈 값·오류 갈래에서 어떻게 되나'],
34
+ ['숨은 실패', '오류를 삼키는 자리가 있나. 실패했는데 성공한 것처럼 보이는 길이 있나'],
35
+ ['정직한가', '화면에 나가는 말과 실제로 일어난 일이 같은가. 짐작한 값을 사실처럼 적지 않나'],
36
+ ['안전', '입력을 그대로 명령·경로·주소로 쓰는 자리가 있나. 비밀이 로그·오류·파일로 새나'],
37
+ ['되돌릴 수 있나', '고장 났을 때 되돌릴 방법이 있나. 지우거나 덮어쓰기 전에 확인하나'],
38
+ ['검사', '이 변경이 깨졌을 때 빨개지는 검사가 있나. 없으면 어디에 무엇을 넣어야 하나'],
39
+ ];
40
+
41
+ const 지시 = `너는 방금 바뀐 코드를 보는 리뷰어다. 이 코드를 쓴 사람이 아니다.
42
+
43
+ **고치지 마라.** 너에게는 도구가 없다. 찾은 것만 말한다.
44
+
45
+ 무엇을 보나 — 위에서부터 중요한 순서다:
46
+ ${검사표.map(([이름, 뜻], i) => `${i + 1}. ${이름} — ${뜻}`).join('\n')}
47
+
48
+ 어떻게 쓰나:
49
+ - 찾은 것마다 **파일:줄** 로 자리를 짚는다. 자리를 못 짚겠으면 그 지적은 쓰지 마라 —
50
+ 사람이 찾아갈 수 없는 지적은 아무 일도 못 하게 한다.
51
+ - **무슨 입력에서 무엇이 잘못되는지**를 쓴다. "예외 처리가 부족합니다" 는 지적이 아니다.
52
+ "파일이 없으면 undefined 가 넘어가 두 줄 뒤에서 터진다" 가 지적이다.
53
+ - diff 에 **보이는 것만** 쓴다. 안 보이는 파일의 내용을 짐작해서 쓰지 마라.
54
+ - 심각한 순서로 놓는다. 사소한 취향은 맨 뒤에 한 줄로 묶거나 아예 쓰지 마라.
55
+ - **아무것도 못 찾았으면 못 찾았다고 한 줄로 답한다.** 채우려고 지어내지 마라.
56
+ 지어낸 지적 하나가 진짜 지적 열 개를 못 믿게 만든다.
57
+
58
+ 형식:
59
+ 심각 / 보통 / 사소 중 하나로 시작하는 줄을 만들고, 그 아래 자리와 까닭을 쓴다.
60
+
61
+ 심각 src/a.js:42
62
+ 열쇠가 오류 문구에 그대로 실린다. 키에 줄바꿈이 섞이면 …
63
+ `;
64
+
65
+ /**
66
+ * 무엇을 볼지 고른다.
67
+ *
68
+ * 이번 대화가 바꾼 것이 있으면 그것만 본다 — 사람이 방금 시킨 일이 그것이라
69
+ * 지금 궁금한 것도 그것이다. 없으면 저장소가 들고 있는 변경(작업 트리)을 본다.
70
+ *
71
+ * @returns {{ok:boolean, 어디:string, diff:string, 통계:string, 파일들:string[], 왜?:string}}
72
+ */
73
+ export function 볼것(session, ctx) {
74
+ const 여기 = ctx?.scope?.root ?? session?.root ?? process.cwd();
75
+ const 뿌리 = 저장소뿌리(여기);
76
+ if (!뿌리) return { ok: false, 왜: '여기는 git 저장소가 아닙니다 — 무엇이 바뀌었는지 알 방법이 없습니다.' };
77
+
78
+ // 이번 대화가 손댄 파일 (agent/commit.js 가 쓰는 것과 같은 셈법 — 살림 폴더와
79
+ // 저장소 밖은 빠지고, 폴더째 적힌 것은 안 딸려 온다).
80
+ const 안쪽 = [...이번에바꾼것(session, 뿌리)];
81
+
82
+ const 인자 = 안쪽.length
83
+ ? ['diff', 'HEAD', '--', ...안쪽]
84
+ : ['diff', 'HEAD'];
85
+ const 몸통 = 깃(뿌리, 인자, {});
86
+ const 통계 = 깃(뿌리, [...인자.slice(0, 2), '--stat', ...인자.slice(2)], {});
87
+ const 이름 = 깃(뿌리, [...인자.slice(0, 2), '--name-only', ...인자.slice(2)], {});
88
+ const 파일들 = 이름.out.split('\n').map((x) => x.trim()).filter(Boolean);
89
+
90
+ if (!몸통.out.trim()) {
91
+ return {
92
+ ok: false,
93
+ 왜: 안쪽.length
94
+ ? '이번 대화가 바꾼 파일이 저장소 기준으로는 그대로입니다 — 볼 것이 없습니다.'
95
+ : '바뀐 것이 없습니다.',
96
+ };
97
+ }
98
+ return {
99
+ ok: true,
100
+ 어디: 안쪽.length ? '이번 대화가 바꾼 것' : '저장소의 바뀐 것 전부',
101
+ diff: 몸통.out,
102
+ 통계: 통계.out.trimEnd(),
103
+ 파일들,
104
+ };
105
+ }
106
+
107
+ /** 리뷰어에게 보낼 한 덩이. 지금 대화는 한 줄도 안 들어간다. */
108
+ export function 보낼것({ diff, 통계, 파일들 }) {
109
+ const 자른것 = diff.length > DIFF상한
110
+ ? `${diff.slice(0, DIFF상한)}\n… (diff 가 길어 여기서 잘랐습니다 — 잘린 뒤는 못 봤습니다)`
111
+ : diff;
112
+ return [
113
+ 지시,
114
+ `바뀐 파일:\n${통계 || 파일들.map((f) => `- ${f}`).join('\n')}`,
115
+ `----- diff -----\n${자른것}`,
116
+ ].join('\n\n');
117
+ }
118
+
119
+ /*
120
+ * 답을 줄로 가른다.
121
+ *
122
+ * 모델이 형식을 안 지킬 때가 있다. 그때 통째로 버리면 사람은 아무것도 못
123
+ * 본다 — 그래서 못 가른 줄은 **그대로 보여 준다.** 우리가 못 읽은 것이지
124
+ * 모델이 아무 말도 안 한 것이 아니다.
125
+ */
126
+ /*
127
+ * 낱말 경계(`\b`)를 한글 뒤에 못 쓴다.
128
+ *
129
+ * 자바스크립트의 낱말 경계는 ASCII 기준이라 '심각' 뒤에는 경계가 안 생긴다.
130
+ * `/^심각\b/` 는 "심각 a.js:1" 에 **안 걸린다.** 그래서 한글은 뒤에 올 수
131
+ * 있는 글자로 끊고, 영어에만 `\b` 를 쓴다. 조용히 안 걸리는 규칙은 없느니만
132
+ * 못하다 — 리뷰가 아무것도 못 찾은 것처럼 보인다.
133
+ */
134
+ const 급표 = [
135
+ [/^\s*심각(?=[\s:.·—-]|$)/, '심각'],
136
+ [/^\s*(critical|high)\b/i, '심각'],
137
+ [/^\s*보통(?=[\s:.·—-]|$)/, '보통'],
138
+ [/^\s*(medium|moderate)\b/i, '보통'],
139
+ [/^\s*사소(?=[\s:.·—-]|$)/, '사소'],
140
+ [/^\s*(low|minor|nit)\b/i, '사소'],
141
+ ];
142
+
143
+ export function 찾은것가르기(글) {
144
+ const 줄들 = String(글 ?? '').split('\n');
145
+ const 것들 = [];
146
+ let 지금 = null;
147
+ for (const 줄 of 줄들) {
148
+ const 급 = 급표.find(([re]) => re.test(줄));
149
+ if (급) {
150
+ if (지금) 것들.push(지금);
151
+ // 자리(파일:줄)를 같은 줄에서 찾아 둔다. 없으면 아래 줄에서 찾는다.
152
+ 지금 = { 급: 급[1], 머리: 줄.trim(), 몸: [], 자리: 자리찾기(줄) };
153
+ continue;
154
+ }
155
+ if (지금) {
156
+ 지금.몸.push(줄);
157
+ if (!지금.자리) 지금.자리 = 자리찾기(줄);
158
+ }
159
+ }
160
+ if (지금) 것들.push(지금);
161
+ return 것들;
162
+ }
163
+
164
+ /**
165
+ * 리뷰어를 부른다.
166
+ *
167
+ * 지금 대화는 한 줄도 안 보낸다. 도구도 안 준다 — 새 창에서, 손 없이 본다.
168
+ * 실패하면 **지어내지 않는다.** 못 봤으면 못 봤다고 돌려준다.
169
+ */
170
+ export async function 리뷰받기(session, 것, { signal = null, onBackoff = null } = {}) {
171
+ try {
172
+ const r = await chat(session.conn, {
173
+ messages: [{ role: 'user', content: 보낼것(것) }],
174
+ maxTokens: 2000,
175
+ think: session.conn.kind === 'ollama' ? false : 'low',
176
+ signal,
177
+ onBackoff,
178
+ timeout: 180000,
179
+ });
180
+ const 글 = (r?.content ?? '').trim();
181
+ if (!글) return { ok: false, 왜: '모델이 빈 답을 줬습니다 — 다시 시켜 보세요.' };
182
+ return { ok: true, 글, 찾은것: 찾은것가르기(글) };
183
+ } catch (err) {
184
+ return { ok: false, 왜: err.message };
185
+ }
186
+ }
187
+
188
+ /** `경로:줄` 을 찾는다. 못 찾으면 null — 지어내지 않는다. */
189
+ export function 자리찾기(줄) {
190
+ const m = /([\w./\\가-힣-]+\.[A-Za-z0-9]{1,8}):(\d+)/.exec(String(줄 ?? ''));
191
+ return m ? { 파일: m[1], 줄: Number(m[2]) } : null;
192
+ }