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
@@ -7,7 +7,7 @@ import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsuti
7
7
  import { 건너뜀말 } from './ignore.js';
8
8
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
9
9
  import { checkCommand, checkPaths, isMutating } from '../safety/guard.js';
10
- import { 띄우기, JOBS_TOOL } from './jobs.js';
10
+ import { 띄우기, 나무끊기, 무리끊기, JOBS_TOOL } from './jobs.js';
11
11
  import { 셸명령 } from './shell.js';
12
12
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
13
13
  import { loadSkill } from '../skills/discover.js';
@@ -20,7 +20,7 @@ import { DEF_TOOL, REFS_TOOL } from './lsp.js';
20
20
  import { 편집후진단, 붙이기 as 진단붙이기, 데우기 } from '../lsp/diag.js';
21
21
  import { 프로젝트갈래 } from '../lsp/servers.js';
22
22
  import { allow as allowedIn } from '../agent/modes.js';
23
- import { 도구정의, 이름풀기 } from '../backend/mcp.js';
23
+ import { 도구정의, 이름풀기, 열쇠뺀환경 } from '../backend/mcp.js';
24
24
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
25
25
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
26
26
  import { 바꿔볼까, 직접못읽나, 변환기찾기, 글로바꾸기, 못바꿈말 } from './convert.js';
@@ -31,7 +31,25 @@ import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent
31
31
  import { 도구설명EN } from './desc.en.js';
32
32
  import { 그림인가, 그림읽기, 크기말, 기본한도 } from '../backend/vision.js';
33
33
  import { 빠르게찾기, 엔진말, 안볼정규식 } from './fastgrep.js';
34
- import { 지시말 } from '../i18n/index.js';
34
+ import { 지시말, 말, 세말 } from '../i18n/index.js';
35
+
36
+ /*
37
+ * 도구 결과 한 줄을 잇는다 — `1개 파일 · 3건 · rg`.
38
+ *
39
+ * 빈 조각은 버린다. 조건부 조각을 `조건 ? '· 뭐' : ''` 로 이어 붙이면
40
+ * 어느 말에서는 가운뎃점이 둘 붙거나(`· ·`) 줄 끝에 하나 남는다.
41
+ * 그 자리를 부르는 쪽마다 살피게 두면 반드시 어딘가는 빠진다.
42
+ */
43
+ const 이어 = (...조각들) => 조각들.filter((x) => x != null && String(x) !== '').join(' · ');
44
+
45
+ /**
46
+ * Bash 가 한 번에 받아 두는 출력의 상한.
47
+ *
48
+ * 넘으면 Node 가 자식을 죽이고 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' 를 준다.
49
+ * 그때 무엇이 일어났는지를 사람 말로 적어 줘야 한다 — 그러지 않으면 모델은
50
+ * 같은 명령을 그대로 다시 부른다.
51
+ */
52
+ const 출력상한 = 8 * 1024 * 1024;
35
53
  import { 표몇군데, 표막는말 } from '../safety/secrets.js';
36
54
 
37
55
  /*
@@ -505,6 +523,38 @@ function pdf읽기(abs, ctx) {
505
523
  * **손대지 않는다** — 옮겨진 자리에 그대로 남는다. 되돌아가지는 않지만
506
524
  * 없어지지도 않는다. 안전망이 파일을 지우는 것보다 이쪽이 낫다.
507
525
  */
526
+ /*
527
+ * ── 복사와 지우기를 한 try 로 묶으면 안 된다 ──────────────────────────────
528
+ *
529
+ * 드라이브가 다르면(EXDEV) rename 이 안 되니 복사하고 원본을 지운다. 그 둘을
530
+ * 한 try 에 묶어 두면 **복사는 다 됐는데 원본을 못 지운** 경우까지 「못
531
+ * 옮겼습니다」가 된다. 윈도우에서 아주 흔하다 — 원본 폴더 안의 파일 하나를
532
+ * 편집기나 탐색기가 잡고 있으면 EPERM/EBUSY 로 rmSync 만 깨진다.
533
+ * 그때 화면은 실패라고 말하는데 실제로는 **양쪽에 다 있다.** 사람은 실패로
534
+ * 알고 다시 옮기고, 원본은 계속 남는다.
535
+ *
536
+ * 세 결과는 서로 다른 말이므로 서로 다르게 돌려준다.
537
+ * {} 다 됐다
538
+ * { 복사깨짐: 까닭 } 닿은 자리가 반쯤 됐다 — 실패다
539
+ * { 원본남음: 까닭 } 닿은 자리는 다 됐고 원본이 남았다 — 실패가 아니다
540
+ *
541
+ * fs 를 밖에서 넣을 수 있게 해 둔 것은 검사 때문이다. 진짜 EXDEV 와 진짜
542
+ * '지우기만 깨짐' 은 어느 PC 에서나 똑같이 만들어 낼 수 없다.
543
+ */
544
+ export function 복사해옮기기(앞, 뒤, { 복사 = cpSync, 지우기 = rmSync } = {}) {
545
+ try {
546
+ 복사(앞, 뒤, { recursive: true });
547
+ } catch (err) {
548
+ return { 복사깨짐: err.message };
549
+ }
550
+ try {
551
+ 지우기(앞, { recursive: true, force: true });
552
+ } catch (err) {
553
+ return { 원본남음: err.message };
554
+ }
555
+ return {};
556
+ }
557
+
508
558
  function 한개옮기기({ from, to, overwrite = false }, ctx) {
509
559
  if (typeof from !== 'string' || !from) return { error: 'from 이 없습니다' };
510
560
  if (typeof to !== 'string' || !to) return { error: 'to 가 없습니다' };
@@ -534,7 +584,7 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
534
584
  * 겹쳤다고 알려 주면 모델이 이름을 고쳐서 다시 부른다.
535
585
  */
536
586
  if (existsSync(뒤) && !overwrite) {
537
- return { error: `이미 있습니다: ${to}\n 덮어쓰려면 overwrite: true 를 주세요.` };
587
+ return { error: `${말('err.alreadyThere', { 경로: to })}\n ${말('err.overwriteHint')}` };
538
588
  }
539
589
 
540
590
  /*
@@ -564,6 +614,7 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
564
614
  }
565
615
 
566
616
  mkdirSync(dirname(뒤), { recursive: true });
617
+ let 원본남음 = null;
567
618
  try {
568
619
  renameSync(앞, 뒤);
569
620
  } catch (err) {
@@ -573,19 +624,23 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
573
624
  * 복사 도중에 끊기면 양쪽에 반씩 남기 때문이다.
574
625
  */
575
626
  if (err.code !== 'EXDEV') return { error: `못 옮겼습니다: ${err.message}` };
576
- try {
577
- cpSync(앞, 뒤, { recursive: true });
578
- rmSync(앞, { recursive: true, force: true });
579
- } catch (err2) {
580
- return { error: `못 옮겼습니다: ${err2.message}` };
627
+ const 벌어진일 = 복사해옮기기(앞, 뒤);
628
+ if (벌어진일.복사깨짐) {
629
+ // 닿은 자리를 여기서 지우지 않는다 — 이미 있던 폴더로 옮기는 중이었으면
630
+ // 남의 파일까지 지운다. 어디에 반쯤 남았는지만 정확히 말한다.
631
+ return { error: `못 옮겼습니다: ${벌어진일.복사깨짐}\n(옮기다 만 것이 ${ctx.scope.show(뒤)} 에 남아 있을 수 있습니다 — 지우기 전에 확인하세요)` };
581
632
  }
633
+ 원본남음 = 벌어진일.원본남음 ?? null;
582
634
  }
583
635
 
584
636
  for (const [, b] of 짝들) ctx.seen.add(b);
585
637
  const 무엇 = 폴더인가 ? `폴더 ${짝들.length}개 파일` : '';
586
- const 경고 = 되돌리기반쪽
638
+ const 경고 = (되돌리기반쪽
587
639
  ? `\n(파일이 ${훑은것.상한.toLocaleString('en-US')}개를 넘어 되돌리기에는 앞부분만 떴습니다 — 옮기기는 전부 됐지만 /undo 는 다 못 되돌립니다)`
588
- : '';
640
+ : '')
641
+ + (원본남음
642
+ ? `\n(닿은 자리에는 다 옮겼는데 원본을 못 지웠습니다 — 지금 ${ctx.scope.show(앞)} 에도 그대로 있습니다: ${원본남음})`
643
+ : '');
589
644
  return {
590
645
  content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}${경고}`,
591
646
  summary: `${ctx.scope.show(뒤)}`,
@@ -636,7 +691,7 @@ function 여러개옮기기(목록, ctx) {
636
691
  + 된것.map((c) => ` ✓ ${c.replace(/^옮김: /, '')}`).join('\n')
637
692
  + (안된것.length ? `\n${안된것.map((e) => ` ✗ ${e.split('\n')[0]}`).join('\n')}` : '')
638
693
  + (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
639
- summary: `${된것.length} 옮김${안된것.length ? ` · ${안된것.length} 실패` : ''}`,
694
+ summary: 이어(말('sum.moved', { n: 된것.length }), 안된것.length ? 말('sum.filesFailed', { n: 안된것.length }) : ''),
640
695
  바뀐것들,
641
696
  };
642
697
  }
@@ -691,7 +746,7 @@ function 한파일쓰기(args, ctx) {
691
746
  const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
692
747
  return {
693
748
  content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄${표기})`,
694
- summary: `${n}줄${표기}`,
749
+ summary: 이어(세말('lines', n), 원래 !== 'utf-8' ? encLabel(원래) : ''),
695
750
  changed: abs,
696
751
  diff: 바뀐자리(이전, args.content),
697
752
  };
@@ -735,8 +790,11 @@ function 여러파일쓰기(목록, ctx) {
735
790
  content: `${된것.length}개 만들었습니다${안된것.length ? `, ${안된것.length}개 실패` : ''}.\n`
736
791
  + 줄들.join('\n')
737
792
  + (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
738
- summary: `${된것.length}개 · ${된것.reduce((a, r) => a + (r.lines ?? 0), 0)}줄`
739
- + (안된것.length ? ` · ${안된것.length}개 실패` : ''),
793
+ summary: 이어(
794
+ 세말('count', 된것.length),
795
+ 세말('lines', 된것.reduce((a, r) => a + (r.lines ?? 0), 0)),
796
+ 안된것.length ? 말('sum.filesFailed', { n: 안된것.length }) : '',
797
+ ),
740
798
  // 화면과 루프가 파일별로 처리하도록 그대로 넘긴다. changed 는 안 넣는다 —
741
799
  // 넣으면 그 한 개만 세어지고 나머지가 조용히 빠진다.
742
800
  여럿: 결과,
@@ -752,7 +810,7 @@ function 여러파일쓰기(목록, ctx) {
752
810
  */
753
811
  function 한군데고치기(args, ctx) {
754
812
  const abs = ctx.scope.resolve(args.file_path);
755
- if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
813
+ if (!existsSync(abs)) return { error: 말('err.noSuchFile', { 경로: args.file_path }) };
756
814
  const 못고치는이유 = 내부살림(abs);
757
815
  if (못고치는이유) return { error: 못고치는이유 };
758
816
  // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
@@ -809,7 +867,12 @@ function 한군데고치기(args, ctx) {
809
867
  const 표기 = 읽음.encoding !== 'utf-8' ? ` · ${encLabel(읽음.encoding)}` : '';
810
868
  return {
811
869
  content: `고침: ${ctx.scope.show(abs)} (${n}군데${how}${표기})`,
812
- summary: `${n}군데${how}${표기}`,
870
+ summary: 이어(
871
+ 세말('spots', n),
872
+ // 화면 말로. TIER_LABELS 는 한국어 원본이라 content 쪽에만 남긴다.
873
+ m.tier === 'exact' ? '' : 말(`tier.${m.tier}`),
874
+ 읽음.encoding !== 'utf-8' ? encLabel(읽음.encoding) : '',
875
+ ),
813
876
  changed: abs,
814
877
  tier: m.tier,
815
878
  diff: 바뀐자리(text, next),
@@ -869,7 +932,11 @@ function 여러군데고치기(목록, ctx) {
869
932
  ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.'
870
933
  + ' 앞엣것이 이미 반영됐으니 **파일을 다시 Read 해서** 지금 내용을 보고 old_string 을 잡으세요.'
871
934
  : ''),
872
- summary: `${파일수}개 파일 · ${군데수}군데` + (안된것.length ? ` · ${안된것.length}개 실패` : ''),
935
+ summary: 이어(
936
+ 세말('files', 파일수),
937
+ 세말('spots', 군데수),
938
+ 안된것.length ? 말('sum.spotsFailed', { n: 안된것.length }) : '',
939
+ ),
873
940
  // 화면과 루프가 자리별로 처리하도록 그대로 넘긴다. changed 는 안 넣는다 —
874
941
  // 넣으면 그 한 개만 세어지고 나머지가 조용히 빠진다 (여러파일쓰기 와 같다).
875
942
  여럿: 결과,
@@ -900,7 +967,7 @@ function 그림보기(abs, ctx) {
900
967
  return { error: `${show} — ${것.왜}` };
901
968
  }
902
969
  ctx.seen.add(abs);
903
- const 잰것 = `그림 · ${크기말(것.bytes)} · ${것.mime}`;
970
+ const 잰것 = 이어(말('sum.image'), 크기말(것.bytes), 것.mime);
904
971
 
905
972
  if (!눈있나) {
906
973
  return {
@@ -908,7 +975,7 @@ function 그림보기(abs, ctx) {
908
975
  + '지금 붙어 있는 모델은 그림을 못 봅니다 — 그래서 보내지 않았습니다.\n'
909
976
  + '이 파일을 코드처럼 읽으려 하지 마세요. 무엇이 찍혀 있는지 알아야 한다면'
910
977
  + ' 사용자에게 말로 설명해 달라고 하세요.',
911
- summary: `${잰것} · 이 모델은 못 봄`,
978
+ summary: 이어(잰것, 말('sum.noVision')),
912
979
  그림없음: true,
913
980
  };
914
981
  }
@@ -940,7 +1007,7 @@ export const TOOLS = {
940
1007
  },
941
1008
  async run(args, ctx) {
942
1009
  const abs = ctx.scope.resolve(args.file_path);
943
- if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
1010
+ if (!existsSync(abs)) return { error: 말('err.noSuchFile', { 경로: args.file_path }) };
944
1011
  if (statSync(abs).isDirectory()) return { error: `폴더입니다. Glob 을 쓰세요: ${args.file_path}` };
945
1012
  // 제 살림·남의 도구 살림은 안 읽는다 — fsutil 의 내부살림() 머리말 참고.
946
1013
  const 막을이유 = 내부살림(abs);
@@ -1021,11 +1088,40 @@ export const TOOLS = {
1021
1088
  */
1022
1089
  const 준줄수 = slice.length;
1023
1090
  const 다못줌 = start + count < lines.length || start > 0;
1091
+ const 통째로 = body + more + 줄인것.알림;
1092
+ const 실린것 = clip(통째로);
1024
1093
  return {
1025
- content: clip(body + more + 줄인것.알림),
1026
- summary: (다못줌 ? `${준줄수}/${lines.length}줄 (일부만)` : `${lines.length}줄`)
1027
- + (줄인것.줄인바이트 ? ` · 그림 ${몇KB(줄인것.줄인바이트)} 생략` : '')
1028
- + (별난인코딩 ? ` · ${encLabel(읽음.encoding)}` : ''),
1094
+ content: 실린것,
1095
+ /*
1096
+ * 어느 파일을 읽었는지 알려 준다 대화에 실을 때 앞서 읽은 것과
1097
+ * 견주려면 열쇠가 있어야 한다(agent/filemem.js).
1098
+ *
1099
+ * `부분인가` 는 **우리가 들고 있는 것이 파일 전부가 아니다** 는 뜻이다.
1100
+ * 줄로 잘렸든(offset·limit·창 상한), 그림을 줄였든, clip 에 걸렸든
1101
+ * 마찬가지다. 조각을 기억해 두고 나중에 「그대로입니다」 라고 하면
1102
+ * 모델은 안 본 데를 봤다고 여긴다 — 줄이려다 잃는 자리다.
1103
+ */
1104
+ 읽은것: abs,
1105
+ 부분인가: 다못줌 || 줄인것.줄인바이트 > 0 || 실린것.length < 통째로.length,
1106
+ summary: 이어(
1107
+ 다못줌
1108
+ ? `${말('sum.linesOf', { 준: 준줄수, 전체: lines.length })} (${말('sum.partial')})`
1109
+ : 세말('lines', lines.length),
1110
+ 줄인것.줄인바이트 ? 말('sum.imageDropped', { 크기: 몇KB(줄인것.줄인바이트) }) : '',
1111
+ /*
1112
+ * 짐작한 인코딩은 짐작이라고 적는다.
1113
+ *
1114
+ * 앞머리 표식(BOM)이 없고 UTF-8 규칙에도 안 맞으면 남은 길은 내용을
1115
+ * 보고 점수를 매기는 것뿐이다 (encoding.js 의 detect). CP949 와
1116
+ * CP932 는 바이트 범위가 겹쳐서 짧은 파일일수록 자주 뒤집힌다.
1117
+ * 여태 그 확신도(sure)를 재 놓고 아무 데서도 안 읽어서, 화면에는
1118
+ * 짐작이 사실처럼 `CP949` 한 낱말로 떴다. 사람은 잘 읽힌 줄 알고
1119
+ * 그 위에서 고치고, 그때 원본이 상한다.
1120
+ */
1121
+ 읽음.sure
1122
+ ? (별난인코딩 ? encLabel(읽음.encoding) : '')
1123
+ : 말('sum.encGuess', { 인코딩: encLabel(읽음.encoding) }),
1124
+ ),
1029
1125
  };
1030
1126
  },
1031
1127
  },
@@ -1137,7 +1233,11 @@ export const TOOLS = {
1137
1233
  return {
1138
1234
  content: `${existed ? '이어 붙임' : '새로 만듦'}: ${ctx.scope.show(abs)}`
1139
1235
  + ` (+${붙인줄}줄, 지금 전체 ${전체줄}줄${표기})`,
1140
- summary: `+${붙인줄}줄 · 전체 ${전체줄}줄${표기}`,
1236
+ summary: 이어(
1237
+ 말('sum.addedLines', { 줄: 세말('lines', 붙인줄) }),
1238
+ 말('sum.total', { 줄: 세말('lines', 전체줄) }),
1239
+ 원래 !== 'utf-8' ? encLabel(원래) : '',
1240
+ ),
1141
1241
  changed: abs,
1142
1242
  // 이어 붙이기는 앞부분이 그대로다. 전후를 통째로 견줄 이유가 없다 —
1143
1243
  // 큰 파일에서 그 비용이 그대로 기다리는 시간이 된다.
@@ -1277,7 +1377,7 @@ export const TOOLS = {
1277
1377
  if (!files.length) {
1278
1378
  return {
1279
1379
  content: `${전부.잘림 ? '본 데까지는 찾은 파일 없음' : '찾은 파일 없음'}: ${args.pattern}${건너뜀}`,
1280
- summary: 전부.잘림 ? '0 (다 못 봄)' : '0개',
1380
+ summary: 전부.잘림 ? `${세말('count', 0)} (${말('sum.notAllSeen')})` : 세말('count', 0),
1281
1381
  };
1282
1382
  }
1283
1383
  // 잘랐으면 잘랐다고 말한다. 전에는 '200개' 라고만 해서, 모델이 그게 전부인 줄
@@ -1287,7 +1387,9 @@ export const TOOLS = {
1287
1387
  : '';
1288
1388
  return {
1289
1389
  content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림 + 건너뜀,
1290
- summary: 맞는것.length > files.length ? `${files.length}/${맞는것.length}개` : `${files.length}개`,
1390
+ summary: 맞는것.length > files.length
1391
+ ? 말('sum.countOf', { n: files.length, 전체: 맞는것.length })
1392
+ : 세말('count', files.length),
1291
1393
  };
1292
1394
  },
1293
1395
  },
@@ -1371,17 +1473,17 @@ export const TOOLS = {
1371
1473
  '(.gitignore·.deelignore 는 지켰습니다. 건너뛴 수는 안 셌습니다)',
1372
1474
  ].filter(Boolean).join(' ');
1373
1475
  const 붙이기2 = (t) => (꼬리2 ? [t, '', 꼬리2].join('\n') : t);
1374
- if (!total) return { content: 붙이기2(`일치 없음: ${args.pattern}`), summary: `0 · ${빠른것.엔진}` };
1375
- if (mode === 'content') return { content: 붙이기2(clip(lines.join('\n'))), summary: `${total}건 · ${빠른것.엔진}` };
1476
+ if (!total) return { content: 붙이기2(`일치 없음: ${args.pattern}`), summary: 이어(세말('hits', 0), 빠른것.엔진) };
1477
+ if (mode === 'content') return { content: 붙이기2(clip(lines.join('\n'))), summary: 이어(세말('hits', total), 빠른것.엔진) };
1376
1478
  if (mode === 'count') {
1377
1479
  return {
1378
1480
  content: 붙이기2(hitFiles.slice(0, limit).map((f) => `${f.n}\t${f.rel}`).join('\n')),
1379
- summary: `${hitFiles.length}개 파일 · ${빠른것.엔진}`,
1481
+ summary: 이어(세말('files', hitFiles.length), 빠른것.엔진),
1380
1482
  };
1381
1483
  }
1382
1484
  return {
1383
1485
  content: 붙이기2(hitFiles.slice(0, limit).map((f) => f.rel).join('\n')),
1384
- summary: `${hitFiles.length}개 파일 · ${total}건 · ${빠른것.엔진}`,
1486
+ summary: 이어(세말('files', hitFiles.length), 세말('hits', total), 빠른것.엔진),
1385
1487
  };
1386
1488
  }
1387
1489
 
@@ -1449,14 +1551,14 @@ export const TOOLS = {
1449
1551
  if (!total) {
1450
1552
  return {
1451
1553
  content: 붙이기(다못봄 ? `본 데까지는 일치 없음: ${args.pattern}` : `일치 없음: ${args.pattern}`),
1452
- summary: 다못봄 ? '0 (다 못 봄)' : '0건',
1554
+ summary: 다못봄 ? `${세말('hits', 0)} (${말('sum.notAllSeen')})` : 세말('hits', 0),
1453
1555
  };
1454
1556
  }
1455
- if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'))), summary: `${total}건` };
1557
+ if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'))), summary: 세말('hits', total) };
1456
1558
  if (mode === 'count') {
1457
- return { content: 붙이기(hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n')), summary: `${hitFiles.length}개 파일` };
1559
+ return { content: 붙이기(hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n')), summary: 세말('files', hitFiles.length) };
1458
1560
  }
1459
- return { content: 붙이기(hitFiles.map((f) => f.rel).join('\n')), summary: `${hitFiles.length}개 파일 · ${total}건` };
1561
+ return { content: 붙이기(hitFiles.map((f) => f.rel).join('\n')), summary: 이어(세말('files', hitFiles.length), 세말('hits', total)) };
1460
1562
  },
1461
1563
  },
1462
1564
 
@@ -1488,7 +1590,8 @@ export const TOOLS = {
1488
1590
  ctx.loadedSkills?.add(hit.name);
1489
1591
  return {
1490
1592
  content: `# 스킬: ${hit.name}\n\n${body}`,
1491
- summary: `${Math.round(body.length / 100) / 10}k자${cut ? ' (일부 잘림)' : ''}`,
1593
+ summary: 말('unit.kchars', { n: Math.round(body.length / 100) / 10 })
1594
+ + (cut ? ` (${말('sum.cut')})` : ''),
1492
1595
  };
1493
1596
  },
1494
1597
  },
@@ -1552,8 +1655,11 @@ export const TOOLS = {
1552
1655
  return {
1553
1656
  content: `${r.번호}번으로 띄웠습니다: ${cmd}\n`
1554
1657
  + (r.출력?.trim() ? `\n${clip(r.출력, 4000)}\n` : '')
1555
- + `\n계속 돌고 있습니다. 새 출력은 Jobs({번호: ${r.번호}}) 읽고, 일이 끝나면 Jobs({번호: ${r.번호}, 끝내기: true}) 로 정리해라.`,
1556
- summary: `${r.번호}번으로 띄움`,
1658
+ // 여기 적는 이름은 **Jobs 설명서에 실린 이름**이어야 한다.
1659
+ // 별칭(번호·끝내기)도 받아 주긴 하지만, 설명서와 다른 이름을
1660
+ // 모델에게 시키면 엄격한 게이트웨이가 그 호출을 튕긴다.
1661
+ + `\n계속 돌고 있습니다. 새 출력은 Jobs({job: ${r.번호}}) 로 읽고, 일이 끝나면 Jobs({job: ${r.번호}, stop: true}) 로 정리해라.`,
1662
+ summary: 말('sum.jobStarted', { n: r.번호 }),
1557
1663
  일감번호: r.번호,
1558
1664
  뒤에서: true,
1559
1665
  되돌릴것: 뜬것,
@@ -1582,9 +1688,29 @@ export const TOOLS = {
1582
1688
  // '�Ľ� ����' 이 된다. 바이트로 받아 이 컴퓨터가 쓰는 것으로 해독한다.
1583
1689
  const kid = execFile(shell.file, shell.args, {
1584
1690
  cwd: ctx.scope.root,
1585
- timeout: 제한,
1586
- maxBuffer: 8 * 1024 * 1024,
1691
+ // 열쇠만 빼고 나머지는 그대로 물려준다 (backend/mcp.js 열쇠뺀환경 머리말).
1692
+ // 빼면 `env` 줄이 열쇠를 화면과 대화 기록에 그대로 싣는다.
1693
+ env: 열쇠뺀환경(),
1694
+ // 우리 시계(아래 뒷북)가 제한에서 먼저 나무를 끊는다. 이건 그것마저
1695
+ // 못 돌았을 때 서는 마지막 그물이라 뒤에 세운다.
1696
+ timeout: 제한 + 3000,
1697
+ maxBuffer: 출력상한,
1587
1698
  windowsHide: true,
1699
+ /*
1700
+ * 유닉스에서는 **무리를 만들어 둔다.**
1701
+ *
1702
+ * 나중에 만들어 줄 방법이 없어서 띄우는 순간에 정해야 한다. 안 만들면
1703
+ * 나중에 손자를 가리킬 길이 아예 없다 — `sh -c "cd app && npm start"`
1704
+ * 처럼 복합 명령이면 sh 가 exec 로 갈아치우지 않고 fork 하므로,
1705
+ * kid.kill() 은 sh 만 죽이고 npm·node·vite 는 포트를 문 채로 남는다.
1706
+ * 화면에는 「시간 초과로 중단됨」 이 뜬다. 이 도구가 없애려던 바로 그
1707
+ * 상태다.
1708
+ *
1709
+ * 윈도우에서는 반대로 안 만든다 — 거기서 detached 는 새 콘솔 창을
1710
+ * 띄우는 쪽으로 작동해서, 조용히 돌아야 할 것이 화면에 튀어나온다.
1711
+ * 윈도우는 taskkill /t 가 나무를 훑는다. (jobs.js 띄우기옵션 과 같은 판단)
1712
+ */
1713
+ detached: process.platform !== 'win32',
1588
1714
  windowsVerbatimArguments: shell.verbatim === true,
1589
1715
  encoding: 'buffer',
1590
1716
  }, (err, stdoutBuf, stderrBuf) => {
@@ -1601,7 +1727,9 @@ export const TOOLS = {
1601
1727
  const out = [stdout, stderr].filter(Boolean).join('\n').trim();
1602
1728
 
1603
1729
  if (끊겼나) return done({ error: '사용자가 중단했습니다', content: clip(out) });
1604
- if (err && err.killed) return done({ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out) });
1730
+ if (시간초과 || (err && err.killed)) {
1731
+ return done({ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out) });
1732
+ }
1605
1733
 
1606
1734
  /*
1607
1735
  * 결과를 사실대로 말한다.
@@ -1613,13 +1741,39 @@ export const TOOLS = {
1613
1741
  * 종료코드도 모델에게 준다. 전에는 화면에만 적고 대화에는 안 실었다.
1614
1742
  * 그러면 모델은 명령이 실패한 줄 모른 채 다음 단계로 넘어간다.
1615
1743
  */
1744
+ /*
1745
+ * err.code 가 **늘 종료코드인 것은 아니다.**
1746
+ *
1747
+ * 프로세스가 0 이 아닌 값으로 끝나면 숫자가 온다. 그런데 Node 자신이
1748
+ * 못 견뎌서 끝낸 경우에는 글자가 온다 —
1749
+ * 출력이 maxBuffer 를 넘으면 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
1750
+ * 명령을 못 찾으면 'ENOENT'.
1751
+ *
1752
+ * 그걸 그대로 종료코드로 썼다. 그래서 12MB 를 뱉는 명령을 부르면
1753
+ * 화면과 모델이 「종료코드 ERR_CHILD_PROCESS_STDIO_MAXBUFFER」 라는
1754
+ * 뜻 모를 말을 받았다. exitCode 가 숫자가 아니라 글자로 나가는 것도
1755
+ * 규격 위반이고, 무엇보다 **무엇을 고쳐야 하는지가 어디에도 없어서**
1756
+ * 모델이 같은 명령을 그대로 다시 부른다.
1757
+ */
1616
1758
  const 시그널 = err?.signal ?? null;
1617
- const code = 시그널 ? null : (err?.code ?? 0);
1618
- const 잘됨 = !시그널 && code === 0;
1619
- const 꼬리 = 잘됨 ? '' : 시그널 ? `\n\n[${시그널} 시그널로 죽었습니다 — 정상 종료가 아닙니다]` : `\n\n[종료코드 ${code}]`;
1759
+ const 넘침 = err?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
1760
+ const 숫자코드 = typeof err?.code === 'number' ? err.code : null;
1761
+ const 글자코드 = typeof err?.code === 'string' ? err.code : null;
1762
+ const code = (시그널 || (err && 숫자코드 === null)) ? null : (숫자코드 ?? 0);
1763
+ const 잘됨 = !err && !시그널;
1764
+ const 꼬리 = 잘됨 ? ''
1765
+ : 시그널 ? `\n\n[${시그널} 시그널로 죽었습니다 — 정상 종료가 아닙니다]`
1766
+ : 넘침 ? `\n\n[출력이 ${출력상한 / (1024 * 1024)}MB 를 넘어 받다 말았습니다 — 명령을 끝까지 못 봤습니다.`
1767
+ + ' `| head -n 200` 처럼 줄이거나 파일로 받아서 다시 부르세요]'
1768
+ : 숫자코드 === null ? `\n\n[${글자코드} — 명령을 아예 못 돌렸습니다]`
1769
+ : `\n\n[종료코드 ${code}]`;
1620
1770
  done({
1621
1771
  content: clip(out || '(출력 없음)') + 꼬리,
1622
- summary: 잘됨 ? '성공' : 시그널 ? `${시그널} 로 죽음` : `종료코드 ${code}`,
1772
+ summary: 잘됨 ? 말('sum.ok')
1773
+ : 시그널 ? 말('sum.killedBy', { 시그널 })
1774
+ : 넘침 ? 말('sum.tooMuchOut')
1775
+ : 숫자코드 === null ? 말('sum.cantRun', { 왜: 글자코드 })
1776
+ : 말('sum.exitCode', { code }),
1623
1777
  failed: !잘됨,
1624
1778
  exitCode: code,
1625
1779
  signal: 시그널,
@@ -1635,23 +1789,71 @@ export const TOOLS = {
1635
1789
  * Ctrl+C 는 다음 요청에나 반영됐다. 터미널을 닫는 수밖에 없었다.
1636
1790
  */
1637
1791
  let 끊겼나 = false;
1638
- const 죽이기 = () => {
1639
- // 윈도우에서는 자식만 죽이면 손자가 남는다. 트리째 끝내야 한다.
1640
- // 그리고 **트리 죽이기를 먼저** 한다 — cmd 를 먼저 죽이면 트리의 뿌리가
1641
- // 없어져서 taskkill 손자를 찾는다. 그러면 손자가 그대로 남아 돈다.
1792
+ let 시간초과 = false;
1793
+ const 죽이기 = ({ 파이프끊기 = true, 더줄까 = 0 } = {}) => {
1794
+ /*
1795
+ * 윈도우에서는 자식만 죽이면 손자가 남는다. 트리째 끝내야 한다.
1796
+ *
1797
+ * 그리고 **기다린다.** 전에는 execFile(비동기)로 불렀는데, 그러면
1798
+ * 바로 아래 kid.kill() 이 먼저 돌아 cmd 를 죽인다. 나무의 뿌리가
1799
+ * 없어진 뒤에 taskkill 이 뜨므로 손자 — `npm run dev` 의 진짜
1800
+ * 서버 — 를 못 찾고, 그놈은 그대로 남아 포트를 문다. 「먼저 부른다」
1801
+ * 는 비동기에서는 「먼저 돈다」 가 아니다.
1802
+ *
1803
+ * jobs.js 의 나무죽이기() 가 이미 같은 자리에서 같은 결론에 닿았다.
1804
+ * 여기만 그 교훈 밖에 있었다.
1805
+ *
1806
+ * ── 판단은 베끼지 않고 **가져다 쓴다** ─────────────────────────
1807
+ *
1808
+ * 전에는 여기서 taskkill 오류를 통째로 삼켰다. 잘린 것과 이미 죽은
1809
+ * 것을 안 가른 채 곧장 뿌리(cmd.exe)를 죽였으니, 컴퓨터가 바쁘면
1810
+ * 손자가 남고 화면에는 「중단됨」 이 떴다. jobs.js 는 그 구분을
1811
+ * 갖고 있었는데 여기만 없었다 — 같은 판단이 두 벌로 있으면 늘
1812
+ * 한쪽만 고쳐진다. 그래서 이제 그 함수 하나를 같이 쓴다.
1813
+ *
1814
+ * 여기는 jobs.js 와 달리 **다시 올 자리가 없다.** 이 부름은 지금
1815
+ * 끝난다. 그러니 못 훑었으면 한 번 더 준 다음 뿌리를 죽인다.
1816
+ * 뿌리를 그냥 살려 두면 이번에는 cmd.exe 까지 남는다.
1817
+ *
1818
+ * 더 줄 시간은 **부르는 쪽이 정한다.** 여기는 동기라 기다리는 동안
1819
+ * 화면이 멈춘다. 시간 초과는 어차피 기다리던 자리라 넉넉히 줘도
1820
+ * 되지만, Ctrl+C 는 사람이 지금 당장 돌려받고 싶어 누른 것이다.
1821
+ * 거기서 몇 초를 더 얼리면 「안 멈춘다」 고 또 누르게 된다 —
1822
+ * 손자를 살리려다 사람을 붙잡는 셈이라, 그 자리는 짧게 준다.
1823
+ */
1642
1824
  if (process.platform === 'win32' && kid.pid) {
1643
- try { execFile('taskkill', ['/pid', String(kid.pid), '/t', '/f'], { windowsHide: true }, () => {}); } catch {}
1825
+ if (!나무끊기(kid.pid, 1500) && 더줄까 > 0) 나무끊기(kid.pid, 더줄까);
1826
+ } else if (kid.pid) {
1827
+ /*
1828
+ * 유닉스는 무리째 끊는다. 위 detached 와 짝이다.
1829
+ *
1830
+ * 한동안 이 갈래가 통째로 없었다. 윈도우 쪽만 고쳐 놓고 「중단하면
1831
+ * 진짜 멈춘다」 를 판 소개에 적었는데, 유닉스에서는 sh 만 죽고
1832
+ * 그 아래가 그대로 남아 있었다. 검사가 초록이었던 것도 까닭이
1833
+ * 있다 — 검사가 쓰는 명령이 `node ticker.cjs` 하나뿐이라
1834
+ * 유닉스 sh 가 그걸 exec 로 갈아치웠고, 애초에 손자가 안 생겼다.
1835
+ */
1836
+ 무리끊기(kid.pid);
1644
1837
  }
1645
1838
  try { kid.kill(); } catch {}
1646
1839
  // 죽이라고 시켰다고 곧바로 죽는 것은 아니다. 그동안 이 프로세스가
1647
1840
  // 그 손을 붙들고 있으면 deel 을 끝내도 안 끝난다 — 실제로 검사가
1648
1841
  // 60초를 더 기다렸다. 놓아 주고 우리 갈 길을 간다.
1649
1842
  try { kid.unref(); } catch {}
1650
- try { kid.stdout?.destroy(); kid.stderr?.destroy(); } catch {}
1843
+ /*
1844
+ * 파이프는 부르는 쪽이 고른다.
1845
+ *
1846
+ * 끊으면 이벤트 루프를 안 붙잡아서 좋은데, 그 순간 아직 안 읽힌
1847
+ * 것이 통째로 사라진다. 하필 그게 제일 중요한 몇 줄이다 — 서버가
1848
+ * 뻗으며 남긴 스택 트레이스가 거기 있다. 시간 초과에서는 그 몇
1849
+ * 줄을 받아야 하므로 안 끊는다. (jobs.js 의 끝내기() 와 같은 판단)
1850
+ */
1851
+ if (파이프끊기) { try { kid.stdout?.destroy(); kid.stderr?.destroy(); } catch {} }
1651
1852
  };
1652
1853
  const 끊기 = () => {
1653
1854
  끊겼나 = true;
1654
- 죽이기();
1855
+ // Ctrl+C 는 짧게 — 사람이 지금 돌려받으려고 누른 것이다.
1856
+ 죽이기({ 더줄까: 1200 });
1655
1857
  clearTimeout(뒷북);
1656
1858
  // 콜백을 기다리지 않는다. 손자가 파이프를 물고 있으면 안 불릴 수도 있다.
1657
1859
  done({ error: '사용자가 중단했습니다' });
@@ -1660,17 +1862,35 @@ export const TOOLS = {
1660
1862
  else ctx.signal?.addEventListener?.('abort', 끊기, { once: true });
1661
1863
 
1662
1864
  /*
1663
- * execFile timeout 믿는 자리가 있다.
1865
+ * ── 시간 초과는 **우리가** 끊는다 ───────────────────────────────
1866
+ *
1867
+ * execFile 의 timeout 은 바로 아래 자식(윈도우면 cmd.exe)에게만
1868
+ * 시그널을 보낸다. 손자는 안 건드린다. 그래서 오래 이랬다 —
1869
+ *
1870
+ * Bash(npm run dev) → 시간 초과로 중단됨 (120000ms)
1871
+ * 실제로는 → 서버가 그대로 돌면서 포트를 물고 있다
1664
1872
  *
1665
- * 손자 프로세스가 파이프를 물고 있으면 부모를 죽여도 stdout 이 안 닫혀서
1666
- * 콜백이 불린다. 그러면 시간 제한이 있는데도 영영 끝난다.
1667
- * 그래서 우리 쪽에서도 시계를 하나 걸고, 넘으면 트리째 끝낸다.
1873
+ * 화면에는 「중단됨」 뜨는데 프로세스는 살아 있다. 무엇이 포트를
1874
+ * 물고 있는지 길이 없는, 프로그램이 없애려던 바로 상태다.
1875
+ *
1876
+ * 그렇다고 콜백에서 뒤늦게 나무를 끊어도 소용없다. 그때는 cmd 가
1877
+ * 이미 죽어 나무의 뿌리가 없어서 taskkill 이 손자를 못 찾는다.
1878
+ * **cmd 가 살아 있는 동안** 끊어야 한다. 그래서 우리 시계를 제한에
1879
+ * 두고, execFile 의 timeout 은 그 뒤에 서는 마지막 그물로 남긴다.
1880
+ *
1881
+ * 끊고 나서 곧장 끝맺지 않는다. 나무가 끊기면 대개 곧바로 콜백이
1882
+ * 오는데, 그때라야 죽기 직전에 나온 몇 줄을 같이 실어 줄 수 있다.
1883
+ * 그것마저 안 오면 아래 그물이 대신 끝맺는다.
1668
1884
  */
1669
1885
  const 뒷북 = setTimeout(() => {
1670
1886
  if (끝났나 || 끊겼나) return;
1671
- 죽이기();
1672
- done({ error: `시간 초과로 중단됨 (${제한}ms) — 자식 프로세스가 안 끝나 강제로 끝냈습니다` });
1673
- }, 제한 + 2000);
1887
+ 시간초과 = true;
1888
+ 죽이기({ 파이프끊기: false, 더줄까: 4000 });
1889
+ const 그물 = setTimeout(() => {
1890
+ done({ error: `시간 초과로 중단됨 (${제한}ms) — 자식 프로세스가 안 끝나 강제로 끝냈습니다` });
1891
+ }, 1000);
1892
+ 그물.unref?.();
1893
+ }, 제한);
1674
1894
  // 이 시계 때문에 프로그램이 안 끝나면 안 된다.
1675
1895
  뒷북.unref?.();
1676
1896
  });
@@ -1718,7 +1938,7 @@ export const TOOLS = {
1718
1938
  * 첫 문장에 제일 중요한 것을 둔다 — 좁은 창에서는 설명줄이기가
1719
1939
  * **뒤에서부터 문장째로** 잘라 낸다. 8k 에서 남는 것은 앞 90자다.
1720
1940
  */
1721
- description: '갈림길에서 사람에게 하나 묻는다. **먼저 `이해` 에 이번 요청을 무엇으로'
1941
+ description: '갈림길에서 사람에게 하나 묻는다. **먼저 `understanding` 에 이번 요청을 무엇으로'
1722
1942
  + ' 알아들었는지 한 줄로 적고**, 그러고도 정말 정할 것이 남았을 때만 묻는다.'
1723
1943
  + ' 글로 "알려주세요" 하고 끝내지 마라 — 턴이 끝나서 여태 본 것이 버려진다.'
1724
1944
  + ' options 에 2~4개를 주면 숫자로 답한다.'
@@ -1727,7 +1947,19 @@ export const TOOLS = {
1727
1947
  parameters: {
1728
1948
  type: 'object',
1729
1949
  properties: {
1730
- 이해: {
1950
+ /*
1951
+ * 인자 이름은 **영문·숫자·`_.-` 만** 쓴다.
1952
+ *
1953
+ * 이 이름은 우리끼리 쓰는 것이 아니라 도구 설명서에 실려 서버로 나간다.
1954
+ * Bedrock 은 `^[a-zA-Z0-9_.-]{1,64}$` 로 검사하고, 안 맞으면 **첫 한마디가
1955
+ * 통째로 400** 이다. 화면에는 그냥 「BadRequest」 라서, 사람은 열쇠가
1956
+ * 틀린 줄 알고 다시 발급받으러 간다.
1957
+ *
1958
+ * 로컬 모델도 OpenAI 호환 서버도 이 검사를 안 한다. 그래서 바깥에
1959
+ * 붙어 보기 전까지는 멀쩡해 보였다. 검사가 이제 그 자리를 잰다
1960
+ * (test/toolargs.test.js).
1961
+ */
1962
+ understanding: {
1731
1963
  type: 'string',
1732
1964
  description: '이번 요청을 무엇으로 알아들었는지 한 줄. 사람은 이 줄을 보고 네가'
1733
1965
  + ' 제대로 읽었는지 판단한다. "요청을 이해했습니다" 같은 인사말은 안 된다 —'
@@ -1740,7 +1972,7 @@ export const TOOLS = {
1740
1972
  description: '고를 것 2~4개. 각각 한 줄로, 무엇이 달라지는지 알 수 있게',
1741
1973
  },
1742
1974
  },
1743
- required: ['이해', 'question'],
1975
+ required: ['understanding', 'question'],
1744
1976
  },
1745
1977
  },
1746
1978
  async run(args, ctx) {
@@ -1749,7 +1981,9 @@ export const TOOLS = {
1749
1981
 
1750
1982
  const 고를것 = (Array.isArray(args.options) ? args.options : [])
1751
1983
  .map((x) => String(x ?? '').trim()).filter(Boolean).slice(0, 4);
1752
- const 이해 = String(args.이해 ?? '').trim();
1984
+ // 이름(`이해`)도 받는다. 이름을 바꾼 것은 서버가 검사하기 때문이지
1985
+ // 뜻이 달라져서가 아니다 — 옛 이름으로 부르던 자리가 조용히 비면 안 된다.
1986
+ const 이해 = String(args.understanding ?? args.이해 ?? '').trim();
1753
1987
 
1754
1988
  /*
1755
1989
  * 물어볼 자리가 없는 데서도 안 죽어야 한다 — `deel -p` 한 방 실행, 파이프,
@@ -1860,7 +2094,7 @@ export const TOOLS = {
1860
2094
  return `[${h.세션} · ${날} · ${h.누구}] ${h.토막}`;
1861
2095
  });
1862
2096
  return {
1863
- summary: `지난 대화에서 ${r.전체맞음}건 ${r.맞은것.length}건`
2097
+ summary: 말('sum.recall', { 전체: r.전체맞음, 맞음: r.맞은것.length })
1864
2098
  + (r.예산초과 ? ` (${r.전체파일}개 중 ${r.본파일}개만 뒤짐)` : ''),
1865
2099
  hits: r.맞은것.map((h) => ({ session: h.세션, when: h.언제, who: h.누구, text: h.토막 })),
1866
2100
  text: 줄들.join('\n'),
@@ -1900,7 +2134,7 @@ export const TOOLS = {
1900
2134
  const r = 더하기(ctx.scope.root, args.text);
1901
2135
  if (!r.ok) return { summary: r.why, remembered: false };
1902
2136
  return {
1903
- summary: `기억했습니다 (${r.줄수})` + (r.넘침 ? ' · 자리가 차서 오래된 것을 뺐습니다' : ''),
2137
+ summary: 이어(말('sum.remembered', { n: r.줄수 }), r.넘침 ? 말('sum.rememberFull') : ''),
1904
2138
  remembered: true,
1905
2139
  line: r.줄,
1906
2140
  // 화면에 무엇을 적었는지 보여 주려고 같이 넘긴다. 사람이 못 보면
@@ -1972,7 +2206,7 @@ export function 설명줄이기(schema, 한도) {
1972
2206
  // 이름만 봐도 아는 인자는 아주 좁은 창에서 설명을 통째로 뺀다.
1973
2207
  // `file_path` 가 무엇인지 설명하는 데 토큰을 쓰는 것은, 8k 모델에서는
1974
2208
  // 그 토큰만큼 대화를 잘라먹는 것과 같다. 헷갈릴 만한 것(offset·files·
1975
- // replace_all·목적·할일)은 그대로 둔다 — 거기서 틀리면 일이 안 된다.
2209
+ // replace_all·purpose·task)은 그대로 둔다 — 거기서 틀리면 일이 안 된다.
1976
2210
  if (한도 <= 100 && 뻔한인자.has(이름)) { 새속성[이름] = { type: 값.type }; continue; }
1977
2211
  /*
1978
2212
  * 배열 안쪽 설명은 좁은 창에서 통째로 뺀다.
@@ -2031,14 +2265,28 @@ function 눈붙이기(fn, 이름, vision) {
2031
2265
  if (이름 !== 'Read' || !vision) return fn;
2032
2266
  // 화면 말을 그대로 본다. 설명 글자를 보고 짐작하면 안 된다 — 줄이기가 문장
2033
2267
  // 한복판을 자르므로 마지막 글자가 무엇일지 정해져 있지 않다.
2034
- const 덧말 = 지시말() === 'en'
2268
+ // 영어설명() 같은 갈림이어야 한다. 여기만 'en' 을 보면, 일본어로
2269
+ // 켠 사람은 영어 설명 뒤에 한국어 한 문장이 붙은 것을 받는다.
2270
+ const 덧말 = 지시말() !== 'ko'
2035
2271
  ? ' Screenshots and images (.png/.jpg/.gif/.webp) can be opened too — this model can see them.'
2036
2272
  : ' 화면 사진·그림(.png/.jpg/.gif/.webp)도 그대로 열 수 있다 — 지금 붙어 있는 모델은 그림을 본다.';
2037
2273
  return { ...fn, description: String(fn.description ?? '') + 덧말 };
2038
2274
  }
2039
2275
 
2040
- function 영어설명(schema, 이름) {
2041
- if (지시말() !== 'en') return schema;
2276
+ /*
2277
+ * 도구 설명을 영어 표로 갈아 끼운다.
2278
+ *
2279
+ * 쓸말을 밖에서 받는다. 두 군데가 서로 다른 말을 봐야 해서다 —
2280
+ * **모델에게 주는 글**은 지시말() 을 따르고, `/tools` 로 **사람이 보는
2281
+ * 화면**은 언어() 를 따른다. 여태 한 함수가 지시말() 만 봐서, `/tools` 는
2282
+ * 이 함수를 아예 안 거치고 원본(한국어)을 그대로 찍고 있었다.
2283
+ *
2284
+ * ko 가 아니면 영어를 쓴다. 일본어·중국어 표에는 도구 설명이 없는데,
2285
+ * 그 사람들에게는 한국어보다 영어가 낫다 — i18n 의 물러날곳(ja→en→ko)이
2286
+ * 이미 그렇게 정해 두었고, 여기만 그 규칙 밖에 있었다.
2287
+ */
2288
+ export function 영어설명(schema, 이름, 쓸말 = 지시말()) {
2289
+ if (쓸말 === 'ko') return schema;
2042
2290
  const 것 = 도구설명EN[이름];
2043
2291
  if (!것) return schema;
2044
2292
 
@@ -2093,8 +2341,9 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
2093
2341
  * 다음 영어로 통째로 바꾸는 셈이라, 자른 것이 아무 뜻이 없어지고 영어 글은
2094
2342
  * 한도를 넘긴 채로 실린다.
2095
2343
  *
2096
- * 이름과 인자 이름은 안 건드린다 — 그건 식별자다. Task 목적·할일처럼
2097
- * 한글로 인자 이름을 바꾸면 도구가 아예 안 불린다.
2344
+ * 이름과 인자 이름은 안 건드린다 — 그건 식별자다. 여기서 인자 이름을
2345
+ * 갈아 끼우면 모델이 부른 이름과 우리가 읽는 이름이 어긋나 도구가 아예
2346
+ * 안 불린다. (인자 이름 자체는 이미 다 영문이다 — test/toolargs.test.js.)
2098
2347
  */
2099
2348
  const 우리것 = list.map((n) => ({
2100
2349
  type: 'function',
@@ -2203,8 +2452,28 @@ export async function runTool(name, args, ctx) {
2203
2452
  * 그러면 같은 편집이 두 번 들어가거나, 되돌리기 기록과 어긋난다.
2204
2453
  * 읽기만 한 것은 버려도 잃을 것이 없으니 중단으로 끝낸다.
2205
2454
  */
2455
+ /*
2456
+ * 바꿔 놨는지는 **도구가 무엇을 돌려줬나**로 판단한다. 이름으로 짐작하면 안 된다.
2457
+ *
2458
+ * 전에는 `isMutating(name)` 이었다. isMutating 은 **셸 명령줄**을 받아
2459
+ * `mv|cp|rm|git commit…` 을 찾는 함수인데(safety/guard.js), 거기에 도구
2460
+ * 이름을 넣고 있었다. 그래서 `Move` 만 우연히 참이 되고 Write·Edit·
2461
+ * Append·Bash 는 전부 거짓이었다 — 바로 위 주석이 지키겠다고 적은
2462
+ * 도구들이 통째로 빠져 있었다.
2463
+ *
2464
+ * 그 결과가 이렇다. 파일을 다 쓰고 난 뒤 ESC 를 누르면 화면과 모델은
2465
+ * 「중단했습니다」 만 받는다. changed 가 같이 떨어져 나가서 턴 끝의 파일
2466
+ * 목록에도, /diff 에도 안 잡힌다. 그런데 디스크는 이미 바뀌어 있고
2467
+ * 되돌리기 기록에도 올라가 있다. 모델은 안 고쳐진 줄 알고 또 고친다.
2468
+ *
2469
+ * 결과를 보면 짐작할 것이 없다. changed 가 있으면 한 파일을 바꾼 것이고,
2470
+ * 바뀐것들·여럿 이 있으면 여러 개를 바꾼 것이다.
2471
+ */
2206
2472
  if (ctx.signal?.aborted) {
2207
- if (!isMutating(name) || r.error) {
2473
+ const 바꿔놨나 = !!(r.changed
2474
+ || r.바뀐것들?.length
2475
+ || (Array.isArray(r.여럿) && r.여럿.some((x) => x?.ok && x.path)));
2476
+ if (!바꿔놨나 || r.error) {
2208
2477
  return { error: '중단했습니다.', 끝났다: true, 중단됨: true };
2209
2478
  }
2210
2479
  return { ...r, 중단됨: true, 중단전에끝남: true };
@@ -2235,11 +2504,16 @@ async function runMcpTool(name, args, ctx) {
2235
2504
  if (!서버) return { error: `${갈린것.서버} 서버가 붙어 있지 않습니다 — /mcp 로 확인하세요` };
2236
2505
  if (!서버.살아있나()) return { error: `${갈린것.서버} 서버가 죽었습니다: ${서버.죽음 ?? '이유 모름'}` };
2237
2506
  try {
2238
- const out = await 서버.부르기(갈린것.도구, args);
2507
+ // 사람이 누른 ESC 남의 프로그램에까지 데려간다. 안 넘기면 도구 한 번에
2508
+ // 60초(부르기제한)를 꼬박 기다리는데, 그 사이 ESC 는 아무 일도 안 한다.
2509
+ const out = await 서버.부르기(갈린것.도구, args, { signal: ctx?.signal ?? null });
2239
2510
  if (out.isError) return { error: out.text || '도구가 오류를 냈습니다' };
2240
2511
  const 글 = out.text ?? '';
2241
2512
  const 줄 = 글 ? 글.split(/\r?\n/).length : 0;
2242
- return { summary: 글 ? `${줄}줄 · ${글.length.toLocaleString()}자` : '빈 답', content: clip(글) };
2513
+ return {
2514
+ summary: 글 ? 이어(세말('lines', 줄), 말('unit.chars', { n: 글.length.toLocaleString() })) : 말('sum.emptyAnswer'),
2515
+ content: clip(글),
2516
+ };
2243
2517
  } catch (e) {
2244
2518
  return { error: e.message };
2245
2519
  }