deel-local-cli 1.20.8 → 1.20.10

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
@@ -110,7 +110,15 @@ function 경로처럼보이나(line) {
110
110
  if (첫낱말.includes('/') || 첫낱말.includes('\\')) return true;
111
111
  // `/tmp` 처럼 슬래시가 하나뿐이어도, 실제로 있는 자리면 경로로 본다.
112
112
  if (첫낱말 && !COMMANDS[첫낱말.toLowerCase()]) {
113
- try { if (existsSync(line.slice(1).trim())) return true; } catch { /* 못 보면 아닌 걸로 */ }
113
+ /*
114
+ * **적힌 그대로**도 보고, 앞 슬래시를 뗀 것도 본다.
115
+ *
116
+ * 앞서는 뗀 것만 봤다. 그러면 `/tmp` 가 지금 폴더의 `tmp` 를 찾다 못 찾아,
117
+ * 바로 위 머리글이 적어 둔 그 자리가 한 번도 안 먹었다. 둘 다 보면 여태
118
+ * 되던 것(지금 폴더의 이름)도 그대로 된다.
119
+ */
120
+ const 통째 = line.trim();
121
+ try { if (existsSync(통째) || existsSync(통째.slice(1))) return true; } catch { /* 못 보면 아닌 걸로 */ }
114
122
  }
115
123
  return false;
116
124
  }
@@ -133,6 +141,29 @@ function 설정남기기(cfg, 옵션 = {}) {
133
141
  return r.ok;
134
142
  }
135
143
 
144
+ /**
145
+ * **지금 이 연결이 온 프로필**을 찾는다. 못 찾으면 null 이다.
146
+ *
147
+ * 여태 `cfg.profiles.find(p => p.id === cfg.active) ?? cfg.profiles[0]` 였다.
148
+ * 그 뒤 갈래가 「못 찾았으면 첫 번째에 쓴다」 라, active 가 어긋나 있으면
149
+ * `/ctx 655360` 이 **엉뚱한 연결**에 박히고 화면에는 「프로필에 저장했습니다」
150
+ * 가 뜬다. 8k 서버 프로필에 655,360 이 적히면 다음에 켤 때 그 서버를
151
+ * 655,360 으로 믿고 시작한다 — 긴 대화에서 서버가 거절하고, 그 까닭을
152
+ * 짚을 자리가 화면에 하나도 없다.
153
+ *
154
+ * 그래서 두 번 본다. active 로 찾고, 없으면 **지금 붙어 있는 주소·모델**로
155
+ * 찾는다. 그래도 없으면 못 찾았다고 하고 아무 데도 안 적는다 (repl.js 의
156
+ * 길이 되살리기가 이미 그렇게 한다).
157
+ */
158
+ function 이연결의프로필(cfg, session) {
159
+ const 있는것 = Array.isArray(cfg?.profiles) ? cfg.profiles : [];
160
+ const 딱 = 있는것.find((p) => p.id === cfg.active);
161
+ if (딱) return 딱;
162
+ const base = session?.conn?.base;
163
+ const model = session?.conn?.model;
164
+ return 있는것.find((p) => p.baseUrl === base && p.model === model) ?? null;
165
+ }
166
+
136
167
  /** /mode 아래에 규칙을 늘어놓는다. 아무것도 안 걸려 있으면 아무 말도 안 한다. */
137
168
  function 규칙보이기(규칙들) {
138
169
  if (!규칙들) return;
@@ -420,13 +451,17 @@ export async function handle(line, session, ctx) {
420
451
  * 결재는 첨부로 돌고, 보고는 한 장으로 한다.
421
452
  */
422
453
  case 'export': {
423
- const 자리 = 보고서적기(ctx.scope.root, session, { scope: ctx.scope, audit: ctx.audit });
454
+ // 남긴 까닭을 받아 온다 디스크가 찼는지, 폴더가 읽기 전용인지는
455
+ // 화면에 적혀야 손을 쓸 수 있다.
456
+ const 탈 = {};
457
+ const 자리 = 보고서적기(ctx.scope.root, session, { scope: ctx.scope, audit: ctx.audit }, 탈);
424
458
  say('');
425
459
  if (자리) {
426
460
  say(` ${mark.ok} ${말('export.saved')} ${c.white(ctx.scope.show(자리))}`);
427
461
  say(` ${c.gray(말('export.openHint'))}`);
428
462
  } else {
429
463
  say(` ${c.red('✗')} ${말('export.failed')}`);
464
+ if (탈.왜) say(` ${c.gray(clip(탈.왜, 90))}`);
430
465
  }
431
466
  say('');
432
467
  return { handled: true };
@@ -504,7 +539,8 @@ export async function handle(line, session, ctx) {
504
539
  const 끄는말 = ['off', '끔', '꺼', '끄기', 'n', 'no', 'ㄴ'];
505
540
  const 값 = String(arg ?? '').trim().toLowerCase();
506
541
  if (!값) {
507
- const 켜져있나 = cfg.bell !== false;
542
+ // 지금 세션이 참이다. 설정 파일은 '다음에 켤 때' 값일 뿐이다.
543
+ const 켜져있나 = ctx?.종알림 ? ctx.종알림.켬 !== false : cfg.bell !== false;
508
544
  const 상태 = 켜져있나 ? c.hgreen(말('bell.stateOn')) : c.gray(말('bell.stateOff'));
509
545
  say('');
510
546
  say(` ${c.gray(말('bell.now', { 상태 }))}`);
@@ -522,6 +558,9 @@ export async function handle(line, session, ctx) {
522
558
  }
523
559
  cfg.bell = 켜는말.includes(값);
524
560
  설정남기기(cfg);
561
+ // 이 세션에도 바로 먹여야 한다 — 아래 줄이 그렇게 적는다.
562
+ // repl 은 켤 때 읽어 둔 그릇으로 울릴지 말지를 정한다(repl.js 의 알림).
563
+ if (ctx?.종알림) ctx.종알림.켬 = cfg.bell;
525
564
  say('');
526
565
  say(` ${mark.ok} ${말(cfg.bell ? 'bell.turnedOn' : 'bell.turnedOff')}`);
527
566
  // 켤 때는 한 번 울려 준다. "켰다는데 소리가 나나?" 를 그 자리에서 확인하게.
@@ -726,7 +765,9 @@ export async function handle(line, session, ctx) {
726
765
  */
727
766
  if (/^(auto|자동)(\s|$)/.test(말)) {
728
767
  const 값 = 말.replace(/^(auto|자동)\s*/, '').trim().toLowerCase();
729
- const 켤까 = === '' ? session.autoThink === false : /^(on|켜|켜기|true)$/.test(값);
768
+ // `켬` 켜는 말이다. /bell 받는데 여기만 빠져 있어서, `/think auto 켬`
769
+ // 이 **끄는** 명령이 됐다 — 켜려고 친 말로 꺼지고 화면은 「껐습니다」 였다.
770
+ const 켤까 = 값 === '' ? session.autoThink === false : /^(on|켬|켜|켜기|true|y|yes|ㅇ)$/.test(값);
730
771
  session.autoThink = 켤까;
731
772
  say(` ${mark.ok} ${옮긴말(켤까 ? 'think.autoOn' : 'think.autoOff')}`);
732
773
  showThink(session);
@@ -745,6 +786,12 @@ export async function handle(line, session, ctx) {
745
786
  }
746
787
 
747
788
  if (!THINK_LEVELS.includes(말)) {
789
+ // 못 알아들은 값은 못 알아들었다고 먼저 말한다. 안 그러면 `/think hgih`
790
+ // 가 오류 없이 표만 띄워서, 강도를 올린 줄 알고 그대로 쓰게 된다.
791
+ if (말) {
792
+ say('');
793
+ say(` ${mark.no} ${c.gray(옮긴말('think.unknown', { 값: 말 }))}`);
794
+ }
748
795
  showThink(session);
749
796
  return { handled: true };
750
797
  }
@@ -769,7 +816,16 @@ export async function handle(line, session, ctx) {
769
816
  */
770
817
  case 'mode': {
771
818
  const { 승인, 차례: 승인차례, 표시: 승인표시 } = await import('./ui/approve.js');
772
- if (!MODES[arg]) {
819
+ /*
820
+ * 표에 **제 것으로 있는** 이름만 받는다.
821
+ *
822
+ * `MODES[arg]` 는 물려받은 열쇠에도 참이다. `/mode constructor` 를
823
+ * 치면 목록으로 안 빠지고 그대로 session.mode 에 들어가는데, 그 값은
824
+ * strict 도 confirm 도 아니라 **아무것도 안 물어보는** 상태가 된다
825
+ * (agent/loop.js 의 needsOk). 화면에는 `엄격 → undefined` 가 찍힌다.
826
+ * `--work` 오타가 가장 센 모드로 돌던 것과 같은 자리다.
827
+ */
828
+ if (!Object.hasOwn(MODES, arg)) {
773
829
  rule('승인 방식 — 무엇을 물어볼까', 70);
774
830
  for (const k of 승인차례) {
775
831
  const 지금 = k === session.mode;
@@ -820,7 +876,23 @@ export async function handle(line, session, ctx) {
820
876
  * 되돌린 것이 다음 순간 되살아난다.
821
877
  */
822
878
  case 'undo': {
823
- const n = Math.max(1, parseInt(arg, 10) || 1);
879
+ /*
880
+ * 못 읽는 인자로 **말없이 한 턴을 되돌리지 않는다.**
881
+ *
882
+ * parseInt 는 `다섯`·`abc` 를 NaN, `-2` 를 음수로 준다. 앞서는 둘 다
883
+ * 1 로 깎아서, 다섯 턴을 되돌리려던 사람이 한 턴만 되돌아간 화면을
884
+ * 봤다. 되돌리기는 **파일을 실제로 되돌리는** 명령이라 잘못 읽은 수로
885
+ * 도는 값이 다른 명령보다 비싸다. /work 가 오타를 먼저 말하는 것과
886
+ * 같은 잣대다.
887
+ */
888
+ const 준말 = String(arg ?? '').trim();
889
+ if (준말 && !/^[0-9]+$/.test(준말)) {
890
+ say(` ${mark.no} ${c.gray('몇 턴을 되돌릴지 숫자로 적어 주세요.')} ${c.cyan('/undo 3')}`);
891
+ say(` ${c.gray('그냥')} ${c.cyan('/undo')} ${c.gray('만 치면 한 턴을 되돌립니다.')}`);
892
+ say('');
893
+ return { handled: true };
894
+ }
895
+ const n = Math.max(1, parseInt(준말, 10) || 1);
824
896
  const r = ctx.history.undo(n);
825
897
  /*
826
898
  * 세는 수는 **진짜로 되돌아간 것**이다.
@@ -832,8 +904,17 @@ export async function handle(line, session, ctx) {
832
904
  */
833
905
  const 되돌린수 = r.되돌린수 ?? r.restored.length;
834
906
  ctx.audit.undo({ turns: r.turns, files: 되돌린수 });
835
- if (!r.restored.length) {
907
+ /*
908
+ * **하나도 안 되돌아갔으면** 되돌렸다고 하지 않는다.
909
+ *
910
+ * restored 에는 못 되돌린 것과 손대지 않은 것(바이너리처럼 내용을 못
911
+ * 떠 둔 파일)도 들어 있다. 그 길이만 보면, 되돌아간 파일이 0개인
912
+ * 판에도 `✓ … 파일 0개를 되돌렸습니다` 를 찍고 그 밑에 못 되돌린
913
+ * 파일 목록을 성공 목록처럼 늘어놓았다.
914
+ */
915
+ if (!되돌린수) {
836
916
  say(` ${c.gray(말('undo.nothing'))}`);
917
+ if (r.못한것?.length) say(` ${mark.warn} ${말('undo.failed', { n: r.못한것.length })}`);
837
918
  say('');
838
919
  return { handled: true };
839
920
  }
@@ -1238,6 +1319,33 @@ export async function handle(line, session, ctx) {
1238
1319
  return { handled: true };
1239
1320
  }
1240
1321
 
1322
+ /*
1323
+ * 지우려다 **적고 마는** 자리를 막는다.
1324
+ *
1325
+ * `/memory 지우기` 처럼 번호를 안 붙이면 위 무늬에 안 걸리고 아래
1326
+ * 「이걸 기억해라」 로 떨어졌다. 그래서 `지우기` 라는 낱말이 기억 파일에
1327
+ * **영영 적히고**, 화면에는 「기억했습니다」 라고 떴다. 그 줄은 그 뒤로
1328
+ * 매 요청마다 모델에게 같이 나간다. 지우려던 사람이 쓰레기를 하나 더
1329
+ * 심는 꼴이고, 그것도 성공 표시를 보면서 그렇게 된다.
1330
+ */
1331
+ // 낱말 끝은 `\\b` 로 못 잡는다 — 한글은 \\w 가 아니라 `지우기` 뒤에 낱말
1332
+ // 경계가 안 생긴다. 실제로 이 무늬로 고쳤다가 한글만 그대로 새 나갔다.
1333
+ if (/^(지우기|잊어|forget|rm)(\s|$)/i.test(말)) {
1334
+ const 있는것 = M.읽기(session.root).줄들;
1335
+ say(` ${mark.no} ${c.gray('몇 번째 줄을 지울지 번호를 적어 주세요.')}`);
1336
+ if (있는것.length) {
1337
+ for (const [i, l] of 있는것.entries()) {
1338
+ say(` ${c.gray(String(i + 1).padStart(2))} ${c.white(clip(l, 82))}`);
1339
+ }
1340
+ say('');
1341
+ say(` ${c.cyan('/memory 지우기 1')} ${c.gray('한 줄만')} ${c.cyan('/memory 비우기')} ${c.gray('전부')}`);
1342
+ } else {
1343
+ say(` ${c.gray('지금은 기억해 둔 것이 없습니다.')}`);
1344
+ }
1345
+ say('');
1346
+ return { handled: true };
1347
+ }
1348
+
1241
1349
  // 그 밖의 말은 '이걸 기억해라' 로 본다. 모델을 안 거치고 바로 적는 길이다.
1242
1350
  if (말) {
1243
1351
  const r = M.더하기(session.root, 말);
@@ -1745,12 +1853,23 @@ function showSkills(session, arg) {
1745
1853
  }
1746
1854
  if (q.startsWith('on ')) {
1747
1855
  const term = q.slice(3).trim().toLowerCase();
1748
- let n = 0;
1749
- for (const s of all) {
1750
- s.enabled = 낮게(s.name).includes(term) || 낮게(s.description).includes(term);
1751
- if (s.enabled) n++;
1856
+ const 걸린것 = all.filter((s) => 낮게(s.name).includes(term) || 낮게(s.description).includes(term));
1857
+ /*
1858
+ * 개도 걸리면 **아무것도 안 건드린다.**
1859
+ *
1860
+ * 앞서는 걸림 여부를 그대로 enabled 에 넣었다. 그래서 오타 하나면 전부
1861
+ * false 가 되고, 화면에는 `✓ "리뷰" 에 걸리는 0개만 올립니다` 가 떴다.
1862
+ * 올리려고 친 명령이 가지고 있던 것까지 다 내려 버리는데, 표시는 ✓ 다.
1863
+ * 그다음 모델이 스킬을 못 쓰는 것을 보고 사람은 스킬이 깨진 줄 안다.
1864
+ */
1865
+ if (!걸린것.length) {
1866
+ say(` ${mark.no} "${term}" 에 걸리는 스킬이 없습니다 ${c.gray('— 올라간 것은 그대로 둡니다.')}`);
1867
+ say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/skills')}`);
1868
+ say('');
1869
+ return;
1752
1870
  }
1753
- say(` ${mark.ok} "${term}" 걸리는 ${n}개만 올립니다.`);
1871
+ for (const s of all) s.enabled = 걸린것.includes(s);
1872
+ say(` ${mark.ok} "${term}" 에 걸리는 ${걸린것.length}개만 올립니다.`);
1754
1873
  say('');
1755
1874
  return;
1756
1875
  }
@@ -1840,7 +1959,15 @@ function showLevel(session, arg) {
1840
1959
  const cfg = load();
1841
1960
  cfg.level = 골라진;
1842
1961
  설정남기기(cfg);
1843
- } catch { /* 설정을 못 읽는 것은 위에서 따로 말한다 */ }
1962
+ } catch (e) {
1963
+ /*
1964
+ * 설정 파일 자체를 못 읽는 경우다(JSON 이 깨졌다). 바로 위 머리글이
1965
+ * 「못 남기면 말한다」 라고 적어 놓고 여기만 통째로 삼키고 있었다.
1966
+ * 켤 때는 repl 이 말해 주지만 **대화 도중에 깨진 경우는 아무도 안 말한다** —
1967
+ * 화면은 `✓ 개발자` 를 찍고 다음에 켜면 그대로 쉬움이다.
1968
+ */
1969
+ say(` ${mark.warn} ${c.yellow(말('common.cfgSaveFailed', { 왜: clip(String(e?.message ?? e), 70) }))}`);
1970
+ }
1844
1971
  const lv = LEVELS[골라진];
1845
1972
  say('');
1846
1973
  say(` ${mark.ok} ${c.bold(lv.name)} ${c.gray('— ' + lv.hint)}`);
@@ -2162,9 +2289,13 @@ function 증거명령(session, ctx, arg = '') {
2162
2289
 
2163
2290
  if (/^(파일|file|저장|save)$/i.test(말)) {
2164
2291
  const 이름 = `${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}`;
2165
- const 자리 = 증거적기(session.root, e, 이름);
2292
+ const = {};
2293
+ const 자리 = 증거적기(session.root, e, 이름, 탈);
2166
2294
  if (자리) say(` ${mark.ok} ${c.gray('남겼습니다 —')} ${c.cyan(자리)}`);
2167
- else say(` ${c.gray('파일로 못 남겼습니다. 화면 것만 쓰세요.')}`);
2295
+ else {
2296
+ say(` ${c.gray('파일로 못 남겼습니다. 화면 것만 쓰세요.')}`);
2297
+ if (탈.왜) say(` ${c.gray(clip(탈.왜, 90))}`);
2298
+ }
2168
2299
  say('');
2169
2300
  } else {
2170
2301
  say(` ${c.gray('파일로 남기려면')} ${c.cyan('/evidence 파일')}`);
@@ -2295,9 +2426,18 @@ async function 리뷰명령(session, ctx) {
2295
2426
 
2296
2427
  async function 커밋명령(session, ctx, arg = '') {
2297
2428
  const 말한것 = String(arg ?? '').trim();
2298
- const 전부 = /^(전부|all)$/i.test(말한것);
2299
- const 미리보기 = /^(미리보기|preview|dry|--dry-run)$/i.test(말한것);
2300
- const 준제목 = 전부 || 미리보기 ? null : (말한것 || null);
2429
+ /*
2430
+ * `전부` 뒤에 제목을 같이 적을 수 있다.
2431
+ *
2432
+ * 앞서는 `^(전부|all)$` 라 `/commit 전부 버그 수정` 이 **둘 다** 어긋났다 —
2433
+ * 전부가 거짓이 되어 폴더째 바뀐 파일이 빠지고, `전부 버그 수정` 이 통째로
2434
+ * 커밋 제목이 됐다. 어느 쪽도 화면에 안 적힌다.
2435
+ */
2436
+ const 전부친것 = /^(전부|all)(\s+|$)/i.exec(말한것);
2437
+ const 전부 = !!전부친것;
2438
+ const 남은말 = 전부 ? 말한것.slice(전부친것[0].length).trim() : 말한것;
2439
+ const 미리보기 = /^(미리보기|preview|dry|--dry-run)$/i.test(남은말);
2440
+ const 준제목 = 미리보기 ? null : (남은말 || null);
2301
2441
 
2302
2442
  say('');
2303
2443
  rule('커밋', 70);
@@ -2532,7 +2672,21 @@ function 배움명령(session, ctx, arg = '') {
2532
2672
  * 몇 주 쌓은 명령 겪음까지 같이 날아갔다 — 그게 아까워서 사람은 안 비우고,
2533
2673
  * 안 비우니 꺼진 기능을 그냥 안고 쓴다. 전선만 지우면 잃을 것이 없다.
2534
2674
  */
2535
- if (/^(전선|wire)\s*(지우기|clear|forget|비우기)?$/i.test(String(arg).trim())) {
2675
+ /*
2676
+ * 지우는 말은 **반드시 적어야** 한다.
2677
+ *
2678
+ * 앞서는 `\s*(…)?` 라 `/learned 전선` 만 쳐도 곧바로 지웠다. 화면이 알려
2679
+ * 주는 명령은 `/learned 전선 지우기` 라 그 짧은 꼴은 어디에도 안 적혀
2680
+ * 있는데, 「전선에서 뭘 배웠나 보자」 는 마음으로 치면 그것이 지우는
2681
+ * 명령이 됐다. 지우는 명령은 지우겠다고 적었을 때만 지운다.
2682
+ */
2683
+ if (/^(전선|wire)$/i.test(String(arg).trim())) {
2684
+ say(` ${c.gray('전선에서 배운 것만 비우려면')} ${c.cyan('/learned 전선 지우기')}`);
2685
+ say(` ${c.gray('쌓인 것을 보려면')} ${c.cyan('/learned')}`);
2686
+ say('');
2687
+ return;
2688
+ }
2689
+ if (/^(전선|wire)\s+(지우기|clear|forget|비우기)$/i.test(String(arg).trim())) {
2536
2690
  배움.지우기('전선');
2537
2691
  if (session.conn) 전선붙이기(session.conn, 배움);
2538
2692
  say(` ${mark.ok} ${c.gray('전선에서 배운 것만 비웠습니다 — 다음 요청부터 짐작으로 다시 시작합니다.')}`);
@@ -2758,13 +2912,15 @@ async function 출력상한(session, arg = '') {
2758
2912
  const { parseSize } = await import('./backend/ctxsize.js');
2759
2913
  const 말 = String(arg ?? '').trim().toLowerCase();
2760
2914
  const cfg = load();
2761
- const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
2915
+ const prof = 이연결의프로필(cfg, session);
2762
2916
  const 알아낸것 = session.conn.maxOut ?? null;
2763
2917
 
2764
2918
  if (말 === 'auto' || 말 === '자동') {
2765
2919
  session.conn.maxTokens = null;
2766
- if (prof) { delete prof.maxTokens; 설정남기기(cfg); }
2767
- say(` ${mark.ok} 직접 정한 값을 지웠습니다.`);
2920
+ const 지웠나 = prof ? (delete prof.maxTokens, 설정남기기(cfg)) : false;
2921
+ say(` ${mark.ok} 직접 정한 값을 지웠습니다. ${c.gray('이번 대화에 바로 먹습니다.')}`);
2922
+ // 프로필을 못 찾으면 다음에 켤 때 그 값이 되살아난다. 숨기면 안 된다.
2923
+ if (!지웠나) say(` ${mark.warn} ${c.yellow('설정에서 이 연결을 못 찾아 파일에는 못 남겼습니다 — 다음에 켜면 옛 값입니다.')}`);
2768
2924
  say(` ${c.gray(알아낸것 ? `서버에서 알아낸 ${알아낸것.toLocaleString()} 토큰을 씁니다.` : '모르는 값이라 16,384 토큰으로 갑니다.')}`);
2769
2925
  say('');
2770
2926
  return;
@@ -2838,7 +2994,7 @@ function 모델급(session, arg = '') {
2838
2994
  return;
2839
2995
  }
2840
2996
 
2841
- if (값 && 별명[]) {
2997
+ if (값 && Object.hasOwn(별명, 값)) {
2842
2998
  session.급정한것 = 별명[값];
2843
2999
  session.급다시재기?.();
2844
3000
  const v = session.급값();
@@ -2849,6 +3005,19 @@ function 모델급(session, arg = '') {
2849
3005
  return;
2850
3006
  }
2851
3007
 
3008
+ /*
3009
+ * 못 알아들은 값은 **못 알아들었다고 말한다.**
3010
+ *
3011
+ * 앞서는 `/grade 크게` 같은 오타가 오류 한 줄 없이 지금 상태 표로
3012
+ * 떨어졌다. 사람은 정해진 줄로 읽고 그대로 쓴다. 바로 옆 `/work` 는
3013
+ * 「그런 모드는 없습니다」 를 먼저 말한다 — 잣대를 같게 맞춘다.
3014
+ */
3015
+ if (값) {
3016
+ say('');
3017
+ say(` ${mark.no} ${c.gray('그런 급은 없습니다:')} ${c.white(값)}`);
3018
+ say(` ${c.gray('고를 수 있는 것:')} ${c.cyan('작음')} ${c.gray('·')} ${c.cyan('보통')} ${c.gray('·')} ${c.cyan('큼')} ${c.gray('·')} ${c.cyan('auto')}`);
3019
+ }
3020
+
2852
3021
  // 인자가 없으면 지금 상태를 보여 준다.
2853
3022
  const g = session.급();
2854
3023
  const v = session.급값();
@@ -2897,13 +3066,15 @@ async function ctxLength(session, arg = '') {
2897
3066
  const 남기기 = (값, 어디서) => {
2898
3067
  session.conn.ctx = 값;
2899
3068
  const cfg = load();
2900
- const prof = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
3069
+ const prof = 이연결의프로필(cfg, session);
2901
3070
  // 못 남기면 아래 「프로필에 저장했습니다」 가 거짓말이 된다. 살아있는지 들고 간다.
2902
3071
  const 남김 = prof ? (prof.ctx = 값, 설정남기기(cfg)) : false;
2903
3072
  const b = session.breakdown();
2904
3073
  say(` ${mark.ok} 컨텍스트 ${c.bold(값.toLocaleString())} 토큰 ${c.gray(`(${fmtSize(값)}) — ${어디서}`)}`);
2905
3074
  say(` ${c.gray('지금 찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음')} ${c.white(b.left.toLocaleString())}`);
2906
3075
  if (남김) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
3076
+ // 못 남긴 까닭이 '설정을 못 찾음' 이면 설정남기기() 가 아무 말도 안 한다.
3077
+ else if (!prof) say(` ${mark.warn} ${c.yellow('설정에서 이 연결을 못 찾아 파일에는 못 남겼습니다 — 다음에 켜면 옛 값입니다.')}`);
2907
3078
  say('');
2908
3079
  };
2909
3080
 
@@ -3084,6 +3255,15 @@ async function 서버모델들(conn) {
3084
3255
  const { req, headersFor } = await import('./backend/http.js');
3085
3256
  if (conn.kind === 'ollama') {
3086
3257
  const r = await req(`${conn.base.replace(/\/v1\/?$/, '')}/api/tags`, { timeout: 4000 });
3258
+ /*
3259
+ * **못 받은 것**과 **안 내주는 것**은 다르다.
3260
+ *
3261
+ * req 는 통신 실패에 안 던지고 `{ok:false, status, error}` 를 준다. 여기만
3262
+ * 그 ok 를 안 보고 빈 배열을 돌려줘서, 404·500·시간초과·프록시 거절이
3263
+ * 전부 「이 서버는 모델 목록을 내주지 않습니다」 가 됐다. 다른 갈래는
3264
+ * 아래처럼 null 을 준다 — 같은 함수가 갈래마다 다른 약속을 하고 있었다.
3265
+ */
3266
+ if (!r.ok) return null;
3087
3267
  return (r.json?.models ?? []).map((m) => m.name ?? m.model).filter(Boolean);
3088
3268
  }
3089
3269
  /*
@@ -3157,7 +3337,12 @@ async function switchModel(session, ctx, arg = '') {
3157
3337
  say('');
3158
3338
  return;
3159
3339
  }
3160
- say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결도 모델도 없습니다.')}`);
3340
+ // 목록을 **못 받은** 것을 「없다」 로 적으면, 사람은 있는 모델을 찾아 헤맨다.
3341
+ if (있는것 == null) {
3342
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결이 없고, 서버에는 물어봤지만 목록을 못 받았습니다.')}`);
3343
+ } else {
3344
+ say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결도 모델도 없습니다.')}`);
3345
+ }
3161
3346
  say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/model list')}${c.gray(', 서버에 물어보려면')} ${c.cyan('/model models')}`);
3162
3347
  say('');
3163
3348
  return;
@@ -3255,8 +3440,17 @@ async function 모델만바꾸기(session, cfg, 모델, ctx = null) {
3255
3440
  // 서버는 그대로지만 문은 같이 지난다. 등록 안 된 서버로 옮겨 가는 길도
3256
3441
  // 여기라서, 여기를 열어 두면 자물쇠에 구멍이 하나 남는다.
3257
3442
  if (!await 나가도되나묻기(session, p)) return;
3258
- if (!이미) 설정남기기(upsert(cfg, p));
3259
- else { cfg.active = p.id; 설정남기기(cfg); }
3443
+ /*
3444
+ * 새로 만든 프로필도 **지금 쓰는 것**으로 못 박는다.
3445
+ *
3446
+ * upsert 는 active 가 비어 있을 때만 채운다. 여기서는 이미 차 있으므로,
3447
+ * 새 프로필을 넣기만 하고 active 는 옛 모델에 그대로 남았다 — 화면에는
3448
+ * 「모델을 X 로 바꿨습니다」 라고 적히고, 다음에 켜면 옛 모델로 돌아온다.
3449
+ * 이미 있는 프로필로 갈아탈 때(아래 갈래)는 제대로 하고 있었다.
3450
+ */
3451
+ if (!이미) upsert(cfg, p);
3452
+ cfg.active = p.id;
3453
+ 설정남기기(cfg);
3260
3454
  연결적용(session, p, ctx);
3261
3455
  say(` ${mark.ok} 모델을 ${c.bold(모델)} 로 바꿨습니다. ${c.gray('서버는 그대로입니다.')}`);
3262
3456
  await 길이맞추기(session, cfg, p);
@@ -3273,14 +3467,29 @@ async function 모델만바꾸기(session, cfg, 모델, ctx = null) {
3273
3467
  async function 길이맞추기(session, cfg, prof) {
3274
3468
  const { probeCtx, fmtSize, 기본값 } = await import('./backend/ctxsize.js');
3275
3469
  let r = null;
3276
- try { r = await probeCtx(session.conn, { timeout: 8000 }); } catch { /* 못 물어보면 아래에서 기본값 */ }
3470
+ // 물어보다 **터진 것**과 서버가 값을 **안 것**은 다르다. 아래에서 갈라 적는다.
3471
+ let 못물어본까닭 = null;
3472
+ try { r = await probeCtx(session.conn, { timeout: 8000 }); }
3473
+ catch (e) { 못물어본까닭 = String(e?.message ?? e); }
3277
3474
  const 값 = r?.value ?? prof.ctx ?? 기본값;
3278
3475
  session.conn.ctx = 값;
3279
3476
  if (prof) {
3280
3477
  prof.ctx = 값;
3281
3478
  설정남기기(cfg);
3282
3479
  }
3283
- say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + (r?.source ? r.source + '에서 읽음' : '서버가 안 알려줘 기본값'))}`);
3480
+ /*
3481
+ * 어디서 온 값인지 **세 가지를 갈라** 적는다.
3482
+ *
3483
+ * 앞서는 물어보다 터진 것도, 서버가 안 준 것도, 프로필에 적혀 있던 값을
3484
+ * 물려받은 것도 전부 「서버가 안 알려줘 기본값」 이었다. 값이 655,360 인데
3485
+ * 출처는 「기본값」 이라고 적히는 판이 실제로 난다 — /ctx 화면은 출처를
3486
+ * 정성껏 가르는데 이 줄만 뭉갰다.
3487
+ */
3488
+ const 어디서 = r?.source ? r.source + '에서 읽음'
3489
+ : 못물어본까닭 ? '못 물어봤습니다 — ' + clip(못물어본까닭, 50)
3490
+ : r?.value == null && prof?.ctx ? '이 프로필에 적혀 있던 값'
3491
+ : '서버가 안 알려줘 기본값';
3492
+ say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + 어디서)}`);
3284
3493
  if (r?.max && r?.loaded && r.max > r.loaded) {
3285
3494
  say(` ${c.yellow('이 모델은 ' + r.max.toLocaleString() + ' 까지 됩니다.')} ${c.gray('서버에서 더 올린 뒤')} ${c.cyan('/ctx auto')}`);
3286
3495
  } else if (!r?.value) {
@@ -3304,10 +3513,21 @@ function 연결목록(cfg, session) {
3304
3513
  async function 서버모델고르기(session, ctx, cfg) {
3305
3514
  const s = spin('서버에 모델 목록을 물어보는 중…');
3306
3515
  let 있는것;
3307
- try { 있는것 = await 서버모델들(session.conn); } catch { 있는것 = null; }
3516
+ /*
3517
+ * 무엇 때문에 못 받았는지를 **버리지 않는다.**
3518
+ *
3519
+ * 여기 catch 는 오프라인 잠금(NetBlocked)까지 삼켰다. 그러면 자물쇠가 막은
3520
+ * 것을 「이 서버는 목록을 안 내줍니다」 라고 서버 탓으로 돌리게 된다 —
3521
+ * 잠금이 화면에서 사라지는 것은 이 저장소가 여러 군데서 막으려던 그 꼴이다.
3522
+ */
3523
+ let 못받은까닭 = null;
3524
+ try { 있는것 = await 서버모델들(session.conn); }
3525
+ catch (e) { 있는것 = null; 못받은까닭 = String(e?.message ?? e); }
3308
3526
  s.stop('');
3309
3527
  if (!있는것 || !있는것.length) {
3310
- say(` ${mark.warn} 이 서버는 모델 목록을 내주지 않습니다.`);
3528
+ if (못받은까닭) say(` ${mark.warn} 모델 목록을 받았습니다 ${c.gray('— ' + clip(못받은까닭, 70))}`);
3529
+ else if (있는것 == null) say(` ${mark.warn} 모델 목록을 못 받았습니다 ${c.gray('— 서버가 답을 안 주거나 주소·열쇠가 안 맞습니다.')}`);
3530
+ else say(` ${mark.warn} 이 서버는 모델 목록을 내주지 않습니다.`);
3311
3531
  say(` ${c.gray('목록이 없는 게이트웨이도 있습니다. 그때는')} ${c.cyan('deel setup')} ${c.gray('에서 모델 이름을 직접 넣으세요.')}`);
3312
3532
  say('');
3313
3533
  return;
@@ -92,7 +92,9 @@ export function 설명(칸, { root = process.cwd(), env = process.env } = {}) {
92
92
  여기 = 여기.find((x) => x?.name === 이름 || x?.id === 이름);
93
93
  continue;
94
94
  }
95
- 여기 = 여기[이름];
95
+ // 물려받은 이름(`constructor` · `toString`)으로 파고들면 **없는 칸이
96
+ // 있는 것이 된다.** 여기서 막아야 아래 세 층이 다 안전하다.
97
+ 여기 = (여기 && typeof 여기 === 'object' && Object.hasOwn(여기, 이름)) ? 여기[이름] : undefined;
96
98
  }
97
99
  return 여기;
98
100
  };
@@ -124,7 +126,14 @@ export function 설명(칸, { root = process.cwd(), env = process.env } = {}) {
124
126
 
125
127
  // ── 3. 환경변수 ─────────────────────────────────────────────────────
126
128
  const 끝칸 = 조각[조각.length - 1] ?? '';
127
- const 볼환경 = 환경변수[끝칸] ?? [];
129
+ /*
130
+ * `환경변수[끝칸] ?? []` 였다.
131
+ *
132
+ * `deel config explain constructor` 는 배열이 아니라 **함수**를 받아
133
+ * 바로 다음 줄에서 터졌다 — 화면에 뜬 말은 「오류 볼환경.filter is not a
134
+ * function」 이다. 없는 칸을 물었으면 없다고 해야 한다.
135
+ */
136
+ const 볼환경 = Object.hasOwn(환경변수, 끝칸) ? 환경변수[끝칸] : [];
128
137
  const 켜진환경 = 볼환경.filter((이름) => !이름.includes('<') && env[이름]);
129
138
  if (켜진환경.length) {
130
139
  층들.push({
@@ -136,7 +145,10 @@ export function 설명(칸, { root = process.cwd(), env = process.env } = {}) {
136
145
  // ── 4. 관리 정책 (제일 위) ──────────────────────────────────────────
137
146
  const 정책 = 정책읽기({ env, 다시: true });
138
147
  if (정책.값 && typeof 정책.값 === 'object') {
139
- const 그값 = 정책.값[끝칸];
148
+ // 정책 파일은 JSON 이라 프로토타입을 그대로 물려받는다. hasOwn 없이 보면
149
+ // 정책에 없는 칸이 「관리 정책에 이렇게 적혀 있습니다」 로 한 줄 올라온다 —
150
+ // 관리자에게 따지러 갈 근거가 통째로 거짓이 되는 자리다.
151
+ const 그값 = Object.hasOwn(정책.값, 끝칸) ? 정책.값[끝칸] : undefined;
140
152
  if (그값 !== undefined) {
141
153
  층들.push({ 층: '관리 정책', 자리: 정책.곳 ?? 정책자리(env)[0], 읽나: true, 값: 지운값(끝칸, 그값) });
142
154
  }
package/src/i18n/en.js CHANGED
@@ -42,6 +42,7 @@ export const en = {
42
42
  // ── /think screen ──────────────────────────────────────────────────
43
43
  'run.noTools': 'Tool calling is unconfirmed — check with deel diagnose',
44
44
  'think.effort': 'Effort',
45
+ 'think.unknown': 'No such effort level: {값}',
45
46
  'think.profile': 'Profile',
46
47
  'think.detail': 'detail',
47
48
  'think.harder': 'harder',
@@ -98,7 +99,7 @@ export const en = {
98
99
  'cmd.commit.desc': 'commit — stages only what this session changed; message from the diff and the evidence',
99
100
  'cmd.review.desc': 'review the changes in a fresh context — reports findings only, changes nothing',
100
101
  'cmd.paste.desc': 'attach the screenshot sitting in the clipboard (capture, then /paste)',
101
- 'cmd.commit.arg': '[all|preview|title]',
102
+ 'cmd.commit.arg': '[all] [title|preview]',
102
103
  'cmd.sessions.desc': 'past sessions in this folder',
103
104
  'cmd.recall.desc': 'search past sessions — by what was said, not by title',
104
105
  'cmd.recall.arg': '<text>',
package/src/i18n/ko.js CHANGED
@@ -31,6 +31,7 @@ export const ko = {
31
31
  // 단계 이름·배분 이름은 agent/effort.js 가 들고 있다 — modes.js 와 같은 방식.
32
32
  'run.noTools': '도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요',
33
33
  'think.effort': '추론 강도',
34
+ 'think.unknown': '그런 강도는 없습니다: {값}',
34
35
  'think.profile': '배분',
35
36
  'think.detail': '자세히',
36
37
  'think.harder': '더 세게',
@@ -87,7 +88,7 @@ export const ko = {
87
88
  'cmd.commit.desc': '커밋 — 이번 대화가 바꾼 것만 담고, diff 와 증거로 메시지를 짓는다',
88
89
  'cmd.review.desc': '바꾼 것을 새 창에서 한 번 더 본다 — 찾은 것만 말하고 아무것도 안 고친다',
89
90
  'cmd.paste.desc': '클립보드에 든 화면 캡처를 붙인다 (캡처 → /paste)',
90
- 'cmd.commit.arg': '[전부|미리보기|제목]',
91
+ 'cmd.commit.arg': '[전부] [제목|미리보기]',
91
92
  'cmd.sessions.desc': '이 폴더의 지난 대화 목록',
92
93
  'cmd.recall.desc': '지난 대화에서 찾기 — 목록 말고 내용으로',
93
94
  'cmd.recall.arg': '<찾을 말>',