deel-local-cli 1.1.1 → 1.4.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 (60) hide show
  1. package/README.en.md +826 -19
  2. package/README.md +689 -14
  3. package/bin/deel.js +73 -4
  4. package/package.json +7 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/budget.js +0 -14
  9. package/src/agent/card.js +110 -0
  10. package/src/agent/compact.js +95 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/evolve.js +213 -0
  13. package/src/agent/grade.js +20 -0
  14. package/src/agent/loop.js +172 -14
  15. package/src/agent/models.js +169 -0
  16. package/src/agent/modes.js +49 -1
  17. package/src/agent/pins.js +140 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +359 -11
  20. package/src/agent/store.js +31 -0
  21. package/src/agent/threads.js +154 -0
  22. package/src/commands.js +751 -55
  23. package/src/config.js +10 -1
  24. package/src/i18n/en.js +244 -0
  25. package/src/i18n/index.js +126 -0
  26. package/src/i18n/ko.js +231 -0
  27. package/src/lsp/client.js +459 -0
  28. package/src/lsp/diag.js +112 -0
  29. package/src/lsp/rpc.js +84 -0
  30. package/src/lsp/servers.js +218 -0
  31. package/src/oneshot.js +23 -0
  32. package/src/pack/sbom.js +218 -0
  33. package/src/pack/selfpack.js +19 -3
  34. package/src/repl.js +273 -22
  35. package/src/safety/guard.js +37 -2
  36. package/src/safety/secrets.js +205 -0
  37. package/src/safety/undo.js +11 -4
  38. package/src/setup.js +2 -2
  39. package/src/skills/discover.js +1 -1
  40. package/src/tools/desc.en.js +219 -0
  41. package/src/tools/edit-match.js +16 -5
  42. package/src/tools/excel-com.js +1 -1
  43. package/src/tools/excel.js +1 -1
  44. package/src/tools/fsutil.js +0 -8
  45. package/src/tools/index.js +149 -6
  46. package/src/tools/lsp.js +327 -0
  47. package/src/tools/task.js +30 -2
  48. package/src/tools/todo.js +19 -3
  49. package/src/ui/ansi.js +42 -2
  50. package/src/ui/approve.js +25 -21
  51. package/src/ui/inputbox.js +69 -13
  52. package/src/ui/intro.js +174 -0
  53. package/src/ui/level.js +11 -5
  54. package/src/ui/md.js +227 -0
  55. package/src/ui/notify.js +101 -0
  56. package/src/ui/prompt.js +1 -1
  57. package/src/ui/screen.js +1 -1
  58. package/src/ui/status.js +190 -34
  59. package/src/ui/working.js +0 -3
  60. package/src/ui/wrap.js +1 -8
package/src/agent/loop.js CHANGED
@@ -6,11 +6,13 @@ import { isMutating } from '../safety/guard.js';
6
6
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
7
7
  import { 살린쓰기 } from './salvage.js';
8
8
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
9
- import { compact, shouldCompact } from './compact.js';
9
+ import { compact, shouldCompact, shouldFold, foldToolResults } from './compact.js';
10
10
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
11
11
  import { Session } from './session.js';
12
12
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
13
- import { isOffline } from '../safety/network.js';
13
+ import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
14
+ import { allowTemporarily, isOffline } from '../safety/network.js';
15
+ import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴도구 } from '../safety/secrets.js';
14
16
  import { get as workMode } from './modes.js';
15
17
 
16
18
  /**
@@ -57,16 +59,20 @@ export function 묶기(calls) {
57
59
  }
58
60
 
59
61
  export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 } = {}) {
60
- session.push({ role: 'user', content: userText });
61
- ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
62
62
  /*
63
63
  * 되돌리기 턴은 **부모만** 연다.
64
64
  *
65
65
  * 하위 작업이 제 턴을 열면 `/undo` 한 번이 하위가 만든 것만 되돌리고
66
66
  * 부모가 만든 것은 남긴다 — 반쪽만 되돌아간 폴더가 된다. 사람 눈에는
67
67
  * 한 번 시킨 일이니 한 번에 되돌아가야 맞다.
68
+ *
69
+ * 턴을 **사람 말을 넣기 전에** 연다. 그래야 그 말 자체가 이 턴의 첫 말로
70
+ * 표시되고, 되감을 때 시킨 말까지 같이 걷힌다. 시킨 말만 남으면 모델은
71
+ * 되돌린 일을 또 하려 든다.
68
72
  */
69
- if (!깊이) ctx.history.nextTurn();
73
+ if (!깊이) session.턴시작(ctx.history.nextTurn());
74
+ session.push({ role: 'user', content: userText });
75
+ ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
70
76
 
71
77
  /*
72
78
  * 중단 신호를 도구도 볼 수 있게 여기 걸어 둔다.
@@ -95,7 +101,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
95
101
  * '헛도는 작업' 을 구분하지 못한다. 여기서는 같은 자리를 반복하는 것만 본다.
96
102
  */
97
103
  const 막힘 = new Map();
98
- const MAX_SAME = 3;
104
+ /*
105
+ * 몇 번까지 봐 줄까는 **이 모델을 겪어 본 만큼** 정한다 (agent/card.js).
106
+ *
107
+ * 되풀이가 버릇인 모델에서는 세 번까지 기다릴 이유가 없다. 되풀이한 만큼
108
+ * 컨텍스트가 차고 그만큼 요약이 빨리 온다 — 걸음만 태우는 것이 아니다.
109
+ * 겪은 것이 모자라면 여태 쓰던 3 그대로다.
110
+ */
111
+ const 이카드 = ctx?.카드 ?? null;
112
+ const MAX_SAME = 이카드?.조정?.같은것한계 ?? 3;
99
113
  let 멈출까 = null;
100
114
  const 막힘셈 = (call, 이유) => {
101
115
  // 파일 하나가 아니라 **그 파일** 을 센다. 이름만 세면 서로 다른 파일 세 개를
@@ -194,6 +208,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
194
208
  work: session.effectiveWork(), // 작업 모드가 쓰는 것만 (modes.js)
195
209
  // 밖에서 붙인 도구(MCP). 붙은 것이 없으면 아무것도 안 는다.
196
210
  mcp: ctx.mcp ?? null,
211
+ // 이 자리에 언어 서버가 있을 때만 Def·Refs 를 보여 준다 (tools/lsp.js).
212
+ // 없는 자리에서 목록에 세워 두면 모델이 부르고, 실패를 받고, 또 부른다.
213
+ lsp: session.lsp === true,
197
214
  // 창이 좁으면 도구 설명을 줄여 싣는다 (budget.js 의 설명길이).
198
215
  // 도구를 빼는 게 아니라 설명만 줄이므로 할 수 있는 일은 안 달라진다.
199
216
  ctx: conn.ctx ?? null,
@@ -240,6 +257,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
240
257
  * 세기만 한다 — 판단은 grade.js 가 하고, 프롬프트는 다음 걸음부터 달라진다.
241
258
  */
242
259
  session.본것?.걸음셈();
260
+ ctx.배움?.모델본것(conn.model, '걸음');
243
261
  // 단계마다 필요한 생각의 양이 다르다. effort.js 가 그 배분을 갖고 있다.
244
262
  const stage = steps === 1 ? 'plan' : lastToolFailed ? 'fix' : 'work';
245
263
  const level = effortFor(think, effort, stage);
@@ -247,7 +265,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
247
265
  // 출력 상한을 아는 값이 둘 있다 — 사용자가 정한 것(/out)과 서버에서 알아낸 것.
248
266
  // 사람이 정한 것이 먼저다. 둘 다 없으면 null 이고, 그때만 effort.js 의 MAX_CAP 에 선다.
249
267
  const room = { ctx: conn.ctx ?? 0, used: session.breakdown().used, max: conn.maxTokens ?? conn.maxOut ?? null };
250
- const cap = tokensFor(effort, stage, room);
268
+ /*
269
+ * 이 모델이 인자를 자주 잘라 먹는다면 처음부터 넉넉히 준다 (agent/card.js).
270
+ *
271
+ * 지금은 잘린 **뒤에야** 상한을 올려 다시 부른다. 한 번은 반드시 버리는 셈이다.
272
+ * 잘리는 것이 이 모델의 버릇으로 재어졌다면 그 한 번을 매번 버릴 이유가 없다.
273
+ * 상한은 최댓값이지 정해진 길이가 아니라서, 짧게 답하는 턴에는 값이 안 든다.
274
+ */
275
+ const cap = 이카드?.조정?.상한먼저올리기
276
+ ? Math.max(tokensFor(effort, stage, room), fullCap(room))
277
+ : tokensFor(effort, stage, room);
251
278
  yield { type: 'stage', stage, level, cap, step: steps };
252
279
 
253
280
  const ask = (maxTokens, think) => ({
@@ -332,6 +359,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
332
359
  }
333
360
  if (빈답인가(msg)) {
334
361
  session.본것?.본것('빈답');
362
+ ctx.배움?.모델본것(conn.model, '빈답');
335
363
  yield {
336
364
  type: 'error',
337
365
  text: '서버가 빈 답을 보냈습니다.\n'
@@ -403,6 +431,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
403
431
  session.usage.out += msg.usage?.out ?? 0;
404
432
  session.usage.calls++;
405
433
 
434
+ /*
435
+ * 방금 보낸 것이 실제로 몇 토큰이었는지 서버가 알려 줬다. 우리 추정과
436
+ * 견줘서 배운다 — 여기가 유일하게 정답을 아는 자리다.
437
+ *
438
+ * 답을 push 하기 **전**이어야 한다. 지금 이력이 곧 방금 보낸 프롬프트다.
439
+ */
440
+ const 새보정 = session.배운다?.(msg.usage?.in);
441
+ // 배운 배수를 디스크에도 남긴다. 다음에 켤 때 이 값으로 시작한다.
442
+ if (새보정) ctx.배움?.보정본것(conn.model, 새보정);
443
+
406
444
  session.push(assistantMessage(conn.kind, msg));
407
445
 
408
446
  if (!msg.toolCalls?.length) {
@@ -438,6 +476,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
438
476
  // 모델은 고칠 게 없다고 보고 똑같이 다시 시도한다. 그래서 끝없이 돈다.
439
477
  if (call.argsBroken) {
440
478
  session.본것?.본것('잘린인자');
479
+ ctx.배움?.모델본것(conn.model, '잘린인자');
441
480
  // ── 버리기 전에, 건질 수 있는지 먼저 본다 ──────────────────────
442
481
  //
443
482
  // 잘린 JSON 안에는 이미 받아 놓은 내용이 들어 있다. 경로도 대개 온전하다.
@@ -579,7 +618,43 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
579
618
  }
580
619
 
581
620
  const 자식모드 = 하위모드(call.args?.모드 ?? call.args?.mode, 모드.id);
582
- const 자식 = new Session(conn, {
621
+
622
+ /*
623
+ * ── 다른 모델에게 떼어 주기 ─────────────────────────────────────
624
+ *
625
+ * 안 적으면 지금 쓰는 것 그대로다. 적었으면 **사람이 설정에 적어 둔
626
+ * 프로필**에서만 찾는다 — 모델이 주소를 지어내면 그 자리로 나갈 뻔하고,
627
+ * 그건 이 프로그램이 하는 약속을 그 자리에서 깨는 것이다.
628
+ *
629
+ * 못 찾으면 조용히 지금 모델로 돌지 않는다. 시킨 쪽은 작은 모델에게
630
+ * 넘긴 줄 알고 창을 아끼고 있는데 실제로는 큰 모델이 다 받는 셈이라,
631
+ * 원인을 못 찾는 쪽으로 어긋난다. 무엇이 있는지 적어 되돌려 준다.
632
+ */
633
+ let 자식conn = conn;
634
+ let 자리닫기 = null;
635
+ let 모델알림 = null;
636
+ const 부른모델 = String(call.args?.모델 ?? call.args?.model ?? '').trim();
637
+ if (부른모델) {
638
+ const 찾음 = 프로필찾기(부른모델);
639
+ if (!찾음.ok) {
640
+ const 있는것 = 목록보기().map((x) => x.id).join(' · ') || '(설정에 프로필이 없습니다)';
641
+ 거절(call, `${찾음.why}. 쓸 수 있는 이름: ${있는것}`);
642
+ yield { type: 'tool', name: 'Task', args: call.args, result: { error: 찾음.why }, showLabel: true };
643
+ continue;
644
+ }
645
+ const 되나 = 쓸수있나(찾음.prof);
646
+ if (!되나.ok) {
647
+ 거절(call, `${부른모델} 은 지금 못 씁니다 — ${되나.why}`);
648
+ yield { type: 'tool', name: 'Task', args: call.args, result: { error: 되나.why }, showLabel: true };
649
+ continue;
650
+ }
651
+ 자식conn = 연결만들기(찾음.prof);
652
+ 모델알림 = 알릴말(conn, 자식conn);
653
+ // 그 일이 도는 동안만 연다. 끝나면 반드시 닫는다 — 아래 finally.
654
+ if (모델알림.다른자리) 자리닫기 = allowTemporarily(자식conn.base);
655
+ }
656
+
657
+ const 자식 = new Session(자식conn, {
583
658
  root: session.root,
584
659
  // 승인 방식은 그대로 물려준다. 하위가 승인을 우회하면 strict 가 거짓말이 된다.
585
660
  mode: session.mode,
@@ -591,7 +666,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
591
666
  // 부모보다 적게 준다 (budget.js). 사람이 직접 정한 값이 있으면 그 절반.
592
667
  maxSteps: session.stepsSet
593
668
  ? Math.max(4, Math.floor(session.maxSteps / 2))
594
- : 하위걸음수(자식모드, conn.ctx),
669
+ // 걸음 수는 **하위가 쓸 창**으로 잰다. 부모 창으로 재면, 작은 모델에게
670
+ // 떼어 준 일이 제 창보다 큰 걸음 수를 받아 중간에 창이 찬 채로 돈다.
671
+ : 하위걸음수(자식모드, 자식conn.ctx),
595
672
  });
596
673
  // 스킬·명령·기억은 부모가 켤 때 한 번 찾아 든 것이다. 하위도 같은 것을 본다.
597
674
  자식.skills = session.skills;
@@ -609,8 +686,19 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
609
686
  * 미리 +1 을 해 두면 두 겹째에서 1이 두 번 붙어 3이 된다.
610
687
  * 경계선은 부모 자리에 그어야 어디부터가 떼어 낸 일인지 보인다.
611
688
  */
612
- yield { type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps };
613
- ctx.audit.tool('Task', { 목적, 모드: 자식모드 }, { summary: `하위 작업 시작 (${깊이 + 1}겹)` });
689
+ /*
690
+ * 다른 모델을 쓰면 **어디로 나가는지까지** 적는다.
691
+ *
692
+ * 조용히 여는 것이 제일 나쁘다. 상태줄의 ⌂ 는 이 세션의 연결을 보고
693
+ * 있어서 하위가 딴 데로 나가는 것을 모른다. 그러니 이 줄과 감사기록이
694
+ * 그 사실을 남기는 유일한 자리다.
695
+ */
696
+ yield {
697
+ type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps,
698
+ 모델: 모델알림?.말 ?? null, 밖으로: 모델알림?.밖으로 ?? false,
699
+ };
700
+ ctx.audit.tool('Task', { 목적, 모드: 자식모드, 모델: 모델알림?.말 ?? null },
701
+ { summary: `하위 작업 시작 (${깊이 + 1}겹)${모델알림 ? ` · ${모델알림.말}` : ''}` });
614
702
 
615
703
  let 끝 = null;
616
704
  try {
@@ -634,6 +722,10 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
634
722
  } catch (err) {
635
723
  // 하위가 터져도 부모 턴은 살린다. 무엇 때문에 터졌는지는 요약에 실린다.
636
724
  끝 = { type: 'stuck', why: String(err?.message ?? err), steps: 0, files: [] };
725
+ } finally {
726
+ // 잠깐 열어 둔 자리는 무슨 일이 있어도 닫는다. 안 닫으면 그 세션이
727
+ // 끝날 때까지 그 주소가 열린 채로 남는다 — '한 자리만 연다' 가 깨진다.
728
+ try { 자리닫기?.(); } catch { /* 닫다 터져도 이번 턴은 이어간다 */ }
637
729
  }
638
730
 
639
731
  /*
@@ -651,13 +743,13 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
651
743
  session.usage.ms += 자식.usage.ms;
652
744
 
653
745
  const 글 = 하위요약({
654
- 목적, 모드: 자식모드, 끝,
746
+ 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null,
655
747
  글자수: 요약길이(conn.ctx),
656
748
  보인이름: (경로) => ctx.scope?.show?.(경로) ?? 경로,
657
749
  });
658
750
  session.push(toolMessage(conn.kind, { callId: call.id, name: 'Task', content: 글 }));
659
751
  ctx.audit.tool('Task', { 목적 }, { summary: 글.slice(0, 300) });
660
- yield { type: 'task_done', 목적, 모드: 자식모드, };
752
+ yield { type: 'task_done', 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null };
661
753
 
662
754
  // 사용자가 중단했으면 부모도 여기서 멈춘다. 하위만 끊고 이어가면
663
755
  // 무엇이 중단된 것인지 알 수 없는 화면이 된다.
@@ -704,6 +796,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
704
796
  if (call.name === 'Edit' && result.error) session.본것?.본것('편집실패');
705
797
  else if (!result.error) session.본것?.본것('도구성공');
706
798
 
799
+ /*
800
+ * 겪은 것을 디스크에도 쌓는다 (agent/evolve.js).
801
+ *
802
+ * 위의 지켜본것은 이 대화가 끝나면 사라진다. 그래서 어제 알아낸 것을
803
+ * 오늘 또 알아내야 했다 — `pnpm` 이 이 PC 에 없다는 걸 매일 다시 겪는다.
804
+ * 명령의 성패는 이 폴더의 사실이므로 폴더 쪽에 남긴다.
805
+ */
806
+ if (call.name === 'Bash' && call.args?.command) {
807
+ ctx.배움?.명령본것(call.args.command, !result.error, result.error ?? '');
808
+ }
809
+ if (call.name === 'Edit' && result.error) ctx.배움?.모델본것(conn.model, '편집실패');
810
+
707
811
  if (result.error) {
708
812
  lastToolFailed = true;
709
813
  // 같은 도구가 같은 이유로 계속 실패하면 헛돌고 있는 것이다.
@@ -735,12 +839,54 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
735
839
  }
736
840
  }
737
841
 
842
+ /*
843
+ * ── 비밀 가리기 ─────────────────────────────────────────────────
844
+ *
845
+ * 여기가 도구 결과가 대화로 들어가는 **유일한** 자리다. 여기서 막으면
846
+ * 모델에게도 안 가고 `.deel/sessions/*.jsonl` 에도 안 적힌다. 한 번
847
+ * 새면 두 벌이 되는 것을 한 곳에서 끊는다.
848
+ *
849
+ * 새는 자리는 거의 항상 명령 출력이다 — env · git remote -v · curl -v ·
850
+ * 검사 실패 로그. 그래서 그런 도구만 가린다.
851
+ *
852
+ * 파일에서 읽어 온 글은 **안 가린다.** 가리면 모델이 가려진 글을 보고
853
+ * 그대로 되돌려 써서, 진짜 열쇠가 있던 자리에 표가 적힌다 — 비밀을
854
+ * 지키려다 비밀을 지우는 셈이다. 대신 무엇이 들어왔는지 알린다.
855
+ */
856
+ const 아는열쇠들 = [conn.key, process.env.DEEL_API_KEY].filter(Boolean);
857
+ let 비밀 = [];
858
+ if (가릴도구.has(call.name)) {
859
+ const 가린 = 가리기(실을것, { 열쇠들: 아는열쇠들 });
860
+ if (가린.가린것.length) {
861
+ 비밀 = 가린.가린것;
862
+ 실을것 = 가린.글 + 가렸다는말(가린.가린것);
863
+ }
864
+ } else if (result.content) {
865
+ // 안 고치고 보기만 한다. 사람이 알아야 손을 쓸 수 있다.
866
+ 비밀 = 훑기(실을것, { 열쇠들: 아는열쇠들 });
867
+ }
868
+ if (비밀.length) {
869
+ ctx.audit?.write?.('secret', {
870
+ tool: call.name,
871
+ 가렸나: 가릴도구.has(call.name),
872
+ 무엇: 비밀.map((x) => `${x.종류}×${x.몇번}`).join(' '),
873
+ });
874
+ }
875
+
738
876
  session.push(toolMessage(conn.kind, {
739
877
  callId: call.id,
740
878
  name: call.name,
741
879
  content: 실을것,
742
880
  }));
743
- yield { type: 'tool', name: call.name, args: call.args, result, ms, parallel: 함께 };
881
+ yield {
882
+ type: 'tool',
883
+ name: call.name,
884
+ args: call.args,
885
+ result,
886
+ ms,
887
+ parallel: 함께,
888
+ ...(비밀.length ? { 비밀: { 가렸나: 가릴도구.has(call.name), 말: 봤다는말(비밀) } } : {}),
889
+ };
744
890
  }
745
891
  }
746
892
 
@@ -755,6 +901,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
755
901
  return;
756
902
  }
757
903
 
904
+ /*
905
+ * 요약해서 접기 **전에** 도구 결과부터 접는다.
906
+ *
907
+ * 자리를 먹는 것은 대개 사람 말이 아니라 옛날에 읽어 둔 파일이다. 그쪽을
908
+ * 먼저 비우면 요약 압축을 한참 미룰 수 있고, 미루는 동안 대화는 한 글자도
909
+ * 안 잃는다. 그래도 차면 아래에서 통째로 요약한다.
910
+ */
911
+ if (shouldFold(session)) {
912
+ const f = foldToolResults(session);
913
+ if (f.접은것) yield { type: 'folded', ...f };
914
+ }
915
+
758
916
  // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
759
917
  if (shouldCompact(session)) {
760
918
  yield { type: 'compacting' };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 한 세션 안에서 여러 모델 쓰기.
3
+ *
4
+ * ── 왜 필요한가 ────────────────────────────────────────────────────────
5
+ *
6
+ * 로컬에서는 모델 하나를 골라도 늘 어딘가 아쉽다. 7B 는 계획을 잘 세우는데
7
+ * 파일 열 개를 고치다 보면 창이 차고, 1.5B 는 창은 넉넉한데 무엇을 할지를
8
+ * 못 정한다. 클라우드였으면 큰 것 하나로 끝날 일인데, 8GB 램에서는 둘 다
9
+ * 올려 두고 **나눠 쓰는 것**이 실제로 가능한 유일한 길이다.
10
+ * 7B q4 (약 4.4GB) + 1.5B q4 (약 1.3GB) ≒ 5.7GB — 8GB 안에 들어간다.
11
+ *
12
+ * 그래서 두 자리를 연다.
13
+ * Task 에 `모델` 큰 것이 계획을 쥐고, 잔일 한 덩이를 작은 것에 떼어 준다.
14
+ * /ask <프로필> 지금 쓰던 것을 안 바꾸고 다른 모델에게 한 번 물어본다.
15
+ *
16
+ * ── 안 하는 것 ─────────────────────────────────────────────────────────
17
+ *
18
+ * 요약(compact)을 작은 모델에게 안 넘긴다. 요약은 '무엇을 버려도 되는가' 를
19
+ * 정하는 일이라 대화 전체를 제일 잘 아는 쪽이 해야 하는데, 그게 작은 모델이면
20
+ * 버려선 안 될 것을 버린다. 그 손해는 몇 턴 뒤에야 드러나고, 그때는 원인을
21
+ * 못 찾는다. 토큰 몇 푼 아끼자고 낼 값이 아니다.
22
+ *
23
+ * ── 경계선은 어떻게 되나 ───────────────────────────────────────────────
24
+ *
25
+ * 이게 이 파일에서 제일 조심한 자리다. deel 은 나갈 수 있는 자리를 **하나만**
26
+ * 연다(safety/network.js). 둘째 모델을 쓴다는 것은 그 자리를 하나 더 연다는
27
+ * 뜻이고, 그 자리가 바깥 게이트웨이면 "이 컴퓨터 안" 이 조용히 거짓이 된다.
28
+ *
29
+ * 1) 모델이 주소를 지어낼 수 없다. **사람이 설정에 적어 둔 프로필**만 쓴다.
30
+ * 2) 그 일이 도는 동안만 열고 끝나면 닫는다 (allowTemporarily).
31
+ * 3) 주소가 지금 쓰는 것과 다르면 **화면과 감사기록에 그렇게 적는다.**
32
+ * 조용히 열면 그게 제일 나쁘다.
33
+ * 4) 오프라인 잠금이면 이 컴퓨터 밖 프로필은 아예 못 쓴다.
34
+ */
35
+ import { load, activeProfile, resolveKey } from '../config.js';
36
+ import { isLocalHost, isOffline } from '../safety/network.js';
37
+
38
+ // 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
39
+ export const CTX_DEFAULT = 32768;
40
+
41
+ /**
42
+ * 프로필 하나로 연결을 만든다.
43
+ *
44
+ * repl.js 도 이걸 쓴다. 두 군데서 따로 만들면 한쪽에만 손이 가고, 그러면
45
+ * `/ask` 로 부른 모델만 스트리밍이 꺼져 있다거나 하는 일이 생긴다 —
46
+ * 화면에는 "느리네" 로만 보이고 원인은 안 보인다.
47
+ */
48
+ export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
49
+ if (!prof) return null;
50
+ return {
51
+ kind: prof.kind,
52
+ base: prof.baseUrl,
53
+ auth: prof.auth,
54
+ key: resolveKey(prof),
55
+ model: prof.model,
56
+ ctx: ctx ?? prof.ctx ?? CTX_DEFAULT,
57
+ maxTokens: maxTokens ?? prof.maxTokens ?? null,
58
+ streaming: prof.streaming ?? false,
59
+ tools: prof.tools ?? false,
60
+ json: prof.json ?? false,
61
+ think: prof.think ?? false,
62
+ };
63
+ }
64
+
65
+ /** 설정에 있는 프로필들. */
66
+ export function 프로필들(cfg = load()) {
67
+ return Array.isArray(cfg?.profiles) ? cfg.profiles : [];
68
+ }
69
+
70
+ /**
71
+ * 이름으로 프로필 찾기.
72
+ *
73
+ * id → 이름 → 모델 이름 → 앞부분 일치 순으로 본다. 사람은 `/ask small ...`
74
+ * 처럼 기억나는 대로 치지, 설정에 적은 id 를 외우고 있지 않다.
75
+ *
76
+ * 앞부분 일치에서 **둘 이상 걸리면 고르지 않는다.** 아무거나 골라 주면
77
+ * 물어본 사람은 어느 모델이 답했는지 모른 채로 그 답을 믿게 된다.
78
+ */
79
+ export function 프로필찾기(이름, cfg = load()) {
80
+ const q = String(이름 ?? '').trim().toLowerCase();
81
+ if (!q) return { ok: false, why: '없음', 후보: [] };
82
+ const 목록 = 프로필들(cfg);
83
+ if (!목록.length) return { ok: false, why: '설정에 프로필이 하나도 없습니다', 후보: [] };
84
+
85
+ const 딱 = 목록.find((p) => String(p.id).toLowerCase() === q)
86
+ ?? 목록.find((p) => String(p.name ?? '').toLowerCase() === q)
87
+ ?? 목록.find((p) => String(p.model ?? '').toLowerCase() === q);
88
+ if (딱) return { ok: true, prof: 딱, 후보: [딱] };
89
+
90
+ const 걸린것 = 목록.filter((p) => [p.id, p.name, p.model]
91
+ .some((v) => String(v ?? '').toLowerCase().includes(q)));
92
+ if (걸린것.length === 1) return { ok: true, prof: 걸린것[0], 후보: 걸린것 };
93
+ if (걸린것.length > 1) {
94
+ return { ok: false, why: `'${이름}' 에 여러 개가 걸립니다`, 후보: 걸린것 };
95
+ }
96
+ return { ok: false, why: `'${이름}' 이라는 프로필이 없습니다`, 후보: [] };
97
+ }
98
+
99
+ /**
100
+ * 이 프로필로 나가도 되는가.
101
+ *
102
+ * 오프라인 잠금은 여기서도 지킨다. 잠갔는데 하위 작업만 밖으로 나가면,
103
+ * 잠금은 화면에만 있고 실제로는 안 잠긴 것이 된다.
104
+ */
105
+ export function 쓸수있나(prof) {
106
+ if (!prof?.baseUrl) return { ok: false, why: '주소가 없는 프로필입니다' };
107
+ let 로컬 = false;
108
+ try { 로컬 = isLocalHost(new URL(prof.baseUrl).hostname); } catch {
109
+ return { ok: false, why: `주소를 읽을 수 없습니다: ${prof.baseUrl}` };
110
+ }
111
+ if (isOffline() && !로컬) {
112
+ return { ok: false, why: '오프라인 잠금 중입니다 — 이 컴퓨터 밖 프로필은 못 씁니다' };
113
+ }
114
+ return { ok: true, 로컬 };
115
+ }
116
+
117
+ /** 두 주소가 같은 자리인가. 다르면 사람에게 알려야 한다. */
118
+ export function 같은자리(a, b) {
119
+ try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
120
+ }
121
+
122
+ /**
123
+ * 다른 모델을 쓸 때 화면과 감사기록에 남길 한 줄.
124
+ *
125
+ * 같은 자리면 모델 이름만, 다른 자리면 **어디로 나가는지**까지 적는다.
126
+ * 이 줄이 없으면 사람은 제 소스가 어느 서버로 갔는지 알 방법이 없다.
127
+ * @returns {{말: string, 밖으로: boolean, 다른자리: boolean}}
128
+ */
129
+ export function 알릴말(지금conn, 새conn) {
130
+ const 다른자리 = !같은자리(지금conn?.base, 새conn?.base);
131
+ let 밖으로 = false;
132
+ try { 밖으로 = !isLocalHost(new URL(새conn.base).hostname); } catch { 밖으로 = true; }
133
+ if (!다른자리) return { 말: `모델 ${새conn.model}`, 밖으로, 다른자리 };
134
+ let 어디 = 새conn.base;
135
+ try { 어디 = new URL(새conn.base).host; } catch { /* 못 읽으면 통째로 */ }
136
+ return {
137
+ 말: `모델 ${새conn.model} · ${밖으로 ? '바깥' : '이 컴퓨터 안'} ${어디}`,
138
+ 밖으로,
139
+ 다른자리,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * 지금 붙어 있는 프로필. `/ask` 가 '나 자신에게 묻기' 를 걸러낼 때 쓴다.
145
+ */
146
+ export function 지금프로필(cfg = load()) {
147
+ return activeProfile(cfg);
148
+ }
149
+
150
+ /**
151
+ * 화면에 낼 프로필 한 줄들.
152
+ * @returns {{id:string, name:string, model:string, 어디:string, 로컬:boolean, 지금:boolean}[]}
153
+ */
154
+ export function 목록보기(cfg = load()) {
155
+ const 지금 = 지금프로필(cfg);
156
+ return 프로필들(cfg).map((p) => {
157
+ let 어디 = String(p.baseUrl ?? '');
158
+ let 로컬 = false;
159
+ try { const u = new URL(p.baseUrl); 어디 = u.host; 로컬 = isLocalHost(u.hostname); } catch { /* 못 읽으면 통째로 */ }
160
+ return {
161
+ id: p.id,
162
+ name: p.name ?? p.id,
163
+ model: p.model ?? '',
164
+ 어디,
165
+ 로컬,
166
+ 지금: 지금?.id === p.id,
167
+ };
168
+ });
169
+ }
@@ -13,7 +13,9 @@
13
13
  // 읽기만 하는 도구. 무엇을 바꾸지 않는다.
14
14
  // Recall 은 지난 대화를 찾는다 — 파일은 안 건드리므로 읽기 쪽이다.
15
15
  // 묻기 모드에도 준다: "저번에 이거 어떻게 했더라" 가 딱 묻기 모드의 일이다.
16
- const 읽기 = ['Read', 'Outline', 'Glob', 'Grep', 'WebFetch', 'Skill', 'Recall'];
16
+ // Def·Refs 읽기다 아무것도 바꾼다. 언어 서버가 없는 자리에서는
17
+ // toolSchemas 가 알아서 빼므로 여기서는 갈래만 정한다.
18
+ const 읽기 = ['Read', 'Outline', 'Glob', 'Grep', 'Def', 'Refs', 'WebFetch', 'Skill', 'Recall'];
17
19
  // 계획을 적는 도구. 파일을 안 건드리므로 읽기 전용 모드에서도 준다.
18
20
  //
19
21
  // Remember 도 여기 있다. 기억은 사용자의 소스를 안 건드리고 .deel/memory.md
@@ -43,6 +45,8 @@ const 확인 = ['Verify'];
43
45
  */
44
46
  const 쪼개기 = ['Task'];
45
47
 
48
+ import { 언어 } from '../i18n/index.js';
49
+
46
50
  export const MODES = {
47
51
  // 처음에는 여기서 시작한다.
48
52
  //
@@ -54,6 +58,9 @@ export const MODES = {
54
58
  // 뒤집지 않는다. 다시 맡기려면 /work 종합 이다.
55
59
  auto: {
56
60
  id: 'auto',
61
+ hintEn: "picks the right mode for what you ask",
62
+ sayEn: "You are in **Auto** mode. What comes next is not fixed.\n\n- Work out what kind of job this is first, then do it that way.\n If it is an edit, read before editing. If it is a diagnosis, confirm before concluding.\n If it is an explanation, back it with the files.\n- For a large job, break it into steps with TodoWrite and **finish everything you wrote down.**\n Do not write the list and then ask. If there are several chunks, hand them off with Task —\n a subtask runs in its own window, so yours does not fill up.\n- For code you did not write, look at the shape with Outline first. Do not Read whole files.\n- Making several files: one Write call (files array). Do not call it once per file.\n Several places to edit: one Edit call the same way (edits array).\n- Commands that never end (dev servers, watch) need background: true on Bash. Called plainly\n they die on timeout. After starting one, read its output with Jobs, and end it with Jobs when done.\n- Verify what can be verified — call Verify. Do not call something done that you did not check.\n- Do what the job needs. Do not start work the job did not ask for.",
63
+ say짧게En: "**Auto** mode. What comes next is not fixed.\n- Work out what kind of job this is, then do it that way.\n- Large job: break it up with TodoWrite and **finish it all.** Several chunks: hand off with Task.\n- Code you did not write: Outline for the shape first. Do not Read whole files.\n- Several files: one Write (files array). Several edits: one Edit (edits array).\n- Commands that never end (dev server, watch): background: true on Bash. Read with Jobs, end with Jobs.\n- Verify before you finish. Do not call something done that you did not check.",
57
64
  name: '종합',
58
65
  en: 'Auto',
59
66
  glyph: '◎',
@@ -91,6 +98,9 @@ export const MODES = {
91
98
 
92
99
  code: {
93
100
  id: 'code',
101
+ hintEn: "edits and builds",
102
+ sayEn: "This is **implementation**. Follow this order.\n\n1. For code you did not write, look at the **shape first** with Outline. Do not Read whole\n files — a folder through Outline is a fraction of the size.\n Pick the places to change there, then Read **only those files**.\n2. Always Read a file before editing it. The tool refuses an edit to a file you have not read.\n3. Follow the conventions of the surrounding code — naming, error handling, comment density.\n Do not import a new convention. Do what this code already does.\n4. Make and change every file the job needs. Do not touch one file and stop.\n For something new, lay out the folder structure first and create **several files in one Write**\n (files array). Several places to change go in **one Edit** (edits array).\n One call per item adds that many round trips, and minutes go with them.\n5. If the work splits into separate strands, hand a chunk off with Task.\n A subtask runs in its own window and returns only a summary — your window does not fill up.\n6. **Call Verify before you finish.** A file existing and a file working are different things.\n Fix what comes back and call it again. Say \"I could not verify this\" for anything you did not check.\n For things you only learn by running (dev servers, watch), give Bash **background: true**.\n Called plainly they never end and die on timeout. Read output with Jobs, and always end it\n with Jobs when done — otherwise that server keeps holding the port.\n7. When done, say what you changed and why in a line or two. Do not paste the code back.",
103
+ say짧게En: "**Implementation.** Follow this order.\n1. Code you did not write: Outline for the shape. Pick the places, then Read **only those files**.\n2. Always Read a file before editing it.\n3. Follow the surrounding conventions. Do not import a new one.\n4. Several files: one Write (files array). Several edits: one Edit (edits array).\n5. Several strands: hand off with Task.\n6. **Verify before you finish.** Fix what comes back and call it again.\n If it must be run, Bash with background: true — called plainly it dies on timeout. Read with Jobs, end with Jobs.\n7. Say what changed and why in a line or two. Do not paste code.",
94
104
  name: '코드',
95
105
  en: 'Code',
96
106
  glyph: '◆',
@@ -137,6 +147,8 @@ export const MODES = {
137
147
 
138
148
  architect: {
139
149
  id: 'architect',
150
+ hintEn: "shapes the structure · touches no files",
151
+ sayEn: "This is **design**. You have not been given the tools that change files.\n\nRead first. Designing without knowing the current structure is imagining, not designing.\n - Start with Outline for the shape of the folder. Narrow with Glob/Grep,\n then actually Read only the files you need\n - Work out what depends on what, and in which direction\n\nThen answer in this order.\n 1. Current structure — files, their roles, where the boundaries are (point with path:line)\n 2. What is wrong — why the current shape does not hold\n 3. Two or three options — what each gains, what each costs, how much work it is\n 4. One recommendation with the reason, and why you dropped the others\n 5. The files this affects\n\nFind the answer inside the conventions this code already uses. Bringing in a new framework is\nthe last resort, and if you go there, first say why the existing conventions cannot do it.",
140
152
  name: '설계',
141
153
  en: 'Architect',
142
154
  glyph: '◈',
@@ -166,6 +178,8 @@ export const MODES = {
166
178
 
167
179
  ask: {
168
180
  id: 'ask',
181
+ hintEn: "explains only · changes nothing",
182
+ sayEn: "This is **explanation**. You change nothing.\n\n- Back it with the files. Give the path and line number (src/a.js:42).\n- If you do not know, say so. Do not invent a plausible answer.\n- Keep it short. Answer what was asked.\n- Do not propose fixes unless asked to fix something. Do not start work you were not asked for.",
169
183
  name: '묻기',
170
184
  en: 'Ask',
171
185
  glyph: '◇',
@@ -185,6 +199,8 @@ export const MODES = {
185
199
 
186
200
  debug: {
187
201
  id: 'debug',
202
+ hintEn: "finds the cause",
203
+ sayEn: "This is **finding the cause**. Do not fix by guessing.\n\nFollow this order.\n 1. Restate the symptom in one sentence — what happens when you do what.\n 2. Get a reproduction. If there is none, build one. Without it you cannot tell whether you fixed it.\n 3. Form two or three hypotheses. For each, write down what you should see if it is true.\n 4. Check them one at a time, for real — read the logs, run something small, print the value.\n Only what you checked is fact. What you did not check is still a hypothesis.\n 5. When you name the cause, bring the evidence. A cause that starts with \"probably\" is not a cause.\n 6. After fixing, run the reproduction from step 2 again. If it is not fixed, go back to step 3.\n\nDo not change several places at once. You will not know which one fixed it.",
188
204
  name: '디버그',
189
205
  en: 'Debug',
190
206
  glyph: '◉',
@@ -210,6 +226,8 @@ export const MODES = {
210
226
 
211
227
  plan: {
212
228
  id: 'plan',
229
+ hintEn: "plan first · run it after approval",
230
+ sayEn: "This is **planning**. You have not been given the tools that change files.\nDo not try to edit code. Produce a plan and stop.\n\nConfirm first — a plan built without knowing the current state is a wish, not a plan.\n Start with Outline for the shape, narrow with Glob/Grep, then Read only what you must.\n\nThen write it in this order.\n 1. Goal — what does \"done\" look like (as a sentence you can check)\n 2. Current state — the files involved and what they do now (point with path:line)\n 3. What changes — per file, what and why\n 4. Order — step by step. Each step small enough to check on its own\n 5. Risks — what could break, and how to get back if it does\n 6. How to check — what do you run to know it worked\n\nWrite the steps into TodoWrite as well. After approval you continue straight from them.\n The number of steps is not fixed — match it to the size of the job. Do not force it to three.\n A small job ends in two or three; a large one lists all ten or more.\nIf something is unknown, do not invent it — write \"this needs to be confirmed\".\n\nEnd with \"Shall I go ahead with this?\". Once approved, switch to /code and run it.",
213
231
  name: '계획',
214
232
  en: 'Plan',
215
233
  glyph: '☰',
@@ -233,6 +251,8 @@ export const MODES = {
233
251
  " 6. 확인 방법 — 무엇을 돌려 보면 됐는지 알 수 있는가",
234
252
  "",
235
253
  "단계는 TodoWrite 로도 적어라. 승인 뒤 그대로 이어서 하게 된다.",
254
+ " 단계 수는 정해져 있지 않다 — 일의 크기에 맞춰라. 세 개로 맞추지 마라.",
255
+ " 작은 일이면 두세 개로 끝내고, 큰 일이면 열 개가 넘어도 그대로 다 적어라.",
236
256
  "모르는 것이 있으면 지어내지 말고 \"이건 확인이 필요하다\" 고 적어라.",
237
257
  "",
238
258
  "마지막에 \"이대로 진행할까요?\" 로 끝내라. 승인을 받으면 /code 로 바꿔 실행한다.",
@@ -241,6 +261,9 @@ export const MODES = {
241
261
 
242
262
  orchestrator: {
243
263
  id: 'orchestrator',
264
+ hintEn: "splits a big job and sees it through",
265
+ sayEn: "This is **carrying a large job through to the end**.\n\n 1. Right at the start, break the whole thing into steps with TodoWrite. Do not keep it in your head.\n Each step must be small enough to check on its own.\n 2. **Hand every single step off with Task.** This is the point of this mode —\n if you do it all yourself, every file's contents pile up in your window, and by the third or\n fourth step the earlier turns fold away and you forget what you were doing.\n Give the subtask the background, the decisions, and the file paths. It cannot see this conversation.\n 3. Keep only one step in progress at a time. Mark it done and move on immediately.\n 4. Verify at the end of each step. Skipping it means you cannot find where things went wrong.\n 5. If you get stuck, stop and report what you are stuck on. Do not quietly take a detour.\n 6. If you learn the plan was wrong, fix the list. Do not push a wrong plan to the end.\n\nWhen it is all done, summarise what you did **and** what you did not.\nDo not leave the unfinished parts out of the summary.",
266
+ say짧게En: "**Carrying a large job through.**\n1. Break the whole thing into steps with TodoWrite right at the start.\n2. **Hand every step off with Task.** Doing it all yourself fills your window and you forget the job.\n The subtask cannot see this conversation — give it the background, decisions, and file paths.\n3. One step in progress at a time. Mark it done and move on.\n4. Verify at the end of every step.\n5. If you get stuck, stop and report. Do not quietly take a detour.\nWhen done, summarise what you did and what you did not. Do not leave the unfinished parts out.",
244
267
  name: '총괄',
245
268
  en: 'Orchestrator',
246
269
  glyph: '❋',
@@ -314,9 +337,34 @@ export function get(id) {
314
337
  export function 말(id, ctx) {
315
338
  const m = get(id);
316
339
  const 좁은가 = Number(ctx) > 0 && Number(ctx) < 24000;
340
+ /*
341
+ * 화면 말이 영어면 **모델이 읽는 글도** 영어로 간다.
342
+ *
343
+ * 화면만 영어로 갈아 끼우고 이 글을 한국어로 두면, 모델은 계속 한국어로
344
+ * 답한다 — 영어권 사람에게는 아무것도 안 고친 것과 같다.
345
+ *
346
+ * 영어 글이 없는 모드는 한국어 글로 되돌아간다. 빈 글을 보내면 그 모드는
347
+ * 아무 지시도 없는 채로 도는데, 그게 화면 빈칸보다 훨씬 나쁘다 —
348
+ * 모드가 있는 것처럼 보이면서 실제로는 아무 일도 안 한다.
349
+ */
350
+ if (언어() === 'en') {
351
+ if (좁은가 && m.say짧게En) return m.say짧게En;
352
+ if (m.sayEn) return m.sayEn;
353
+ }
317
354
  return (좁은가 && m.say짧게) ? m.say짧게 : m.say;
318
355
  }
319
356
 
357
+ /** 화면에 낼 모드 이름·한 줄 설명. 영어 것이 없으면 한국어로 되돌아간다. */
358
+ export function 보일이름(id) {
359
+ const m = get(id);
360
+ return 언어() === 'en' ? (m.en ?? m.name) : m.name;
361
+ }
362
+
363
+ export function 보일한줄(id) {
364
+ const m = get(id);
365
+ return 언어() === 'en' ? (m.hintEn ?? m.hint) : m.hint;
366
+ }
367
+
320
368
  /** Ctrl+O 로 돌릴 때 다음 모드. (Shift+Tab 은 승인 방식이 가져갔다) */
321
369
  export function next(id) {
322
370
  const i = ORDER.indexOf(normalize(id) ?? DEFAULT);