deel-local-cli 1.17.9 → 1.18.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.
@@ -29,6 +29,31 @@ const 확장자 = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
29
29
  /** 한 장 최대 크기. 창 크기와 상관없이 이 위로는 안 싣는다. */
30
30
  export const 기본한도 = 4 * 1024 * 1024;
31
31
 
32
+ /**
33
+ * 한 변의 최대 픽셀. 이 위로는 서버가 400 을 준다.
34
+ *
35
+ * ── 왜 바이트만으로는 안 되나 ───────────────────────────────────────────
36
+ *
37
+ * 여태 크기만 보고 있었다. 그런데 **바이트와 픽셀은 따로 논다** — 화면을 길게
38
+ * 찍은 스크린샷은 잘 압축돼서 12000×3000 이어도 4MB 를 한참 밑돈다. 그래서
39
+ * 크기 검사를 멀쩡히 통과하고, 게이트웨이에서 이렇게 튕겼다.
40
+ *
41
+ * messages.8.content.1.image.source.bytes:
42
+ * At least one of the image dimensions exceed max allowed size: 8000 pixels
43
+ *
44
+ * ── 그리고 이건 그 턴 하나로 안 끝난다 ──────────────────────────────────
45
+ *
46
+ * 튕긴 뒤에도 그 그림은 **대화에 남는다.** 다음에 무슨 말을 걸어도 같은
47
+ * 메시지가 같이 나가서 같은 400 을 받는다. 「대시보드 실행해줘」 처럼 그림과
48
+ * 아무 상관 없는 말까지 안 된다 — 세션이 통째로 죽는다. 사람이 보기에는
49
+ * 프로그램이 갑자기 고장 난 것이고, 원인은 아홉 번째 메시지에 있다.
50
+ *
51
+ * 그러니 **들어오기 전에 막아야 한다.** 한 번 실려 들어가면 그 뒤는 늦다.
52
+ *
53
+ * 8000 은 Bedrock 이 거절한 값이고 Anthropic 규격이 적어 둔 값과 같다.
54
+ */
55
+ export const 픽셀한도 = 8000;
56
+
32
57
  /** 경로만 보고 그림인지. 실제로 그림인지는 그림읽기() 가 속을 보고 정한다. */
33
58
  export function 그림인가(경로) {
34
59
  const s = String(경로 ?? '').toLowerCase();
@@ -107,7 +132,91 @@ export function 그림읽기(abs, { 한도 = 기본한도 } = {}) {
107
132
  + ' 내려받다 만 파일이거나, 로그인 화면 HTML 이 그림 이름으로 저장된 것일 수 있습니다.',
108
133
  };
109
134
  }
110
- return { ok: true, b64: buf.toString('base64'), mime, bytes };
135
+
136
+ /*
137
+ * 치수를 본다. 못 읽으면 **막지 않는다** — 우리가 못 읽는 모양이라고 서버도
138
+ * 못 읽는다는 뜻은 아니다. 모르는 것을 이유로 막으면 멀쩡한 그림이 안 실린다.
139
+ */
140
+ const 잰것 = 치수읽기(buf, mime);
141
+ if (잰것 && (잰것.가로 > 픽셀한도 || 잰것.세로 > 픽셀한도)) {
142
+ return {
143
+ ok: false,
144
+ bytes,
145
+ 가로: 잰것.가로,
146
+ 세로: 잰것.세로,
147
+ 왜: `그림의 한 변이 깁니다 (${잰것.가로}×${잰것.세로} · 한 변 한도 ${픽셀한도}px) —`
148
+ + ' 잘라서 저장한 뒤 다시 주세요. 화면 전체를 길게 찍은 사진이 흔히 여기 걸립니다.'
149
+ + ' 여기서는 크기를 줄이지 않습니다. 줄이려면 다른 프로그램이 필요한데,'
150
+ + ' 이 도구는 아무것도 안 깔고 도는 것이 규칙입니다.',
151
+ };
152
+ }
153
+ return { ok: true, b64: buf.toString('base64'), mime, bytes, ...(잰것 ?? {}) };
154
+ }
155
+
156
+ /**
157
+ * 그림의 가로·세로를 **머리말만 읽어서** 잰다. 못 알아보면 null.
158
+ *
159
+ * 파일을 통째로 해석하지 않는다. 각 형식이 앞쪽에 치수를 적어 두므로 그 자리만
160
+ * 본다 — 의존성 0개를 지키면서 픽셀을 아는 유일한 길이다.
161
+ */
162
+ export function 치수읽기(buf, mime = null) {
163
+ // 10 은 형식들 가운데 제일 짧은 머리말(GIF)이다. 그보다 크게 잡으면 GIF 가
164
+ // 형식별 검사에 닿기도 전에 잘린다 — 실제로 그랬다.
165
+ if (!buf || buf.length < 10) return null;
166
+ const 꼴 = mime ?? 그림종류(buf);
167
+ try {
168
+ // PNG: 서명 8바이트 + 길이 4 + 'IHDR' 4 뒤에 가로·세로가 빅엔디안 4바이트씩.
169
+ if (꼴 === 'image/png') {
170
+ if (buf.length < 24 || buf.toString('ascii', 12, 16) !== 'IHDR') return null;
171
+ return { 가로: buf.readUInt32BE(16), 세로: buf.readUInt32BE(20) };
172
+ }
173
+ // GIF: 머리말 6바이트 뒤에 가로·세로가 리틀엔디안 2바이트씩.
174
+ if (꼴 === 'image/gif') {
175
+ if (buf.length < 10) return null;
176
+ return { 가로: buf.readUInt16LE(6), 세로: buf.readUInt16LE(8) };
177
+ }
178
+ if (꼴 === 'image/webp') return webp치수(buf);
179
+ if (꼴 === 'image/jpeg') return jpeg치수(buf);
180
+ } catch { /* 잘린 파일이면 그냥 모르는 것으로 둔다 */ }
181
+ return null;
182
+ }
183
+
184
+ /*
185
+ * JPEG 는 치수가 고정된 자리에 없다. SOF 표시를 찾아 그 안에서 읽는다.
186
+ *
187
+ * SOF 는 0xFFC0~0xFFCF 인데 C4(허프만표)·C8·CC 는 SOF 가 아니다. 그 셋을 빼야
188
+ * 엉뚱한 토막에서 숫자를 읽고 「3×1 그림」 같은 답을 내지 않는다.
189
+ */
190
+ function jpeg치수(buf) {
191
+ let i = 2; // 0xFFD8 다음부터
192
+ while (i + 9 < buf.length) {
193
+ if (buf[i] !== 0xff) { i += 1; continue; } // 채움 바이트를 건너뛴다
194
+ const 표시 = buf[i + 1];
195
+ if (표시 === 0xd8 || 표시 === 0x01 || (표시 >= 0xd0 && 표시 <= 0xd7)) { i += 2; continue; }
196
+ const 길이 = buf.readUInt16BE(i + 2);
197
+ if (길이 < 2) return null;
198
+ const SOF = 표시 >= 0xc0 && 표시 <= 0xcf && 표시 !== 0xc4 && 표시 !== 0xc8 && 표시 !== 0xcc;
199
+ if (SOF) return { 가로: buf.readUInt16BE(i + 7), 세로: buf.readUInt16BE(i + 5) };
200
+ i += 2 + 길이;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /* WebP 는 속이 셋(VP8 · VP8L · VP8X)이고 치수가 저마다 다른 자리에 다르게 담긴다. */
206
+ function webp치수(buf) {
207
+ if (buf.length < 30) return null;
208
+ const 갈래 = buf.toString('ascii', 12, 16);
209
+ if (갈래 === 'VP8 ') return { 가로: buf.readUInt16LE(26) & 0x3fff, 세로: buf.readUInt16LE(28) & 0x3fff };
210
+ if (갈래 === 'VP8L') {
211
+ const b = buf.readUInt32LE(21);
212
+ return { 가로: (b & 0x3fff) + 1, 세로: ((b >> 14) & 0x3fff) + 1 };
213
+ }
214
+ if (갈래 === 'VP8X') {
215
+ // 24비트 리틀엔디안으로 '한 변 - 1' 이 적혀 있다.
216
+ const 셋 = (at) => (buf[at] | (buf[at + 1] << 8) | (buf[at + 2] << 16)) + 1;
217
+ return { 가로: 셋(24), 세로: 셋(27) };
218
+ }
219
+ return null;
111
220
  }
112
221
 
113
222
  /*
@@ -163,6 +163,18 @@ export function 기본카드(conn) {
163
163
  표식칸: 'cache_control',
164
164
  // 캐시 표식이 잡히는 최소 크기 (그 아래면 붙여도 안 잡힌다 — 탈은 아니다)
165
165
  캐시최소: 1024,
166
+ /*
167
+ * 굳은 앞머리를 **한 시간** 살릴까 (backend/cachemark.js 의 표식긴것).
168
+ *
169
+ * 기본으로 켠다. 코딩 에이전트는 검사 한 번에 6분을 쓰는 일이 흔한데,
170
+ * 5분 수명은 그 틈을 못 넘긴다 — 그러면 도구가 돌 때마다 앞머리를 통째로
171
+ * 다시 쓴다. 나란히 재 봤을 때 캐시 쓰기가 여섯 배로 나온 자리다.
172
+ *
173
+ * 안 받는 창구는 400 으로 말해 주고, 그러면 아래 배울전선 이 이 칸만
174
+ * 끈다 — 캐시 자체를 끄지 않는다. 그 둘을 섞으면 `ttl` 하나를 모르는
175
+ * 창구에서 캐시를 통째로 잃는다.
176
+ */
177
+ 긴수명: true,
166
178
  /*
167
179
  * 출력 상한을 **어느 이름으로** 싣나 — '둘다' · '새것' · '옛것'
168
180
  *
@@ -474,6 +486,20 @@ export function 배울전선(문구, 규격) {
474
486
  * 조용히 비싸지는 쪽이라 화면에 아무 표시도 안 난다 — 이 파일이 없애려던
475
487
  * 바로 그 고장이다.
476
488
  */
489
+ /*
490
+ * ── `ttl` 만 모르는 창구 ────────────────────────────────────────────
491
+ *
492
+ * 이 갈래가 아래 `cache_control` 갈래보다 **먼저** 와야 한다. 순서가
493
+ * 바뀌면 수명 칸 하나를 모르는 창구에서 **캐시를 통째로 끈다** — 받을 수
494
+ * 있었던 것까지 잃고, 그건 조용히 비싸지는 쪽이라 화면에 아무 표시도
495
+ * 안 난다.
496
+ *
497
+ * 한 시간 수명은 정식 기능이지만 게이트웨이가 몸통을 그대로 넘겨 주지
498
+ * 않고 제 스키마로 검사하는 자리가 있어서, 거기서는 이 칸만 튕긴다.
499
+ */
500
+ if (/\bttl\b/i.test(s) && 거절.test(s)) {
501
+ return { 무엇: '긴수명', 값: false, 왜: 짧게(s) };
502
+ }
477
503
  if (/cache_control/i.test(s) && 거절.test(s)) {
478
504
  /*
479
505
  * 다른 이름으로 바꿔 보는 것은 **OpenAI 규격일 때만** 이다.
@@ -494,14 +520,38 @@ export function 배울전선(문구, 규격) {
494
520
  if (규격 === 'anthropic') return { 무엇: '캐시', 값: 'none', 왜: 짧게(s) };
495
521
  return { 무엇: '표식칸', 값: 'prompt_cache_breakpoint', 왜: 짧게(s) };
496
522
  }
497
- if (/prompt_cache_breakpoint|prompt_cache_key|cache_creation/i.test(s) && 거절.test(s)) {
523
+ /*
524
+ * 여기 `cache_creation` 도 적혀 있었다. 지워야 하는 낱말이었다.
525
+ *
526
+ * 우리는 그런 이름의 칸을 **보내지 않는다.** 그 낱말이 실제로 나오는 자리는
527
+ * 답장의 셈판 하나뿐이다 — `cache_creation_input_tokens`(adapter.js 의 씀).
528
+ * 그러니 이 갈래는 우리가 보낸 것을 두고는 영영 안 걸리고, 대신 창구가 답장
529
+ * 칸 이름을 오류 문장에 얹어 주는 날 **엉뚱하게 걸린다.** 그리고 걸리면 캐시를
530
+ * 통째로 끄고 그 값은 디스크에 남는다. 안 걸리는 규칙은 없느니만 못하고,
531
+ * 엉뚱하게 걸리는 규칙은 없는 것보다 나쁘다.
532
+ */
533
+ if (/prompt_cache_breakpoint|prompt_cache_key/i.test(s) && 거절.test(s)) {
498
534
  return { 무엇: '캐시', 값: 'none', 왜: 짧게(s) };
499
535
  }
500
536
 
501
537
  // ── 세션 이름 ───────────────────────────────────────────────────────
502
538
  // 'user' 는 흔한 낱말이라 **칸 이야기일 때만** 본다. 안 그러면 아무
503
539
  // 오류 문장에나 걸려서 멀쩡한 칸을 꺼 버린다.
504
- if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?(?:user|metadata)['"`]?/i.test(s) && 거절.test(s)) {
540
+ /*
541
+ * 낱말 경계가 있어야 한다. 없으면 **이름 안에 든 것까지** 걸린다.
542
+ *
543
+ * Unrecognized request argument supplied: username → 걸렸다
544
+ * Invalid property 'browser_metadata_extra' in request → 걸렸다
545
+ * Unrecognized request argument supplied: user_agent → 걸렸다
546
+ *
547
+ * 셋 다 세션 이름과 아무 상관이 없는 칸이다. 그런데 걸리면 「이 창구는
548
+ * 세션 이름을 안 받는구나」 로 배우고 **그 값을 디스크에 남긴다.** 멀쩡히
549
+ * 되던 기능이 그 창구에서 영영 꺼지고, 되돌릴 길은 화면에 안 보인다.
550
+ *
551
+ * 밑줄은 낱말 글자라 `\b` 가 제 일을 한다 — `user_agent` 의 `user` 뒤는
552
+ * 경계가 아니고, `username` 도 마찬가지다. 따옴표·빈칸·점 앞에서만 걸린다.
553
+ */
554
+ if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?\b(?:user|metadata)\b['"`]?/i.test(s) && 거절.test(s)) {
505
555
  return { 무엇: '세션자리', 값: null, 왜: 짧게(s) };
506
556
  }
507
557
  if (/['"`](?:user|metadata)['"`][^\n]{0,40}(?:not supported|unsupported|unknown|unexpected|invalid)/i.test(s)) {
@@ -562,7 +612,7 @@ export function 카드고치기(카드, 고침) {
562
612
  return 새;
563
613
  }
564
614
 
565
- export const 카드칸들 = ['생각형식', '눈금', '끄는말', '효력칸', '캐시', '표식칸', '출력칸', '세션자리', '스트림usage'];
615
+ export const 카드칸들 = ['생각형식', '눈금', '끄는말', '효력칸', '캐시', '표식칸', '긴수명', '출력칸', '세션자리', '스트림usage'];
566
616
 
567
617
  /**
568
618
  * 집 파일에 적을 만한 것만. 주소·모델은 부르는 쪽이 열쇠로 쓴다.
package/src/commands.js CHANGED
@@ -12,7 +12,7 @@ import { allowEndpoint } from './safety/network.js';
12
12
  import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
13
13
  import { 주소가리기 } from './safety/secrets.js';
14
14
  import { pick, confirm } from './ui/prompt.js';
15
- import { load, save, 저장시도, resolveKey, upsert, 열쇠보관, configPath } from './config.js';
15
+ import { load, 저장시도, resolveKey, upsert, 열쇠보관, configPath } from './config.js';
16
16
  import { 지금상태 as 지금열쇠상태, 받기설정, 잊기 as 받은열쇠잊기 } from './safety/authcmd.js';
17
17
  // 열쇠받기 명령을 정책이 못박아 뒀을 수 있다 — 받기설정 이 그 값을 같이 본다.
18
18
  import { 정책읽기 } from './safety/policy.js';
@@ -1142,7 +1142,9 @@ export async function handle(line, session, ctx) {
1142
1142
  const cfg2 = load();
1143
1143
  const ps = toProfiles(found, cfg2.profiles);
1144
1144
  for (const x of ps) upsert(cfg2, x);
1145
- save(cfg2);
1145
+ // 설정남기기() 를 안 쓰고 있었다 — 못 남기면 명령이 그 자리에서 죽고,
1146
+ // 남겼는데도 뭐가 틀리면 「등록했습니다」 만 보고 다음에 없는 것을 본다.
1147
+ if (!설정남기기(cfg2)) return { handled: true };
1146
1148
  say(` ${mark.ok} ${ps.length}개 등록했습니다. ${c.cyan('/model')} 로 고르세요.`);
1147
1149
  } else {
1148
1150
  say(` ${c.gray('등록하려면')} ${c.cyan('/scan save')} ${c.gray('고르려면')} ${c.cyan('/model')}`);
@@ -1829,11 +1831,16 @@ function showLevel(session, arg) {
1829
1831
  return;
1830
1832
  }
1831
1833
  session.level = 골라진;
1834
+ /*
1835
+ * 못 남기면 말한다. 바로 아래가 `✓ 개발자` 를 찍는데, 못 남긴 판에서는
1836
+ * 그 말이 이번 판에서만 참이다 — 설정남기기() 가 아홉 자리에서 없앤 그
1837
+ * 꼴인데 이 자리만 옛 모양으로 남아 있었다.
1838
+ */
1832
1839
  try {
1833
1840
  const cfg = load();
1834
1841
  cfg.level = 골라진;
1835
- save(cfg);
1836
- } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
1842
+ 설정남기기(cfg);
1843
+ } catch { /* 설정을 읽는 것은 위에서 따로 말한다 */ }
1837
1844
  const lv = LEVELS[골라진];
1838
1845
  say('');
1839
1846
  say(` ${mark.ok} ${c.bold(lv.name)} ${c.gray('— ' + lv.hint)}`);
@@ -2845,11 +2852,12 @@ async function ctxLength(session, arg = '') {
2845
2852
  session.conn.ctx = 값;
2846
2853
  const cfg = load();
2847
2854
  const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
2848
- if (prof) { prof.ctx = 값; save(cfg); }
2855
+ // 남기면 아래 「프로필에 저장했습니다」 거짓말이 된다. 살아있는지 들고 간다.
2856
+ const 남김 = prof ? (prof.ctx = 값, 설정남기기(cfg)) : false;
2849
2857
  const b = session.breakdown();
2850
2858
  say(` ${mark.ok} 컨텍스트 ${c.bold(값.toLocaleString())} 토큰 ${c.gray(`(${fmtSize(값)}) — ${어디서}`)}`);
2851
2859
  say(` ${c.gray('지금 찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음')} ${c.white(b.left.toLocaleString())}`);
2852
- if (prof) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
2860
+ if (남김) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
2853
2861
  say('');
2854
2862
  };
2855
2863
 
package/src/i18n/en.js CHANGED
@@ -426,7 +426,6 @@ export const en = {
426
426
  'sum.justDone': '{n} just now',
427
427
  'sum.noChange': 'unchanged',
428
428
  'sum.verified': '{n} checked',
429
- 'sum.readAs': 'read via {쓴것}',
430
429
  'sum.encGuess': '{인코딩} (guessed)',
431
430
  'sum.linesOf': '{준}/{전체} lines',
432
431
  'sum.countOf': '{n}/{전체}',
@@ -496,7 +495,6 @@ export const en = {
496
495
  'paste.pasted': 'Pasted {n} lines — Enter sends them all at once',
497
496
  'run.thinking': 'thinking…',
498
497
  'run.thinkingChars': 'thinking… {n} chars',
499
- 'run.turnFoot': '{초}s · {n} tool calls · ↑{입력} ↓{출력}',
500
498
  'run.bye': 'Bye.',
501
499
  'run.byeStats': '{n} model calls · {초}s in tools · ↑{입력} ↓{출력}',
502
500
  'run.notMade': '— was not created',
@@ -596,9 +594,5 @@ export const en = {
596
594
  'undo.wayDeleted': 'deleted (did not exist before)',
597
595
 
598
596
  // ── Common ────────────────────────────────────────────────────────────
599
- 'common.unknownCommand': 'Unknown command',
600
- 'common.seeHelp': 'Run /help for the list',
601
- 'common.yes': 'yes',
602
- 'common.no': 'no',
603
597
  'common.cfgSaveFailed': 'Could not write the setting to disk ({왜}) — it applies now but reverts on restart.',
604
598
  };
package/src/i18n/ja.js CHANGED
@@ -386,7 +386,6 @@ export const ja = {
386
386
  'sum.justDone': 'たった今{n}個',
387
387
  'sum.noChange': '変更なし',
388
388
  'sum.verified': '確認{n}個',
389
- 'sum.readAs': '{쓴것} で変換して読み込み',
390
389
  'sum.encGuess': '{인코딩}(推定)',
391
390
  'sum.linesOf': '{준}/{전체}行',
392
391
  'sum.countOf': '{n}/{전체}個',
@@ -454,7 +453,6 @@ export const ja = {
454
453
  'paste.pasted': '{n}行を貼り付けました — Enter で一度に送ります',
455
454
  'run.thinking': '考え中…',
456
455
  'run.thinkingChars': '考え中… {n}文字',
457
- 'run.turnFoot': '{초}秒 · ツール{n}回 · ↑{입력} ↓{출력}',
458
456
  'run.bye': '終了します。',
459
457
  'run.byeStats': 'モデル呼び出し{n}回 · ツール時間{초}秒 · ↑{입력} ↓{출력}',
460
458
  'run.notMade': '— 作られませんでした',
@@ -549,9 +547,5 @@ export const ja = {
549
547
  'undo.wayRestored': '戻しました',
550
548
  'undo.wayDeleted': '削除(元々なかったファイル)',
551
549
 
552
- 'common.unknownCommand': '知らないコマンド',
553
- 'common.seeHelp': '/help で一覧が出ます',
554
- 'common.yes': 'はい',
555
- 'common.no': 'いいえ',
556
550
  'common.cfgSaveFailed': '設定をファイルに保存できませんでした({왜})— 今回は効きますが、開き直すと元に戻ります。',
557
551
  };
package/src/i18n/ko.js CHANGED
@@ -445,7 +445,6 @@ export const ko = {
445
445
  'sum.justDone': '방금 {n}개',
446
446
  'sum.noChange': '그대로 (바뀐 것 없음)',
447
447
  'sum.verified': '확인 {n}개',
448
- 'sum.readAs': '{쓴것} 로 바꿔 읽음',
449
448
  'sum.encGuess': '{인코딩} (추정)',
450
449
  'sum.linesOf': '{준}/{전체}줄',
451
450
  'sum.countOf': '{n}/{전체}개',
@@ -542,7 +541,6 @@ export const ko = {
542
541
  'paste.pasted': '{n}줄을 붙여넣었습니다 — Enter 로 한 번에 보냅니다',
543
542
  'run.thinking': '생각 중…',
544
543
  'run.thinkingChars': '생각 중… {n}자',
545
- 'run.turnFoot': '{초}초 · 도구 {n}회 · ↑{입력} ↓{출력}',
546
544
  'run.bye': '끝냅니다.',
547
545
  'run.byeStats': '모델 호출 {n}회 · 도구 시간 {초}초 · ↑{입력} ↓{출력}',
548
546
  'run.notMade': '— 만들어지지 않았습니다',
@@ -643,9 +641,5 @@ export const ko = {
643
641
  'undo.wayDeleted': '삭제됨(원래 없던 파일)',
644
642
 
645
643
  // ── 자주 나오는 것 ────────────────────────────────────────────────────
646
- 'common.unknownCommand': '모르는 명령입니다',
647
- 'common.seeHelp': '명령 목록은 /help',
648
- 'common.yes': '네',
649
- 'common.no': '아니요',
650
644
  'common.cfgSaveFailed': '설정을 파일에 남기지 못했습니다 ({왜}) — 이번 판에는 먹지만 다시 켜면 돌아갑니다.',
651
645
  };
package/src/i18n/zh.js CHANGED
@@ -386,7 +386,6 @@ export const zh = {
386
386
  'sum.justDone': '刚完成{n}个',
387
387
  'sum.noChange': '没有变化',
388
388
  'sum.verified': '核对{n}项',
389
- 'sum.readAs': '用 {쓴것} 转换后读取',
390
389
  'sum.encGuess': '{인코딩}(推测)',
391
390
  'sum.linesOf': '{준}/{전체}行',
392
391
  'sum.countOf': '{n}/{전체}个',
@@ -454,7 +453,6 @@ export const zh = {
454
453
  'paste.pasted': '已粘贴 {n} 行 — 按 Enter 一次发送',
455
454
  'run.thinking': '思考中…',
456
455
  'run.thinkingChars': '思考中… {n}字',
457
- 'run.turnFoot': '{초}秒 · 工具{n}次 · ↑{입력} ↓{출력}',
458
456
  'run.bye': '结束。',
459
457
  'run.byeStats': '模型调用{n}次 · 工具耗时{초}秒 · ↑{입력} ↓{출력}',
460
458
  'run.notMade': '— 没有被创建',
@@ -549,9 +547,5 @@ export const zh = {
549
547
  'undo.wayRestored': '已还原',
550
548
  'undo.wayDeleted': '已删除(原本不存在的文件)',
551
549
 
552
- 'common.unknownCommand': '不认识的命令',
553
- 'common.seeHelp': '用 /help 看命令列表',
554
- 'common.yes': '是',
555
- 'common.no': '否',
556
550
  'common.cfgSaveFailed': '设置没能写入文件({왜})— 本次有效,重开就会恢复原样。',
557
551
  };
package/src/repl.js CHANGED
@@ -26,7 +26,7 @@ import { 언어서버있나 } from './tools/index.js';
26
26
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
27
27
  import { History } from './safety/undo.js';
28
28
  import { Audit, 열쇠묻기 } from './safety/audit.js';
29
- import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식, 열쇠탈소식, 프로젝트설정소식 } from './config.js';
29
+ import { activeProfile, load, resolveKey, save as saveCfg, 저장시도, homeDir, 잠금소식, 열쇠탈소식, 프로젝트설정소식 } from './config.js';
30
30
  import { 프로젝트설정줄들 } from './safety/trust.js';
31
31
  import { 남길것읽기 } from './safety/shellenv.js';
32
32
  import { discover } from './skills/discover.js';
@@ -1439,7 +1439,29 @@ export async function chatLoop(opts = {}) {
1439
1439
  // 알아낸 값은 프로필에 남긴다. 다음에 켤 때 화면이 곧바로 맞게 뜬다.
1440
1440
  if (prof.ctx !== r.value) {
1441
1441
  prof.ctx = r.value;
1442
- try { const cfg2 = load(); const t = cfg2.profiles.find((p) => p.id === prof.id); if (t) { t.ctx = r.value; saveCfg(cfg2); } } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
1442
+ /*
1443
+ * 못 남기면 **말한다.**
1444
+ *
1445
+ * 여기는 `try { … saveCfg(cfg2) } catch {}` 였다. 그런데 바로 다음
1446
+ * 줄이 「컨텍스트를 32,768 → 128,000 로 맞췄습니다」 를 찍는다.
1447
+ * 홈이 읽기 전용이거나 디스크가 차 있으면 그 말이 **그 자리에서만**
1448
+ * 참이고, 다음에 켜면 옛 값으로 돌아가 있다. 사람은 왜 그런지 알
1449
+ * 길이 없다 — commands.js 의 설정남기기() 가 아홉 자리에서 없앤
1450
+ * 바로 그 꼴인데, 이 자리만 옛 모양으로 남아 있었다.
1451
+ */
1452
+ let 남김 = { ok: true };
1453
+ const cfg2 = (() => { try { return load(); } catch (e) { 남김 = { ok: false, 왜: e?.message ?? String(e) }; return null; } })();
1454
+ const t = cfg2?.profiles?.find((p) => p.id === prof.id);
1455
+ /*
1456
+ * 프로필을 못 찾으면 **그것도 못 남긴 것이다.** 처음 고칠 때 `if (t)`
1457
+ * 하나로 끝냈더니, 못 찾은 판에서는 아무것도 안 남기고 `남김.ok` 가
1458
+ * 처음값 그대로 참이라 경고도 안 나갔다 — 없애려던 그 모양이 한 갈래에
1459
+ * 그대로 남아 있었다(2차 리뷰가 짚었다). 디스크의 설정이 이 판과 어긋난
1460
+ * 때(다른 창에서 프로필을 지운 뒤)가 그 자리다.
1461
+ */
1462
+ if (t) { t.ctx = r.value; 남김 = 저장시도(cfg2); }
1463
+ else if (cfg2) 남김 = { ok: false, 왜: '설정에서 이 프로필을 못 찾았습니다' };
1464
+ if (!남김.ok) 길이경고.push(옮긴말('common.cfgSaveFailed', { 왜: String(남김.왜 ?? '').slice(0, 70) }));
1443
1465
  }
1444
1466
  if (전 !== r.value) 길이알림.push(`컨텍스트를 ${전.toLocaleString()} ${c.gray('→')} ${c.white(r.value.toLocaleString())} 로 맞췄습니다 ${c.gray('(' + (r.source ?? '서버') + '에서 읽음)')}`);
1445
1467
  if (r.max && r.loaded && r.max > r.loaded) {
@@ -1515,6 +1537,14 @@ export async function chatLoop(opts = {}) {
1515
1537
  }
1516
1538
  // 안 뜬 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
1517
1539
  for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
1540
+ /*
1541
+ * 이 폴더에 스킬을 적어 뒀는데 안 믿는 폴더라 안 읽었다. **말해야 한다** —
1542
+ * 제가 적은 스킬이 왜 안 뜨는지 모르는 것이 두 번째로 나쁜 일이다
1543
+ * (safety/hooks.js 의 안믿음 과 같은 방식).
1544
+ */
1545
+ if (found.안믿음) {
1546
+ warn.push(`이 폴더의 스킬은 안 읽었습니다 — 믿는 폴더가 아닙니다. 읽게 하려면 ${c.cyan('deel trust')}`);
1547
+ }
1518
1548
  if (!conn.tools) warn.push(옮긴말('run.noTools'));
1519
1549
  if (!conn.streaming) warn.push(옮긴말('run.noStream'));
1520
1550
  warn.push(...길이경고);
@@ -150,11 +150,50 @@ const BLOCKED = [
150
150
  // 가운데 `(-?[a-z]*\s*)*` 는 양쪽 다 빈 문자열을 허용해서, 안 맞는 긴 입력이
151
151
  // 들어오면 되돌아가는 경우의 수가 기하급수로 늘어난다 — guard 검사 자체가
152
152
  // 멈춰 버린다(ReDoS). 각 겹이 최소 한 글자는 먹게 고쳐서 그 길을 막는다.
153
+ /*
154
+ * ── 우리가 적어 둔 어순만 막고 있었다 ──────────────────────────────────
155
+ *
156
+ * 옛 규칙은 두 가지를 못박았다 — 플래그가 **경로보다 앞**에 오고, 드라이브
157
+ * 뒤가 **역빗금**일 것. 둘 다 그냥 우리가 예로 적은 모양이었지 파워셸이
158
+ * 요구하는 것이 아니다.
159
+ *
160
+ * Remove-Item -Recurse -Force C:\ 막힘 ← 예로 적어 둔 모양
161
+ * Remove-Item C:\ -Recurse -Force 통과 ← 파워셸의 흔한 어순
162
+ * Remove-Item -Recurse -Force C:/ 통과 ← 파워셸은 빗금도 받는다
163
+ * Remove-Item ~ -Recurse -Force 통과 ←
164
+ *
165
+ * 셋 다 드라이브나 집을 통째로 민다. 「막았다」 가 아니라 「내가 적은
166
+ * 모양으로 적은 사람만 막았다」 였다.
167
+ *
168
+ * 그래서 어순을 안 따진다. `-Recurse` 가 이 마디 안에 **있기만** 하면
169
+ * 되고(앞보기), 목표는 마디 어디에 있어도 된다. 뒤에 `\s|$` 를 붙여
170
+ * 「드라이브 **뿌리**」 일 때만 잡는다 — `C:\proj\tmp` 는 그대로 통과한다.
171
+ *
172
+ * 되돌아가는 길이 폭발하지 않게 별(`*`)은 하나씩만 둔다. 옛 주석이 적어
173
+ * 둔 ReDoS 자리를 다시 만들면 안 된다 — guard 검사 자체가 멈춰 선다.
174
+ */
153
175
  {
154
- re: /Remove-Item\b[^|;&]*\s-(Recurse|r)\b[^|;&]*\s(?:-?[a-z]+\s+)*([a-z]:\\?|~|\$HOME|\$env:USERPROFILE)\s*$/i,
176
+ /*
177
+ * `-r[a-z]*` 인 까닭: 파워셸은 헷갈리지 않는 한 매개변수를 **줄여서**
178
+ * 받는다. Remove-Item 에서 `r` 로 시작하는 것은 `-Recurse` 하나뿐이라
179
+ * `-rec` · `-recur` 가 다 통한다. `-(?:Recurse|r)\b` 로 못박아 뒀더니
180
+ * 그 사이 철자가 전부 새어 나갔다.
181
+ *
182
+ * 목표에 `[\\/]` 한 갈래를 더 둔 까닭: 파워셸에서 `/` 나 `\` 하나만
183
+ * 적으면 **지금 드라이브의 뿌리**다. 둘 다 2차 리뷰가 짚었다.
184
+ */
185
+ re: /Remove-Item\b(?=[^|;&]*\s-r[a-z]*\b)[^|;&]*\s(?:(?:\$env:USERPROFILE|\$HOME|[a-z]:|~)[\\/]?|[\\/])(?=\s|$)/i,
155
186
  why: '드라이브나 집 폴더를 통째로 지웁니다',
156
187
  },
157
- { re: /git\s+push\b[^|;&]*--force(?!-with-lease)/i, why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)' },
188
+ /*
189
+ * `--force` 만 보고 있었다. 짧은 꼴 `-f` 는 그대로 나갔다 —
190
+ * `git push -f origin main` 은 사람이 실제로 더 많이 치는 쪽이다.
191
+ * 긴 꼴만 막으면 「막았다」 가 아니라 「긴 꼴로 적은 사람만 막았다」 다.
192
+ */
193
+ {
194
+ re: /git\s+push\b[^|;&]*(?:--force(?!-with-lease)|\s-[a-z]*f[a-z]*\b)/i,
195
+ why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)',
196
+ },
158
197
  // 낱말만 보면 안 된다. `node scripts/shutdown.js` 나 `npm run reboot` 이
159
198
  // '시스템을 끕니다' 로 막히고 감사기록에까지 남았다. 명령의 **첫 낱말**일 때만 본다.
160
199
  { re: /(^|[|;&]\s*)(sudo\s+)?(shutdown|reboot|halt|poweroff)\b/i, why: '시스템을 끕니다' },
@@ -186,10 +225,74 @@ const BLOCKED = [
186
225
  { re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
187
226
  ];
188
227
 
228
+ /*
229
+ * ── 뒤에 한 마디만 붙이면 울타리가 통째로 열렸다 ────────────────────────
230
+ *
231
+ * 위 규칙 여럿이 `\s*$` 로 **글 끝**에 못을 박는다. `rm -rf /` 가 글 끝일
232
+ * 때만 걸린다는 뜻이다. 그런데 셸은 한 줄에 여러 마디를 넣는다.
233
+ *
234
+ * rm -rf / 막힘
235
+ * rm -rf / ; echo done 통과 ←
236
+ * rm -rf / && echo done 통과 ←
237
+ * rm -rf ~ ; ls 통과 ←
238
+ * del /f /s /q C:\ & echo done 통과 ←
239
+ * Remove-Item -Recurse -Force C:\ ; ... 통과 ←
240
+ *
241
+ * 셸은 두 마디를 **둘 다** 실행한다. 즉 `; echo done` 여덟 글자로 이 파일이
242
+ * 파는 약속이 통째로 깨졌다. 앞에 붙이는 것은 막혔고(`echo hi && rm -rf /`
243
+ * 는 걸린다) 뒤에 붙이는 것만 샜다 — 한쪽만 막힌 울타리라 더 안 보였다.
244
+ *
245
+ * 그래서 **마디로 갈라 각 마디를 따로 본다.** 규칙은 그대로 두고 보는 자리만
246
+ * 늘린다 — 전체 글도 여전히 본다. 막는 쪽으로만 늘어나므로 여태 통과하던
247
+ * 것이 새로 막히는 일은 「위험한 것이 마디 안에 있었다」 뿐이다.
248
+ *
249
+ * 따옴표 안은 안 가린다. 가려서 얻는 것(`echo "rm -rf / ; x"` 를 안 막기)보다
250
+ * 잃는 것(`sh -c "rm -rf / ; x"` 를 못 막기)이 크다. 이 목록은 되돌릴 수
251
+ * 없는 것만 담으므로, 애매하면 막고 사람이 다시 적는 편이 낫다.
252
+ */
253
+ const 마디로 = (s) => s
254
+ .split(/(?:\|\||&&|[;&|\n\r])+/)
255
+ .map((x) => x.trim())
256
+ .filter(Boolean);
257
+
189
258
  export function checkCommand(cmd) {
190
259
  const s = String(cmd);
260
+ /*
261
+ * ── 세지 말고 늘 같이 본다 ──────────────────────────────────────────
262
+ *
263
+ * 여기는 `마디로(s).length > 1` 일 때만 마디를 봤다. 2차 리뷰가 그 틈을
264
+ * 짚었다 — `rm -rf / ;` 는 마디가 **하나**다. 뒤가 빈 마디라 걸러지기
265
+ * 때문이다. 그래서 마디 목록이 통째로 버려지고, 남은 전체 글은 `\s*$` 에
266
+ * 안 걸린다. 「뒤에 한 마디 붙이면 샌다」 를 고치면서 **반 마디 붙이면
267
+ * 새는** 자리를 새로 만든 셈이다.
268
+ *
269
+ * 세는 것을 없앤다. 마디가 하나뿐이면 그 마디는 앞뒤 빈칸만 뺀 전체 글이라
270
+ * 더 볼 것도 덜 볼 것도 없다.
271
+ */
272
+ /*
273
+ * ── 따옴표를 **떼고도** 한 번 본다 ──────────────────────────────────
274
+ *
275
+ * 위 머리말은 「따옴표 안은 안 가린다」 고 적어 뒀다. 그런데 **안 가리는
276
+ * 것과 떼고 보는 것은 다르다.** 안 가리기만 했지 떼 보지는 않아서,
277
+ * 경로에 따옴표를 두르면 규칙이 그대로 빗나갔다 — 셸에서는 뜻이 하나도
278
+ * 안 바뀌는데.
279
+ *
280
+ * rm -rf / 막힘
281
+ * rm -rf "/" 통과 ← 같은 일을 한다
282
+ * Remove-Item -Recurse "C:\" 통과 ←
283
+ *
284
+ * 그래서 따옴표를 뺀 꼴도 볼 것에 넣는다. 막는 쪽으로만 늘어난다 —
285
+ * `echo "결과" > out.txt` 처럼 따옴표를 떼도 아무 규칙에 안 걸리는 것은
286
+ * 그대로 통과한다. 반대로 `sh -c "rm -rf /"` 는 이제 걸린다. 이 목록은
287
+ * 되돌릴 수 없는 것만 담으므로, 애매하면 막고 사람이 다시 적는 편이 낫다.
288
+ */
289
+ const 따옴표뺀 = (x) => x.replace(/["'`]/g, '');
290
+ const 마디들 = 마디로(s);
291
+ const 볼것 = [...new Set([s, ...마디들, 따옴표뺀(s), ...마디들.map(따옴표뺀)])];
191
292
  for (const b of BLOCKED) {
192
- if (b.re.test(s)) throw new BlockedError(`${b.why}\n 막힌 명령: ${s.slice(0, 120)}`);
293
+ for (const 하나 of 볼것) {
294
+ if (b.re.test(하나)) throw new BlockedError(`${b.why}\n 막힌 명령: ${s.slice(0, 120)}`);
295
+ }
193
296
  }
194
297
  return true;
195
298
  }