deel-local-cli 1.8.0 → 1.10.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 (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
package/src/repl.js CHANGED
@@ -22,8 +22,8 @@ import { makeScope } from './safety/guard.js';
22
22
  import { 언어서버있나 } from './tools/index.js';
23
23
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
24
24
  import { History } from './safety/undo.js';
25
- import { Audit } from './safety/audit.js';
26
- import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식 } from './config.js';
25
+ import { Audit, 열쇠묻기 } from './safety/audit.js';
26
+ import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식, 열쇠탈소식 } from './config.js';
27
27
  import { discover } from './skills/discover.js';
28
28
  import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
29
29
  import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
@@ -39,7 +39,8 @@ import { explain, shows as levelShows } from './ui/level.js';
39
39
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
40
40
  import { 추천, 채울글 } from './ui/complete.js';
41
41
  import { 접어쓰기 } from './ui/wrap.js';
42
- import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들 } from './ui/pastechip.js';
42
+ import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글 } from './ui/pastechip.js';
43
+ import { 고른것풀기, 계획답풀기 } from './ui/pick.js';
43
44
  import { 이력지킴이 } from './ui/histline.js';
44
45
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
45
46
  import { renderDiff, shortStat } from './ui/diff.js';
@@ -167,6 +168,22 @@ export async function chatLoop(opts = {}) {
167
168
  바로쓰기(` ${mark.ok} ${잠금}`);
168
169
  }
169
170
 
171
+ /*
172
+ * 잠근 열쇠를 못 풀었으면 **그 까닭을** 적는다.
173
+ *
174
+ * 이걸 안 하면 빈 열쇠가 그대로 나가서 Authorization 머리말이 아예 안 붙고,
175
+ * 사람은 401 만 보고 방화벽·주소·계정을 뒤진다. 진짜 까닭은 대개
176
+ * 「설정 파일을 다른 PC 로 옮겼다」 이고, 그건 우리만 알고 있다.
177
+ *
178
+ * 아래 conn 을 짓는 자리에서 resolveKey 가 불리므로, 읽는 것은 그 뒤여야 한다.
179
+ */
180
+ const 열쇠탈보이기 = () => {
181
+ const 탈 = 열쇠탈소식();
182
+ if (!탈) return;
183
+ 바로쓰기('');
184
+ 바로쓰기(` ${mark.warn} ${탈}`);
185
+ };
186
+
170
187
  /*
171
188
  * 여기서부터 화면에 나가는 것은 전부 `화면` 을 거친다.
172
189
  *
@@ -207,6 +224,9 @@ export async function chatLoop(opts = {}) {
207
224
  vision: prof.vision ?? false,
208
225
  };
209
226
 
227
+ // conn 을 짓느라 resolveKey 가 방금 불렸다. 못 푼 것이 있으면 여기서 말한다.
228
+ 열쇠탈보이기();
229
+
210
230
  /*
211
231
  * ── 주소와 허가를 뗀다 ───────────────────────────────────────────────
212
232
  *
@@ -276,7 +296,9 @@ export async function chatLoop(opts = {}) {
276
296
  * 여기서 한 번만 읽는다. 상태줄은 글자를 칠 때마다 다시 그려지는데,
277
297
  * 그때마다 설정 파일을 열면 디스크를 초당 수십 번 두드리게 된다.
278
298
  */
279
- session.요금표 = cfg?.요금 ?? null;
299
+ // `pricing` 으로 적어도 받는다 — 설정은 사람이 손으로 적는 것이라, 한글
300
+ // 자판이 없는 사람이 못 적는 이름 하나에 기능이 통째로 막히면 안 된다.
301
+ session.요금표 = cfg?.요금 ?? cfg?.pricing ?? null;
280
302
  session.제공자 = prof?.제공자 ?? null;
281
303
 
282
304
  /*
@@ -367,7 +389,7 @@ export async function chatLoop(opts = {}) {
367
389
  */
368
390
  const mcp붙임 = await 다붙이기(root, {
369
391
  offline: isOffline(),
370
- audit: new Audit(root),
392
+ audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
371
393
  });
372
394
 
373
395
  // 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
@@ -612,7 +634,7 @@ export async function chatLoop(opts = {}) {
612
634
  * 백틱으로 이어 쓴 경우에는 앞줄이 이미 찍혀 있으므로 마지막 줄만
613
635
  * 이어 찍는다 — 안 그러면 같은 줄이 두 번 보인다.
614
636
  */
615
- const 찍을것 = 이어쓴것 ? l : 보낼것;
637
+ const 찍을것 = 남길글(이어쓴것 ? l : 보낼것, 붙인것들);
616
638
  if (찍을것.trim() || 이었나) {
617
639
  for (const [i, 한줄] of String(찍을것).split('\n').entries()) {
618
640
  say(`${i === 0 && !이어쓴것 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(한줄)}`);
@@ -759,7 +781,23 @@ export async function chatLoop(opts = {}) {
759
781
  const 붙인것 = 붙인줄들 ? 펴기(붙인줄들.join('\n')) : '';
760
782
  const 폭 = Math.max(20, (process.stdout.columns ?? 80) - 8);
761
783
 
762
- if (붙인줄들 && 입력기다림 && 묻는중 === null && 붙임접을까(붙인것, { 폭 })) {
784
+ /*
785
+ * ── 일하는 도중에 붙여도 접는다 ──────────────────────────────────
786
+ *
787
+ * 여태 이 갈래는 `입력기다림` 일 때만 돌았다. 그런데 붙여넣기는
788
+ * **일하는 도중이 더 흔하다** — 몇 분짜리 턴이 도는 동안 다음에
789
+ * 시킬 것을 미리 붙여 두는 것이 그것이다.
790
+ *
791
+ * 그때 무슨 일이 났나. 붙인 마흔 줄 중 마지막 한 줄만 rl.line 에
792
+ * 남고 나머지는 상자 밖 버퍼로 간다. 일하는 중 화면(대기갱신)은
793
+ * rl.line 만 그리므로, 사람 눈에는 **한 줄만 들어간 것**으로 보인다.
794
+ * 그래서 다시 붙인다. 이번엔 진짜로 두 번 들어간다 — 접기가 애초에
795
+ * 막으려던 바로 그 사고가, 기다릴 때는 안 나고 일할 때만 났다.
796
+ *
797
+ * 펴는 자리는 그대로다(아래 붙임펼치기). 어느 길로 보내든 원문이
798
+ * 간다 — 접는 자리만 넓혔지 되돌리는 길은 안 건드렸다.
799
+ */
800
+ if (붙인줄들 && 묻는중 === null && 붙임접을까(붙인것, { 폭 })) {
763
801
  const 번호 = ++붙임번호;
764
802
  붙인것들.set(번호, 붙인것);
765
803
  이어쓰기줄들 = 붙임앞줄수 ? (이어쓰기줄들 ?? []).slice(0, 붙임앞줄수) : null;
@@ -767,13 +805,22 @@ export async function chatLoop(opts = {}) {
767
805
  const 표 = 붙임표(번호, 붙인것);
768
806
  rl.line = 앞머리 + 표;
769
807
  rl.cursor = rl.line.length;
770
- 화면.입력지움();
771
- say(` ${c.gray(`${표} — 접어 뒀습니다. 보낼 때 그대로 펴집니다.`)}`);
808
+ if (입력기다림) {
809
+ 화면.입력지움();
810
+ say(` ${c.gray(옮긴말('paste.folded', { 표 }))}`);
811
+ } else {
812
+ /*
813
+ * 일하는 중에는 대화에 안 찍는다. 찍으면 이미 보낸 것처럼 보인다
814
+ * (아래 미리 쳐 둔 것 갈래와 같은 까닭이다). 일감 상자에 지금
815
+ * 들고 있는 것으로 그려 주면 그것으로 족하다.
816
+ */
817
+ 화면.대기갱신(펴기(rl.line), queue.length);
818
+ }
772
819
  } else if (이어쓰기줄들?.length && 입력기다림) {
773
820
  // 접지 않은 덩이는 몇 줄인지라도 알려 준다. 안 보이면 사라진 줄
774
821
  // 알고 다시 붙인다.
775
822
  화면.입력지움();
776
- say(` ${c.gray(`${이어쓰기줄들.length + 1}줄을 붙여넣었습니다 — Enter 로 한 번에 보냅니다`)}`);
823
+ say(` ${c.gray(옮긴말('paste.pasted', { n: 이어쓰기줄들.length + 1 }))}`);
777
824
  }
778
825
  화면.입력갱신?.(session, 펴기(rl.line), rl.cursor ?? 0);
779
826
  return;
@@ -788,8 +835,25 @@ export async function chatLoop(opts = {}) {
788
835
  * ESC 는 **멈추기만** 한다. 끝내지 않는다. 그래서 마음 놓고 누를 수 있다.
789
836
  * 도는 중이 아닐 때는 아무것도 안 한다 — 입력칸에서 ESC 를 눌렀다고
790
837
  * 치던 글이 날아가면 그게 더 놀랍다.
838
+ *
839
+ * ── meta 를 안 본다 ──────────────────────────────────────────────
840
+ *
841
+ * 여기 `!key.meta` 가 붙어 있었다. 그래서 이 갈래는 **한 번도 안
842
+ * 돌았다** — 사람이 ESC 를 눌러도 아무 일이 안 났다.
843
+ *
844
+ * 홑 ESC 는 그 자체가 escape 열쇠의 시작 바이트이기도 해서, Node 의
845
+ * readline 은 뒤에 뭐가 더 올지 500ms 를 기다렸다가 아무것도 안 오면
846
+ * `{ name:'escape', meta:true }` 로 배달한다. 즉 **ESC 는 언제나
847
+ * meta 가 참**이다. 직접 재어 봤다 —
848
+ *
849
+ * \x1b → {"name":"escape","meta":true}
850
+ * \x1b\x1b → {"name":"escape","meta":true}
851
+ *
852
+ * 그러니 meta 를 보고 거르면 ESC 를 통째로 거르는 셈이다. 이름이
853
+ * escape 인 것에 Ctrl·Shift 가 붙는 조합은 터미널이 아예 안 보내므로
854
+ * 나머지 둘만 본다.
791
855
  */
792
- if (key?.name === 'escape' && !key.ctrl && !key.meta && !key.shift) {
856
+ if (key?.name === 'escape' && !key.ctrl && !key.shift) {
793
857
  if (turn && !turn.signal.aborted) { turn.abort(); 멈추는중(); return; }
794
858
  return;
795
859
  }
@@ -927,12 +991,46 @@ export async function chatLoop(opts = {}) {
927
991
  });
928
992
  }
929
993
 
930
- const nextLine = () => {
994
+ /*
995
+ * 다음 줄을 기다린다. 멈춤신호를 주면 **기다리다가도 손을 뗀다.**
996
+ *
997
+ * 신호 없이 기다리는 자리(맨 위 입력칸)는 여태 그대로다 — 거기서 ESC 는
998
+ * 아무 일도 안 하는 것이 맞다. 신호를 주는 쪽은 turn 이 도는 동안 사람
999
+ * 답을 기다리는 자리뿐이다.
1000
+ */
1001
+ const nextLine = (멈춤 = null) => {
931
1002
  if (queue.length) return Promise.resolve(queue.shift());
932
1003
  if (closed) return Promise.resolve(null);
933
- return new Promise((res) => { waiter = res; });
1004
+ if (멈춤?.aborted) return Promise.resolve(null);
1005
+ return new Promise((res) => {
1006
+ waiter = res;
1007
+ if (!멈춤) return;
1008
+ /*
1009
+ * 기다리던 자리를 **반드시 비우고** 돌아간다.
1010
+ *
1011
+ * 안 비우면 다음에 사람이 친 줄이 이미 없어진 물음에게 배달되고,
1012
+ * 그 줄은 화면에서 그냥 사라진다 — 「쳤는데 안 먹었다」 가 된다.
1013
+ */
1014
+ 멈춤.addEventListener('abort', () => {
1015
+ if (waiter === res) waiter = null;
1016
+ res(null);
1017
+ }, { once: true });
1018
+ });
934
1019
  };
935
1020
 
1021
+ /*
1022
+ * ── 사람에게 되묻는 자리 ────────────────────────────────────────────────
1023
+ *
1024
+ * 여기도 ESC 로 손을 뗄 수 있어야 한다. 예전에는 이 자리가 **멈춤에서
1025
+ * 빠져 있었다** — 도구가 「실행할까요?」 를 띄운 채 답을 기다리면 ESC 를
1026
+ * 몇 번을 눌러도 아무 일도 안 났다. turn 은 이미 끊긴 상태인데 화면은
1027
+ * 물음을 그대로 들고 서 있으니, 사람은 Ctrl+C 로 손이 가고 두 번 누르면
1028
+ * 대화가 통째로 닫힌다. "ESC 를 눌러도 안 멈춘다" 는 제보의 한 갈래다.
1029
+ *
1030
+ * 멈췄을 때 무엇으로 답한 셈 칠지는 부르는 쪽이 정한다(`멈추면`). 기본값을
1031
+ * `def` 로 하면 안 된다 — confirm 의 def 는 'y' 라, 멈추라고 누른 것이
1032
+ * 승인으로 읽힌다.
1033
+ */
936
1034
  const ask = async (label, o = {}) => {
937
1035
  const 앞 = ` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`;
938
1036
  화면.붙임(앞);
@@ -944,8 +1042,15 @@ export async function chatLoop(opts = {}) {
944
1042
  */
945
1043
  묻는중 = 상자쓰나 ? 앞 : null;
946
1044
  이어쓰기줄들 = null; 이어쓴것 = false; // 되묻는 사이에 걸쳐 있던 미완성 이어쓰기는 버린다
1045
+ const 멈춤 = o.signal ?? turn?.signal ?? null;
947
1046
  try {
948
- const a = await nextLine();
1047
+ const a = await nextLine(멈춤);
1048
+ if (멈춤?.aborted) {
1049
+ // 물음이 화면에 걸린 채로 남으면 안 된다. 줄을 끊고 멈췄다고 적는다.
1050
+ say('');
1051
+ say(` ${c.gray(옮긴말('ask.stopped'))}`);
1052
+ return o.멈추면 ?? '';
1053
+ }
949
1054
  if (a === null) return o.def ?? '';
950
1055
  return a.trim() || o.def || '';
951
1056
  } finally { 묻는중 = null; }
@@ -992,6 +1097,24 @@ export async function chatLoop(opts = {}) {
992
1097
  }
993
1098
  };
994
1099
 
1100
+ /*
1101
+ * 시킨 것 중 자국이 하나도 안 남은 항목을 적는다.
1102
+ *
1103
+ * 조용히 넘어가면 사람은 다 된 줄 알고 그 위에 다음 것을 쌓는다. 나중에
1104
+ * 발견했을 때는 이미 늦다. 「몇 번 까먹거나 누락되거나」라는 제보가 정확히
1105
+ * 그 모양이었다 — 빠진 것 자체보다, 빠졌다는 말을 안 한 것이 문제다.
1106
+ *
1107
+ * 세 자리(done·limit·stuck)가 같은 글을 내야 한다. 한 군데만 고치면
1108
+ * 「끝났을 때는 알려 주는데 끊겼을 때는 안 알려 주는」 화면이 된다 —
1109
+ * 정작 빠질 확률은 끊겼을 때가 훨씬 높다.
1110
+ */
1111
+ const 빠진것보이기 = (빠진) => {
1112
+ if (!빠진?.length) return;
1113
+ say('');
1114
+ say(` ${c.yellow('!')} ${c.white(`시킨 것 중 ${빠진.length}가지가 안 다뤄졌습니다`)}`);
1115
+ for (const x of 빠진) say(` ${c.gray(`${x.번호}. ${clip(x.글, 68)}`)}`);
1116
+ };
1117
+
995
1118
  const ctx = {
996
1119
  scope: makeScope(root),
997
1120
  // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
@@ -1002,7 +1125,7 @@ export async function chatLoop(opts = {}) {
1002
1125
  // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
1003
1126
  규칙들: 규칙모으기(cfg),
1004
1127
  history: new History(root),
1005
- audit: new Audit(root),
1128
+ audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
1006
1129
  seen: new Set(),
1007
1130
  // 붙은 MCP 서버. 도구를 부를 때 여기서 찾는다.
1008
1131
  mcp: mcp붙임.서버들,
@@ -1146,10 +1269,21 @@ export async function chatLoop(opts = {}) {
1146
1269
  창제목(제목글('도는중', { 폴더: 알림.폴더 }));
1147
1270
 
1148
1271
  if (!답) return null;
1149
- // 숫자로 골랐으면 그 줄의 글을 그대로 돌려준다 — 모델에게 "2" 만 가면
1150
- // 무엇을 고른 것인지 모른다.
1151
- const n = Number(답);
1152
- const 고른것 = Number.isInteger(n) && n >= 1 && n <= 고를것.length ? 고를것[n - 1] : 답;
1272
+ /*
1273
+ * 숫자로 골랐으면 줄의 글을 그대로 돌려준다 — 모델에게 "2" 만 가면
1274
+ * 무엇을 고른 것인지 모른다.
1275
+ *
1276
+ * 그리고 **고르면서 한마디 덧붙일 수 있다.** 여태는 둘 중 하나였다 —
1277
+ * 숫자만 치면 고른 것이고, 그 밖의 글은 고른 것 없는 자유 답이었다.
1278
+ * 그래서 「2 파일은 나눠서」 라고 치면 2번을 골랐다는 사실이 사라졌다.
1279
+ * 가르는 규칙은 ui/pick.js 머리말에 적어 두었다.
1280
+ */
1281
+ const 푼것 = 고른것풀기(답, 고를것);
1282
+ const 고른것 = 푼것.글;
1283
+ /*
1284
+ * 되비추는 줄에도 **둘 다** 보인다. 고른 것만 보이면 덧붙인 말이
1285
+ * 갔는지 안 갔는지 알 길이 없어서, 사람은 다음 턴에 그 말을 또 친다.
1286
+ */
1153
1287
  say(` ${c.hgreen('▶')} ${c.gray(clip(고른것, 70))}`);
1154
1288
  say('');
1155
1289
  return 고른것;
@@ -1164,7 +1298,8 @@ export async function chatLoop(opts = {}) {
1164
1298
  */
1165
1299
  if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
1166
1300
  창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
1167
- const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
1301
+ // 멈추라고 누른 것은 **거절**이다. def 'y' 로 물러나면 ESC 가 승인이 된다.
1302
+ const a = (await ask('실행할까요? (y/n)', { def: 'y', 멈추면: 'n' })).toLowerCase();
1168
1303
  창제목(제목글('도는중', { 폴더: 알림.폴더 }));
1169
1304
  return a === 'y' || a === 'yes' || a === 'ㅇ';
1170
1305
  },
@@ -1304,7 +1439,7 @@ export async function chatLoop(opts = {}) {
1304
1439
  }
1305
1440
  // 안 뜬 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
1306
1441
  for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
1307
- if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
1442
+ if (!conn.tools) warn.push(옮긴말('run.noTools'));
1308
1443
  if (!conn.streaming) warn.push(옮긴말('run.noStream'));
1309
1444
  warn.push(...길이경고);
1310
1445
  // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
@@ -1945,6 +2080,30 @@ export async function chatLoop(opts = {}) {
1945
2080
  break;
1946
2081
  }
1947
2082
 
2083
+ /*
2084
+ * 자리가 다 차서 기억을 비웠다. **턴은 안 죽었다.**
2085
+ *
2086
+ * 여기 붉은 줄을 찍으면 사람은 하던 일이 엎어진 줄 알고 /clear 를
2087
+ * 치고 시킨 말을 다시 타이핑한다 — 정작 지금은 시킨 말과 남은 할 일을
2088
+ * 들고 그대로 이어 가는 중이다. 접기(compacted)와 같은 자리·같은
2089
+ * 빛깔로 한 줄만 적는다.
2090
+ */
2091
+ case 'reset': {
2092
+ 접기멈춤();
2093
+ clearThinking();
2094
+ const 할일수 = ev.kept?.할일 ?? 0;
2095
+ say(` ${c.cyan('◱')} ${c.gray(할일수
2096
+ ? 옮긴말('ev.reset', { 버린수: ev.dropped, 할일: 할일수 })
2097
+ : 옮긴말('ev.resetBare', { 버린수: ev.dropped }))}`);
2098
+ /*
2099
+ * 비우면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞춘다.
2100
+ * 여기서 안 적으면, 비운 뒤에 죽었을 때 /resume 이 옛 대화를
2101
+ * 통째로 되살려 놓는다 — 자리가 없어서 비운 것이 도로 차 있다.
2102
+ */
2103
+ ctx.갈래.현재store().replace(session.messages, `자리부족 — 앞선 대화 ${ev.dropped}개를 비움`);
2104
+ saved = session.messages.length;
2105
+ break;
2106
+ }
1948
2107
  // 서버가 거절하면서 알려 준 한계를 받아 적었다. 실패로 보이면 안 된다 —
1949
2108
  // 사용자 눈에는 잠깐 멈췄다가 그냥 잘 되는 것으로 보여야 맞다.
1950
2109
  case 'learned':
@@ -1966,13 +2125,38 @@ export async function chatLoop(opts = {}) {
1966
2125
  * 대충 답한 줄 알고 같은 것을 다시 시키고, 같은 자리에서 또 잘린다.
1967
2126
  * 손댈 자리를 그 자리에서 알려 준다.
1968
2127
  */
2128
+ /*
2129
+ * ── 붙잡아 둔 반 줄을 먼저 비운다 ────────────────────────────
2130
+ *
2131
+ * 표를 그리려면 줄이 다 모여야 해서, 마크다운 그림은 마지막
2132
+ * 몇 줄을 손에 쥐고 있다. `done` 은 그걸 비우고 끝나는데
2133
+ * capped·cutoff·error 는 안 비우고 있었다.
2134
+ *
2135
+ * 그러면 두 가지가 난다. 붙잡힌 글이 **화면에서 그냥 사라지거나**
2136
+ * (오류로 끝나면 뒤에 done 이 없다), 무엇이 잘렸는지 보라고 띄운
2137
+ * 경고가 정작 그 답의 마지막 줄보다 **위에** 찍힌다.
2138
+ */
1969
2139
  case 'capped':
1970
2140
  clearThinking();
2141
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1971
2142
  say(` ${mark.warn} ${c.gray(`답이 ${c.white(ev.cap.toLocaleString())} 토큰에서 잘렸습니다`)}`
1972
2143
  + `${ev.정한값 ? c.gray(' (직접 정하신 상한입니다)') : c.gray(' — 더 못 올리는 천장입니다')}`);
1973
2144
  say(` ${c.gray('한 번에 더 길게 받으려면')} ${c.cyan('/out 32k')}${c.gray('. 파일을 쓰는 중이었다면 Append 로 나눠 쓰게 하세요.')}`);
1974
2145
  break;
1975
2146
 
2147
+ /*
2148
+ * 상한에서 잘린 것이 아니라, 서버가 **끝난 까닭을 한 번도 안 주고**
2149
+ * 흘려보내기를 멈춘 것이다. 여태 이건 정상 종료와 구별이 안 돼서
2150
+ * 중간에서 끊긴 답이 온전한 답인 척 지나갔다.
2151
+ * /out 을 권하면 안 된다 — 상한 얘기가 아니라 연결 얘기다.
2152
+ */
2153
+ case 'cutoff':
2154
+ clearThinking();
2155
+ if (streamed) { 답비우기(); say(''); streamed = false; }
2156
+ say(` ${mark.warn} ${c.gray('서버가 끝났다는 말 없이 답을 멈췄습니다 — 중간에서 끊겼을 수 있습니다.')}`);
2157
+ say(` ${c.gray('중계 프록시·게이트웨이를 거치면 나는 일입니다. 같은 것을 다시 물어 보세요.')}`);
2158
+ break;
2159
+
1976
2160
  case 'compact_failed':
1977
2161
  접기멈춤();
1978
2162
  say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
@@ -1993,6 +2177,9 @@ export async function chatLoop(opts = {}) {
1993
2177
  if (ev.남은할일.length > 8) say(` ${c.gray(`… 그 밖에 ${ev.남은할일.length - 8}개`)}`);
1994
2178
  }
1995
2179
  만든파일보이기(ev.files);
2180
+ // 할 일 목록에 못 들어간 채로 빠진 것도 같이 적는다 — 목록에
2181
+ // 없으면 「안 끝난 것」 에도 안 뜨므로 여기서 말 안 하면 아무 데도 없다.
2182
+ 빠진것보이기(ev.빠진);
1996
2183
  break;
1997
2184
 
1998
2185
  // 같은 자리를 계속 반복하고 있다. 두면 컨텍스트만 차고 아무것도 안 나온다.
@@ -2016,6 +2203,7 @@ export async function chatLoop(opts = {}) {
2016
2203
  // 멈췄어도 여기까지 만든 것은 있다. 그것부터 알려 준다 —
2017
2204
  // 없는 줄 알고 다시 시키면 앞서 만든 것을 덮어쓴다.
2018
2205
  만든파일보이기(ev.files);
2206
+ 빠진것보이기(ev.빠진);
2019
2207
  break;
2020
2208
 
2021
2209
  case 'aborted':
@@ -2034,8 +2222,17 @@ export async function chatLoop(opts = {}) {
2034
2222
  case 'error':
2035
2223
  턴탈났나 = true;
2036
2224
  clearThinking();
2225
+ // 붙잡아 둔 반 줄을 먼저 비운다 — 까닭은 위 capped 자리에 적어 두었다.
2226
+ if (streamed) { 답비우기(); say(''); streamed = false; }
2037
2227
  say('');
2038
2228
  오류보이기(ev.text);
2229
+ /*
2230
+ * 반쪽이라도 남겼으면 그렇다고 말해 준다.
2231
+ *
2232
+ * 안 말하면 사람은 화면에 보이는 200자가 대화에도 들어갔는지를
2233
+ * 모른다. 모르면 "이어서 해줘" 를 못 치고 처음부터 다시 친다.
2234
+ */
2235
+ if (ev.kept) say(` ${c.gray(옮긴말('run.errKept'))}`);
2039
2236
  break;
2040
2237
 
2041
2238
  /*
@@ -2065,11 +2262,7 @@ export async function chatLoop(opts = {}) {
2065
2262
  * 제보가 정확히 그 모양이었다 — 빠진 것 자체보다, 빠졌다는 말을
2066
2263
  * 안 한 것이 문제다.
2067
2264
  */
2068
- if (ev.빠진?.length) {
2069
- say('');
2070
- say(` ${c.yellow('!')} ${c.white(`시킨 것 중 ${ev.빠진.length}가지가 안 다뤄졌습니다`)}`);
2071
- for (const x of ev.빠진) say(` ${c.gray(`${x.번호}. ${clip(x.글, 68)}`)}`);
2072
- }
2265
+ 빠진것보이기(ev.빠진);
2073
2266
  break;
2074
2267
  }
2075
2268
  }
@@ -2107,6 +2300,10 @@ export async function chatLoop(opts = {}) {
2107
2300
  turn = null;
2108
2301
  interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
2109
2302
  flush(); // 오류로 끝났어도 여기까지는 남긴다
2303
+ // 대화가 파일에 안 적히고 있으면 여기서 **한 번** 말한다 (agent/store.js).
2304
+ // 여태는 아무 말이 없어서, 사람이 알아차리는 자리가 다음 날 --resume 이었다.
2305
+ const 저장샘 = ctx.갈래?.현재store?.()?.처음못쓴것?.();
2306
+ if (저장샘) say(` ${mark.warn} ${c.yellow(옮긴말('store.notSaving', { n: 저장샘.수, 까닭: 저장샘.까닭 }))}`);
2110
2307
 
2111
2308
  // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
2112
2309
  const secs = ((Date.now() - started) / 1000).toFixed(1);
@@ -2148,14 +2345,29 @@ export async function chatLoop(opts = {}) {
2148
2345
  if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
2149
2346
  창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
2150
2347
  const 답 = String(await ask(
2151
- `이대로 진행할까요? ${c.gray('⏎ 진행 · n 취소 · 그 밖엔 고칠 점')}`,
2348
+ `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`,
2152
2349
  { def: 'y' },
2153
2350
  )).trim();
2154
- const 낮춘 = 답.toLowerCase();
2351
+ /*
2352
+ * ── 골라진 것 + 하고 싶은 말 ────────────────────────────────────
2353
+ *
2354
+ * 여태는 셋 중 하나였다. ⏎ 면 진행, n 이면 그만, 그 밖의 글은 통째로
2355
+ * 「이걸 고쳐서 다시 내라」. 그래서 「그대로 하되 검사도 넣어줘」 라고
2356
+ * 치면 **계획을 다시 짜러 갔다** — 사람은 진행하라고 한 것인데.
2357
+ * 반대로 「3단계는 빼자」 도 그냥 글 한 줄로 갔다. 어느 단계 얘긴지
2358
+ * 모델이 다시 세어 봐야 한다.
2359
+ *
2360
+ * 이제 넷이다. 가르는 규칙은 ui/pick.js 의 계획답풀기() 에 있다.
2361
+ * ⏎ · y → 그대로 진행
2362
+ * y 검사도 넣어줘 → 진행하되 그 말을 얹어서
2363
+ * 3 이건 빼자 → 3단계에 대고 하는 말로 붙여 다시 짜라
2364
+ * 그 밖의 글 → 여태 그대로 (다시 짜라)
2365
+ */
2366
+ const 계획답 = 계획답풀기(답, 할일.length);
2155
2367
 
2156
- if (['n', 'no', '', '취소', '아니', '아니요', '아니오'].includes(낮춘)) {
2157
- say(` ${c.gray('그만뒀습니다. 계획은 위에 남아 있으니 이어서 말씀하셔도 됩니다.')}`);
2158
- } else if (['y', 'yes', 'ㅇ', 'ㅇㅇ', 'ㄱ', 'ㄱㄱ', '네', '응', '진행', 'ok'].includes(낮춘)) {
2368
+ if (계획답.갈래 === '그만') {
2369
+ say(` ${c.gray(옮긴말('plan.cancelled'))}`);
2370
+ } else if (계획답.갈래 === '진행') {
2159
2371
  /*
2160
2372
  * 승인한 계획을 **글자 그대로 실어 보낸다.**
2161
2373
  *
@@ -2175,16 +2387,33 @@ export async function chatLoop(opts = {}) {
2175
2387
  : '바로 위에 네가 적은 계획을 사람이 승인했다.'
2176
2388
  + ' 그 계획을 다시 적지 말고 지금부터 그대로 해라.')
2177
2389
  + ' 계획을 다시 적지 마라. **지난 대화를 뒤지지 마라** — 필요한 것은'
2178
- + ' 이 대화 안에 다 있다. 다 끝나면 무엇을 만들었는지만 짧게 알려라.';
2390
+ + ' 이 대화 안에 다 있다. 다 끝나면 무엇을 만들었는지만 짧게 알려라.'
2391
+ /*
2392
+ * 승인하며 덧붙인 말은 **맨 뒤에** 붙인다.
2393
+ *
2394
+ * 계획 다음에 오는 말이라 「계획은 그대로, 다만 이것」 으로 읽힌다.
2395
+ * 앞에 붙이면 계획보다 먼저 읽혀서, 작은 모델은 그 한마디만
2396
+ * 하고 계획은 안 한다.
2397
+ */
2398
+ + (계획답.덧말 ? `\n\n사람이 승인하며 덧붙인 말: ${계획답.덧말}` : '');
2179
2399
  이어갈모드 = 'code';
2180
2400
  say('');
2181
- say(` ${c.hgreen('▶')} ${c.gray('계획대로 진행합니다.')}`);
2401
+ say(` ${c.hgreen('▶')} ${c.gray(계획답.덧말
2402
+ ? 옮긴말('plan.goingWith', { 말: clip(계획답.덧말, 50) })
2403
+ : 옮긴말('plan.going'))}`);
2182
2404
  } else {
2183
2405
  // 'e' 를 따로 두지 않는다 — 고칠 점을 바로 치는 것이 한 걸음 짧다.
2184
- 이어갈것 = `계획에서 이걸 고쳐서 **계획만** 다시 내라 (아직 실행하지 마라): ${답}`;
2406
+ // 단계 번호를 댔으면 단계에 대고 하는 말로 적어 준다. 모델이
2407
+ // 어느 단계 얘긴지 다시 세어 볼 까닭이 없다.
2408
+ const 고칠말 = 계획답.단계
2409
+ ? `${계획답.단계}단계에 대해: ${계획답.덧말}`
2410
+ : 계획답.덧말;
2411
+ 이어갈것 = `계획에서 이걸 고쳐서 **계획만** 다시 내라 (아직 실행하지 마라): ${고칠말}`;
2185
2412
  이어갈모드 = 'plan';
2186
2413
  say('');
2187
- say(` ${c.hcyan('☰')} ${c.gray('그 점을 반영해 계획을 다시 냅니다.')}`);
2414
+ say(` ${c.hcyan('☰')} ${c.gray(계획답.단계
2415
+ ? 옮긴말('plan.replanStep', { 단계: 계획답.단계 })
2416
+ : 옮긴말('plan.replan'))}`);
2188
2417
  }
2189
2418
  } else if (끊긴할일) {
2190
2419
  /*
@@ -1,22 +1,106 @@
1
1
  // 감사 로그. 무엇을 언제 어떻게 했는지 전부 남긴다.
2
2
  // 자율 실행을 사내에 설득할 때 이 파일이 근거가 된다.
3
3
  import { join } from 'node:path';
4
- import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
4
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, chmodSync } from 'node:fs';
5
+ import { 가리기 } from './secrets.js';
6
+
7
+ /**
8
+ * 감사기록에 넘길 '아는 열쇠' 를 묻는 길. loop.js 가 대화 쪽에서 쓰는 것과 같은 값이다.
9
+ * 값이 아니라 길로 주는 까닭은 Audit 만들기 머리말에 적어 뒀다.
10
+ */
11
+ export const 열쇠묻기 = (conn) => () => [conn?.key, process.env.DEEL_API_KEY].filter(Boolean);
5
12
 
6
13
  export class Audit {
7
- constructor(root) {
14
+ /**
15
+ * @param {string} root 작업 폴더
16
+ * @param {object} o
17
+ * @param {string[]|(() => string[])} o.열쇠들 정확히 아는 비밀 (설정에 든 게이트웨이 열쇠).
18
+ * 값이 아니라 **묻는 길**로 받아도 된다 — /model 로 갈아타면 conn 이 통째로
19
+ * 바뀌므로, 만들 때 붙잡아 둔 값은 그 판이 끝나기 전에 옛것이 된다.
20
+ */
21
+ constructor(root, { 열쇠들 = [] } = {}) {
8
22
  const dir = join(root, '.deel');
9
23
  mkdirSync(dir, { recursive: true });
10
24
  this.file = join(dir, 'audit.jsonl');
11
25
  this.session = `${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}`;
26
+ /** 적으려다 못 적은 건수와 첫 까닭. 0 이 아니면 이 세션의 기록은 불완전하다. */
27
+ this.못쓴수 = 0;
28
+ this.못쓴까닭 = null;
29
+ this.열쇠들 = 열쇠들;
30
+ /** 파일 권한을 건 결과. `{모드}` 면 걸었고, `{못함}` 이면 못 걸었다. */
31
+ this.잠금 = null;
32
+ }
33
+
34
+ /*
35
+ * ── 적기 전에 가린다 ────────────────────────────────────────────────
36
+ *
37
+ * 여기 적히는 target 은 Bash 명령줄 그 자체다. 모델은 열쇠를 헤더로
38
+ * (`-H "Authorization: Bearer …"`), 환경변수로(`DEEL_API_KEY=… node x`),
39
+ * 주소 안에 박아서(`https://사람:토큰@github.com/…`) 넘긴다.
40
+ *
41
+ * 도구 **결과**는 loop.js 가 secrets.js 로 가려서 대화에 싣는데, 감사기록은
42
+ * 그 길을 안 지난다. 그래서 대화에서는 지운 값이 디스크에는 평문으로 남았다 —
43
+ * 그것도 `deel audit` 로 사내 심사 자료에 딸려 나가는 파일에.
44
+ *
45
+ * 가리는 것은 자유롭게 적히는 글뿐이다. 도구 이름·성패 같은 정해진 값은
46
+ * 안 건드린다 — 넓게 잡으면 「무엇을 했는지」 가 «가림» 으로 뭉개진다.
47
+ */
48
+ #가린것(값) {
49
+ if (값 == null) return 값;
50
+ const 것들 = typeof this.열쇠들 === 'function' ? this.열쇠들() : this.열쇠들;
51
+ return 가리기(String(값), { 열쇠들: 것들 ?? [] }).글;
12
52
  }
13
53
 
54
+ /*
55
+ * 못 적어도 던지지 않는다 — 기록을 남기려다 작업을 죽이면 본말이 뒤집힌다.
56
+ *
57
+ * 다만 **조용히 삼키면 안 된다.** 이 파일의 첫 줄이 파는 문장이
58
+ * 「무엇을 언제 어떻게 했는지 전부 남는다」 이고, /mcp 화면은 대놓고
59
+ * `.deel/audit.jsonl 에 남습니다` 라고 적어 준다. 디스크가 차거나 폴더가
60
+ * 읽기 전용이면 그 두 문장이 거짓이 되는데, 그때도 화면은 아무 말이 없었다.
61
+ * 더 나쁜 건 증거모으기다 — 기록이 안 적히면 「고쳤는데 확인 안 됨」이
62
+ * 아니라 아예 **안 고친 것처럼** 보고서가 짧아진다.
63
+ * 그래서 못 적은 건수를 세어 두고, 보고서와 화면이 그걸 말하게 한다.
64
+ */
14
65
  write(kind, data) {
15
66
  const rec = { at: new Date().toISOString(), session: this.session, kind, ...data };
16
- try { appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8'); } catch {}
67
+ try {
68
+ appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
69
+ this.#잠그기();
70
+ } catch (err) {
71
+ this.못쓴수 += 1;
72
+ if (!this.못쓴까닭) this.못쓴까닭 = err?.message ?? String(err);
73
+ }
17
74
  return rec;
18
75
  }
19
76
 
77
+ /*
78
+ * 만들자마자 본인만 읽게 잠근다 (config.js 가 설정 파일에 하는 것과 같다).
79
+ *
80
+ * 가리기를 지나도 여기에는 무엇을 언제 어디서 했는지가 남는다 — 파일 경로,
81
+ * 돌린 명령, 사람이 친 말. 같은 PC 를 여럿이 쓰거나 홈이 공유 폴더에 있으면
82
+ * 그게 그대로 남에게 읽힌다. 한 번만 건다 — 줄마다 걸 일이 아니다.
83
+ * 윈도우(NTFS)에서는 chmod 가 아무 일도 안 한다. 거기서는 못 잠근 것이지
84
+ * 잠근 척하지 않는다 — 보관방식 화면이 그 사실을 따로 말한다.
85
+ *
86
+ * 건 결과를 `잠금` 에 남긴다. "본인만 읽게 잠근다" 는 말은 지켜지는지
87
+ * **밖에서 볼 수 있어야** 뜻이 있다 (keystore.js 의 마지막명령줄 과 같은
88
+ * 까닭). 윈도우에서 값이 남는다고 실제로 잠겼다는 뜻은 아니다 —
89
+ * 우리가 걸어는 봤다는 뜻이다.
90
+ */
91
+ #잠갔나 = false;
92
+ #잠그기() {
93
+ if (this.#잠갔나) return;
94
+ this.#잠갔나 = true;
95
+ try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
96
+ catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
97
+ }
98
+
99
+ /** 기록이 새고 있나. 새고 있으면 `{수, 까닭}`, 멀쩡하면 null. */
100
+ 못쓴것() {
101
+ return this.못쓴수 ? { 수: this.못쓴수, 까닭: this.못쓴까닭 } : null;
102
+ }
103
+
20
104
  tool(name, args, result) {
21
105
  return this.write('tool', {
22
106
  tool: name,
@@ -24,15 +108,15 @@ export class Audit {
24
108
  // '하위 작업을 돌렸다' 만 남고 **무엇을** 돌렸는지가 안 남는다.
25
109
  // 옛 이름(목적)도 같이 본다 — 이름을 바꾼 판 이전의 기록이 남아 있고,
26
110
  // 옛 이름으로 부르는 모델도 있다.
27
- target: args?.file_path ?? args?.path ?? args?.pattern ?? args?.command
28
- ?? args?.purpose ?? args?.목적 ?? null,
111
+ target: this.#가린것(args?.file_path ?? args?.path ?? args?.pattern ?? args?.command
112
+ ?? args?.purpose ?? args?.목적 ?? null),
29
113
  ok: !result?.error,
30
- note: result?.error ?? result?.summary ?? null,
114
+ note: this.#가린것(result?.error ?? result?.summary ?? null),
31
115
  });
32
116
  }
33
117
 
34
- turn(text) { return this.write('turn', { text: String(text).slice(0, 500) }); }
35
- blocked(why, what) { return this.write('blocked', { why, what: String(what).slice(0, 300) }); }
118
+ turn(text) { return this.write('turn', { text: this.#가린것(String(text).slice(0, 500)) }); }
119
+ blocked(why, what) { return this.write('blocked', { why, what: this.#가린것(String(what).slice(0, 300)) }); }
36
120
  undo(info) { return this.write('undo', info); }
37
121
 
38
122
  recent(n = 20) {