deel-local-cli 1.1.1 → 1.2.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.
package/src/commands.js CHANGED
@@ -54,6 +54,8 @@ export const COMMANDS = {
54
54
  cost: { desc: '이번 세션 사용량' },
55
55
  status: { desc: '연결 상태' },
56
56
  scan: { desc: '이 PC 의 로컬 모델 서버 훑기', arg: '[save]' },
57
+ thread: { desc: '대화 갈래 — 곁가지를 딴 자리에서. 연결·되돌리기는 같이 씀', arg: '[new|fork|close|번호|이름]' },
58
+ learned: { desc: '쓰면서 저절로 알게 된 것 — 되는 명령·이 모델의 버릇', arg: '[지우기]' },
57
59
  sessions:{ desc: '이 폴더의 지난 대화 목록' },
58
60
  recall: { desc: '지난 대화에서 찾기 — 목록 말고 내용으로', arg: '<찾을 말>' },
59
61
  mcp: { desc: '밖에서 붙인 도구(MCP) 서버 보기' },
@@ -530,6 +532,12 @@ export async function handle(line, session, ctx) {
530
532
  return { handled: true };
531
533
  }
532
534
 
535
+ case 'thread':
536
+ case '갈래': return 갈래명령(session, ctx, arg), { handled: true };
537
+
538
+ case 'learned':
539
+ case '배움': return 배움명령(session, ctx, arg), { handled: true };
540
+
533
541
  case 'sessions': {
534
542
  const rows = listSessions(session.root, { limit: 12 });
535
543
  rule('이 폴더의 지난 대화', 70);
@@ -701,10 +709,6 @@ export async function 미리보기끄기() {
701
709
  }
702
710
 
703
711
  /** 지금 띄워 둔 것. 상태줄·검사에서 본다. */
704
- export const 미리보기상태 = () => (미리보기중
705
- ? { url: 미리보기중.서버.url, 폴더: 미리보기중.보인이름, 되살아나나: 미리보기중.서버.되살아나나 }
706
- : null);
707
-
708
712
  async function 미리보기(session, ctx, arg) {
709
713
  const 말 = String(arg ?? '').trim();
710
714
 
@@ -986,6 +990,154 @@ function showThink(session, { 자세히 = false } = {}) {
986
990
  say('');
987
991
  }
988
992
 
993
+ /**
994
+ * 대화 갈래.
995
+ *
996
+ * 여기서 하는 일은 화면과 말뿐이다. 갈래를 들고 있는 것은 agent/threads.js 다.
997
+ */
998
+ function 갈래명령(session, ctx, arg = '') {
999
+ const 갈래 = ctx?.갈래;
1000
+ say('');
1001
+ if (!갈래) {
1002
+ // 한 번 돌리고 끝내는 자리(-p)에는 갈래가 없다. 없는 것을 있는 척하지 않는다.
1003
+ say(` ${c.gray('이 자리에서는 갈래를 못 씁니다 — 대화 화면에서만 됩니다.')}`);
1004
+ say('');
1005
+ return;
1006
+ }
1007
+
1008
+ const 말 = String(arg ?? '').trim();
1009
+ const [머리, ...나머지] = 말.split(/\s+/);
1010
+ const 뒷말 = 나머지.join(' ');
1011
+ const 알림 = (g) => {
1012
+ say(` ${c.hcyan('⑂')} ${c.bold(g.이름)} ${c.gray('갈래로 왔습니다.')} `
1013
+ + c.gray(session.messages.length ? `오간 말 ${session.messages.length}개` : '빈 대화입니다'));
1014
+ };
1015
+
1016
+ if (/^(new|새|새로)$/i.test(머리 ?? '')) {
1017
+ 알림(갈래.새로(뒷말));
1018
+ say(` ${c.gray('본줄기로 돌아가려면')} ${c.cyan('/thread 1')}`);
1019
+ say('');
1020
+ return;
1021
+ }
1022
+
1023
+ if (/^(fork|갈라|분기)$/i.test(머리 ?? '')) {
1024
+ const g = 갈래.갈라내기(뒷말);
1025
+ say(` ${c.hcyan('⑂')} ${c.bold(g.이름)} ${c.gray('로 갈라 나왔습니다.')} `
1026
+ + c.gray(`여기까지 오간 말 ${session.messages.length}개를 그대로 물려받았습니다.`));
1027
+ say(` ${c.gray('여기서 무엇을 하든 본줄기는 그대로입니다.')}`);
1028
+ say('');
1029
+ return;
1030
+ }
1031
+
1032
+ if (/^(close|닫기|끝)$/i.test(머리 ?? '')) {
1033
+ const r = 갈래.닫기(뒷말);
1034
+ if (!r.ok) say(` ${c.red('못 닫았습니다')} ${c.gray(r.why)}`);
1035
+ else {
1036
+ say(` ${mark.ok} ${c.gray(`${r.닫은것.이름} 갈래를 닫았습니다. 적어 둔 것은 남아 있습니다 —`)} ${c.cyan('/sessions')}`);
1037
+ 알림(r.지금);
1038
+ }
1039
+ say('');
1040
+ return;
1041
+ }
1042
+
1043
+ if (말) {
1044
+ const g = 갈래.옮기기(말);
1045
+ if (!g) {
1046
+ say(` ${c.red('그런 갈래가 없습니다')} ${c.gray(말)}`);
1047
+ say(` ${c.gray('/thread 만 치면 목록이 나옵니다.')}`);
1048
+ } else 알림(g);
1049
+ say('');
1050
+ return;
1051
+ }
1052
+
1053
+ // 그냥 /thread — 목록.
1054
+ rule('대화 갈래', 70);
1055
+ for (const r of 갈래.목록()) {
1056
+ const 표 = r.지금 ? c.hcyan('▶') : c.gray(' ');
1057
+ const 이름 = r.지금 ? c.white(r.이름) : c.gray(r.이름);
1058
+ say(` ${표} ${c.gray(String(r.번호))} ${pad(이름, 24)} ${c.gray(`말 ${String(r.말수).padStart(3)}개`)}`
1059
+ + (r.id ? ` ${c.gray(r.id)}` : ''));
1060
+ }
1061
+ say('');
1062
+ say(` ${c.gray('/thread new [이름]')} ${c.gray('빈 갈래로 나간다 — 곁가지 질문을 여기서')}`);
1063
+ say(` ${c.gray('/thread fork [이름]')} ${c.gray('지금까지를 물려받아 갈라 나간다')}`);
1064
+ say(` ${c.gray('/thread <번호>')} ${c.gray('그 갈래로 옮긴다')}`);
1065
+ say(` ${c.gray('/thread close')} ${c.gray('지금 갈래를 닫는다')}`);
1066
+ say('');
1067
+ say(` ${c.gray('연결·모델·도구·되돌리기는 갈래끼리 같이 씁니다. 오간 말과 토큰만 따로입니다.')}`);
1068
+ say('');
1069
+ }
1070
+
1071
+ /**
1072
+ * 쓰면서 저절로 알게 된 것.
1073
+ *
1074
+ * 보여 주는 것이 중요하다. 프롬프트에 몰래 들어가는 글이 있으면 사람은
1075
+ * 모델이 왜 그렇게 답했는지 알 수 없게 된다 — 여기서 통째로 볼 수 있어야
1076
+ * '자동으로 쌓인다' 가 무섭지 않은 말이 된다. 지우는 길도 같이 둔다.
1077
+ */
1078
+ function 배움명령(session, ctx, arg = '') {
1079
+ const 배움 = ctx?.배움;
1080
+ say('');
1081
+ if (!배움) {
1082
+ say(` ${c.gray('이 자리에서는 못 봅니다 — 대화 화면에서만 됩니다.')}`);
1083
+ say('');
1084
+ return;
1085
+ }
1086
+
1087
+ if (/^(지우기|clear|forget|비우기)$/i.test(String(arg).trim())) {
1088
+ 배움.지우기('전부');
1089
+ session.배움요약 = null;
1090
+ say(` ${mark.ok} ${c.gray('쌓아 둔 것을 비웠습니다. 다시 겪으면서 새로 쌓습니다.')}`);
1091
+ say('');
1092
+ return;
1093
+ }
1094
+
1095
+ const { 명령, 모델, 모델이름 } = 배움.현황(session.conn.model);
1096
+ rule('겪어 본 것', 70);
1097
+
1098
+ if (!명령.length && !모델) {
1099
+ say(` ${c.gray('아직 쌓인 것이 없습니다. 명령을 돌리고 대화를 나눌수록 여기가 찹니다.')}`);
1100
+ say('');
1101
+ return;
1102
+ }
1103
+
1104
+ if (명령.length) {
1105
+ say(` ${c.bold('이 폴더에서 돌려 본 명령')}`);
1106
+ for (const r of 명령.slice(0, 12)) {
1107
+ const 표 = r.no === 0 ? c.green('✓') : r.ok === 0 ? c.red('✗') : c.yellow('~');
1108
+ say(` ${표} ${pad(r.이름, 24)} ${c.gray(`됨 ${r.ok} · 안 됨 ${r.no}`)}`);
1109
+ }
1110
+ say('');
1111
+ }
1112
+
1113
+ if (모델) {
1114
+ say(` ${c.bold('이 모델에 대해')} ${c.gray(모델이름)}`);
1115
+ const 걸음 = 모델.걸음 ?? 0;
1116
+ const 줄 = (이름, n) => {
1117
+ if (!n) return;
1118
+ const 비율 = 걸음 ? Math.round((n / 걸음) * 100) : 0;
1119
+ say(` ${c.gray(pad(이름, 24))} ${pad(String(n), 5, 'right')} ${c.gray(걸음 ? `(${비율}%)` : '')}`);
1120
+ };
1121
+ say(` ${c.gray(pad('같이 걸어 본 걸음', 24))} ${pad(String(걸음), 5, 'right')}`);
1122
+ 줄('인자가 잘림', 모델.잘린인자);
1123
+ 줄('빈 답', 모델.빈답);
1124
+ 줄('편집이 빗나감', 모델.편집실패);
1125
+ if (모델.보정) say(` ${c.gray(pad('토큰 추정 보정', 24))} ${pad(`×${모델.보정.toFixed(2)}`, 5, 'right')}`);
1126
+ say('');
1127
+ }
1128
+
1129
+ const 실린것 = 배움.요약(session.conn.model);
1130
+ if (실린것) {
1131
+ say(` ${c.bold('이 중 프롬프트에 실리는 것')}`);
1132
+ for (const l of 실린것.split('\n').slice(1)) say(` ${c.gray(l)}`);
1133
+ } else {
1134
+ say(` ${c.gray('아직 프롬프트에 실을 만큼 확실한 것은 없습니다 — 두 번 이상 겪어야 싣습니다.')}`);
1135
+ }
1136
+ say('');
1137
+ say(` ${c.gray('지우려면')} ${c.cyan('/learned 지우기')}`);
1138
+ say('');
1139
+ }
1140
+
989
1141
  function showContext(session) {
990
1142
  const b = session.breakdown();
991
1143
  say('');
@@ -1002,7 +1154,17 @@ function showContext(session) {
1002
1154
  say(` ${c.gray(pad('남음', 26))} ${pad(b.left.toLocaleString(), 8, 'right')}`);
1003
1155
  say('');
1004
1156
  say(` ${c.gray('/compact 대화 줄이기 /clear 통째로 비우기')}`);
1005
- say(` ${c.gray('숫자는 추정입니다 — 정확한 토크나이저를 쓰지 않습니다.')}`);
1157
+ /*
1158
+ * 추정이라고만 적어 두면 사람은 얼마나 믿어야 할지 모른다. 서버가 알려 준
1159
+ * 실제값에 맞춰 가고 있으면 그 사실을 적는다 — '추정' 과 '맞춰 본 추정' 은
1160
+ * 믿을 만한 정도가 다르다.
1161
+ */
1162
+ if (b.보정잰것 > 0) {
1163
+ const 차이 = Math.round((b.보정 - 1) * 100);
1164
+ say(` ${c.gray(`서버가 알려 준 실제값에 맞춰 ${차이 >= 0 ? '+' : ''}${차이}% 보정했습니다 (${b.보정잰것}번 재봄).`)}`);
1165
+ } else {
1166
+ say(` ${c.gray('숫자는 추정입니다 — 정확한 토크나이저를 쓰지 않습니다.')}`);
1167
+ }
1006
1168
  say('');
1007
1169
  }
1008
1170
 
package/src/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 연결 프로필 저장/읽기. ~/.deel/config.json (프로젝트 폴더의 .deel/config.json 이 우선)
2
2
  import { homedir } from 'node:os';
3
- import { join, dirname, resolve } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
5
5
 
6
6
  // 설정이 놓이는 자리.
@@ -19,6 +19,15 @@ function projectDir() {
19
19
  return join(process.cwd(), '.deel');
20
20
  }
21
21
 
22
+ /**
23
+ * 이 PC 의 설정 폴더 (`~/.deel` 또는 DEEL_HOME).
24
+ *
25
+ * configPath() 와 다르다 — 저쪽은 프로젝트에 설정이 있으면 그쪽을 준다.
26
+ * 여기는 언제나 **이 PC 것**이다. 모델에 대해 알아낸 것처럼 폴더를 옮겨도
27
+ * 따라와야 하는 것을 여기 둔다 (agent/evolve.js).
28
+ */
29
+ export function homeDir() { return userDir(); }
30
+
22
31
  export function configPath() {
23
32
  const local = join(projectDir(), 'config.json');
24
33
  if (existsSync(local)) return local;
package/src/oneshot.js CHANGED
@@ -318,6 +318,10 @@ export async function runOnce(opts = {}) {
318
318
  곁(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
319
319
  break;
320
320
 
321
+ case 'folded':
322
+ 곁(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 (${ev.아낀토큰.toLocaleString()} 토큰을 비움)`)}`);
323
+ break;
324
+
321
325
  case 'compacted':
322
326
  곁(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} → ${ev.after.toLocaleString()} 토큰)`)}`);
323
327
  break;
package/src/repl.js CHANGED
@@ -10,14 +10,17 @@ import { handle, COMMANDS, 미리보기끄기 } from './commands.js';
10
10
  import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
11
11
  import { route } from './agent/route.js';
12
12
  import { run } from './agent/loop.js';
13
- import { Session } from './agent/session.js';
13
+ import { Session, repairToolPairs } from './agent/session.js';
14
14
  import { makeScope } from './safety/guard.js';
15
15
  import { History } from './safety/undo.js';
16
16
  import { Audit } from './safety/audit.js';
17
- import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
17
+ import { activeProfile, load, resolveKey, save as saveCfg, homeDir } from './config.js';
18
18
  import { discover } from './skills/discover.js';
19
- import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
19
+ import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
20
20
  import { Store, latest, prune } from './agent/store.js';
21
+ import { Threads } from './agent/threads.js';
22
+ import { 배움 } from './agent/evolve.js';
23
+ import { 마크다운 } from './ui/md.js';
21
24
  import { askHidden } from './ui/prompt.js';
22
25
  import { explain, shows as levelShows } from './ui/level.js';
23
26
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
@@ -27,7 +30,7 @@ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
27
30
  import { renderDiff, shortStat } from './ui/diff.js';
28
31
  import { expand as expandMentions } from './agent/mention.js';
29
32
  import { 다붙이기 } from './backend/mcp.js';
30
- import { 프롬프트토막 as 기억토막, 읽기 as 기억읽기 } from './agent/memory.js';
33
+ import { 읽기 as 기억읽기 } from './agent/memory.js';
31
34
  import { 갈래고르기 } from './ui/working.js';
32
35
  import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
33
36
 
@@ -179,11 +182,19 @@ export async function chatLoop(opts = {}) {
179
182
  say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
180
183
  } else {
181
184
  store = new Store(root, target);
182
- const { messages } = store.load();
185
+ const { messages: 적힌것 } = store.load();
186
+ /*
187
+ * 도구가 도는 중에 죽었으면 호출만 적히고 결과가 없다. 그대로 보내면
188
+ * 규격 서버가 400 을 내서 이어받자마자 첫 마디에서 죽는다. 손봐서 받는다.
189
+ */
190
+ const { messages, 고친것 } = repairToolPairs(적힌것);
183
191
  if (messages.length) {
184
192
  session.messages = messages;
185
193
  say('');
186
194
  say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
195
+ if (고친것) {
196
+ say(` ${c.gray(`중단된 도구 호출 ${고친것}개를 걷어냈습니다 — 그때 하던 일은 다시 시켜 주세요.`)}`);
197
+ }
187
198
  }
188
199
  }
189
200
  }
@@ -506,6 +517,26 @@ export async function chatLoop(opts = {}) {
506
517
  },
507
518
  };
508
519
 
520
+ /*
521
+ * 겪어 본 것 (agent/evolve.js). 쓸수록 이 PC 에 맞춰 나아지는 자리다.
522
+ *
523
+ * 켤 때 두 가지를 받아 온다.
524
+ * · 프롬프트에 실을 몇 줄 — 여기서 되는 명령, 이 모델의 버릇
525
+ * · 지난번에 알아낸 토큰 배수 — 첫 턴부터 제대로 셈한다
526
+ *
527
+ * 둘 다 없으면 아무 일도 안 일어난다. 처음 켠 PC 는 지금과 똑같이 돈다.
528
+ */
529
+ ctx.배움 = new 배움(root, homeDir());
530
+ session.배움요약 = ctx.배움.요약(conn.model);
531
+ const 아는배수 = ctx.배움.아는보정(conn.model);
532
+ if (아는배수) { session.보정 = 아는배수; session.보정잰것 = 1; }
533
+
534
+ /*
535
+ * 대화 갈래. 연결·도구·되돌리기는 같이 쓰고 오간 말만 여러 벌 갖는다.
536
+ * 갈래마다 저장 파일을 따로 열어서, 나중에 `/sessions` 로 각각 찾아갈 수 있다.
537
+ */
538
+ ctx.갈래 = new Threads(session, ctx, () => new Store(root).begin({ model: conn.model, base: conn.base, root }), store);
539
+
509
540
  // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
510
541
  //
511
542
  // 켤 때마다 서버에 물어본다. 저장된 값을 그대로 믿지 않는다 —
@@ -750,7 +781,7 @@ export async function chatLoop(opts = {}) {
750
781
  // 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
751
782
  let saved = session.messages.length;
752
783
  const flush = () => {
753
- for (const m of session.messages.slice(saved)) store.append(m);
784
+ for (const m of session.messages.slice(saved)) ctx.갈래.현재store().append(m);
754
785
  saved = session.messages.length;
755
786
  };
756
787
  let tools = 0;
@@ -759,6 +790,18 @@ export async function chatLoop(opts = {}) {
759
790
  let 턴탈났나 = false;
760
791
  let thinkChars = 0;
761
792
  let streamed = false;
793
+ /*
794
+ * 답을 그리는 자리. 턴마다 새로 만든다 — 앞 턴의 코드 울타리 상태가
795
+ * 다음 턴으로 새면 멀쩡한 답이 통째로 코드 블록으로 그려진다.
796
+ */
797
+ const 답그림 = new 마크다운({ 폭: (process.stdout.columns || 80) - 6 });
798
+ // 답 흐름이 끊기는 자리마다 남은 반 줄을 비운다. 안 비우면 마지막 줄이 사라진다.
799
+ const 답비우기 = () => {
800
+ for (const 조각 of 답그림.끝()) {
801
+ if (typeof 조각 === 'string') 화면.붙임(` ${답표시} ${조각}\n`);
802
+ else 화면.붙임(조각.이어붙임 + (조각.끝났나 ? '\n' : ''));
803
+ }
804
+ };
762
805
  let thinkingShown = false;
763
806
  let stage = null;
764
807
  // 접는 중 표시. 끝나거나 다른 글을 찍기 전에 반드시 멈춰야 한다.
@@ -830,15 +873,28 @@ export async function chatLoop(opts = {}) {
830
873
  * 세로줄 하나면 된다. 칸을 나눠 그리는 화면으로 갈 이유가 없다 —
831
874
  * 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
832
875
  */
876
+ /*
877
+ * 답을 마크다운으로 그린다 (ui/md.js).
878
+ *
879
+ * 줄이 끝나야 그릴 수 있다 — `**굵` 까지 왔을 때는 그게 굵은 글씨가
880
+ * 될지 알 수 없고, 한 번 찍은 글자는 되돌릴 수 없다. 그래서 md 가
881
+ * 줄 단위로 모았다가 내놓는다. 줄이 화면 폭보다 길어지면 거기까지를
882
+ * 날것으로 흘려보낸다(md 가 알아서 한다) — 긴 문단에서 몇 초씩
883
+ * 아무것도 안 나오면 멈춘 것처럼 보이기 때문이다.
884
+ */
833
885
  case 'content':
834
886
  clearThinking();
835
- if (!streamed) { streamed = true; 화면.일바꿈('답'); 화면.붙임(` ${답표시} `); }
836
- 화면.붙임(ev.text.replace(/\n/g, `\n ${답표시} `));
887
+ if (!streamed) { streamed = true; 화면.일바꿈('답'); }
888
+ for (const 조각 of 답그림.넣기(ev.text)) {
889
+ if (typeof 조각 === 'string') { 화면.붙임(` ${답표시} ${조각}\n`); continue; }
890
+ // 아직 안 끝난 긴 줄. 그 줄이 처음 나가는 것이면 세로줄을 앞에 세운다.
891
+ 화면.붙임((조각.첫조각 ? ` ${답표시} ` : '') + 조각.이어붙임 + (조각.끝났나 ? '\n' : ''));
892
+ }
837
893
  break;
838
894
 
839
895
  case 'tool_start':
840
896
  clearThinking();
841
- if (streamed) { say(''); streamed = false; }
897
+ if (streamed) { 답비우기(); say(''); streamed = false; }
842
898
  // 문구를 지금 하는 일에 맞춘다. 아무 말이나 돌려 대면 두 번째부터
843
899
  // 아무도 안 읽고, 그때부터는 화면이 조용한 것과 같아진다.
844
900
  화면.일바꿈(갈래고르기(ev.name));
@@ -849,7 +905,7 @@ export async function chatLoop(opts = {}) {
849
905
  // 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
850
906
  case 'tools_start':
851
907
  clearThinking();
852
- if (streamed) { say(''); streamed = false; }
908
+ if (streamed) { 답비우기(); say(''); streamed = false; }
853
909
  화면.일바꿈(갈래고르기(ev.names?.[0]));
854
910
  say('');
855
911
  say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
@@ -861,7 +917,7 @@ export async function chatLoop(opts = {}) {
861
917
  // '시작' 을 안 거쳤다. 그래서 '생각 중…' 줄이 안 지워진 채로 결과가
862
918
  // 그 줄 뒤에 가서 붙고, 이름도 없이 "└ 인자가 잘렸습니다" 만 남는다.
863
919
  // 무슨 도구가 왜 그랬는지 알 수 없는 화면이 된다.
864
- if (ev.showLabel) { clearThinking(); if (streamed) { say(''); streamed = false; } say(''); }
920
+ if (ev.showLabel) { clearThinking(); if (streamed) { 답비우기(); say(''); streamed = false; } say(''); }
865
921
  // 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
866
922
  if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
867
923
  if (ev.name === 'TodoWrite' && ev.result?.todos) {
@@ -936,7 +992,7 @@ export async function chatLoop(opts = {}) {
936
992
  */
937
993
  case 'task_start':
938
994
  clearThinking();
939
- if (streamed) { say(''); streamed = false; }
995
+ if (streamed) { 답비우기(); say(''); streamed = false; }
940
996
  화면.일바꿈('하위', clip(ev.목적, 24));
941
997
  say('');
942
998
  say(` ${c.hmagenta('⌥')} ${c.bold('하위 작업')} ${c.white(clip(ev.목적, 60))}`
@@ -946,7 +1002,7 @@ export async function chatLoop(opts = {}) {
946
1002
 
947
1003
  case 'task_done': {
948
1004
  clearThinking();
949
- if (streamed) { say(''); streamed = false; }
1005
+ if (streamed) { 답비우기(); say(''); streamed = false; }
950
1006
  const 끝 = ev.끝 ?? {};
951
1007
  const 잘됨 = 끝.type === 'done';
952
1008
  const 왜 = { done: '끝냈습니다', limit: '걸음 수를 다 써서 멈췄습니다 — 다 못 했습니다',
@@ -981,6 +1037,16 @@ export async function chatLoop(opts = {}) {
981
1037
  접는중 = true;
982
1038
  break;
983
1039
 
1040
+ /*
1041
+ * 요약 압축보다 먼저 오는 것. 대화는 안 건드리고 옛 도구 결과만 접었다.
1042
+ * 조용히 하면 사람은 어느 파일 내용이 왜 사라졌는지 모른다 — 한 줄로 알린다.
1043
+ */
1044
+ case 'folded':
1045
+ clearThinking();
1046
+ say(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 — `)}`
1047
+ + `${c.white(ev.아낀토큰.toLocaleString())} ${c.gray('토큰을 비웠습니다. 대화는 그대로입니다.')}`);
1048
+ break;
1049
+
984
1050
  case 'compacted': {
985
1051
  접기멈춤();
986
1052
  const 줄인 = ev.before - ev.after;
@@ -989,7 +1055,7 @@ export async function chatLoop(opts = {}) {
989
1055
  `${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
990
1056
  if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
991
1057
  // 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
992
- store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
1058
+ ctx.갈래.현재store().replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
993
1059
  saved = session.messages.length;
994
1060
  break;
995
1061
  }
@@ -1033,7 +1099,7 @@ export async function chatLoop(opts = {}) {
1033
1099
  case 'stuck':
1034
1100
  턴탈났나 = true;
1035
1101
  clearThinking();
1036
- if (streamed) { say(''); streamed = false; }
1102
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1037
1103
  say('');
1038
1104
  say(` ${c.yellow('⊘')} ${c.bold('같은 자리에서 헛돌고 있어 멈췄습니다.')}`);
1039
1105
  say(` ${c.gray(ev.why)}`);
@@ -1055,7 +1121,7 @@ export async function chatLoop(opts = {}) {
1055
1121
  case 'aborted':
1056
1122
  턴탈났나 = true;
1057
1123
  clearThinking();
1058
- if (streamed) { say(''); streamed = false; }
1124
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1059
1125
  say('');
1060
1126
  // 남았는지 아닌지를 **사실대로** 말한다. 전에는 무조건 '남아 있다' 고 했는데
1061
1127
  // 실제로는 아무것도 안 남는 경우가 있었다. 그러면 "이어서 해줘" 라고 했을 때
@@ -1074,7 +1140,7 @@ export async function chatLoop(opts = {}) {
1074
1140
 
1075
1141
  case 'done':
1076
1142
  clearThinking();
1077
- if (streamed) say('');
1143
+ if (streamed) { 답비우기(); say(''); }
1078
1144
  만든파일보이기(ev.files);
1079
1145
  break;
1080
1146
  }
@@ -72,12 +72,47 @@ const BLOCKED = [
72
72
  { re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
73
73
  { re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
74
74
  { re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
75
- { re: /\b(rd|rmdir)\s+\/s\b/i, why: '폴더를 통째로 지웁니다 정션이 있으면 원본까지 딸려 갑니다' },
76
- { re: /\bdel\s+\/[sq]\b.*\\\*/i, why: '하위 폴더까지 전부 지웁니다' },
75
+ // 윈도우 짝을 빠뜨리면 된다deel 윈도우에서 주로 돈다.
76
+ //
77
+ // 옛 규칙은 스위치가 **차례대로** 오고 끝에 `\*` 가 붙은 모양만 봤다.
78
+ // 그래서 `del /f /s /q C:\` 와 `rd /q /s C:\` 가 그대로 새어 나갔다.
79
+ // 스위치는 아무 차례로나 오므로 차례를 안 따진다.
80
+ { re: /\b(rd|rmdir)\b[^|;&]*\s\/s\b/i, why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
81
+ { re: /\bdel\b[^|;&]*\s\/[sq]\b[^|;&]*(\\\*|\s[a-z]:\\?\s*$)/i, why: '하위 폴더까지 전부 지웁니다' },
82
+ // 파워셸도 같은 일을 한다. 드라이브 뿌리나 집 폴더를 통째로 미는 것만 본다 —
83
+ // 폴더 하나 지우는 평범한 Remove-Item 까지 막으면 도구를 못 쓴다.
84
+ {
85
+ re: /Remove-Item\b[^|;&]*\s-(Recurse|r)\b[^|;&]*\s(-?[a-z]*\s*)*([a-z]:\\?|~|\$HOME|\$env:USERPROFILE)\s*$/i,
86
+ why: '드라이브나 집 폴더를 통째로 지웁니다',
87
+ },
77
88
  { re: /git\s+push\b[^|;&]*--force(?!-with-lease)/i, why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)' },
78
89
  // 낱말만 보면 안 된다. `node scripts/shutdown.js` 나 `npm run reboot` 이
79
90
  // '시스템을 끕니다' 로 막히고 감사기록에까지 남았다. 명령의 **첫 낱말**일 때만 본다.
80
91
  { re: /(^|[|;&]\s*)(sudo\s+)?(shutdown|reboot|halt|poweroff)\b/i, why: '시스템을 끕니다' },
92
+ // 포크 폭탄.
93
+ //
94
+ // 껐다 켜면 기계는 돌아오니 '되돌릴 수 없는 것' 은 아니다. 그런데 되돌아오지
95
+ // 않는 것이 하나 있다 — **아직 안 적은 것들.** 기계가 굳으면 열어 둔 것을
96
+ // 저장할 수도, 하던 대화를 끝낼 수도 없다. 그래서 목록에 넣는다.
97
+ //
98
+ // 이름은 아무거나 될 수 있으니(`:` 든 `bomb` 든) 이름 자체를 보지 않고 **모양**을
99
+ // 본다: 함수를 만들고, 그 안에서 **저를 불러 곧바로 파이프나 뒤로 보내기에**
100
+ // 물리고, 밖에서 그 이름을 부른다. 뒷갈이(\2)로 같은 이름인지까지 본다.
101
+ //
102
+ // '저를 부른 **바로 뒤**가 | 나 &' 인 것이 열쇠다. 그냥 '이름이 몸 안에 있고
103
+ // & 도 있다' 로 보면 `up(){ docker compose up -d & }; up` 같은 평범한 함수가
104
+ // 걸린다 — 거기서는 up 뒤에 `-d` 가 온다.
105
+ //
106
+ // 이름 앞뒤로 낱말 경계도 본다. 그래야 `tee -a out.log` 의 log 나
107
+ // `deploy_app` 의 deploy 를 저를 부르는 것으로 잘못 읽지 않는다.
108
+ // 앞자리에 따옴표와 여는 괄호도 넣는다. 안 넣으면 `bash -c ":(){ :|:& };:"` 처럼
109
+ // 한 겹 싸는 것만으로 그대로 새어 나간다.
110
+ {
111
+ re: /(^|[|;&\s"'(])([\w.:]{1,16})\s*\(\s*\)\s*\{[^}]*(?<![\w.])\2(?![\w.])\s*[|&][^}]*\}\s*;?\s*(?<![\w.])\2(?![\w.])/,
112
+ why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)',
113
+ },
114
+ // 같은 것을 한 줄로 적는 오래된 방법. 이 모양으로 쓸 다른 일이 없다.
115
+ { re: /\bfork\s+while\s+fork\b/i, why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)' },
81
116
  { re: /curl[^|]*\|\s*(ba)?sh/i, why: '받은 스크립트를 그대로 실행합니다' },
82
117
  { re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
83
118
  ];
@@ -1,7 +1,7 @@
1
1
  // 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
2
2
  // 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
3
3
  import { join } from 'node:path';
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync } from 'node:fs';
5
5
  import { looksBinary } from '../tools/encoding.js';
6
6
 
7
7
  // 되돌리기 이력은 파일 내용을 통째로 담는다. 이만큼 커지면 오래된 턴을 버린다.
package/src/setup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 첫 실행 마법사 + 진단 실행.
2
- import { c, say, rule, mark, width } from './ui/ansi.js';
3
- import { ask, pick, confirm } from './ui/prompt.js';
2
+ import { c, say, rule, mark } from './ui/ansi.js';
3
+ import { ask, pick } from './ui/prompt.js';
4
4
  import { spin } from './ui/spinner.js';
5
5
  import { detect } from './backend/detect.js';
6
6
  import { probe } from './backend/probe.js';
@@ -7,7 +7,7 @@
7
7
  // 플러그인도 없다 — 거기서는 방법론이 0개였고, 모델은 매번 제 나름대로 했다.
8
8
  // 시킨 것만 겨우 하고 끝나는 얄팍한 결과가 거기서 나온다.
9
9
  // 품고 다니는 것은 **가장 낮은 자리**에 둔다. 같은 이름을 사용자가 만들면 그쪽이 이긴다.
10
- import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
10
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
11
11
  import { join, basename, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { homedir } from 'node:os';
@@ -46,11 +46,22 @@ function findAll(text, needle, tier) {
46
46
  let re;
47
47
  try { re = new RegExp(p, 'g'); } catch { return []; }
48
48
  const out = [];
49
- for (const m of text.matchAll(re)) {
50
- if (m[0].length === 0) continue;
51
- out.push({ start: m.index, end: m.index + m[0].length });
52
- if (out.length > 50) break; // 너무 많으면 어차피 모호하다
53
- }
49
+ // 만들 말고 **돌릴 때** 터지는 것이 있다.
50
+ //
51
+ // 모델은 파일을 고칠 덩이를 통째로 old_string 에 담아 보낸다. 4만 자쯤
52
+ // 넘어가면 new RegExp 멀쩡히 만들어지는데(패턴만 훑는다) 실제로 돌릴 때
53
+ // 프로그램 크기 한도에 걸려 SyntaxError 가 난다. 위의 try 는 만들 때만 감싸므로
54
+ // 그 오류가 그대로 튀어나가서 "찾지 못했습니다" 대신 도구가 죽었다.
55
+ //
56
+ // 여기서는 못 찾은 것으로 친다. 정확히 일치하는 경우는 위에서 indexOf 로 이미
57
+ // 처리했으니, 느슨하게 맞춰 보는 이 길만 포기하는 것이다.
58
+ try {
59
+ for (const m of text.matchAll(re)) {
60
+ if (m[0].length === 0) continue;
61
+ out.push({ start: m.index, end: m.index + m[0].length });
62
+ if (out.length > 50) break; // 너무 많으면 어차피 모호하다
63
+ }
64
+ } catch { return []; }
54
65
  return out;
55
66
  }
56
67
 
@@ -17,7 +17,7 @@
17
17
  // 2) 엑셀은 바쁘면 호출을 거절한다(0x80010001, 0x800AC472). 잘못된 호출이
18
18
  // 아니라 '지금은 말고' 라는 뜻이라, 쉬었다 다시 부르면 된다.
19
19
  import { spawn } from 'node:child_process';
20
- import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
20
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { join } from 'node:path';
23
23
  import { decode } from './encoding.js';
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // 부르는 쪽은 어느 갈래인지 몰라도 된다. 다만 암호가 필요할 때는 물어볼 수
7
7
  // 있어야 하므로, 물어보는 방법을 받아 온다(askPassword).
8
- import { readFileSync, statSync } from 'node:fs';
8
+ import { readFileSync } from 'node:fs';
9
9
  import { extname } from 'node:path';
10
10
  import { readXlsx, toCsv, looksXlsx, looksOle } from './xlsx.js';
11
11
  import { excelToTables, canUseExcel } from './excel-com.js';
@@ -171,14 +171,6 @@ export function walk(root, { limit = 20000, skipDirs = SKIP_DIRS } = {}) {
171
171
  return out;
172
172
  }
173
173
 
174
- // 텍스트 파일인지 — 앞부분에 NUL 이 있으면 바이너리로 본다.
175
- export function isText(path) {
176
- try {
177
- const fd = readFileSync(path);
178
- return !fd.subarray(0, 8000).includes(0);
179
- } catch { return false; }
180
- }
181
-
182
174
  /**
183
175
  * 글 파일을 읽는다. 무엇으로 쓰여 있든 알아보고 읽는다.
184
176
  *
@@ -3,7 +3,7 @@
3
3
  import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
- import { globToRegex, walk, readText, readTextFull, SKIP_DIRS, 내부살림 } from './fsutil.js';
6
+ import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
7
7
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
8
8
  import { checkCommand, checkPaths, isMutating } from '../safety/guard.js';
9
9
  import { 띄우기, JOBS_TOOL } from './jobs.js';