deel-local-cli 1.7.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 (61) hide show
  1. package/README.ko.md +1166 -0
  2. package/README.md +1223 -1107
  3. package/bin/deel.js +78 -12
  4. package/package.json +4 -4
  5. package/src/acp/map.js +1 -1
  6. package/src/acp/serve.js +40 -3
  7. package/src/agent/compact.js +314 -296
  8. package/src/agent/effort.js +27 -7
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/filemem.js +141 -0
  11. package/src/agent/grade.js +51 -1
  12. package/src/agent/loop.js +218 -40
  13. package/src/agent/session.js +136 -14
  14. package/src/agent/store.js +186 -9
  15. package/src/agent/threads.js +26 -1
  16. package/src/backend/adapter.js +272 -23
  17. package/src/backend/learn.js +24 -0
  18. package/src/backend/mcp.js +96 -5
  19. package/src/backend/probe.js +131 -61
  20. package/src/backend/quota.js +29 -5
  21. package/src/backend/retry.js +3 -0
  22. package/src/backend/scanui.js +1 -1
  23. package/src/backend/toolfit.js +325 -0
  24. package/src/commands.js +170 -61
  25. package/src/completion.js +20 -2
  26. package/src/config.js +47 -2
  27. package/src/i18n/en.js +210 -3
  28. package/src/i18n/index.js +40 -0
  29. package/src/i18n/ja.js +204 -3
  30. package/src/i18n/ko.js +270 -3
  31. package/src/i18n/zh.js +204 -3
  32. package/src/lsp/client.js +49 -5
  33. package/src/oneshot.js +92 -5
  34. package/src/pack/sbom.js +30 -4
  35. package/src/pack/selfpack.js +26 -10
  36. package/src/pack/sheet.en.js +288 -0
  37. package/src/pack/tar.js +65 -2
  38. package/src/plugins/manage.js +46 -9
  39. package/src/repl.js +344 -61
  40. package/src/reset.js +397 -0
  41. package/src/safety/audit.js +95 -8
  42. package/src/safety/authcmd.js +316 -0
  43. package/src/safety/guard.js +143 -0
  44. package/src/safety/keystore.js +114 -38
  45. package/src/safety/undo.js +25 -6
  46. package/src/tools/desc.en.js +19 -14
  47. package/src/tools/index.js +349 -75
  48. package/src/tools/jobs.js +175 -42
  49. package/src/tools/lsp.js +5 -4
  50. package/src/tools/outline.js +7 -3
  51. package/src/tools/task.js +10 -6
  52. package/src/tools/todo.js +9 -2
  53. package/src/tools/verify.js +42 -7
  54. package/src/tools/webfetch.js +94 -10
  55. package/src/ui/export.js +1 -1
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/README.en.md +0 -1117
package/src/agent/loop.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
2
2
  // 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
3
- import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
3
+ import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남 } from '../backend/adapter.js';
4
4
  import { 그림메시지 } from '../backend/vision.js';
5
5
  import { 어떻게할까 } from '../safety/policy.js';
6
6
  import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
@@ -8,7 +8,7 @@ import { isMutating } from '../safety/guard.js';
8
8
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
9
9
  import { 살린쓰기 } from './salvage.js';
10
10
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
11
- import { compact, shouldCompact, shouldFold, foldToolResults, foldImages } from './compact.js';
11
+ import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것 } from './compact.js';
12
12
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
13
13
  import { Session } from './session.js';
14
14
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
@@ -215,7 +215,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
215
215
  // 파일 하나가 아니라 **그 파일** 을 센다. 이름만 세면 서로 다른 파일 세 개를
216
216
  // 고치다 실패한 것이 한 덩어리로 뭉쳐 턴이 죽는다. 다섯 군데 중 두 군데만
217
217
  // 고쳐 놓고 '헛돌고 있어 멈췄습니다' 가 되는 것이 그 모습이다.
218
- const 어디 = call.args?.file_path ?? call.args?.path ?? call.args?.pattern ?? call.args?.command ?? call.args?.목적 ?? '';
218
+ const 어디 = call.args?.file_path ?? call.args?.path ?? call.args?.pattern ?? call.args?.command
219
+ ?? call.args?.purpose ?? call.args?.목적 ?? '';
219
220
  const 서명 = `${call.name}|${이유}|${String(어디).slice(0, 200)}`;
220
221
  const n = (막힘.get(서명) ?? 0) + 1;
221
222
  막힘.set(서명, n);
@@ -248,6 +249,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
248
249
  // 이번 턴에 서버에게서 한계를 배웠나. 배웠는데도 또 거절당하면 다른 문제다 —
249
250
  // 그때는 끝없이 다시 부르지 않고 오류를 그대로 보여 준다.
250
251
  let 배운적 = false;
252
+ // 이번 턴에 자리가 없어 기억을 비운 적이 있나. 한 턴에 한 번뿐이다 —
253
+ // 비우고도 또 막히면 자리 문제가 아니라 다른 탈이다.
254
+ let 비운적 = false;
251
255
  const 짧게말 = (s) => String(s ?? '').replace(/\s+/g, ' ').slice(0, 200);
252
256
 
253
257
  /**
@@ -454,6 +458,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
454
458
  });
455
459
  };
456
460
 
461
+ /*
462
+ * 걸음이 끊긴 자리에서 대조할 '마지막으로 한 말'.
463
+ *
464
+ * done 은 방금 받은 답(msg.content)을 자국으로 넘긴다. limit·stuck 에는
465
+ * 그 답이 없다 — 도구를 부르다 끊겼기 때문이다. 그래도 모델이 앞서 글로
466
+ * 적어 둔 것은 자국이 되므로 대화에서 마지막 assistant 글을 꺼내 쓴다.
467
+ */
468
+ const 마지막말 = () => String(
469
+ [...session.messages].reverse().find((m) => m.role === 'assistant' && m.content)?.content ?? '',
470
+ );
471
+
457
472
  const 되미는말 = () => {
458
473
  const 시킨 = String(userText ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
459
474
  return 지시말() === 'en'
@@ -465,9 +480,64 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
465
480
  + '정말 도구가 막고 있으면 무엇이 막았는지 한 줄로 말하세요.';
466
481
  };
467
482
 
483
+ /*
484
+ * ── 자리를 만든다 (접기 → 요약) ────────────────────────────────────────
485
+ *
486
+ * 여태 이 검사는 **도구를 한 번 돌린 뒤에만** 했다. 그래서 첫 부름 전에는
487
+ * 아무리 꽉 차 있어도 그대로 보냈다. 실제로 이렇게 죽는다 —
488
+ *
489
+ * 컨텍스트가 78% 인 대화에 긴 파일을 하나 붙인다
490
+ * → 첫 요청이 창을 넘겨 서버가 거절한다
491
+ * → 배운 값으로 다시 부른다(같은 이력 그대로) → 또 거절
492
+ * → 이번엔 배운 적이 있으니 오류로 끝난다
493
+ *
494
+ * 사람이 보는 것은 「턴이 죽었다」 이고, /clear 하고 시킨 말을 다시 쳐야
495
+ * 한다. 붙인 파일도 다시 붙여야 한다. 검사는 걸음마다 **부르기 전에**
496
+ * 해야 맞다 — 자리가 모자란 것은 부른 뒤가 아니라 부르기 전에 안다.
497
+ *
498
+ * 문턱은 안 건드린다(FOLD_AT 0.55 · COMPACT_AT 0.8). 여기서 바뀐 것은
499
+ * '언제 재느냐' 하나다.
500
+ *
501
+ * @returns {boolean} 멈추라는 것이 닿아서 턴을 접어야 하나
502
+ */
503
+ let 접다멈췄나 = false;
504
+ const 자리만들기 = async function* () {
505
+ 접다멈췄나 = false;
506
+ /*
507
+ * 요약해서 접기 **전에** 도구 결과부터 접는다.
508
+ *
509
+ * 자리를 먹는 것은 대개 사람 말이 아니라 옛날에 읽어 둔 파일이다. 그쪽을
510
+ * 먼저 비우면 요약 압축을 한참 미룰 수 있고, 미루는 동안 대화는 한 글자도
511
+ * 안 잃는다. 그래도 차면 아래에서 통째로 요약한다.
512
+ */
513
+ if (shouldFold(session)) {
514
+ const f = foldToolResults(session);
515
+ if (f.접은것) yield { type: 'folded', ...f };
516
+ // 그림은 사람 말 자리에 실려서 위 접기가 못 건드린다. 따로 뺀다.
517
+ const g = foldImages(session);
518
+ if (g.뺀것) yield { type: 'images_folded', ...g };
519
+ }
520
+
521
+ // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
522
+ if (shouldCompact(session)) {
523
+ yield { type: 'compacting' };
524
+ // 요약을 부르다 서버가 막으면 그 알림도 화면으로 — '접는 중' 뒤에 60초를 숨기지 않는다.
525
+ const 편지 = 우편함((onBackoff) => compact(session, { auto: true, signal, onBackoff }));
526
+ for await (const 알림 of 편지.소식()) yield 알림;
527
+ const r = await 편지.부름;
528
+ if (r.aborted) { 접다멈췄나 = true; yield { type: 'aborted', steps, kept: true }; return; }
529
+ if (r.ok) yield { type: 'compacted', ...r };
530
+ else yield { type: 'compact_failed', why: r.why };
531
+ }
532
+ };
533
+
468
534
  while (steps < maxSteps) {
469
535
  steps++;
470
536
 
537
+ // 부르기 **전에** 자리를 본다. 왜 여기인지는 자리만들기() 머리말에.
538
+ yield* 자리만들기();
539
+ if (접다멈췄나) return;
540
+
471
541
  /*
472
542
  * ── 도중에 한 말을 여기서 받는다 (끼어들기) ──────────────────────────
473
543
  *
@@ -531,6 +601,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
531
601
  think: thinkFor(conn, think),
532
602
  maxTokens,
533
603
  signal,
604
+ /*
605
+ * 열쇠받기(safety/authcmd.js)가 걸린 연결에서 쓴다.
606
+ *
607
+ * 두 갈래를 위층으로 올린다 — 「부를까요」 를 사람에게 묻는 길과,
608
+ * 「지금 사내 로그인을 기다리는 중」 을 화면에 적는 길. 여기서 화면을
609
+ * 직접 만지지 않는 것은, 이 루프가 대화 화면 말고 oneshot·ACP 에서도
610
+ * 돌기 때문이다. 그쪽에는 물어볼 사람이 없다.
611
+ */
612
+ 열쇠물어보기: session.열쇠물어보기 ?? null,
613
+ onAuth: (것) => { session.onAuth?.(것); },
534
614
  });
535
615
 
536
616
  let msg;
@@ -628,6 +708,14 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
628
708
  cap: 마지막상한,
629
709
  정한값: conn.maxTokens ?? conn.maxOut ?? null,
630
710
  };
711
+ } else if (msg?.stopped === 말없이끝남) {
712
+ /*
713
+ * 상한 때문에 잘린 것과는 다른 사고다. 서버가 끝난 까닭을 한 번도 안
714
+ * 주고 흘려보내기를 멈췄다 — 중계 프록시가 몸통을 자르고 연결을 곱게
715
+ * 닫으면 이 모양이 된다. 상한을 올려 다시 부르는 것은 답이 아니라서
716
+ * capped 와 섞으면 엉뚱한 곳(/out)을 고치게 만든다.
717
+ */
718
+ yield { type: 'cutoff' };
631
719
  }
632
720
 
633
721
  /*
@@ -716,7 +804,82 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
716
804
  continue;
717
805
  }
718
806
 
719
- yield { type: 'error', text: err.message };
807
+ /*
808
+ * ── 자리가 다 찼으면, 턴을 죽이지 말고 비우고 이어 간다 ──────────────
809
+ *
810
+ * 여기가 사용자가 이름 대어 말한 자리다 — "current 가 다 차서 막힐 경우,
811
+ * 초기화되고 난 후에 다시 바로 작업할 수 있게".
812
+ *
813
+ * 여태 있었던 일: 서버가 「자리가 없다」고 거절한다 → 위에서 한계를
814
+ * 배우고 다시 부른다 → 접기가 걸음의 머리에서 도니 대개 여기서 낫는다.
815
+ * 그래도 안 맞으면(요약 자체가 크거나, 요약을 못 받았거나) 배운적이
816
+ * 이미 참이라 그대로 오류로 떨어졌다. 화면에는 붉은 줄 하나가 뜨고
817
+ * **턴이 죽는다.** 사람은 /clear 를 치고, 시킨 말을 다시 타이핑하고,
818
+ * 붙였던 파일을 다시 붙여야 한다. 한 걸음도 못 나아간 채로.
819
+ *
820
+ * 그래서 이 자리에서 **턴 안에서** 비우고 이어 간다. 버리는 것은 지나간
821
+ * 대화뿐이고, 잃으면 안 되는 것은 그대로 옮긴다 —
822
+ * · 시킨 말 원문과 남은 할 일 (못박을것)
823
+ * · 이번 턴에 이미 손댄 파일 이름
824
+ * 모델이 다음 걸음에서 보는 것은 「무엇을 시켰고 · 어디까지 했나」 다.
825
+ * 그거면 이어서 할 수 있다.
826
+ *
827
+ * 읽어 둔 파일 기억도 같이 지운다. 내용은 대화에서 사라졌는데 '이미
828
+ * 읽었다' 는 표만 남으면, 모델은 다시 안 읽고 기억에 없는 파일을
829
+ * 고치려 든다 — 비운 것보다 나쁘다.
830
+ *
831
+ * 한 턴에 한 번뿐이다. 비우고도 또 막히면 그건 자리 문제가 아니라
832
+ * 다른 탈이라, 끝없이 비우며 도는 대신 있는 그대로 오류로 말한다.
833
+ */
834
+ const 자리문제 = 배운?.kind === 'ctx' || 길이문제인가(err.serverMessage ?? err.message);
835
+ if (자리문제 && !비운적) {
836
+ 비운적 = true;
837
+ const 지운수 = session.messages.length;
838
+ const 손댄것 = [...손댄파일];
839
+ const 남은할일수 = (ctx.todos ?? []).filter((x) => x?.state !== 'done').length;
840
+ session.messages = [{
841
+ role: 'user',
842
+ content: `(자리가 모자라 앞선 대화 ${지운수}개를 비웠습니다. 필요한 파일은 다시 읽으세요.)\n`
843
+ + (손댄것.length ? `이번 턴에 이미 손댄 파일: ${손댄것.join(', ')}\n` : '')
844
+ + '\n' + 못박을것(session),
845
+ }];
846
+ // 내용은 없어졌는데 '읽었다' 는 표만 남으면 모델이 다시 안 읽는다.
847
+ try { session.filesRead?.clear?.(); session.파일기억?.잊기?.(); } catch { /* 없어도 그만 */ }
848
+ yield {
849
+ type: 'reset',
850
+ dropped: 지운수,
851
+ kept: { 요청: !!String(session.이번요청 ?? '').trim(), 할일: 남은할일수, 파일: 손댄것.length },
852
+ };
853
+ /*
854
+ * 배우기(위)와 달리 여기서는 걸음을 **안 돌려준다.**
855
+ *
856
+ * 돌려주면 걸음 수가 영영 안 올라간다. 그러면 위의 한 번 규칙이
857
+ * 어쩌다 무너졌을 때 걸음 상한도 못 잡아서 턴이 **영영 돈다** —
858
+ * 화면은 멈춘 채로 있고 사람은 왜 그런지 알 길이 없다. 붉은 줄
859
+ * 하나보다 훨씬 나쁘다. 한 걸음 값은 어차피 실제로 한 번 부르고
860
+ * 거절당한 값이라, 안 돌려주는 쪽이 사실에도 맞다.
861
+ */
862
+ continue;
863
+ }
864
+
865
+ /*
866
+ * 탈이 나도 **여기까지 흘러온 말은 남긴다.**
867
+ *
868
+ * 중단(위)에서는 이미 남기고 있었는데 오류에서는 안 남기고 있었다.
869
+ * 그런데 사람이 겪는 것은 오히려 이쪽이 잦다 — 200자를 받아 화면에
870
+ * 읽고 있는데 연결이 끊기면, 화면에는 그 200자가 그대로 보이는데
871
+ * 대화에는 한 글자도 안 들어간다. 그 상태에서 "이어서 해줘" 라고
872
+ * 하면 모델은 방금 제가 한 말을 모른다. 화면과 대화가 어긋나 있으면
873
+ * 사람은 그 뒤로 무엇을 믿어야 할지 알 수 없다.
874
+ *
875
+ * 반쪽인 것은 중단과 같은 표를 붙여 남긴다 — 온전한 답으로 읽히면
876
+ * 안 된다. 도구 호출만 있고 결과가 빈 자리도 같이 채운다(짝맞추기).
877
+ * 안 채우면 다음 요청에서 게이트웨이가 통째로 400 을 준다.
878
+ */
879
+ const 오류때남길것 = 흘린것.trim();
880
+ if (!msg && 오류때남길것) session.push({ role: 'assistant', content: 오류때남길것 });
881
+ 짝맞추기();
882
+ yield { type: 'error', text: err.message, kept: !!오류때남길것 || !!msg };
720
883
  return;
721
884
  }
722
885
 
@@ -838,7 +1001,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
838
1001
  + ` 다시 보내면 또 같은 자리에서 잘립니다. 한 번에 300줄 안쪽으로 끊어 보내세요.`;
839
1002
  session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
840
1003
  if (r.changed) { session.noteChange(r.changed, r.diff); 손댄파일.add(r.changed); }
841
- ctx.audit.tool(call.name, { file_path: 건진것.path }, `잘린 것을 살려 ${건진것.lines}줄 씀`);
1004
+ ctx.audit.tool(call.name, { file_path: 건진것.path }, { summary: `잘린 것을 살려 ${건진것.lines}줄 씀` });
842
1005
  yield {
843
1006
  type: 'tool', name: call.name, args: { file_path: 건진것.path },
844
1007
  result: { ...r, summary: `${건진것.lines}줄 · 잘린 데까지`, warn: `잘린 데까지만 썼습니다 — ${건진것.lines}줄` },
@@ -945,8 +1108,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
945
1108
  */
946
1109
  if (실행할것.length === 1 && 실행할것[0].name === 'Task') {
947
1110
  const call = 실행할것[0];
948
- const 목적 = String(call.args?.목적 ?? call.args?.purpose ?? '').trim() || '이름 없는 작업';
949
- const 할일 = String(call.args?.할일 ?? call.args?.task ?? '').trim();
1111
+ const 목적 = String(call.args?.purpose ?? call.args?.목적 ?? '').trim() || '이름 없는 작업';
1112
+ const 할일 = String(call.args?.task ?? call.args?.할일 ?? '').trim();
950
1113
 
951
1114
  // 할 일이 비면 하위는 아무것도 모른 채로 시작한다. 하위는 이 대화를
952
1115
  // 못 보므로, 여기서 통과시키면 걸음만 태우고 빈손으로 돌아온다.
@@ -959,7 +1122,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
959
1122
  continue;
960
1123
  }
961
1124
 
962
- const 자식모드 = 하위모드(call.args?.모드 ?? call.args?.mode, 모드.id);
1125
+ const 자식모드 = 하위모드(call.args?.mode ?? call.args?.모드, 모드.id);
963
1126
 
964
1127
  /*
965
1128
  * ── 다른 모델에게 떼어 주기 ─────────────────────────────────────
@@ -975,7 +1138,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
975
1138
  let 자식conn = conn;
976
1139
  let 자리닫기 = null;
977
1140
  let 모델알림 = null;
978
- const 부른모델 = String(call.args?.모델 ?? call.args?.model ?? '').trim();
1141
+ const 부른모델 = String(call.args?.model ?? call.args?.모델 ?? '').trim();
979
1142
  if (부른모델) {
980
1143
  const 찾음 = 프로필찾기(부른모델);
981
1144
  if (!찾음.ok) {
@@ -1175,11 +1338,52 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1175
1338
 
1176
1339
  // 앞에서 똑같이 부른 적이 있고 결과도 같으면, 결과를 다시 싣지 않는다.
1177
1340
  let 실을것 = 실을글(result);
1341
+ const 날것 = 실을것; // 되풀이는 도구가 진짜 돌려준 것으로 센다(아래 참고)
1342
+ /*
1343
+ * ── 읽은 파일은 **바뀐 만큼만** 싣는다 ────────────────────────────
1344
+ *
1345
+ * 아래 되풀이 막기는 「인자도 결과도 똑같을 때」만 걸리고, 그 표는
1346
+ * 이 턴 하나짜리다. 그래서 제일 흔한 모양이 그물을 빠져나갔다 —
1347
+ * 읽고, 한 줄 고치고, 확인하러 다시 읽는 것. 결과가 달라졌으니
1348
+ * 1,200줄이 통째로 한 벌 더 실렸다. 새로운 것은 한 줄뿐인데.
1349
+ *
1350
+ * 여기서는 앞서 실은 글과 견줘서, 그대로면 그렇다고만 적고 달라졌으면
1351
+ * 바뀐 자리만 적는다. 창이 밀리지 않으니 앞선 대화가 그만큼 오래 산다.
1352
+ */
1353
+ /*
1354
+ * ── 할 일 목록은 세션에도 들고 있는다 ────────────────────────────
1355
+ *
1356
+ * 여태 목록이 사는 자리는 도구 결과 하나뿐이었다. 그런데 도구 결과는
1357
+ * 55%에서 한 줄로 접히고 80%에서 요약에 뭉개진다. 접힘 문구는
1358
+ * 「필요하면 다시 읽으세요」인데 **할 일 목록은 다시 읽을 파일이 없다.**
1359
+ * 그래서 긴 작업일수록 남은 항목이 통째로 사라졌다 — 「대화를 잘
1360
+ * 기억 못 한다」로 제보받은 것의 큰 몫이 여기다.
1361
+ *
1362
+ * ctx.todos 는 우리 코드만 본다. 모델이 보는 자리에 남기려면 접거나
1363
+ * 줄일 때 다시 실어 줘야 하고, 그러려면 session 이 들고 있어야 한다.
1364
+ */
1365
+ if (!result.error && Array.isArray(result.todos)) session.할일 = result.todos;
1366
+
1367
+ if (!result.error && result.읽은것 && session.파일기억) {
1368
+ const 정한것 = session.파일기억.실을것(result.읽은것, 실을것, { 부분인가: !!result.부분인가 });
1369
+ if (정한것.어떻게 !== 'full') {
1370
+ session.본것?.본것('아낀읽기');
1371
+ 실을것 = 정한것.글;
1372
+ }
1373
+ }
1178
1374
  if (!result.error) {
1179
1375
  const 서명 = 서명만들기(call);
1180
- // 결과도 한 모양으로 맞춰 견준다. 여러 도구가 받은 경로를 답에 그대로
1181
- // 되돌려 쓰므로, 자모가 쪼개진 경로로 부르면 결과까지 달라 보인다.
1182
- const 같은꼴 = 실을것.normalize('NFC');
1376
+ /*
1377
+ * 결과도 모양으로 맞춰 견준다. 여러 도구가 받은 경로를 답에 그대로
1378
+ * 되돌려 쓰므로, 자모가 쪼개진 경로로 부르면 결과까지 달라 보인다.
1379
+ *
1380
+ * 견주는 것은 **바꿔치기 전** 글이다. 위에서 파일 기억이 실을것 을
1381
+ * 「앞에서 읽은 그대로입니다」 로 갈아 끼우는데, 그것까지 표에 넣으면
1382
+ * 같은 파일을 두 번째 읽을 때가 첫 번째와 달라 보인다. 그러면 되풀이가
1383
+ * 한 바퀴 늦게 걸린다 — 헛도는 것을 한 걸음 더 돌고 나서야 잡는 셈이다.
1384
+ * 도구가 실제로 무엇을 돌려줬나로 세는 것이 맞다.
1385
+ */
1386
+ const 같은꼴 = 날것.normalize('NFC');
1183
1387
  const 앞것 = 부른것.get(서명);
1184
1388
  if (앞것 !== undefined && 앞것 === 같은꼴) {
1185
1389
  session.본것?.본것('되풀이');
@@ -1293,36 +1497,10 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1293
1497
  // 컨텍스트만 채우고 사람은 기다리기만 한다.
1294
1498
  if (멈출까) {
1295
1499
  ctx.audit.blocked('같은 자리를 반복해 스스로 멈춤', 멈출까);
1296
- yield { type: 'stuck', why: 멈출까, steps, files: 마무리() };
1500
+ yield { type: 'stuck', why: 멈출까, steps, files: 마무리(), 빠진: 빠뜨린것(마지막말()) };
1297
1501
  return;
1298
1502
  }
1299
1503
 
1300
- /*
1301
- * 요약해서 접기 **전에** 도구 결과부터 접는다.
1302
- *
1303
- * 자리를 먹는 것은 대개 사람 말이 아니라 옛날에 읽어 둔 파일이다. 그쪽을
1304
- * 먼저 비우면 요약 압축을 한참 미룰 수 있고, 미루는 동안 대화는 한 글자도
1305
- * 안 잃는다. 그래도 차면 아래에서 통째로 요약한다.
1306
- */
1307
- if (shouldFold(session)) {
1308
- const f = foldToolResults(session);
1309
- if (f.접은것) yield { type: 'folded', ...f };
1310
- // 그림은 사람 말 자리에 실려서 위 접기가 못 건드린다. 따로 뺀다.
1311
- const g = foldImages(session);
1312
- if (g.뺀것) yield { type: 'images_folded', ...g };
1313
- }
1314
-
1315
- // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
1316
- if (shouldCompact(session)) {
1317
- yield { type: 'compacting' };
1318
- // 요약을 부르다 서버가 막으면 그 알림도 화면으로 — '접는 중' 뒤에 60초를 숨기지 않는다.
1319
- const 편지 = 우편함((onBackoff) => compact(session, { auto: true, signal, onBackoff }));
1320
- for await (const 알림 of 편지.소식()) yield 알림;
1321
- const r = await 편지.부름;
1322
- if (r.aborted) { yield { type: 'aborted', steps, kept: true }; return; }
1323
- if (r.ok) yield { type: 'compacted', ...r };
1324
- else yield { type: 'compact_failed', why: r.why };
1325
- }
1326
1504
  }
1327
1505
 
1328
1506
  /*
@@ -1334,5 +1512,5 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1334
1512
  * 할 때 무엇을 이어야 하는지가 화면에 있어야 한다.
1335
1513
  */
1336
1514
  const 남은할일 = (ctx.todos ?? []).filter((x) => x.state !== 'done');
1337
- yield { type: 'limit', steps, files: 마무리(), 남은할일 };
1515
+ yield { type: 'limit', steps, files: 마무리(), 남은할일, 빠진: 빠뜨린것(마지막말()) };
1338
1516
  }
@@ -1,5 +1,5 @@
1
1
  // 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { 그림장수, 글만, 그림한장토큰 } from '../backend/vision.js';
5
5
  import { get as workMode, 말 as 모드말, DEFAULT as WORK_DEFAULT } from './modes.js';
@@ -9,7 +9,8 @@ import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
9
9
  import { 지문 } from './project.js';
10
10
  import { 프롬프트토막 as 기억토막 } from './memory.js';
11
11
  import { 못박기 } from './pins.js';
12
- import { 언어, 지시말 } from '../i18n/index.js';
12
+ import { 파일기억 } from './filemem.js';
13
+ import { 언어, 지시말, 말 as 옮긴말 } from '../i18n/index.js';
13
14
  import { 셸안내 } from '../tools/shell.js';
14
15
 
15
16
  // 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
@@ -231,6 +232,27 @@ export class Session {
231
232
  */
232
233
  this.못박은것 = new 못박기();
233
234
  this.filesRead = new Map(); // 경로 → 추정 토큰
235
+ /*
236
+ * 읽은 파일을 들고 있다가 **바뀐 만큼만** 다시 싣는다 (agent/filemem.js).
237
+ *
238
+ * filesRead 와 둘로 나눠 둔 이유: 저쪽은 `/context` 가 「몇 개 읽었나」 를
239
+ * 세는 자리라 값이 토큰 수뿐이다. 여기는 글 자체를 들고 있어야 한다.
240
+ * 한 맵에 두 가지를 담으면 화면 셈이 글자까지 들고 다니게 된다.
241
+ */
242
+ this.파일기억 = new 파일기억();
243
+ /*
244
+ * 접거나 줄여도 잃으면 안 되는 두 가지 (아래 못박을것).
245
+ *
246
+ * 이번요청 — 이번 턴에 사람이 시킨 말 원문. loop.js 가 턴마다 채운다.
247
+ * 할일 — 마지막 TodoWrite 목록. loop.js 가 그 결과에서 채운다.
248
+ *
249
+ * 요약은 요약이라 「네 가지를 고쳐 달라고 했다」로 뭉개지고, 할 일 목록은
250
+ * 도구 결과 자리에만 살아서 접히면 통째로 사라진다. 둘 다 접는 자리에서
251
+ * 다시 박으려면 세션이 들고 있어야 한다 — ctx 에만 두면 우리 코드만 보고
252
+ * 모델은 못 본다.
253
+ */
254
+ this.이번요청 = '';
255
+ this.할일 = [];
234
256
  this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
235
257
  /*
236
258
  * 상태줄이 보는 두 숫자.
@@ -269,6 +291,8 @@ export class Session {
269
291
  this.본것 = new 지켜본것();
270
292
  this.급정한것 = null;
271
293
  this.startedAt = Date.now();
294
+ /** 규칙 파일이 있는데 못 읽었나. `{이름, 까닭}` — /status 가 이걸 말한다. */
295
+ this.규칙못읽음 = null;
272
296
  this.rules = this.#loadRules();
273
297
  /*
274
298
  * 이 폴더가 무슨 프로젝트인가 (agent/project.js).
@@ -289,11 +313,27 @@ export class Session {
289
313
  this.memory = 기억토막(this.root);
290
314
  }
291
315
 
316
+ /**
317
+ * 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md 중 먼저 읽히는 하나.
318
+ *
319
+ * 없는 것은 그냥 없는 것이라 아무 말도 안 한다. 그런데 **있는데 못 읽는**
320
+ * 것까지 같이 삼키면 안 된다 — 권한이 막혔거나 같은 이름의 폴더가 있으면
321
+ * 그렇게 된다. 사람은 규칙을 적어 뒀으니 걸려 있다고 믿는데 실제로는 하나도
322
+ * 안 걸린 채로 일이 돈다. 「운영 DB 는 건드리지 마라」 를 적어 놓고 그게
323
+ * 안 걸린 것이 여기서 나올 수 있는 제일 나쁜 모양이다.
324
+ * 못 읽은 것은 적어 두고 /status 가 '없음' 대신 그 까닭을 말한다.
325
+ */
292
326
  #loadRules() {
293
327
  for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
294
- const p = join(this.root, name);
295
- if (existsSync(p)) {
296
- try { return { name, text: readFileSync(p, 'utf8').slice(0, 20000) }; } catch {}
328
+ try { return { name, text: readFileSync(join(this.root, name), 'utf8').slice(0, 20000) }; }
329
+ catch (err) {
330
+ // 없으면 그냥 없는 것이다 말할 일이 아니다. existsSync 먼저 보지
331
+ // 않는 이유도 여기 있다: 보고 나서 읽는 사이에 지워지면 그 ENOENT 를
332
+ // 「있는데 못 읽었다」 로 적게 된다.
333
+ if (err?.code === 'ENOENT') continue;
334
+ // 첫 번째 것만 적어 둔다. 뒤엣것이 읽히면 그게 규칙이 되지만, 사람이
335
+ // 적어 둔 자리를 못 읽었다는 사실은 그래도 남아야 한다.
336
+ if (!this.규칙못읽음) this.규칙못읽음 = { 이름: name, 까닭: err?.code ?? err?.message ?? String(err) };
297
337
  }
298
338
  }
299
339
  return null;
@@ -505,6 +545,19 @@ export class Session {
505
545
  this.messages = 고침.messages;
506
546
  this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
507
547
  this.#다음턴 = null;
548
+ /*
549
+ * 들고 있던 파일 내용도 같이 버린다. clear() 가 하는 것과 같은 까닭이다.
550
+ *
551
+ * 되감기는 그 턴의 Read 결과까지 대화에서 **진짜로 지운다.** 그런데 파일
552
+ * 기억은 그대로 남아서, 같은 파일을 다시 읽으면 「앞에서 읽은 그대로입니다」
553
+ * 나 「나머지는 앞에 실린 그대로입니다」 를 내민다 — 그 「앞엣것」 은 방금
554
+ * 이 줄이 지웠다.
555
+ *
556
+ * /undo 는 특히 그렇다. 파일을 고치고(기억에 담김) 되돌리면 디스크는 옛
557
+ * 모습으로 돌아가는데 기억은 고친 모습이라, 다시 읽을 때 있지도 않은 차이를
558
+ * 적어 보내게 된다. 여기서 한 줄 지우면 그냥 통째로 다시 읽는다.
559
+ */
560
+ this.파일기억?.잊기();
508
561
  return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
509
562
  }
510
563
 
@@ -517,6 +570,17 @@ export class Session {
517
570
  clear() {
518
571
  this.messages = [];
519
572
  this.filesRead.clear();
573
+ this.파일기억.잊기();
574
+ /*
575
+ * 할 일 목록과 시킨 말도 같이 버린다.
576
+ *
577
+ * 이 둘은 접거나 줄일 때 다시 박히는 것들이다(못박을것). 대화를 지운 뒤에도
578
+ * 들고 있으면, 새로 시작한 일이 처음 접히는 순간 **지운 대화의 할 일**이
579
+ * 되살아나 붙는다. 모델은 그걸 지금 시킨 것으로 알고 하러 간다 —
580
+ * 안 지운 것만 못하다.
581
+ */
582
+ this.할일 = [];
583
+ this.이번요청 = '';
520
584
  this.#턴표 = [];
521
585
  this.#다음턴 = null;
522
586
  return this;
@@ -630,15 +694,21 @@ export class Session {
630
694
  const 기억 = this.memory ? estimateTokens(this.memory) : 0;
631
695
  const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
632
696
 
697
+ /*
698
+ * 이름은 화면에 그대로 나간다(`/context`). 그래서 여기서 말 표를 거친다.
699
+ * 여기만 한국어로 두면 영어로 켠 사람의 컨텍스트 표가 통째로 한국어가
700
+ * 되는데, `/lang` 은 그 사이에도 100% 라고 답한다 — 표에 없는 글은
701
+ * 세지지 못하기 때문이다. test/langleak.test.js 가 이 자리를 지킨다.
702
+ */
633
703
  const rows = [
634
- { label: '시스템 프롬프트', n: sys },
635
- { label: this.rules ? `규칙 (${this.rules.name})` : '규칙 (없음)', n: rules },
636
- { label: `기억 (${기억줄})`, n: 기억 },
637
- { label: '겪어 본 것', n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
638
- { label: `스킬 목록 (${listed.length}/${this.skills.length})`, n: skills },
639
- { label: '도구 정의', n: 도구 },
640
- { label: '대화 이력', n: history },
641
- { label: `도구 결과 (파일 ${this.filesRead.size})`, n: files },
704
+ { label: 옮긴말('ctx.system'), n: sys },
705
+ { label: this.rules ? 옮긴말('ctx.rules', { 이름: this.rules.name }) : 옮긴말('ctx.rulesNone'), n: rules },
706
+ { label: 옮긴말('ctx.memory', { n: 기억줄 }), n: 기억 },
707
+ { label: 옮긴말('ctx.learned'), n: this.배움요약 ? estimateTokens(this.배움요약) : 0 },
708
+ { label: 옮긴말('ctx.skills', { 실림: listed.length, 전체: this.skills.length }), n: skills },
709
+ { label: 옮긴말('ctx.tools'), n: 도구 },
710
+ { label: 옮긴말('ctx.history'), n: history },
711
+ { label: 옮긴말('ctx.toolResults', { n: this.filesRead.size }), n: files },
642
712
  ];
643
713
  const used = rows.reduce((a, r) => a + r.n, 0);
644
714
  const total = this.conn.ctx ?? 32768;
@@ -719,13 +789,65 @@ export class Session {
719
789
  if (dropped <= 0) return 0;
720
790
  this.messages = [
721
791
  ...this.messages.slice(0, keepHead),
722
- { role: 'user', content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)` },
792
+ {
793
+ role: 'user',
794
+ /*
795
+ * 줄일 때도 시킨 말과 남은 할 일을 다시 박는다.
796
+ *
797
+ * 이 길은 **요약을 못 받아 물러선** 길이다. 서버가 흔들릴 때 지나가는
798
+ * 길이라 제일 자주 밟히는데, 여태 여기에는 못 박는 것이 없었다.
799
+ * 머리 2개만 남기므로, 사용자가 세 번째 턴에서 네 가지를 시켰다면
800
+ * 그 네 가지가 그냥 사라진다. 접기(compact) 쪽에만 못 박아 두면
801
+ * 정작 제일 자주 지나가는 길에서만 조용히 어긋난다.
802
+ */
803
+ content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)\n\n` + 못박을것(this),
804
+ },
723
805
  ...this.messages.slice(keepTail),
724
806
  ];
725
807
  return dropped;
726
808
  }
727
809
  }
728
810
 
811
+ /*
812
+ * ── 접거나 줄여도 이것만은 글자 그대로 남긴다 ──────────────────────────
813
+ *
814
+ * 요약은 요약이다. 네 가지를 적어 준 요청이 「네 가지를 고쳐 달라고 했다」
815
+ * 한 줄로 뭉개지고, **그 네 가지가 무엇이었는지는 사라진다.** 접힌 뒤로는
816
+ * 원문이 어디에도 없으니 남은 것을 이어 하려 해도 무엇이 남았는지 모른다.
817
+ *
818
+ * 할 일 목록도 같다. 목록이 사는 자리가 도구 결과 하나뿐이라, 55%에서 한 줄로
819
+ * 접히고 80%에서 요약에 뭉개진다. 접힘 문구는 「필요하면 다시 읽으세요」인데
820
+ * 할 일 목록은 **다시 읽을 파일이 없다.**
821
+ *
822
+ * 그래서 둘 다 접는 자리·줄이는 자리에서 다시 박는다. 끝난 항목은 안 싣는다 —
823
+ * 자리를 먹기만 하고, 이 쪽지가 답할 질문은 「무엇이 남았나」 하나다.
824
+ *
825
+ * compact.js 가 이 두 함수를 그대로 내보낸다. safeCut·safeHead 와 같은 까닭이다 —
826
+ * 접기와 줄이기가 **같은 것**을 박아야 하고, 한쪽만 고치면 물러서는 순간
827
+ * 대화가 어긋난다.
828
+ */
829
+ const 못박을길이 = 1200;
830
+
831
+ export function 못박은요청(session) {
832
+ const 원문 = String(session?.이번요청 ?? '').trim();
833
+ if (!원문) return '';
834
+ const 실을것 = 원문.length > 못박을길이
835
+ ? `${원문.slice(0, 못박을길이)}\n…(뒷부분 줄임)`
836
+ : 원문;
837
+ return `[이번에 시킨 말 — 요약이 아니라 원문 그대로입니다. 여기 적힌 것을 빠짐없이 하세요.]\n${실을것}\n\n`;
838
+ }
839
+
840
+ export function 못박은할일(session) {
841
+ const 남은 = (session?.할일 ?? []).filter((x) => x?.state !== 'done');
842
+ if (!남은.length) return '';
843
+ const 줄 = 남은.map((x) => `${x.state === 'doing' ? '▶ (하는 중)' : '☐'} ${String(x.text ?? '').trim()}`);
844
+ return `[아직 안 끝난 할 일 — 접히기 전 목록 그대로입니다. 이걸 이어서 하세요.]\n${줄.join('\n')}\n\n`;
845
+ }
846
+
847
+ export function 못박을것(session) {
848
+ return 못박은요청(session) + 못박은할일(session);
849
+ }
850
+
729
851
  /**
730
852
  * 도구 호출과 결과가 갈라지지 않는 자리를 찾는다.
731
853
  * i 번째부터 남긴다고 할 때, 안전한 i 로 옮겨 준다.