deel-local-cli 1.20.12 → 2.0.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 (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
package/src/commands.js CHANGED
@@ -1,21 +1,13 @@
1
1
  // 슬래시 명령. 이름은 Claude Code / Codex 관례에 맞춘다.
2
- import { writeFileSync, existsSync, statSync } from 'node:fs';
3
- import { 볼것, 리뷰받기 } from './agent/review.js';
2
+ import { writeFileSync, existsSync } from 'node:fs';
4
3
  import { 마지막할당량, 할당량말, 아슬아슬한가 } from './backend/quota.js';
5
4
  import { 세션요금, 돈셈, 돈말, 어디서온값, 요금적는법 } from './backend/price.js';
6
5
  import { join } from 'node:path';
7
- import { c, say, rule, pad, bar, mark, width, clip } from './ui/ansi.js';
6
+ import { c, say, rule, pad, mark, width, clip } from './ui/ansi.js';
8
7
  import { compact } from './agent/compact.js';
9
- import { 증거모으기, 증거적기 } from './agent/evidence.js';
10
- import { 커밋준비, 커밋실행 } from './agent/commit.js';
11
- import { allowEndpoint } from './safety/network.js';
12
- import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
13
- import { 주소가리기 } from './safety/secrets.js';
14
- import { pick, confirm } from './ui/prompt.js';
15
- import { load, 저장시도, resolveKey, upsert, 열쇠보관, configPath } from './config.js';
16
- import { 지금상태 as 지금열쇠상태, 받기설정, 잊기 as 받은열쇠잊기 } from './safety/authcmd.js';
17
- // 열쇠받기 명령을 정책이 못박아 뒀을 수 있다 — 받기설정 이 그 값을 같이 본다.
18
- import { 정책읽기 } from './safety/policy.js';
8
+ import { 바깥인가 } from './safety/runmode.js';
9
+ import { load, upsert, 열쇠보관, configPath } from './config.js';
10
+ import { 지금상태 as 지금열쇠상태 } from './safety/authcmd.js';
19
11
  import { 걸음수 } from './agent/budget.js';
20
12
  import { 제공자고르기 } from './providers/index.js';
21
13
  import { 종, 알릴만한초 } from './ui/notify.js';
@@ -29,19 +21,18 @@ import { 종, 알릴만한초 } from './ui/notify.js';
29
21
  import { 말, 말 as 옮긴말, 언어, 언어들, 언어정하기, 언어고르기, 옮긴만큼, 지시말, 지시말정하기, 지시말따로정했나 } from './i18n/index.js';
30
22
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
31
23
  import { allowTemporarily } from './safety/network.js';
32
- import { chat, 규격이름, 더할머리, 나가는눈금 } from './backend/adapter.js';
33
- import { 잠잠기본, 무소식기본 } from './backend/http.js';
34
- import { 인증서설정, 인증서말 } from './backend/clientcert.js';
24
+ import { chat, 규격이름 } from './backend/adapter.js';
25
+ import { 인증서말 } from './backend/clientcert.js';
35
26
  import { 알림채움, 알림말 } from './backend/retry.js';
36
- import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
27
+ import { 프록시고르기, 프록시설정, 프록시비켜가나 } from './backend/proxy.js';
37
28
  import { 정한셸 } from './tools/shell.js';
38
29
  import { TOOLS, 영어설명 } from './tools/index.js';
39
30
  import { 둘러보기, 프로젝트갈래 } from './lsp/servers.js';
40
31
  import { 지금것들 } from './lsp/client.js';
41
32
  import { 보고서적기 } from './ui/export.js';
42
- import { loadCommand, discover } from './skills/discover.js';
43
- import { install, list, remove, pack } from './plugins/manage.js';
33
+ import { loadCommand } from './skills/discover.js';
44
34
  import { spin } from './ui/spinner.js';
35
+ import { 빗금펴기 } from './ui/complete.js';
45
36
  import { 그림고르기설정, 끔설정, 환경그림, 환경으로껐나 } from './ui/motion.js';
46
37
  import { 사무실설정, 최소높이, 최소폭 } from './ui/office.js';
47
38
 
@@ -85,18 +76,19 @@ function 환경이하는말() {
85
76
  if (환경으로껐나()) 말들.push(c.gray(말('motion.envOff')));
86
77
  return 말들;
87
78
  }
88
- import { PROFILES, LEVELS as THINK_LEVELS, normalizeProfile, table as effortTable, 가벼운강도, shiftLevel } from './agent/effort.js';
79
+ import { PROFILES, LEVELS as THINK_LEVELS, normalizeProfile } from './agent/effort.js';
89
80
  import { 전선붙이기, 전선말 } from './backend/wire.js';
90
81
  import { scanLocal, toProfiles } from './backend/scan.js';
91
82
  import { list as listSessions } from './agent/store.js';
92
- import { MODES as WORK_MODES, ORDER as WORK_ORDER, DEFAULT as WORK_DEFAULT, normalize as normWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
93
- import { LEVELS, ORDER as LEVEL_ORDER, DEFAULT as LEVEL_DEFAULT, normalize as normLevel, shows as levelShows } from './ui/level.js';
94
- import { diffLines, renderDiff, shortStat } from './ui/diff.js';
95
- import { readTextFull } from './tools/fsutil.js';
96
- import { 띄우기, 브라우저로 } from './preview/serve.js';
97
- import { 클립보드그림, 그림앉히기 } from './tools/clipboard.js';
98
- import { 크기말 } from './backend/vision.js';
99
- import { 명령들 } from './cmdnames.js';
83
+ import { MODES as WORK_MODES, ORDER as WORK_ORDER, normalize as normWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
84
+ import { COMMANDS, 설정남기기 } from './commands/common.js';
85
+ import { 딴이름들 } from './cmdnames.js';
86
+ import { doPlugin, 미리보기, showSkills, 미리보기끄기 } from './commands/extend.js';
87
+ import { help, showLevel, 강조, showThink, showContext, showWork } from './commands/view.js';
88
+ import { 갈래명령, 증거명령, 붙여넣기명령, 리뷰명령, 커밋명령, 카드명령, 못박기명령, 배움명령, 바뀐것보기 } from './commands/work.js';
89
+ import { 출력상한, 모델급, ctxLength, switchModel } from './commands/model.js';
90
+ // 나눠 옮긴 조각의 이름 중 바깥이 이 파일에서 찾는 것 — repl.js 와 검사들이 여기서 가져간다.
91
+ export { COMMANDS, 미리보기끄기, 붙여넣기명령 };
100
92
  const MODES = {
101
93
  auto: '자율 — 전부 알아서. 되돌리기가 안전망',
102
94
  confirm: '확인 — 되돌릴 수 없는 것만 물어봄',
@@ -104,18 +96,6 @@ const MODES = {
104
96
  };
105
97
 
106
98
 
107
- /**
108
- * 화면에 낼 명령표. desc·arg 는 볼 때마다 지금 언어로 읽는다.
109
- *
110
- * @type {Record<string, {desc: string, arg?: string}>}
111
- */
112
- export const COMMANDS = Object.fromEntries(Object.entries(명령들).map(([이름, 꼴]) => {
113
- const 것 = { get desc() { return 말(`cmd.${이름}.desc`); } };
114
- if (꼴.arg) Object.defineProperty(것, 'arg', { get: () => 말(`cmd.${이름}.arg`), enumerable: true });
115
- return [이름, 것];
116
- }));
117
-
118
-
119
99
  /**
120
100
  * 이 줄이 명령이 아니라 '경로' 인가.
121
101
  *
@@ -131,7 +111,10 @@ function 경로처럼보이나(line) {
131
111
  const 첫낱말 = line.slice(1).split(/\s+/)[0] ?? '';
132
112
  if (첫낱말.includes('/') || 첫낱말.includes('\\')) return true;
133
113
  // `/tmp` 처럼 슬래시가 하나뿐이어도, 실제로 있는 자리면 경로로 본다.
134
- if (첫낱말 && !COMMANDS[첫낱말.toLowerCase()]) {
114
+ // 딴이름(`/serve` · `/plugins`)도 이름이다. 이 줄이 표 하나만 보던 동안에는
115
+ // `plugins/` 폴더가 있는 저장소에서 `/plugins` 가 경로로 읽혀 명령이 안 돌고
116
+ // 모델에게 그대로 넘어갔다.
117
+ if (첫낱말 && !COMMANDS[첫낱말.toLowerCase()] && !딴이름들[첫낱말.toLowerCase()]) {
135
118
  /*
136
119
  * **적힌 그대로**도 보고, 앞 슬래시를 뗀 것도 본다.
137
120
  *
@@ -147,45 +130,6 @@ function 경로처럼보이나(line) {
147
130
 
148
131
  // 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
149
132
 
150
- /**
151
- * 설정을 남기고, 못 남겼으면 화면에 한 줄 (config.js 의 저장시도).
152
- *
153
- * 여태 아홉 자리가 전부 `try { save(cfg) } catch {}` 였다. 못 남겨도 이번 판에는
154
- * 먹으니 대화는 계속되는데, 바로 다음 줄에서 화면은 `✓ 바꿨습니다` 를 찍는다.
155
- * 사람은 정해진 줄 알고 창을 닫았다가 다음에 옛 값을 보고, 그때는 무엇 때문인지
156
- * 알 길이 없다 — 홈이 읽기 전용인지, 디스크가 찼는지.
157
- *
158
- * @returns {boolean} 남겼나
159
- */
160
- function 설정남기기(cfg, 옵션 = {}) {
161
- const r = 저장시도(cfg, 옵션);
162
- if (!r.ok) say(` ${mark.warn} ${c.yellow(말('common.cfgSaveFailed', { 왜: clip(r.왜, 70) }))}`);
163
- return r.ok;
164
- }
165
-
166
- /**
167
- * **지금 이 연결이 온 프로필**을 찾는다. 못 찾으면 null 이다.
168
- *
169
- * 여태 `cfg.profiles.find(p => p.id === cfg.active) ?? cfg.profiles[0]` 였다.
170
- * 그 뒤 갈래가 「못 찾았으면 첫 번째에 쓴다」 라, active 가 어긋나 있으면
171
- * `/ctx 655360` 이 **엉뚱한 연결**에 박히고 화면에는 「프로필에 저장했습니다」
172
- * 가 뜬다. 8k 서버 프로필에 655,360 이 적히면 다음에 켤 때 그 서버를
173
- * 655,360 으로 믿고 시작한다 — 긴 대화에서 서버가 거절하고, 그 까닭을
174
- * 짚을 자리가 화면에 하나도 없다.
175
- *
176
- * 그래서 두 번 본다. active 로 찾고, 없으면 **지금 붙어 있는 주소·모델**로
177
- * 찾는다. 그래도 없으면 못 찾았다고 하고 아무 데도 안 적는다 (repl.js 의
178
- * 길이 되살리기가 이미 그렇게 한다).
179
- */
180
- function 이연결의프로필(cfg, session) {
181
- const 있는것 = Array.isArray(cfg?.profiles) ? cfg.profiles : [];
182
- const 딱 = 있는것.find((p) => p.id === cfg.active);
183
- if (딱) return 딱;
184
- const base = session?.conn?.base;
185
- const model = session?.conn?.model;
186
- return 있는것.find((p) => p.baseUrl === base && p.model === model) ?? null;
187
- }
188
-
189
133
  /** /mode 아래에 규칙을 늘어놓는다. 아무것도 안 걸려 있으면 아무 말도 안 한다. */
190
134
  function 규칙보이기(규칙들) {
191
135
  if (!규칙들) return;
@@ -201,7 +145,75 @@ function 규칙보이기(규칙들) {
201
145
  if (규칙들.탈) say(` ${mark.warn} ${c.yellow(규칙들.탈)}`);
202
146
  }
203
147
 
148
+ /*
149
+ * ── 오타에 「혹시 이것」 ─────────────────────────────────────────────────
150
+ *
151
+ * `/hepl` · `/modle` 에는 「모르는 명령」 만 나왔다. 비슷한 것은 이 PC 에서 찾은 명령(스킬·플러그인)
152
+ * 에서만 찾았고 **내장 명령은 안 봤다** — 제일 흔한 오타는 내장 명령에서 난다.
153
+ *
154
+ * 넣기·빼기·바꾸기와 **이웃 두 글자 뒤바꾸기**를 한 번으로 친다. 손가락이 틀리는 모양이 대개
155
+ * 그 넷이다(hepl · modle). 짧은 이름은 한 번까지만 봐준다 — 넷 글자에 두 번을 봐주면 아무
156
+ * 명령이나 비슷해진다.
157
+ */
158
+ function 오타거리(a, b) {
159
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
160
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
161
+ for (let i = 1; i <= a.length; i++) {
162
+ for (let j = 1; j <= b.length; j++) {
163
+ const 다름 = a[i - 1] === b[j - 1] ? 0 : 1;
164
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + 다름);
165
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
166
+ }
167
+ }
168
+ return d[a.length][b.length];
169
+ }
170
+
171
+ function 비슷한이름(친것, 이름들) {
172
+ if (!친것) return [];
173
+ const 한도 = 친것.length <= 4 ? 1 : 2;
174
+ return 이름들.map((n) => [n, 오타거리(친것, n.toLowerCase())])
175
+ .filter(([, 거리]) => 거리 <= 한도)
176
+ .sort((x, y) => x[1] - y[1] || x[0].localeCompare(y[0]))
177
+ .map(([n]) => n);
178
+ }
179
+
180
+ /** `ext:ReviewCode` 의 꼬리 — 언제나 낮춰서 돌려준다. 찾기와 「비슷한 것」 이 같은 잣대를 쓰게 한다. */
181
+ const 꼬리이름 = (이름) => String(이름 ?? '').split(':').pop().toLowerCase();
182
+
183
+ /*
184
+ * ── 이 PC 에서 찾은 슬래시 명령을 이름으로 찾는다 ───────────────────────
185
+ *
186
+ * 대화 화면(아래 default 갈래)과 배치(oneshot.js)가 **같은 규칙**을 써야 한다. 같은 글자를
187
+ * 쳤는데 창에 따라 되고 안 되면 사람은 어느 쪽을 믿을지 정할 수 없다. 그래서 두 벌로 적지
188
+ * 않고 여기 한 군데에 둔다.
189
+ *
190
+ * 찾는 차례는 셋이다 — 적은 그대로 · 대소문자 무시 · 꼬리 이름.
191
+ * 마지막 칸이 `x.name.split(':').pop() === 낮춘` 이었다 (8회차 그밖 명령2). 왼쪽은 파일에
192
+ * 적힌 그대로고 오른쪽은 낮춘 말이라 둘이 만날 수가 없다. `ext:ReviewCode` 는 `/ReviewCode`
193
+ * 로도 `/reviewcode` 로도 「모르는 명령」 이었고, 「비슷한 것」 에도 안 떴다 — 깔려 있는
194
+ * 명령이 어느 길로도 안 보이는 자리다. 배치에서는 그대로 1 로 선다.
195
+ */
196
+ export function 슬래시명령찾기(목록, 부른이름) {
197
+ const 것들 = 목록 ?? [];
198
+ const 낮춘 = String(부른이름 ?? '').toLowerCase();
199
+ return 것들.find((x) => x.name === 부른이름)
200
+ ?? 것들.find((x) => x.name.toLowerCase() === 낮춘)
201
+ ?? 것들.find((x) => 꼬리이름(x.name) === 낮춘)
202
+ ?? null;
203
+ }
204
+
205
+ /** 못 찾았을 때 「비슷한 것」 으로 늘어놓을 것. 찾기와 같은 잣대(낮춘 꼬리)를 쓴다. */
206
+ export function 비슷한슬래시명령(목록, 부른이름, 몇 = 5) {
207
+ const 낮춘 = String(부른이름 ?? '').toLowerCase();
208
+ if (!낮춘) return [];
209
+ return (목록 ?? [])
210
+ .filter((x) => x.name.toLowerCase().includes(낮춘) || 낮춘.includes(꼬리이름(x.name)))
211
+ .slice(0, 몇);
212
+ }
213
+
204
214
  export async function handle(line, session, ctx) {
215
+ // 일본어·중국어 입력기의 전각 빗금(/help). 그대로 두면 명령이 말이 되어 모델에게 간다(ui/complete.js).
216
+ line = 빗금펴기(line);
205
217
  if (!line.startsWith('/')) return { handled: false };
206
218
  if (경로처럼보이나(line)) return { handled: false };
207
219
  const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
@@ -210,6 +222,8 @@ export async function handle(line, session, ctx) {
210
222
 
211
223
  switch (name) {
212
224
  case 'help': return help(session), { handled: true };
225
+ // 슬래시만 치고 Enter. 「모르는 명령 /」 은 무엇을 모르는지 알 수 없는 말이다 — 목록을 보인다.
226
+ case '': return help(session), { handled: true };
213
227
  case 'level': return showLevel(session, arg), { handled: true };
214
228
 
215
229
  /*
@@ -476,7 +490,10 @@ export async function handle(line, session, ctx) {
476
490
  // 못 남긴 까닭을 받아 온다 — 디스크가 찼는지, 폴더가 읽기 전용인지는
477
491
  // 화면에 적혀야 손을 쓸 수 있다.
478
492
  const 탈 = {};
479
- const 자리 = 보고서적기(ctx.scope.root, session, { scope: ctx.scope, audit: ctx.audit }, 탈);
493
+ // 도움말이 `[파일이름]` 이라고 적어 둔 그 인자. 여기서 안 넘기던 동안에는
494
+ // 무엇을 적어 주든 늘 시각으로 지은 이름이 됐다.
495
+ const 자리 = 보고서적기(ctx.scope.root, session,
496
+ { scope: ctx.scope, audit: ctx.audit, 이름: arg }, 탈);
480
497
  say('');
481
498
  if (자리) {
482
499
  say(` ${mark.ok} ${말('export.saved')} ${c.white(ctx.scope.show(자리))}`);
@@ -724,9 +741,12 @@ export async function handle(line, session, ctx) {
724
741
  return { handled: true };
725
742
  }
726
743
  const 줄인 = r.before - r.after;
727
- s.stop(` ${mark.ok} 대화 ${r.folded}개를 요약으로 접었습니다.`);
744
+ // 요약을 못 받고 잘라 낸 것을 「✓ 요약으로 접었습니다」 로 먼저 적지 않는다 (repl.js 의 compacted).
745
+ s.stop(r.fallback
746
+ ? ` ${mark.warn} 요약을 못 받아 옛 대화 ${r.folded}개를 잘라 냈습니다.`
747
+ : ` ${mark.ok} 대화 ${r.folded}개를 요약으로 접었습니다.`);
728
748
  say(` ${c.gray(r.before.toLocaleString())} ${c.gray('→')} ${c.white(r.after.toLocaleString())} ${c.gray('토큰')} ${c.green(`${Math.round((줄인 / Math.max(1, r.before)) * 100)}% 줄어듦`)}`);
729
- if (r.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
749
+ if (r.fallback) say(` ${c.yellow(clip(String(r.why ?? '요약을 못 받아 그냥 줄였습니다.'), 100))}`);
730
750
  else if (r.summary) {
731
751
  say('');
732
752
  for (const line of r.summary.split('\n').slice(0, 14)) say(` ${c.gray(clip(line, 76))}`);
@@ -852,7 +872,9 @@ export async function handle(line, session, ctx) {
852
872
  * (agent/loop.js 의 needsOk). 화면에는 `엄격 → undefined` 가 찍힌다.
853
873
  * `--work` 오타가 가장 센 모드로 돌던 것과 같은 자리다.
854
874
  */
855
- if (!Object.hasOwn(MODES, arg)) {
875
+ // 대소문자는 안 가린다. `/mode AUTO` 가 목록만 보이고 안 바뀌면 사람은 먹은 줄 안다.
876
+ const 승인이름 = arg.trim().toLowerCase();
877
+ if (!Object.hasOwn(MODES, 승인이름)) {
856
878
  rule('승인 방식 — 무엇을 물어볼까', 70);
857
879
  for (const k of 승인차례) {
858
880
  const 지금 = k === session.mode;
@@ -876,14 +898,14 @@ export async function handle(line, session, ctx) {
876
898
  return { handled: true };
877
899
  }
878
900
  const 앞 = session.mode;
879
- session.mode = arg;
880
- const m = 승인[arg];
901
+ session.mode = 승인이름;
902
+ const m = 승인[승인이름];
881
903
  say('');
882
- say(` ${mark.ok} ${승인표시(앞)} ${c.gray('→')} ${승인표시(arg)}`);
904
+ say(` ${mark.ok} ${승인표시(앞)} ${c.gray('→')} ${승인표시(승인이름)}`);
883
905
  say(` ${c.gray(m.한줄)}`);
884
906
  // 안 묻는 쪽으로 옮길 때만 안전망을 짚어 준다. 반대로 갈 때는 안 짚는다 —
885
907
  // 조심하는 쪽으로 가는 사람에게 경고를 붙일 이유가 없다.
886
- if (arg === 'auto' && 앞 !== 'auto') {
908
+ if (승인이름 === 'auto' && 앞 !== 'auto') {
887
909
  say(` ${c.gray('되돌리려면')} ${c.cyan('/undo')}${c.gray(', 무엇이 바뀌었는지는')} ${c.cyan('/diff')}`);
888
910
  }
889
911
  say('');
@@ -912,13 +934,24 @@ export async function handle(line, session, ctx) {
912
934
  * 도는 값이 다른 명령보다 비싸다. /work 가 오타를 먼저 말하는 것과
913
935
  * 같은 잣대다.
914
936
  */
915
- const 준말 = String(arg ?? '').trim();
937
+ // 전각 숫자(3)는 보통 숫자로 편다 — 일본어·중국어 입력기는 번호를 이렇게 낸다. 목록 번호(pick)는
938
+ // 이미 펴서 읽는데 여기만 「숫자로 적어 주세요」 로 돌려보냈다(4회차 이월).
939
+ const 준말 = String(arg ?? '').trim().replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xfee0));
916
940
  if (준말 && !/^[0-9]+$/.test(준말)) {
917
941
  say(` ${mark.no} ${c.gray('몇 턴을 되돌릴지 숫자로 적어 주세요.')} ${c.cyan('/undo 3')}`);
918
942
  say(` ${c.gray('그냥')} ${c.cyan('/undo')} ${c.gray('만 치면 한 턴을 되돌립니다.')}`);
919
943
  say('');
920
944
  return { handled: true };
921
945
  }
946
+ /*
947
+ * `/undo 0` 은 되돌릴 것이 없다는 말이다. 여태 아래 `|| 1` 에 걸려 **한 턴을 말없이 되돌렸다** —
948
+ * 파일을 실제로 되돌리는 명령이라, 친 수보다 많이 도는 것이 가장 비싸다(위 머리말과 같은 잣대).
949
+ */
950
+ if (준말 && parseInt(준말, 10) === 0) {
951
+ say(` ${c.gray('0 턴이라 아무것도 되돌리지 않았습니다.')} ${c.cyan('/undo 1')} ${c.gray('처럼 1 이상을 적어 주세요.')}`);
952
+ say('');
953
+ return { handled: true };
954
+ }
922
955
  const n = Math.max(1, parseInt(준말, 10) || 1);
923
956
  const r = ctx.history.undo(n);
924
957
  /*
@@ -940,8 +973,36 @@ export async function handle(line, session, ctx) {
940
973
  * 파일 목록을 성공 목록처럼 늘어놓았다.
941
974
  */
942
975
  if (!되돌린수) {
943
- say(` ${c.gray(말('undo.nothing'))}`);
976
+ /*
977
+ * ── 턴이 있었는데 **하나도 안 돌아간** 것과 되돌릴 턴이 없는 것은 다른 말이다 (6회차 Gemini 되돌림명령6q R2·R3) ──
978
+ *
979
+ * 여기는 「되돌릴 것이 없습니다.」 한 줄과 실패 수만 찍고 돌아갔다. 옮긴 그림만 있던 턴처럼 기록은 있었는데
980
+ * 전부 그대로 두었거나 실패한 판에서, 사람은 턴이 없었던 줄 알았고 왜 안 돌아왔는지(그대로 둔 까닭 · 실패 까닭)도
981
+ * 못 봤다. 아래 이력 줄임 경고도 이 갈래를 못 지나갔다.
982
+ */
983
+ const 남긴것 = r.restored ?? [];
984
+ if (!r.turns) say(` ${c.gray(말('undo.nothing'))}`);
985
+ else if (!남긴것.length) {
986
+ /*
987
+ * ── 턴을 **먹고도** 「되돌릴 것이 없습니다」 라고 하지 않는다 (8회차 그밖 한번더) ──
988
+ *
989
+ * 만들어 둔 파일을 사람이 손으로 지운 턴이 이 자리로 온다. undo() 는 되돌릴 것을
990
+ * 하나도 못 찾지만 그 턴을 **이력에서는 지운다.** 그런데 화면은 턴이 아예 없을 때와
991
+ * 똑같이 「되돌릴 것이 없습니다.」 한 줄이었다. 사람은 아무 일도 안 난 줄 알고 /undo
992
+ * 를 한 번 더 치고, 그러면 그 **앞 턴**이 되돌아간다 — 두 번 쳐서 한 턴만 돌아가고
993
+ * 사이의 턴은 말없이 사라진 것이다. 재 보니 그대로였다(첫 /undo 뒤 turns 가 둘에서
994
+ * 하나로 줄고, 둘째 /undo 가 그 앞 턴의 파일을 되돌렸다).
995
+ */
996
+ say(` ${mark.warn} ${c.gray(`${r.turns}개 턴을 봤지만 되돌릴 것이 하나도 없었습니다.`)}`);
997
+ if (r.이력줄임?.ok !== false) {
998
+ say(` ${c.gray('그 턴은 되돌리기 기록에서 빠졌습니다 — 여기서')} ${c.cyan('/undo')} ${c.gray('를 또 치면 그 앞 턴이 되돌아갑니다.')}`);
999
+ }
1000
+ } else {
1001
+ say(` ${mark.warn} ${c.gray(`${r.turns}개 턴을 봤지만 되돌아간 파일은 없습니다 — 아래는 그대로 두었거나 못 되돌린 것입니다.`)}`);
1002
+ for (const f of 남긴것) say(` ${c.gray(ctx.scope.show(f.path))} ${c.gray(f.how)}`);
1003
+ }
944
1004
  if (r.못한것?.length) say(` ${mark.warn} ${말('undo.failed', { n: r.못한것.length })}`);
1005
+ if (r.이력줄임 && r.이력줄임.ok === false) say(` ${mark.warn} ${c.gray('되돌리기 기록을 못 지웠습니다')} ${c.gray(`— ${r.이력줄임.왜}`)}`);
945
1006
  say('');
946
1007
  return { handled: true };
947
1008
  }
@@ -1001,11 +1062,30 @@ export async function handle(line, session, ctx) {
1001
1062
  const 한줄 = 되감음.사람말.replace(/\s+/g, ' ').trim();
1002
1063
  say(` ${c.gray(말('undo.saidWas'))} ${c.cyan(한줄.length > 60 ? `${한줄.slice(0, 60)}…` : 한줄)}`);
1003
1064
  }
1065
+ /*
1066
+ * ── 걷은 턴과 **못 걷은** 턴이 섞이면 둘 다 말한다 ──────────────────
1067
+ *
1068
+ * `/undo 2` 에서 앞 턴은 요약에 접혀 자리표가 없고 뒤 턴만 살아 있으면,
1069
+ * 파일은 둘 다 되돌아가도 말은 뒤 턴 것만 걷힌다. 여태 이 갈래는
1070
+ * 「대화도 걷어냈습니다」 만 적어서, 사람은 앞 턴 이야기도 없어진 줄
1071
+ * 알았다 — 아래 else 갈래가 통째로 못 걷었을 때 하는 말을 반만 못
1072
+ * 걷었을 때는 안 했다. session.js 의 되감기() 머리말대로 「못 찾으면
1073
+ * 그 턴은 되감을 수 없다고 정직하게 말한다」.
1074
+ */
1075
+ if (되감음.못걷은턴?.length) {
1076
+ say(` ${c.gray(말('undo.talkPartial', { n: 되감음.못걷은턴.length }))}`);
1077
+ say(` ${c.gray(말('undo.talkKeptWhy'))}`);
1078
+ }
1004
1079
  } else {
1005
1080
  // 접기·요약이 그 자리를 이미 가져갔을 때다. 파일은 되돌아갔지만 대화는
1006
1081
  // 못 걷었다는 것을 숨기지 않는다 — 숨기면 위의 사고가 그대로 난다.
1007
1082
  say(` ${c.gray(말('undo.talkKept'))}`);
1008
1083
  say(` ${c.gray(말('undo.talkKeptWhy'))}`);
1084
+ /*
1085
+ * 대화는 못 걷었어도 **그 턴이 박은 쪽지**는 뺐다(session.js 의 되감기).
1086
+ * 뺀 것을 안 말하면 「그대로 둡니다」 가 반만 맞는 말이 된다.
1087
+ */
1088
+ if (되감음.뺀쪽지) say(` ${c.gray(말('undo.notesDropped', { n: 되감음.뺀쪽지 }))}`);
1009
1089
  }
1010
1090
  say('');
1011
1091
  return { handled: true };
@@ -1176,7 +1256,8 @@ export async function handle(line, session, ctx) {
1176
1256
  // 적어 놨는데 못 쓰는 것(socks5 등)이면 그 까닭을 — 조용히 직접 가면 사람은 프록시를 탄 줄 안다.
1177
1257
  const 프록시 = 프록시고르기(k.base);
1178
1258
  if (프록시) say(` ${c.gray(pad(말('status.proxy'), 10))} ${프록시.url} ${c.gray(`(${프록시.출처})`)}`);
1179
- else if (프록시설정().탈) say(` ${c.gray(pad(말('status.proxy'), 10))} ${c.yellow(말('status.proxyOff'))} ${c.gray(`— ${프록시설정().탈}`)}`);
1259
+ // 어차피 안 거칠 주소(이 PC · NO_PROXY)면 그 줄도 없다 — 상관없는 프록시를 탓하게 된다 (2.0.0 6회차 RP1b).
1260
+ else if (프록시설정().탈 && !프록시비켜가나(k.base)) say(` ${c.gray(pad(말('status.proxy'), 10))} ${c.yellow(말('status.proxyOff'))} ${c.gray(`— ${프록시설정().탈}`)}`);
1180
1261
  say(` ${c.gray(pad(말('status.model'), 10))} ${k.model}`);
1181
1262
  /*
1182
1263
  * 이 모델이 이 주소에서 **실제로 받는 것** (backend/wire.js).
@@ -1396,7 +1477,14 @@ export async function handle(line, session, ctx) {
1396
1477
  const M = await import('./agent/memory.js');
1397
1478
  const 말 = String(arg ?? '').trim();
1398
1479
 
1399
- if (/^(비우기|clear|지우기전부)$/.test(말)) {
1480
+ /*
1481
+ * 무늬 둘에 `i` 가 빠져 있었다 (8회차 그밖 명령1). 아래 「번호를 적어 주세요」 쪽만
1482
+ * `i` 가 붙어 있어서, `/memory RM 1` 은 안 지우고 `/memory CLEAR` 는 어느 무늬에도
1483
+ * 안 걸린 채 맨 아래 「이걸 기억해라」 로 떨어졌다 — **지우려던 낱말이 기억으로 적혔다.**
1484
+ * 그 줄은 그 뒤로 매 요청마다 모델에게 같이 나간다. 아래 주석이 막으려던 바로 그 결말이,
1485
+ * 대문자로 친 사람에게만 그대로 일어나고 있었다.
1486
+ */
1487
+ if (/^(비우기|clear|지우기전부)$/i.test(말)) {
1400
1488
  M.비우기(session.root);
1401
1489
  session.memory = M.프롬프트토막(session.root);
1402
1490
  say(` ${mark.ok} 기억을 비웠습니다.`);
@@ -1404,7 +1492,7 @@ export async function handle(line, session, ctx) {
1404
1492
  return { handled: true };
1405
1493
  }
1406
1494
 
1407
- const 지움 = /^(지우기|잊어|forget|rm)\s+(\d+)$/.exec(말);
1495
+ const 지움 = /^(지우기|잊어|forget|rm)\s+(\d+)$/i.exec(말);
1408
1496
  if (지움) {
1409
1497
  const r = M.지우기(session.root, 지움[2]);
1410
1498
  if (!r.ok) say(` ${mark.no} ${r.why}`);
@@ -1451,6 +1539,11 @@ export async function handle(line, session, ctx) {
1451
1539
  session.memory = M.프롬프트토막(session.root);
1452
1540
  say(` ${mark.ok} 기억했습니다 ${c.gray(`(${r.줄수}줄)`)}`);
1453
1541
  if (r.넘침) say(` ${c.gray('자리가 차서 오래된 것을 뺐습니다.')}`);
1542
+ // 적기는 했지만 안 실리는 판을 말한다 (6회차 Gemini 기억6z-b Z2′). 여태 memory.js 가
1543
+ // 돌려주는 안실림 을 아무도 안 읽어, 「기억했습니다」 뒤로 한 번도 안 실렸다.
1544
+ if (r.안실림) {
1545
+ say(` ${c.yellow('적었지만 다음 요청부터 안 실립니다')} ${c.gray('— 믿는 폴더가 아니고, 이 PC 가 적지 않은 줄이 섞여 있습니다. 실으려면')} ${c.cyan('deel trust')}${c.gray(', 남의 줄을 빼려면')} ${c.cyan('/memory 지우기 N')}`);
1546
+ }
1454
1547
  }
1455
1548
  say('');
1456
1549
  return { handled: true };
@@ -1722,18 +1815,19 @@ export async function handle(line, session, ctx) {
1722
1815
 
1723
1816
  default: {
1724
1817
  // 이 PC 에서 찾은 슬래시 명령인지 본다. 있으면 그 내용을 모델에게 보낸다.
1725
- const found = (session.commands ?? []).find((x) => x.name === raw)
1726
- ?? (session.commands ?? []).find((x) => x.name.toLowerCase() === name)
1727
- ?? (session.commands ?? []).find((x) => x.name.split(':').pop() === name);
1818
+ // 찾는 규칙은 배치(oneshot.js)와 한 군데에서 같이 쓴다 — 위 슬래시명령찾기.
1819
+ const found = 슬래시명령찾기(session.commands, raw);
1728
1820
  if (found) {
1729
1821
  const { text, error } = loadCommand(found, arg);
1730
1822
  if (error) { say(` ${c.red('명령을 읽지 못했습니다')} ${error}`); say(''); return { handled: true }; }
1731
1823
  say(` ${c.cyan('⌘')} ${found.name} ${c.gray(found.source)}`);
1732
1824
  return { handled: false, text };
1733
1825
  }
1734
- const near = (session.commands ?? [])
1735
- .filter((x) => x.name.includes(name) || name.includes(x.name.split(':').pop()))
1736
- .slice(0, 5).map((x) => '/' + x.name);
1826
+ // 내장 명령의 오타가 먼저, 이 PC 에서 찾은 명령이 그 뒤다(위 비슷한이름 머리말).
1827
+ const near = [...new Set([
1828
+ ...비슷한이름(name, Object.keys(COMMANDS)).map((x) => '/' + x),
1829
+ ...비슷한슬래시명령(session.commands, raw).map((x) => '/' + x.name),
1830
+ ])].slice(0, 5);
1737
1831
  say(` ${c.red('모르는 명령')} /${name}`);
1738
1832
  if (near.length) say(` ${c.gray('비슷한 것:')} ${near.join(' ')}`);
1739
1833
  say(` ${c.gray('/help 로 목록을, /skills 로 스킬을 봅니다.')}`);
@@ -1743,1956 +1837,6 @@ export async function handle(line, session, ctx) {
1743
1837
  }
1744
1838
  }
1745
1839
 
1746
- async function doPlugin(session, arg) {
1747
- const [sub = '', ...rest] = arg.trim().split(/\s+/);
1748
- const param = rest.join(' ').trim();
1749
-
1750
- // 설치·삭제 뒤에는 다시 훑어 이번 대화에 바로 반영한다.
1751
- const rescan = () => {
1752
- const f = discover(session.root);
1753
- session.skills = f.skills;
1754
- session.commands = f.commands;
1755
- session.plugins = f.plugins;
1756
- return f;
1757
- };
1758
-
1759
- if (sub === 'install' || sub === 'add') {
1760
- if (!param) {
1761
- say(` ${c.gray('예')} /plugin install affaan-m/ECC`);
1762
- say(` ${c.gray(' ')} /plugin install owner/repo#가지이름`);
1763
- say('');
1764
- return;
1765
- }
1766
- say('');
1767
- const s = spin(`${param} 받는 중...`);
1768
- const r = await install(param, { onStep: (m) => {} });
1769
- if (r.error) {
1770
- s.stop(` ${mark.no} ${c.red(r.error.split('\n')[0])}`);
1771
- for (const line of r.error.split('\n').slice(1)) say(` ${c.gray(line.trim())}`);
1772
- say(` ${c.gray('오프라인이면 zip 을 ~/.deel/plugins/ 에 직접 풀면 됩니다.')}`);
1773
- say('');
1774
- return;
1775
- }
1776
- s.stop(` ${mark.ok} ${c.bold(r.name)}${r.version ? ' ' + c.gray(r.version) : ''} ${c.gray(`(${r.license ?? '라이선스 미상'})`)}`);
1777
- say(` ${c.gray('스킬')} ${r.skills}개 ${c.gray('명령')} ${r.commands}개 ${c.gray(`· ${r.how} 로 받음`)}`);
1778
- if (r.hooks) say(` ${mark.warn} ${c.yellow(`실행 스크립트 ${r.hooks}개는 쓰지 않습니다`)} ${c.gray('(hook 미지원 · 반입 심사 대상)')}`);
1779
- const f = rescan();
1780
- say(` ${c.gray(`이제 스킬 ${f.skills.length}개 · 명령 ${f.commands.length}개`)}`);
1781
- say('');
1782
- return;
1783
- }
1784
-
1785
- if (sub === 'remove' || sub === 'rm' || sub === 'uninstall') {
1786
- if (!param) { say(` ${c.gray('예')} /plugin remove ecc`); say(''); return; }
1787
- const r = remove(param);
1788
- if (r.error) { say(` ${mark.no} ${c.red(r.error)}`); say(''); return; }
1789
- const f = rescan();
1790
- say(` ${mark.ok} ${c.bold(r.removed)} 지웠습니다. ${c.gray(`이제 스킬 ${f.skills.length}개`)}`);
1791
- say('');
1792
- return;
1793
- }
1794
-
1795
- if (sub === 'pack') {
1796
- const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
1797
- const out = param || join(session.root, `deel-plugins-${stamp}.zip`);
1798
- const r = pack(out, { only: null });
1799
- if (r.error) { say(` ${mark.no} ${c.red(r.error)}`); say(''); return; }
1800
- say('');
1801
- say(` ${mark.ok} ${c.cyan(r.out)}`);
1802
- say(` ${c.gray('플러그인')} ${r.plugins.length}개 ${c.gray('· 파일')} ${r.files}개 ${c.gray('·')} ${(r.bytes / 1024).toFixed(0)}KB`);
1803
- if (r.skipped) say(` ${c.gray(`실행 스크립트 ${r.skipped}개는 뺐습니다`)}`);
1804
- say('');
1805
- rule('담긴 것', 74);
1806
- for (const p of r.plugins) {
1807
- say(` ${c.cyan(pad(p.name, 22))} ${c.gray(pad(p.version || '-', 9))} ${c.gray(pad(p.license || '미상', 14))} ${c.gray(`스킬 ${p.skills} · 명령 ${p.commands}`)}`);
1808
- }
1809
- say('');
1810
- say(` ${c.gray('오프라인 기기의')} ~/.deel/plugins/ ${c.gray('에 풀면 바로 인식됩니다. 설치 명령은 필요 없습니다.')}`);
1811
- say(` ${c.gray('묶음 안에 라이선스 표가 담긴 사용안내.txt 가 함께 들어 있습니다.')}`);
1812
- say('');
1813
- return;
1814
- }
1815
-
1816
- // 인자 없으면 목록
1817
- const items = list();
1818
- if (!items.length) {
1819
- say('');
1820
- say(` ${c.gray('설치된 플러그인이 없습니다.')}`);
1821
- say(` ${c.gray('받기')} /plugin install owner/repo`);
1822
- say(` ${c.gray('직접')} ~/.deel/plugins/ 에 폴더를 풀어 넣어도 됩니다`);
1823
- say('');
1824
- return;
1825
- }
1826
- say('');
1827
- rule('플러그인', 74);
1828
- for (const p of items) {
1829
- say(` ${c.cyan(pad(p.name, 22))} ${c.gray(pad(p.version || '-', 9))} ${c.gray(pad(p.license || '미상', 14))} ${c.gray(`스킬 ${p.skills} · 명령 ${p.commands}`)}`);
1830
- say(` ${c.gray(p.from)}`);
1831
- }
1832
- say('');
1833
- say(` ${c.gray('/plugin install <owner/repo> /plugin remove <이름> /plugin pack [파일]')}`);
1834
- say('');
1835
- }
1836
-
1837
- /*
1838
- * 만든 웹을 그 자리에서 띄운다.
1839
- *
1840
- * 여기 있는 것은 화면과 말뿐이고, 서버는 preview/serve.js 가 한다.
1841
- * 한 번에 하나만 띄운다 — 여러 개를 띄워 놓으면 어느 주소가 무엇인지
1842
- * 아무도 못 외우고, 끌 때도 뭘 껐는지 모른다.
1843
- */
1844
- let 미리보기중 = null;
1845
-
1846
- /** 켜져 있으면 끈다. 프로그램을 끝낼 때도 이걸 부른다. */
1847
- export async function 미리보기끄기() {
1848
- if (!미리보기중) return null;
1849
- const 끈것 = 미리보기중;
1850
- 미리보기중 = null;
1851
- try { await 끈것.서버.닫기(); } catch { /* 이미 닫혔다 */ }
1852
- return 끈것;
1853
- }
1854
-
1855
- /** 지금 띄워 둔 것. 상태줄·검사에서 본다. */
1856
- async function 미리보기(session, ctx, arg) {
1857
- const 말 = String(arg ?? '').trim();
1858
-
1859
- if (/^(off|끄기|중지|stop)$/i.test(말)) {
1860
- const 끈것 = await 미리보기끄기();
1861
- say('');
1862
- say(끈것 ? ` ${mark.ok} 미리보기를 껐습니다. ${c.gray(끈것.서버.url)}`
1863
- : ` ${c.gray('띄워 둔 것이 없습니다.')}`);
1864
- say('');
1865
- return;
1866
- }
1867
-
1868
- // 이미 떠 있는데 또 치면, 주소를 다시 알려 주고 브라우저만 연다.
1869
- // 여기서 조용히 하나 더 띄우면 포트가 둘이 되고 어느 쪽이 진짜인지 모르게 된다.
1870
- if (미리보기중 && !말) {
1871
- say('');
1872
- say(` ${c.hgreen('▶')} 이미 띄워 뒀습니다 ${c.cyan(미리보기중.서버.url)} ${c.gray(미리보기중.보인이름)}`);
1873
- 브라우저로(미리보기중.서버.url);
1874
- say(` ${c.gray('끄려면')} ${c.cyan('/preview off')}`);
1875
- say('');
1876
- return;
1877
- }
1878
- // 다른 폴더를 주면 앞엣것은 끄고 새로 띄운다.
1879
- if (미리보기중) await 미리보기끄기();
1880
-
1881
- let 뿌리;
1882
- try {
1883
- 뿌리 = ctx.scope.resolve(말 || '.');
1884
- } catch (err) {
1885
- say('');
1886
- say(` ${c.red('띄울 수 없습니다')} ${c.gray(err.message)}`);
1887
- say('');
1888
- return;
1889
- }
1890
-
1891
- if (!existsSync(뿌리)) {
1892
- say('');
1893
- say(` ${c.red('그런 폴더가 없습니다')} ${c.gray(ctx.scope.show(뿌리))}`);
1894
- say('');
1895
- return;
1896
- }
1897
- // 파일 하나를 줬으면 그 파일이 든 폴더를 띄우고, 브라우저는 그 파일로 연다.
1898
- let 첫주소 = '';
1899
- if (!statSync(뿌리).isDirectory()) {
1900
- 첫주소 = encodeURIComponent(뿌리.split(/[\\/]/).pop());
1901
- 뿌리 = join(뿌리, '..');
1902
- 뿌리 = ctx.scope.resolve(뿌리);
1903
- }
1904
-
1905
- let 서버;
1906
- try {
1907
- 서버 = await 띄우기({ 뿌리, scope: ctx.scope });
1908
- } catch (err) {
1909
- say('');
1910
- say(` ${c.red('못 띄웠습니다')} ${c.gray(err.message)}`);
1911
- say('');
1912
- return;
1913
- }
1914
- 미리보기중 = { 서버, 보인이름: ctx.scope.show(뿌리) };
1915
-
1916
- const url = 서버.url + 첫주소;
1917
- say('');
1918
- say(` ${c.hgreen('▶')} ${c.bold('띄웠습니다')} ${c.cyan(url)}`);
1919
- say(` ${c.gray('보여 주는 것')} ${c.white(미리보기중.보인이름)}`);
1920
- say(서버.되살아나나
1921
- ? ` ${c.gray('파일을 고치면 화면이 저절로 새로 뜹니다.')}`
1922
- : ` ${c.yellow('⚠')} ${c.gray('이 자리에서는 파일 변화를 못 봅니다 — 브라우저를 손으로 새로 고치세요.')}`);
1923
- // 어디까지 열리는지 반드시 말한다. '서버를 띄웠다' 는 말은 사람마다 다르게 읽힌다.
1924
- say(` ${c.gray('이 컴퓨터에서만 열립니다(127.0.0.1). 다른 PC 에서는 안 보입니다.')}`);
1925
- say(` ${c.gray('끄려면')} ${c.cyan('/preview off')}${c.gray(' · deel 을 끝내면 같이 꺼집니다.')}`);
1926
- 브라우저로(url);
1927
- say('');
1928
- }
1929
-
1930
- function showSkills(session, arg) {
1931
- const all = session.skills ?? [];
1932
- if (!all.length) {
1933
- say('');
1934
- say(` ${c.gray('이 PC 에서 찾은 스킬이 없습니다.')}`);
1935
- say(` ${c.gray('찾는 자리: ./.deel/skills ./.claude/skills ~/.claude/skills ~/.claude/plugins')}`);
1936
- say('');
1937
- return;
1938
- }
1939
-
1940
- // 스킬은 남의 폴더·플러그인에서 온다. 앞머리(frontmatter)가 빠진 파일이 섞이면
1941
- // 이름이나 설명이 없다. 그걸 그대로 만지면 목록 하나 보려다 대화가 끝난다.
1942
- const 낮게 = (v) => String(v ?? '').toLowerCase();
1943
-
1944
- const q = arg.trim();
1945
- if (q === 'all') {
1946
- all.forEach((s) => { s.enabled = true; });
1947
- session.maxSkillsListed = Math.min(all.length, 200);
1948
- say(` ${mark.ok} 전부 올립니다 (${session.listedSkills().length}개). ${c.yellow('컨텍스트를 많이 차지합니다 — /context 로 확인하세요.')}`);
1949
- say('');
1950
- return;
1951
- }
1952
- if (q === 'off') {
1953
- all.forEach((s) => { s.enabled = false; });
1954
- say(` ${mark.ok} 스킬을 모두 내렸습니다.`);
1955
- say('');
1956
- return;
1957
- }
1958
- if (q.startsWith('on ')) {
1959
- const term = q.slice(3).trim().toLowerCase();
1960
- const 걸린것 = all.filter((s) => 낮게(s.name).includes(term) || 낮게(s.description).includes(term));
1961
- /*
1962
- * 한 개도 안 걸리면 **아무것도 안 건드린다.**
1963
- *
1964
- * 앞서는 걸림 여부를 그대로 enabled 에 넣었다. 그래서 오타 하나면 전부
1965
- * false 가 되고, 화면에는 `✓ "리뷰" 에 걸리는 0개만 올립니다` 가 떴다.
1966
- * 올리려고 친 명령이 가지고 있던 것까지 다 내려 버리는데, 표시는 ✓ 다.
1967
- * 그다음 모델이 스킬을 못 쓰는 것을 보고 사람은 스킬이 깨진 줄 안다.
1968
- */
1969
- if (!걸린것.length) {
1970
- say(` ${mark.no} "${term}" 에 걸리는 스킬이 없습니다 ${c.gray('— 올라간 것은 그대로 둡니다.')}`);
1971
- say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/skills')}`);
1972
- say('');
1973
- return;
1974
- }
1975
- for (const s of all) s.enabled = 걸린것.includes(s);
1976
- say(` ${mark.ok} "${term}" 에 걸리는 ${걸린것.length}개만 올립니다.`);
1977
- say('');
1978
- return;
1979
- }
1980
-
1981
- const hits = q
1982
- ? all.filter((s) => 낮게(s.name).includes(q.toLowerCase()) || 낮게(s.description).includes(q.toLowerCase()))
1983
- : session.listedSkills();
1984
-
1985
- say('');
1986
- rule(q ? `스킬 검색: ${q}` : '지금 올라간 스킬', 74);
1987
- for (const s of hits.slice(0, 30)) {
1988
- const tag = s.enabled ? c.green('●') : c.gray('○');
1989
- // 설명이 없을 수 있다. 스킬은 남의 폴더·플러그인에서 오는 것이라
1990
- // 앞머리(frontmatter)가 빠진 파일이 섞인다. 여기서 터지면 목록 하나 보려다
1991
- // 대화가 통째로 끝난다 — 화면 그리기는 무슨 일이 있어도 안 죽어야 한다.
1992
- say(` ${tag} ${c.cyan(pad(s.name, 32))} ${c.gray(String(s.description ?? '').slice(0, 60))}`);
1993
- }
1994
- if (hits.length > 30) say(` ${c.gray(`… 그 밖에 ${hits.length - 30}개`)}`);
1995
- say('');
1996
-
1997
- const bySource = { project: 0, user: 0, plugin: 0, builtin: 0 };
1998
- for (const s of all) bySource[s.source] = (bySource[s.source] ?? 0) + 1;
1999
- say(` ${c.gray('전체')} ${all.length}개 ${c.gray('(프로젝트')} ${bySource.project} ${c.gray('· 사용자')} ${bySource.user} ${c.gray('· 플러그인')} ${bySource.plugin} ${c.gray('· 품고 다니는 것')} ${bySource.builtin}${c.gray(')')}`);
2000
- say(` ${c.gray('프롬프트에 올라간 것')} ${session.listedSkills().length}개 ${c.gray(`(상한 ${session.maxSkillsListed})`)}`);
2001
- if ((session.plugins ?? []).length) {
2002
- say(` ${c.gray('플러그인')} ${session.plugins.filter((p) => p.skills > 0).map((p) => p.name).slice(0, 8).join(', ')}`);
2003
- }
2004
- say('');
2005
- say(` ${c.gray('/skills <검색어> 찾아보기')}`);
2006
- say(` ${c.gray('/skills on <검색어> 걸리는 것만 올리기')}`);
2007
- say(` ${c.gray('/skills all | off 전부 올리기 | 내리기')}`);
2008
- say('');
2009
- }
2010
-
2011
- function help(session) {
2012
- const level = session?.level ?? LEVEL_DEFAULT;
2013
- const 쉬움 = !levelShows(level, '__전부__'); // 개발자면 show 가 null 이라 전부 참
2014
- say('');
2015
- rule(말(쉬움 ? 'help.titleCommon' : 'help.title'), 70);
2016
- let 감춘수 = 0;
2017
- for (const [n, m] of Object.entries(COMMANDS)) {
2018
- if (n === 'quit') continue;
2019
- if (!levelShows(level, n)) { 감춘수++; continue; }
2020
- say(` ${c.cyan(pad('/' + n + (m.arg ? ' ' + m.arg : ''), 22))} ${c.gray(m.desc)}`);
2021
- }
2022
- say('');
2023
- // 감춘 것은 '못 쓰는 것' 이 아니다. 그 말을 분명히 해 둔다.
2024
- if (감춘수) {
2025
- say(` ${c.gray(말('help.moreHidden', { n: 감춘수 }))}`);
2026
- say(` ${c.gray(말('help.showAll'))} ${c.cyan('/level developer')}`);
2027
- say('');
2028
- }
2029
- say(` ${c.gray(말('help.restGoesToModel'))}`);
2030
- say('');
2031
- }
2032
-
2033
- // /level — 수준 보기·바꾸기. 설정 파일에 남겨 다음에도 그대로 쓴다.
2034
- function showLevel(session, arg) {
2035
- if (!arg) {
2036
- rule('사용자 수준', 70);
2037
- for (const k of LEVEL_ORDER) {
2038
- const lv = LEVELS[k];
2039
- const 지금 = k === (normLevel(session.level) ?? LEVEL_DEFAULT);
2040
- say(` ${지금 ? c.hgreen('●') : c.gray('·')} ${지금 ? c.bold(c.white(pad(lv.name, 10))) : c.gray(pad(lv.name, 10))}${c.gray(lv.hint)}`);
2041
- }
2042
- say('');
2043
- say(` ${c.gray('안전 장치는 두 수준이 똑같습니다 — 되돌리기·작업 범위·위험 명령 차단.')}`);
2044
- say(` ${c.gray('수준은 무엇을 보여줄지만 정합니다.')}`);
2045
- say('');
2046
- say(` ${c.gray('바꾸려면')} ${c.cyan('/level 쉬움')} ${c.gray('또는')} ${c.cyan('/level 개발자')}`);
2047
- say('');
2048
- return;
2049
- }
2050
- const 골라진 = normLevel(arg);
2051
- if (!골라진) {
2052
- say(` ${mark.warn} 그런 수준은 없습니다: ${c.white(arg)} ${c.gray('(쉬움 · 개발자)')}`);
2053
- say('');
2054
- return;
2055
- }
2056
- session.level = 골라진;
2057
- /*
2058
- * 못 남기면 말한다. 바로 아래가 `✓ 개발자` 를 찍는데, 못 남긴 판에서는
2059
- * 그 말이 이번 판에서만 참이다 — 설정남기기() 가 아홉 자리에서 없앤 그
2060
- * 꼴인데 이 자리만 옛 모양으로 남아 있었다.
2061
- */
2062
- try {
2063
- const cfg = load();
2064
- cfg.level = 골라진;
2065
- 설정남기기(cfg);
2066
- } catch (e) {
2067
- /*
2068
- * 설정 파일 자체를 못 읽는 경우다(JSON 이 깨졌다). 바로 위 머리글이
2069
- * 「못 남기면 말한다」 라고 적어 놓고 여기만 통째로 삼키고 있었다.
2070
- * 켤 때는 repl 이 말해 주지만 **대화 도중에 깨진 경우는 아무도 안 말한다** —
2071
- * 화면은 `✓ 개발자` 를 찍고 다음에 켜면 그대로 쉬움이다.
2072
- */
2073
- say(` ${mark.warn} ${c.yellow(말('common.cfgSaveFailed', { 왜: clip(String(e?.message ?? e), 70) }))}`);
2074
- }
2075
- const lv = LEVELS[골라진];
2076
- say('');
2077
- say(` ${mark.ok} ${c.bold(lv.name)} ${c.gray('— ' + lv.hint)}`);
2078
- say(` ${c.gray('안전 장치는 그대로입니다.')}`);
2079
- say('');
2080
- }
2081
-
2082
- /**
2083
- * 찾은 낱말에 색을 입힌다.
2084
- *
2085
- * 토막만 보여 주면 **왜 이게 걸렸는지** 안 보인다. 특히 조사를 떼고 찾기
2086
- * 때문에("인코딩을" 로 "인코딩" 을 찾는다) 눈으로는 안 맞는 것처럼 보이는
2087
- * 경우가 있다. 맞은 자리를 칠해 주면 그 의심이 사라진다.
2088
- */
2089
- function 강조(글, 낱말들) {
2090
- let 조각 = [{ 글, 맞음: false }];
2091
- for (const w of 낱말들) {
2092
- const 다음 = [];
2093
- for (const p of 조각) {
2094
- if (p.맞음) { 다음.push(p); continue; }
2095
- const 낮은 = p.글.toLowerCase();
2096
- let i = 0;
2097
- let 자리 = 낮은.indexOf(w);
2098
- while (자리 >= 0) {
2099
- if (자리 > i) 다음.push({ 글: p.글.slice(i, 자리), 맞음: false });
2100
- 다음.push({ 글: p.글.slice(자리, 자리 + w.length), 맞음: true });
2101
- i = 자리 + w.length;
2102
- 자리 = 낮은.indexOf(w, i);
2103
- }
2104
- if (i < p.글.length) 다음.push({ 글: p.글.slice(i), 맞음: false });
2105
- }
2106
- 조각 = 다음;
2107
- }
2108
- return 조각.map((p) => (p.맞음 ? c.hyellow(p.글) : c.gray(p.글))).join('');
2109
- }
2110
-
2111
- // 추론 강도는 값 하나가 아니라 '단계별 배분' 이다. 그 배분을 눈에 보이게 그린다.
2112
- /**
2113
- * 지금 추론 강도가 어떻게 되어 있는지.
2114
- *
2115
- * 기본은 **한 줄**이다. 사람이 알고 싶은 것은 '지금 얼마나 생각하나' 이지
2116
- * 단계별 표가 아니다. 표는 /think 자세히 로 뺐다.
2117
- *
2118
- * 쉬움 수준에서는 배분 이야기를 아예 안 꺼낸다 — 고를 일이 없는 사람에게
2119
- * 고르는 법을 보여 주면 그것부터 걱정하게 된다.
2120
- */
2121
- function showThink(session, { 자세히 = false } = {}) {
2122
- const b = session.breakdown();
2123
- const 아는상한 = session.conn.maxTokens ?? session.conn.maxOut ?? null;
2124
- const t = effortTable(session.think, session.effort, { ctx: b.total, used: b.used, max: 아는상한 });
2125
- const 개발자 = session.level === '개발자';
2126
- const 단계 = t.rows.map((r) => `${r.label} ${r.level}`).join(c.gray(' · '));
2127
-
2128
- /*
2129
- * 이 화면은 여태 통째로 한국어였다.
2130
- *
2131
- * `/lang en` 으로 켠 사람에게 「추론 강도 medium (첫 판단 medium · …)」 이
2132
- * 뜨면 아무것도 안 읽힌다. 명령 이름만 영어고 화면이 한국어인 자리는
2133
- * 「덜 옮겨졌다」 가 아니라 「고장 났다」 로 읽힌다 — i18n/index.js 머리말.
2134
- *
2135
- * 예로 드는 명령도 말을 따라간다. `/think 배분 절약` 은 영어로 켠 사람이
2136
- * 칠 수 없는 글자다. 두 이름 다 진짜로 받는다(위 배분말·자세히 갈래).
2137
- */
2138
- const 한국어 = 언어() === 'ko';
2139
- const 배분예 = 한국어 ? '/think 배분 절약' : '/think profile save';
2140
- const 자세히예 = 한국어 ? '/think 자세히' : '/think detail';
2141
- say('');
2142
- say(` ${c.gray(말('think.effort'))} ${c.bold(session.think)} ${c.gray(`(${단계})`)}`);
2143
- /*
2144
- * ── 정한 값과 **실제로 나가는 값**을 같이 보여 준다 ──────────────────
2145
- *
2146
- * 여기가 여태 비어 있던 자리다. `/think max` 를 쳐도 전선이 max 를 안
2147
- * 받으면 high 가 나갔고, 화면에는 그 사실이 어디에도 없었다. 사람은
2148
- * 세게 생각하라고 시켰다고 믿는다.
2149
- *
2150
- * 자동 조절도 같이 적는다. 정한 값이 천장이 되므로, 「max 라고 했는데 왜
2151
- * medium 인가」 에 답할 수 있어야 한다.
2152
- */
2153
- /*
2154
- * 자동 조절이 **실제로 무언가를 바꿀 때만** 적는다.
2155
- *
2156
- * `/think low` 로 정해 둔 사람에게 「가벼운 말은 low 까지 낮춰 씁니다」 는
2157
- * 아무 말도 아니다 — 이미 low 다. 아무 일도 안 하는 줄을 화면에 남기면
2158
- * 사람은 그 줄을 읽는 데 시간을 쓰고 아무것도 얻지 못한다.
2159
- */
2160
- if (session.autoThink !== false) {
2161
- const 가장낮게 = 가벼운강도(session.think);
2162
- if (가장낮게 !== session.think) {
2163
- say(` ${c.gray(pad(말('think.auto'), 한국어 ? 8 : 10))}${c.gray(말('think.autoNote', { 천장: session.think, 지금: 가장낮게 }))}`);
2164
- }
2165
- }
2166
- if (session.conn?.전선) {
2167
- const 카드 = session.conn.전선;
2168
- /*
2169
- * **정말로 나가는 것만** 적는다.
2170
- *
2171
- * 여태 여기서 `눈금맞추기()` 를 직접 불렀다. 그 함수는 카드가 아는 눈금으로
2172
- * 낮추는 일만 하고 그 눈금이 실릴 칸이 있는지는 모른다 — 그래서 눈금이
2173
- * 아예 안 나가는 전선(budget·boolean·none)에서도 「지금 나가는 값 high」
2174
- * 가 떴다. 몸을 만드는 자리와 같은 함수를 쓴다(backend/adapter.js).
2175
- */
2176
- const 나가는것 = 나가는눈금(session.conn.kind, 카드, session.think);
2177
- /*
2178
- * ── 「끈다」 가 정말로 꺼지는 창구인가 ────────────────────────────
2179
- *
2180
- * off 인데 나갈 말이 없으면 두 가지 중 하나다.
2181
- *
2182
- * a. 이 규격에서는 **칸을 안 싣는 것이 곧 끄는 것**이다.
2183
- * anthropic 은 thinking 을 빼면 안 생각하고, ollama 는 think:false
2184
- * 를 싣는다. 이쪽은 아무 말도 안 해도 된다.
2185
- * b. 이 규격에서는 칸을 안 실으면 **서버 기본값**으로 돈다. openai 꼴의
2186
- * 추론 모델이 그렇다 — reasoning_effort 를 빼면 서버가 제 기본값
2187
- * (보통 medium)으로 생각한다. 여기서 아무 말도 안 하면 화면은 off
2188
- * 라고 적어 두고 실제로는 생각이 도는 셈이 된다.
2189
- *
2190
- * b 를 잠자코 두면 화면이 거짓말을 한다. 창구가 끄는 말을 알려 준 적이
2191
- * 없으면 우리는 못 끈다 — 못 끄면 못 끈다고 적는다.
2192
- */
2193
- const 못끄나 = session.think === 'off' && !나가는것 && 카드.생각형식 === 'effort';
2194
- say(` ${c.gray(pad(말('think.wire'), 한국어 ? 8 : 10))}${c.gray(전선말(카드))}`
2195
- + (나가는것 ? ` ${c.gray(말('think.wireSends', { 값: 나가는것 }))}` : '')
2196
- + (못끄나 ? ` ${c.yellow(말('think.wireNoOff'))}` : ''));
2197
- }
2198
- if (개발자 || 자세히) say(` ${c.gray(pad(말('think.profile'), 한국어 ? 8 : 10))}${c.bold(t.name)} ${c.gray(t.desc)}`);
2199
-
2200
- if (자세히) {
2201
- say('');
2202
- say(` ${c.gray(pad(말('think.col.stage'), 12) + pad(말('think.col.level'), 10) + pad(말('think.col.cap'), 10) + 말('think.col.when'))}`);
2203
- for (const r of t.rows) {
2204
- const 화살 = r.moved > 0 ? c.yellow('↑') : r.moved < 0 ? c.cyan('↓') : c.gray('·');
2205
- say(` ${pad(r.label, 12)}${화살} ${pad(r.level, 8)}${pad(r.cap.toLocaleString(), 10, 'right')} ${c.gray(r.why)}`);
2206
- }
2207
- say('');
2208
- // 상한이 어디서 왔는지 밝힌다. 세 줄이 같은 값일 때 그게 고장인지 아닌지
2209
- // 이 한 줄로 갈린다 — 아는 상한이 낮으면 셋이 같아지는 것이 맞다.
2210
- const 어디서 = session.conn.maxTokens ? 말('think.cap.you')
2211
- : session.conn.maxOut ? 말('think.cap.server') : 말('think.cap.guess');
2212
- say(` ${c.gray(말('think.cap.head'))} ${c.white((아는상한 ?? 16384).toLocaleString())} ${c.gray(어디서)}${c.gray(말('think.cap.tail'))} ${c.cyan('/out')}`);
2213
- say(` ${c.gray(말('think.ctx'))} ${c.white(t.ctx.toLocaleString())}${c.gray(말('think.ctx.used'))}${c.white(t.used.toLocaleString())}`);
2214
- say('');
2215
- say(` ${c.gray(말('think.effort'))} ${THINK_LEVELS.join(' · ')} ${c.gray(말('think.eg'))} ${c.cyan('/think high')}`);
2216
- say(` ${c.gray(말('think.profile'))} ${Object.entries(PROFILES).map(([k, v]) => { const n = 한국어 ? v.name : (v.en ?? v.name); return n === k ? k : `${k}(${n})`; }).join(' · ')} ${c.gray(말('think.eg'))} ${c.cyan(배분예)}`);
2217
- } else if (개발자) {
2218
- say(` ${c.gray(말('think.effort'))} ${c.cyan('/think high')} ${c.gray(말('think.profile'))} ${c.cyan(배분예)} ${c.gray(말('think.detail'))} ${c.cyan(자세히예)}`);
2219
- } else {
2220
- /*
2221
- * 다음 칸을 **지금 값에서** 셈한다.
2222
- *
2223
- * 여태 이 두 자리는 high · low 로 박혀 있었다. 눈금이 넷일 때는 그럭저럭
2224
- * 맞았는데 xhigh 가 늘면서 티가 났다 — max 로 쓰고 있는 사람에게
2225
- * 「더 세게 → /think high」 라고 적어 준다. high 는 지금보다 **약하다.**
2226
- *
2227
- * 끝에 서 있으면 그쪽은 아예 안 적는다. 갈 데가 없는 길을 적어 주는 것은
2228
- * 없는 기능을 알려 주는 것과 같다.
2229
- */
2230
- const 위 = shiftLevel(session.think, 1);
2231
- const 아래 = shiftLevel(session.think, -1);
2232
- const 조각 = [];
2233
- if (위 !== session.think) 조각.push(`${c.gray(말('think.harder'))} ${c.cyan(`/think ${위}`)}`);
2234
- if (아래 !== session.think && 아래 !== 'off') 조각.push(`${c.gray(말('think.faster'))} ${c.cyan(`/think ${아래}`)}`);
2235
- if (조각.length) say(` ${조각.join(' ')}`);
2236
- }
2237
-
2238
- if (!session.conn.think && session.think !== 'off') {
2239
- say(` ${c.yellow(말('think.nomodel'))} ${c.gray(말('think.nomodel.note'))}`);
2240
- }
2241
- say('');
2242
- }
2243
-
2244
- /**
2245
- * 대화 갈래.
2246
- *
2247
- * 여기서 하는 일은 화면과 말뿐이다. 갈래를 들고 있는 것은 agent/threads.js 다.
2248
- */
2249
- function 갈래명령(session, ctx, arg = '') {
2250
- const 갈래 = ctx?.갈래;
2251
- say('');
2252
- if (!갈래) {
2253
- // 한 번 돌리고 끝내는 자리(-p)에는 갈래가 없다. 없는 것을 있는 척하지 않는다.
2254
- say(` ${c.gray('이 자리에서는 갈래를 못 씁니다 — 대화 화면에서만 됩니다.')}`);
2255
- say('');
2256
- return;
2257
- }
2258
-
2259
- const 말 = String(arg ?? '').trim();
2260
- const [머리, ...나머지] = 말.split(/\s+/);
2261
- const 뒷말 = 나머지.join(' ');
2262
- const 알림 = (g) => {
2263
- say(` ${c.hcyan('⑂')} ${c.bold(g.이름)} ${c.gray('갈래로 왔습니다.')} `
2264
- + c.gray(session.messages.length ? `오간 말 ${session.messages.length}개` : '빈 대화입니다'));
2265
- };
2266
-
2267
- if (/^(new|새|새로)$/i.test(머리 ?? '')) {
2268
- 알림(갈래.새로(뒷말));
2269
- say(` ${c.gray('본줄기로 돌아가려면')} ${c.cyan('/thread 1')}`);
2270
- say('');
2271
- return;
2272
- }
2273
-
2274
- if (/^(fork|갈라|분기)$/i.test(머리 ?? '')) {
2275
- const g = 갈래.갈라내기(뒷말);
2276
- say(` ${c.hcyan('⑂')} ${c.bold(g.이름)} ${c.gray('로 갈라 나왔습니다.')} `
2277
- + c.gray(`여기까지 오간 말 ${session.messages.length}개를 그대로 물려받았습니다.`));
2278
- say(` ${c.gray('여기서 무엇을 하든 본줄기는 그대로입니다.')}`);
2279
- say('');
2280
- return;
2281
- }
2282
-
2283
- if (/^(close|닫기|끝)$/i.test(머리 ?? '')) {
2284
- const r = 갈래.닫기(뒷말);
2285
- if (!r.ok) say(` ${c.red('못 닫았습니다')} ${c.gray(r.why)}`);
2286
- else {
2287
- say(` ${mark.ok} ${c.gray(`${r.닫은것.이름} 갈래를 닫았습니다. 적어 둔 것은 남아 있습니다 —`)} ${c.cyan('/sessions')}`);
2288
- 알림(r.지금);
2289
- }
2290
- say('');
2291
- return;
2292
- }
2293
-
2294
- if (말) {
2295
- const g = 갈래.옮기기(말);
2296
- if (!g) {
2297
- say(` ${c.red('그런 갈래가 없습니다')} ${c.gray(말)}`);
2298
- say(` ${c.gray('/thread 만 치면 목록이 나옵니다.')}`);
2299
- } else 알림(g);
2300
- say('');
2301
- return;
2302
- }
2303
-
2304
- // 그냥 /thread — 목록.
2305
- rule('대화 갈래', 70);
2306
- for (const r of 갈래.목록()) {
2307
- const 표 = r.지금 ? c.hcyan('▶') : c.gray(' ');
2308
- const 이름 = r.지금 ? c.white(r.이름) : c.gray(r.이름);
2309
- say(` ${표} ${c.gray(String(r.번호))} ${pad(이름, 24)} ${c.gray(`말 ${String(r.말수).padStart(3)}개`)}`
2310
- + (r.id ? ` ${c.gray(r.id)}` : ''));
2311
- }
2312
- say('');
2313
- say(` ${c.gray('/thread new [이름]')} ${c.gray('빈 갈래로 나간다 — 곁가지 질문을 여기서')}`);
2314
- say(` ${c.gray('/thread fork [이름]')} ${c.gray('지금까지를 물려받아 갈라 나간다')}`);
2315
- say(` ${c.gray('/thread <번호>')} ${c.gray('그 갈래로 옮긴다')}`);
2316
- say(` ${c.gray('/thread close')} ${c.gray('지금 갈래를 닫는다')}`);
2317
- say('');
2318
- say(` ${c.gray('연결·모델·도구·되돌리기는 갈래끼리 같이 씁니다. 오간 말과 토큰만 따로입니다.')}`);
2319
- say('');
2320
- }
2321
-
2322
- /**
2323
- * 쓰면서 저절로 알게 된 것.
2324
- *
2325
- * 보여 주는 것이 중요하다. 프롬프트에 몰래 들어가는 글이 있으면 사람은
2326
- * 모델이 왜 그렇게 답했는지 알 수 없게 된다 — 여기서 통째로 볼 수 있어야
2327
- * '자동으로 쌓인다' 가 무섭지 않은 말이 된다. 지우는 길도 같이 둔다.
2328
- */
2329
- /**
2330
- * 증거 — 「다 됐습니다」 대신 검토할 수 있는 것.
2331
- *
2332
- * 화면에서 제일 중요한 것은 맨 아래 '증명 안 된 것' 이다. 바꾼 것을 늘어놓는
2333
- * 일은 /diff 도 한다. 안 한 것을 말하는 자리는 여기뿐이다.
2334
- */
2335
- function 증거명령(session, ctx, arg = '') {
2336
- const e = 증거모으기(session, { audit: ctx?.audit });
2337
- const 말 = String(arg ?? '').trim();
2338
- say('');
2339
- rule('작업 증거', 70);
2340
-
2341
- /*
2342
- * 기록이 새고 있으면 **목록보다 먼저** 말한다.
2343
- *
2344
- * 이 화면은 감사기록을 읽어서 만든다. 기록이 안 적히면 목록이 짧아지는
2345
- * 게 아니라 「아무것도 안 했다」로 보인다 — 아래 이른 반환이 그 자리다.
2346
- * 안 한 것을 말하라고 만든 화면이 안 한 것처럼 보이게 하면 안 된다.
2347
- */
2348
- if (e.기록못씀) {
2349
- say(` ${mark.warn} ${c.yellow(`감사기록 ${e.기록못씀.수}건이 안 적혔습니다`)} ${c.gray(`— ${clip(e.기록못씀.까닭, 60)}`)}`);
2350
- say(` ${c.gray('아래는 실제로 한 것보다 짧습니다.')}`);
2351
- say('');
2352
- }
2353
-
2354
- if (!e.바꾼것.length && !e.돌린것.length) {
2355
- say(` ${c.gray('이번 대화에서 아직 바꾸거나 돌린 것이 없습니다.')}`);
2356
- say('');
2357
- return;
2358
- }
2359
-
2360
- if (e.바꾼것.length) {
2361
- say(` ${c.bold('바꾼 것')} ${c.gray(`— 파일 ${e.셈.파일}개 · +${e.셈.더한줄} / -${e.셈.뺀줄}`)}`);
2362
- for (const x of e.바꾼것.slice(0, 12)) {
2363
- const 표 = x.증명 ? c.green('✓') : c.yellow('?');
2364
- const 뒤 = x.증명 ? c.gray(`← ${x.증명}`) : c.yellow('확인 안 됨');
2365
- say(` ${표} ${pad(x.파일, 34)} ${c.gray(`+${x.더한줄} -${x.뺀줄}`)} ${뒤}`);
2366
- }
2367
- if (e.바꾼것.length > 12) say(` ${c.gray(`… 그 밖에 ${e.바꾼것.length - 12}개`)}`);
2368
- say('');
2369
- }
2370
-
2371
- if (e.돌린것.length) {
2372
- say(` ${c.bold('돌린 것')} ${c.gray(`— ${e.셈.돌린것}개${e.셈.실패한것 ? `, 그중 ${e.셈.실패한것}개 실패` : ''}`)}`);
2373
- for (const x of e.돌린것.slice(-10)) {
2374
- const 표 = x.됐나 ? c.green('✓') : c.red('✗');
2375
- say(` ${표} ${clip(x.무엇 || x.도구, 44)} ${c.gray(clip(x.남긴말 ?? '', 24))}`);
2376
- }
2377
- say('');
2378
- }
2379
-
2380
- // 여기가 요점이다.
2381
- if (e.증명안된것.length) {
2382
- say(` ${c.yellow('증명 안 된 것')} ${c.gray(`— ${e.증명안된것.length}개`)}`);
2383
- for (const x of e.증명안된것.slice(0, 8)) {
2384
- say(` ${c.yellow('·')} ${c.bold(x.파일)}`);
2385
- say(` ${c.gray(x.왜)}`);
2386
- }
2387
- say('');
2388
- say(` ${c.gray('돌려 볼 것이 있으면 지금 돌리고 다시 보세요.')}`);
2389
- } else {
2390
- say(` ${c.green('증명 안 된 것 없음')} ${c.gray('— 바꾼 것마다 그 뒤에 돌린 확인이 있습니다.')}`);
2391
- }
2392
- say('');
2393
-
2394
- if (/^(파일|file|저장|save)$/i.test(말)) {
2395
- const 이름 = `${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}`;
2396
- const 탈 = {};
2397
- const 자리 = 증거적기(session.root, e, 이름, 탈);
2398
- if (자리) say(` ${mark.ok} ${c.gray('남겼습니다 —')} ${c.cyan(자리)}`);
2399
- else {
2400
- say(` ${c.gray('파일로 못 남겼습니다. 화면 것만 쓰세요.')}`);
2401
- if (탈.왜) say(` ${c.gray(clip(탈.왜, 90))}`);
2402
- }
2403
- say('');
2404
- } else {
2405
- say(` ${c.gray('파일로 남기려면')} ${c.cyan('/evidence 파일')}`);
2406
- say('');
2407
- }
2408
- }
2409
-
2410
- /**
2411
- * /commit — 이번 대화가 바꾼 것을 커밋한다.
2412
- *
2413
- * 화면에서 지키는 것 두 가지.
2414
- *
2415
- * 1) **찍기 전에 다 보여 준다.** 메시지 전문과 `git status --short` 를
2416
- * 먼저 낸다. 커밋은 남는 것이고, 남을 것을 안 보여 주고 남기면 사람은
2417
- * 나중에 `git log` 에서 처음 읽게 된다. 그때는 이미 늦다.
2418
- * 2) **놀랄 자리를 미리 말한다.** 남이 먼저 담아 둔 것(index)이 있으면
2419
- * 그것도 같이 실린다고 적는다. 우리가 말없이 풀어 버리면 남의 준비가
2420
- * 사라지고, 말없이 실으면 남의 변경이 이 커밋에 묻어 들어간다.
2421
- * 둘 다 나쁘니 풀지 않고 알린다.
2422
- *
2423
- * push 는 안 한다. 그건 되돌릴 수 없는 자리라 사람이 직접 할 일이다.
2424
- */
2425
- /*
2426
- * `/paste` — 클립보드에 든 화면 캡처를 붙인다 (tools/clipboard.js).
2427
- *
2428
- * 돌려주는 것은 `@경로` 한 줄이다. 그러면 바깥(repl.js)의 @-붙이기가 평소대로
2429
- * 그림을 실어 보낸다 — 예산 셈도, 눈 없는 모델일 때의 처리도 그쪽에 이미
2430
- * 있으니 두 벌로 만들지 않는다.
2431
- *
2432
- * 세 가지를 갈라 말한다. 「그림이 없다」(사람이 캡처를 다시 하면 된다),
2433
- * 「못 꺼냈다」(까닭과 길을 준다), 「눈이 없는 모델이다」(붙여도 못 본다).
2434
- * 셋을 뭉뚱그려 「안 됩니다」로 내면 사람은 무엇을 고쳐야 할지 모른다.
2435
- */
2436
- export function 붙여넣기명령(session, ctx, { 꺼내기 = 클립보드그림 } = {}) {
2437
- // 꺼내기를 갈아 끼울 수 있게 열어 둔다 — 검사가 **사람의 진짜 클립보드를
2438
- // 건드리지 않고** 세 갈래(그림 있음·없음·못 꺼냄)를 다 재려면 이 자리가 필요하다.
2439
- const r = 꺼내기();
2440
- if (!r.ok) {
2441
- if (r.없음) {
2442
- say(` ${mark.warn} ${c.gray('클립보드에 그림이 없습니다. 화면을 캡처한 뒤 다시 /paste 하세요.')}`);
2443
- say(` ${c.gray('(윈도우: Win+Shift+S · 맥: Cmd+Ctrl+Shift+4)')}`);
2444
- } else {
2445
- for (const 줄 of String(r.왜).split('\n')) say(` ${mark.warn} ${c.gray(줄)}`);
2446
- }
2447
- return { handled: true };
2448
- }
2449
-
2450
- let 앉힌것;
2451
- try {
2452
- 앉힌것 = 그림앉히기(r.buf, join(ctx?.scope?.root ?? session.root, '.deel'));
2453
- } catch (err) {
2454
- say(` ${mark.warn} ${c.gray(`그림을 저장 못 했습니다: ${err.message}`)}`);
2455
- return { handled: true };
2456
- }
2457
-
2458
- const 보일 = ctx?.scope?.show ? ctx.scope.show(앉힌것.자리) : 앉힌것.자리;
2459
- say(` ${c.blue('◧')} ${c.gray('클립보드에서 가져와 앉혔습니다')} ${c.white(보일)} ${c.gray(`(${크기말(앉힌것.바이트)})`)}`);
2460
-
2461
- // 눈이 없는 모델이면 붙여도 못 본다. 보내기 전에 말한다 — 보내고 나서
2462
- // "그림이 안 보인다" 는 답을 받으면 사람은 이 기능이 고장 난 줄 안다.
2463
- if (!session.conn?.vision) {
2464
- say(` ${mark.warn} ${c.gray('지금 모델은 그림을 못 봅니다 — 파일은 남았지만 글로만 나갑니다.')}`);
2465
- }
2466
- return { handled: false, text: `@${보일}` };
2467
- }
2468
-
2469
- /*
2470
- * `/review` — 이번에 바꾼 것을 새 창에서 한 번 더 본다 (agent/review.js).
2471
- *
2472
- * 여기서는 **아무것도 안 고친다.** 찾은 것을 늘어놓고 끝이다. 고칠지 말지는
2473
- * 사람이 정한다. 리뷰가 제 손으로 고치기 시작하면 사람이 무엇을 승인한 것인지
2474
- * 흐려진다.
2475
- */
2476
- async function 리뷰명령(session, ctx) {
2477
- const 것 = 볼것(session, ctx);
2478
- if (!것.ok) {
2479
- say('');
2480
- say(` ${c.gray(것.왜)}`);
2481
- say('');
2482
- return;
2483
- }
2484
-
2485
- rule(`리뷰 — ${것.어디}`, 70);
2486
- say(` ${c.gray(것.통계.split(/\r?\n/).pop()?.trim() || `${것.파일들.length}개 파일`)}`);
2487
- say(` ${c.gray('지금 대화는 안 보냅니다 — 바뀐 코드만 새 창에서 봅니다.')}`);
2488
- say('');
2489
-
2490
- const s = spin('보는 중...');
2491
- const r = await 리뷰받기(session, 것, {
2492
- signal: ctx.signal ?? null,
2493
- onBackoff: (다시) => s.set?.(`서버가 잠깐 막아 ${Math.round((다시.wait ?? 0) / 1000)}초 기다리는 중...`),
2494
- });
2495
- s.stop('');
2496
-
2497
- if (!r.ok) {
2498
- say(` ${mark.no} ${c.red('못 봤습니다')} ${c.gray(`— ${r.왜}`)}`);
2499
- say('');
2500
- return;
2501
- }
2502
-
2503
- /*
2504
- * 가른 것이 없으면 **원문을 그대로** 보여 준다.
2505
- *
2506
- * 모델이 형식을 안 지켰다고 아무것도 안 보여 주면, 사람은 리뷰가 아무것도
2507
- * 못 찾은 줄 안다. 우리가 못 읽은 것이지 모델이 침묵한 것이 아니다.
2508
- */
2509
- if (!r.찾은것.length) {
2510
- for (const 줄 of r.글.split(/\r?\n/)) say(` ${줄}`);
2511
- say('');
2512
- return;
2513
- }
2514
-
2515
- const 색 = { 심각: c.red, 보통: c.yellow, 사소: c.gray };
2516
- for (const f of r.찾은것) {
2517
- const 칠 = 색[f.급] ?? c.white;
2518
- say(` ${칠(f.머리)}`);
2519
- for (const 줄 of f.몸) if (줄.trim()) say(` ${c.gray(줄.trim())}`);
2520
- say('');
2521
- }
2522
- const 셈 = ['심각', '보통', '사소'].map((g) => [g, r.찾은것.filter((x) => x.급 === g).length]).filter(([, n]) => n);
2523
- say(` ${c.gray(셈.map(([g, n]) => `${g} ${n}`).join(' · '))}`);
2524
- // 자리를 못 짚은 지적은 몇 개인지 적는다. 찾아갈 수 없는 지적은 값이 다르다.
2525
- const 자리없음 = r.찾은것.filter((x) => !x.자리).length;
2526
- if (자리없음) say(` ${c.gray(`${자리없음}개는 파일:줄 을 안 짚었습니다 — 그만큼 확인하기 어렵습니다.`)}`);
2527
- say(` ${c.gray('아무것도 안 고쳤습니다. 고칠 것을 골라 시키세요.')}`);
2528
- say('');
2529
- }
2530
-
2531
- async function 커밋명령(session, ctx, arg = '') {
2532
- const 말한것 = String(arg ?? '').trim();
2533
- /*
2534
- * `전부` 뒤에 제목을 같이 적을 수 있다.
2535
- *
2536
- * 앞서는 `^(전부|all)$` 라 `/commit 전부 버그 수정` 이 **둘 다** 어긋났다 —
2537
- * 전부가 거짓이 되어 폴더째 바뀐 파일이 빠지고, `전부 버그 수정` 이 통째로
2538
- * 커밋 제목이 됐다. 어느 쪽도 화면에 안 적힌다.
2539
- */
2540
- const 전부친것 = /^(전부|all)(\s+|$)/i.exec(말한것);
2541
- const 전부 = !!전부친것;
2542
- const 남은말 = 전부 ? 말한것.slice(전부친것[0].length).trim() : 말한것;
2543
- const 미리보기 = /^(미리보기|preview|dry|--dry-run)$/i.test(남은말);
2544
- const 준제목 = 미리보기 ? null : (남은말 || null);
2545
-
2546
- say('');
2547
- rule('커밋', 70);
2548
-
2549
- const 도는말 = '메시지를 짓는 중…';
2550
- let 돌림 = spin(도는말);
2551
- const r = await 커밋준비(session, ctx, {
2552
- 전부,
2553
- 제목: 준제목,
2554
- onBackoff: (알림) => {
2555
- 돌림.stop((알림.미리
2556
- ? ` ${c.yellow('⏸')} ${c.gray(말(알림말(알림), { 초: 알림채움(알림).초 }))}`
2557
- : ` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`));
2558
- 돌림 = spin(도는말);
2559
- },
2560
- });
2561
-
2562
- if (!r.ok) {
2563
- 돌림.stop(` ${c.gray(r.why)}`);
2564
- say('');
2565
- return;
2566
- }
2567
- 돌림.stop(` ${c.bold('담은 것')} ${c.gray(`— 파일 ${r.파일들.length}개`)}`);
2568
-
2569
- for (const f of r.파일들.slice(0, 12)) say(` ${c.gray('·')} ${c.white(f)}`);
2570
- if (r.파일들.length > 12) say(` ${c.gray(`… 그 밖에 ${r.파일들.length - 12}개`)}`);
2571
- if (r.살림뺌) {
2572
- say(` ${c.gray('· .deel/ 은 안 담았습니다 — 열쇠와 감사기록이 든 곳입니다')}`);
2573
- }
2574
- if (r.폴더통째?.length) {
2575
- say('');
2576
- say(` ${c.yellow('폴더째 바뀐 자리는 안 담았습니다')} ${c.gray(`— ${r.폴더통째.slice(0, 4).join(', ')}`)}`);
2577
- say(` ${c.gray('그 안에는 남이 고치던 파일도 있습니다. 통째로 담으려면')} ${c.cyan('/commit 전부')}`);
2578
- }
2579
- if (r.남의것.length) {
2580
- say('');
2581
- say(` ${c.yellow('먼저 담겨 있던 것도 같이 실립니다')} ${c.gray(`— ${r.남의것.slice(0, 6).join(', ')}`)}`);
2582
- say(` ${c.gray('빼려면 `git restore --staged <파일>` 뒤에 다시 부르세요.')}`);
2583
- }
2584
-
2585
- say('');
2586
- say(` ${c.bold('메시지')}`);
2587
- for (const line of r.메시지.trimEnd().split('\n')) {
2588
- say(` ${line.trim() ? c.white(clip(line, 76)) : ''}`);
2589
- }
2590
- if (r.사실로만) {
2591
- say('');
2592
- say(` ${c.yellow('모델이 메시지를 못 만들어 바뀐 것만 적었습니다.')}`);
2593
- }
2594
- /*
2595
- * diff 를 못 읽고 지은 메시지라면 그렇다고 적는다.
2596
- *
2597
- * 담긴 것이 64MB 를 넘으면 git 이 몸통을 못 뱉는다. 파일 목록은 멀쩡해서
2598
- * 커밋은 그대로 이어지는데, 그때 메시지는 **내용을 한 줄도 안 보고** 지은
2599
- * 것이다. 그 사실이 화면에 없으면 사람은 여느 메시지와 똑같이 믿는다.
2600
- */
2601
- if (r.diff못읽음) {
2602
- say('');
2603
- say(` ${c.yellow('내용(diff)을 못 읽고 지은 메시지입니다')} ${c.gray(`— ${clip(r.diff못읽음, 60)}`)}`);
2604
- say(` ${c.gray('파일 이름과 줄 수만 보고 썼습니다. 찍기 전에 한 번 읽어 주세요.')}`);
2605
- }
2606
-
2607
- const 상태줄 = r.상태 ? r.상태.split('\n') : [];
2608
- if (상태줄.length) {
2609
- say('');
2610
- say(` ${c.bold('git status --short')}`);
2611
- for (const line of 상태줄.slice(0, 10)) say(` ${c.gray(clip(line, 76))}`);
2612
- if (상태줄.length > 10) say(` ${c.gray(`… 그 밖에 ${상태줄.length - 10}줄`)}`);
2613
- }
2614
- say('');
2615
-
2616
- if (미리보기) {
2617
- say(` ${c.gray('미리보기입니다 — 찍지 않았습니다. 그대로 찍으려면')} ${c.cyan('/commit')}`);
2618
- say('');
2619
- return;
2620
- }
2621
-
2622
- // 엄격 모드에서만 묻는다. 여기서 무르면 담은 것은 그대로 둔다 —
2623
- // 사람이 메시지만 다시 받고 싶을 수도 있는데, 담은 것까지 풀면 처음부터다.
2624
- if (session.mode === 'strict') {
2625
- const 예 = await confirm('이대로 커밋할까요?', true);
2626
- if (!예) {
2627
- say(` ${c.gray('안 찍었습니다. 담은 것은 그대로 둡니다.')}`);
2628
- say('');
2629
- return;
2630
- }
2631
- // 묻는 사이에 담긴 것이 바뀌었을 수 있다(다른 창·다른 도구). 보여 준 것과
2632
- // 다른 것을 찍으면, 승인을 받은 의미가 없어진다.
2633
- const 지금 = r.다시확인();
2634
- if (JSON.stringify(지금) !== JSON.stringify(r.파일들)) {
2635
- say(` ${c.yellow('묻는 사이에 담긴 것이 바뀌었습니다')} ${c.gray(`— 보여 준 ${r.파일들.length}개 → 지금 ${지금.length}개`)}`);
2636
- say(` ${c.gray('안 찍었습니다. 다시')} ${c.cyan('/commit')} ${c.gray('으로 확인하세요.')}`);
2637
- say('');
2638
- return;
2639
- }
2640
- }
2641
-
2642
- const 찍음 = 커밋실행(r.뿌리, r.메시지, { audit: ctx?.audit, 파일들: r.파일들, 제목: r.제목 });
2643
- if (!찍음.ok) {
2644
- say(` ${c.red('✗')} ${c.gray(clip(찍음.why, 90))}`);
2645
- say('');
2646
- return;
2647
- }
2648
- say(` ${mark.ok} ${c.green(찍음.hash)} ${c.white(clip(r.제목, 60))}`);
2649
- if (r.미확인 > 0) say(` ${c.yellow(`검증: ${r.확인}건 확인 · ${r.미확인}건 미확인`)} ${c.gray('— 메시지에도 적었습니다')}`);
2650
- say(` ${c.gray('push 는 안 했습니다. 되돌리려면')} ${c.cyan('git reset --soft HEAD~1')}`);
2651
- say('');
2652
- }
2653
-
2654
- /**
2655
- * 모델 카드 — 겪어 본 버릇과, 그 때문에 deel 이 바꾼 것.
2656
- *
2657
- * 여기서 중요한 것은 아래쪽 '그래서 바꾼 것' 이다. 위쪽 숫자만 보여 주면
2658
- * 그냥 통계지만, 무엇이 달라졌는지까지 보여야 사람이 판단할 수 있다 —
2659
- * "이 모델을 계속 쓸까, 다른 걸 받을까" 가 실제로 묻는 것이다.
2660
- */
2661
- function 카드명령(session, ctx) {
2662
- const 장 = ctx?.카드다시?.() ?? ctx?.카드 ?? null;
2663
- say('');
2664
- if (!장) {
2665
- say(` ${c.gray('이 자리에서는 못 봅니다 — 대화 화면에서만 됩니다.')}`);
2666
- say('');
2667
- return;
2668
- }
2669
-
2670
- rule('모델 카드', 70);
2671
- say(` ${c.bold(장.모델 || '(이름 없음)')} ${c.gray(`· 같이 걸어 본 걸음 ${장.걸음}`)}`);
2672
- /*
2673
- * 미리 아는 모델이면 이름표 한 줄 (agent/preset.js). 겪어서 안 것이 아니라
2674
- * 공개 문서로 아는 것이라, '겪어 본 버릇' 과 섞이지 않게 따로 선다.
2675
- */
2676
- if (장.내장) {
2677
- const 표지 = 언어() === 'en' ? 'known model' : '아는 모델';
2678
- say(` ${c.hcyan('◆')} ${c.bold(장.내장.이름)} ${c.gray(`· ${표지}`)}`);
2679
- if (장.내장.한줄) say(` ${c.gray(장.내장.한줄)}`);
2680
- }
2681
- say('');
2682
-
2683
- const 줄 = (이름, v) => {
2684
- if (!v?.n) return;
2685
- const 센가 = v.율 >= 0.15;
2686
- const 표 = 센가 ? c.yellow('●') : c.gray('○');
2687
- say(` ${표} ${pad(이름, 18)} ${pad(String(v.n), 5, 'right')} ${c.gray(`(${Math.round(v.율 * 100)}%)`)}`);
2688
- };
2689
- say(` ${c.bold('겪어 본 버릇')}`);
2690
- 줄('인자가 잘림', 장.버릇.잘린인자);
2691
- 줄('빈 답', 장.버릇.빈답);
2692
- 줄('편집이 빗나감', 장.버릇.편집실패);
2693
- 줄('같은 것 되풀이', 장.버릇.되풀이);
2694
- if (!Object.values(장.버릇).some((v) => v.n)) say(` ${c.gray('아직 걸린 것이 없습니다.')}`);
2695
- if (장.보정 && Math.abs(장.보정 - 1) > 0.01) {
2696
- say(` ${c.gray(pad('토큰 추정 보정', 18))} ${pad(`×${장.보정.toFixed(2)}`, 5, 'right')}`);
2697
- }
2698
- say('');
2699
-
2700
- say(` ${c.bold('그래서 바꾼 것')}`);
2701
- if (장.아직모름) {
2702
- say(` ${c.gray('아직 판단하지 않습니다 — 몇 걸음 안 걸어 보고 바꾸면 멀쩡한 모델을 붙들어 맵니다.')}`);
2703
- } else if (!장.왜.length) {
2704
- say(` ${c.gray('바꾼 것 없음. 이 모델은 그대로 두어도 괜찮습니다.')}`);
2705
- } else {
2706
- for (const w of 장.왜) say(` ${c.hcyan('→')} ${w}`);
2707
- }
2708
- say('');
2709
- say(` ${c.gray('이 카드는 프롬프트에 안 실립니다 — deel 이 제 행동을 바꾸는 것이라 모델에게 말할 필요가 없습니다.')}`);
2710
- say('');
2711
- }
2712
-
2713
- /**
2714
- * 못 박기.
2715
- *
2716
- * 여기서 하는 일은 화면과 말뿐이다. 들고 있는 것은 agent/pins.js 이고,
2717
- * 그것이 session 에 붙어 있어서 접기·요약이 닿지 못한다.
2718
- */
2719
- function 못박기명령(session, ctx, arg = '') {
2720
- const 못 = session?.못박은것;
2721
- say('');
2722
- if (!못) {
2723
- say(` ${c.gray('이 자리에서는 못 씁니다 — 대화 화면에서만 됩니다.')}`);
2724
- say('');
2725
- return;
2726
- }
2727
-
2728
- const 적어두기 = () => { try { ctx?.갈래?.현재store?.()?.못박기목록(못.직렬화()); } catch { /* 못 적어도 대화는 계속된다 */ } };
2729
- const 말 = String(arg ?? '').trim();
2730
- const [머리, ...나머지] = 말.split(/\s+/);
2731
-
2732
- if (/^(지우기|빼기|clear|rm|remove)$/i.test(머리 ?? '')) {
2733
- const r = 못.지우기(나머지.join(' ') || '전부');
2734
- if (!r.ok) say(` ${c.red(r.why)}`);
2735
- else if (typeof r.뺀것 === 'number') say(` ${mark.ok} ${c.gray(`못 박아 둔 것 ${r.뺀것}개를 뺐습니다.`)}`);
2736
- else say(` ${mark.ok} ${c.gray('뺐습니다 —')} ${r.뺀것}`);
2737
- 적어두기();
2738
- say('');
2739
- return;
2740
- }
2741
-
2742
- if (말) {
2743
- const r = 못.더하기(말);
2744
- if (!r.ok) {
2745
- say(` ${c.red(r.why)}`);
2746
- say('');
2747
- return;
2748
- }
2749
- 적어두기();
2750
- say(` ${c.hcyan('📌')} ${c.bold(`${r.번호}.`)} ${말}`);
2751
- say(` ${c.gray('접거나 요약해도 안 지워집니다. 빼려면')} ${c.cyan(`/pin 지우기 ${r.번호}`)}`);
2752
- say('');
2753
- return;
2754
- }
2755
-
2756
- const 목록 = 못.목록();
2757
- rule('못 박은 것', 70);
2758
- if (!목록.length) {
2759
- say(` ${c.gray('아직 없습니다. 대화 내내 지켜야 할 말을 박아 두세요.')}`);
2760
- say(` ${c.gray('예:')} ${c.cyan('/pin 운영 DB 는 건드리지 마라')}`);
2761
- say('');
2762
- return;
2763
- }
2764
- for (const x of 목록) say(` ${c.hcyan(`${x.번호}.`)} ${x.말}`);
2765
- say('');
2766
- const 실린것 = 못.실린것();
2767
- say(` ${c.gray(`매 턴 프롬프트에 실립니다 — 지금 ${실린것.개수}개, ${실린것.토큰}토큰.`)}`);
2768
- if (!실린것.다실렸나) {
2769
- say(` ${c.yellow(`자리가 모자라 뒤의 ${목록.length - 실린것.개수}개는 안 실립니다.`)} ${c.gray('짧게 줄이거나 빼세요.')}`);
2770
- }
2771
- say('');
2772
- }
2773
-
2774
- function 배움명령(session, ctx, arg = '') {
2775
- const 배움 = ctx?.배움;
2776
- say('');
2777
- if (!배움) {
2778
- say(` ${c.gray('이 자리에서는 못 봅니다 — 대화 화면에서만 됩니다.')}`);
2779
- say('');
2780
- return;
2781
- }
2782
-
2783
- /*
2784
- * ── 전선만 지우는 길 ────────────────────────────────────────────────────
2785
- *
2786
- * 전선 모양은 틀리게 배울 수 있는 유일한 것이고(backend/wire.js), 틀리면
2787
- * 멀쩡한 기능이 꺼진 채 굳는다. 그런데 되돌릴 길이 「전부 비우기」 하나라,
2788
- * 몇 주 쌓은 명령 겪음까지 같이 날아갔다 — 그게 아까워서 사람은 안 비우고,
2789
- * 안 비우니 꺼진 기능을 그냥 안고 쓴다. 전선만 지우면 잃을 것이 없다.
2790
- */
2791
- /*
2792
- * 지우는 말은 **반드시 적어야** 한다.
2793
- *
2794
- * 앞서는 `\s*(…)?` 라 `/learned 전선` 만 쳐도 곧바로 지웠다. 화면이 알려
2795
- * 주는 명령은 `/learned 전선 지우기` 라 그 짧은 꼴은 어디에도 안 적혀
2796
- * 있는데, 「전선에서 뭘 배웠나 보자」 는 마음으로 치면 그것이 지우는
2797
- * 명령이 됐다. 지우는 명령은 지우겠다고 적었을 때만 지운다.
2798
- */
2799
- if (/^(전선|wire)$/i.test(String(arg).trim())) {
2800
- say(` ${c.gray('전선에서 배운 것만 비우려면')} ${c.cyan('/learned 전선 지우기')}`);
2801
- say(` ${c.gray('쌓인 것을 보려면')} ${c.cyan('/learned')}`);
2802
- say('');
2803
- return;
2804
- }
2805
- if (/^(전선|wire)\s+(지우기|clear|forget|비우기)$/i.test(String(arg).trim())) {
2806
- 배움.지우기('전선');
2807
- if (session.conn) 전선붙이기(session.conn, 배움);
2808
- say(` ${mark.ok} ${c.gray('전선에서 배운 것만 비웠습니다 — 다음 요청부터 짐작으로 다시 시작합니다.')}`);
2809
- say(` ${c.gray('겪어 본 명령과 토큰 보정은 그대로 둡니다.')}`);
2810
- say('');
2811
- return;
2812
- }
2813
-
2814
- if (/^(지우기|clear|forget|비우기)$/i.test(String(arg).trim())) {
2815
- 배움.지우기('전부');
2816
- session.배움요약 = null;
2817
- say(` ${mark.ok} ${c.gray('쌓아 둔 것을 비웠습니다. 다시 겪으면서 새로 쌓습니다.')}`);
2818
- say('');
2819
- return;
2820
- }
2821
-
2822
- const { 명령, 모델, 모델이름 } = 배움.현황(session.conn.model);
2823
- rule('겪어 본 것', 70);
2824
-
2825
- if (!명령.length && !모델) {
2826
- say(` ${c.gray('아직 쌓인 것이 없습니다. 명령을 돌리고 대화를 나눌수록 여기가 찹니다.')}`);
2827
- say('');
2828
- return;
2829
- }
2830
-
2831
- if (명령.length) {
2832
- say(` ${c.bold('이 폴더에서 돌려 본 명령')}`);
2833
- for (const r of 명령.slice(0, 12)) {
2834
- const 표 = r.no === 0 ? c.green('✓') : r.ok === 0 ? c.red('✗') : c.yellow('~');
2835
- /*
2836
- * 언제 겪었는지와 **지금 실리는지**를 같이 적는다.
2837
- *
2838
- * 셈은 삭는다 (agent/신뢰도.js). 그래서 「됨 3 · 안 됨 0」 두 줄이 화면에
2839
- * 똑같이 보여도 한 줄은 프롬프트에 실리고 다른 줄은 안 실릴 수 있다.
2840
- * 특히 「안 된다」 는 스스로를 봉인하던 자리라, 그것이 지금 입을 다물고
2841
- * 있다는 사실이야말로 사람이 봐야 하는 것이다.
2842
- *
2843
- * 판정은 배움이 낸 것을 그대로 받는다 — 여기서 다시 계산하면 언젠가
2844
- * 한쪽만 고쳐지고, 그때부터 화면이 실제와 다른 말을 한다.
2845
- */
2846
- const 셈 = (n) => (Number.isFinite(n) ? String(Math.round(n * 10) / 10) : '?');
2847
- const 날 = Number.isFinite(r.나이) ? `${Math.floor(r.나이)}일 전` : '언제인지 모름';
2848
- const 말 = r.판정 === '모름' ? `${날} — 아직 안 싣습니다` : `${날} · 프롬프트에 실림`;
2849
- say(` ${표} ${pad(r.이름, 24)} ${c.gray(`됨 ${셈(r.ok)} · 안 됨 ${셈(r.no)}`)}`
2850
- + ` ${c.gray(말)}`);
2851
- }
2852
- say('');
2853
- }
2854
-
2855
- if (모델) {
2856
- say(` ${c.bold('이 모델에 대해')} ${c.gray(모델이름)}`);
2857
- const 걸음 = 모델.걸음 ?? 0;
2858
- const 줄 = (이름, n) => {
2859
- if (!n) return;
2860
- const 비율 = 걸음 ? Math.round((n / 걸음) * 100) : 0;
2861
- say(` ${c.gray(pad(이름, 24))} ${pad(String(n), 5, 'right')} ${c.gray(걸음 ? `(${비율}%)` : '')}`);
2862
- };
2863
- say(` ${c.gray(pad('같이 걸어 본 걸음', 24))} ${pad(String(걸음), 5, 'right')}`);
2864
- 줄('인자가 잘림', 모델.잘린인자);
2865
- 줄('빈 답', 모델.빈답);
2866
- 줄('편집이 빗나감', 모델.편집실패);
2867
- if (모델.보정) say(` ${c.gray(pad('토큰 추정 보정', 24))} ${pad(`×${모델.보정.toFixed(2)}`, 5, 'right')}`);
2868
- say('');
2869
- }
2870
-
2871
- const 실린것 = 배움.요약(session.conn.model);
2872
- if (실린것) {
2873
- say(` ${c.bold('이 중 프롬프트에 실리는 것')}`);
2874
- for (const l of 실린것.split('\n').slice(1)) say(` ${c.gray(l)}`);
2875
- } else {
2876
- say(` ${c.gray('아직 프롬프트에 실을 만큼 확실한 것은 없습니다 — 두 번 이상 겪어야 싣습니다.')}`);
2877
- }
2878
- /*
2879
- * ── 전선에서 배운 것도 여기 보인다 ──────────────────────────────────────
2880
- *
2881
- * 쌓인 것 중 **제일 크게 작용하는 것**이 전선 모양인데(무엇을 실어 보낼지
2882
- * 가 여기서 갈린다) 이 화면에 아예 안 떴다. 그래서 잘못 배운 판에서 사람이
2883
- * 「배운 것을 보자」 고 여기를 열면, 정작 문제인 줄만 안 보였다.
2884
- */
2885
- const 전선 = session.conn?.전선;
2886
- if (전선) {
2887
- const 배운칸 = 전선.배운칸 ?? [];
2888
- say(` ${c.bold('이 창구에 실어 보내는 모양')}`);
2889
- say(` ${c.gray(전선말(전선))}`);
2890
- if (배운칸.length) {
2891
- say(` ${c.gray('「배움」 은 짐작이 아니라 서버가 거절해서 고친 자리입니다 — 틀릴 수도 있습니다.')}`);
2892
- }
2893
- say('');
2894
- }
2895
-
2896
- say('');
2897
- say(` ${c.gray('지우려면')} ${c.cyan('/learned 지우기')}`);
2898
- if (전선?.배운칸?.length) {
2899
- say(` ${c.gray('전선에서 배운 것만 되돌리려면')} ${c.cyan('/learned 전선 지우기')}`);
2900
- }
2901
- say('');
2902
- }
2903
-
2904
- function showContext(session) {
2905
- const b = session.breakdown();
2906
- say('');
2907
- rule(말('scr.context'), 70);
2908
- say(` ${c.bold(session.conn.model)} ${c.gray('·')} ${말('unit.tokens', { n: b.total.toLocaleString() })}`);
2909
- say('');
2910
- say(` ${bar(b.used, b.total, 32)} ${b.used.toLocaleString()} / ${b.total.toLocaleString()} ${c.gray(`${Math.round((b.used / b.total) * 100)}%`)}`);
2911
- say('');
2912
- for (const r of b.rows) {
2913
- if (!r.n) continue;
2914
- say(` ${c.gray(pad(r.label, 26))} ${pad(r.n.toLocaleString(), 8, 'right')}`);
2915
- }
2916
- say(` ${c.gray('─'.repeat(35))}`);
2917
- say(` ${c.gray(pad(말('ctx.left'), 26))} ${pad(b.left.toLocaleString(), 8, 'right')}`);
2918
- say('');
2919
- say(` ${c.gray(`/compact ${말('ctx.compactHint')} /clear ${말('ctx.clearHint')}`)}`);
2920
- /*
2921
- * 추정이라고만 적어 두면 사람은 얼마나 믿어야 할지 모른다. 서버가 알려 준
2922
- * 실제값에 맞춰 가고 있으면 그 사실을 적는다 — '추정' 과 '맞춰 본 추정' 은
2923
- * 믿을 만한 정도가 다르다.
2924
- */
2925
- if (b.보정잰것 > 0) {
2926
- const 차이 = Math.round((b.보정 - 1) * 100);
2927
- say(` ${c.gray(말('ctx.calibrated', { 부호: 차이 >= 0 ? '+' : '', 퍼센트: 차이, 번: b.보정잰것 }))}`);
2928
- } else {
2929
- say(` ${c.gray(말('ctx.estimate'))}`);
2930
- }
2931
- /*
2932
- * 재 봤는데 **못 믿어서 안 쓴** 것도 말한다.
2933
- *
2934
- * session.배운다() 는 서버가 알려 준 값이 우리 추정의 절반 아래거나 두 배
2935
- * 위면 표본을 버린다. 튄 값 하나에 게이지가 휘둘리면 안 되니 버리는 것은
2936
- * 맞다. 그런데 버린 것을 아무 데도 안 남기면, 「아직 한 번도 못 재 봤다」 와
2937
- * 「재 봤는데 세 배가 나와서 못 믿었다」 가 화면에서 똑같아진다.
2938
- *
2939
- * 뒤엣것은 우리 추정이 크게 틀렸다는 단서다. 그 말을 안 하면 게이지는 영영
2940
- * 짐작인 채로 도는데 사람은 그저 「추정입니다」 만 읽는다.
2941
- */
2942
- if (session?.보정버림 > 0) {
2943
- const 배 = session.보정마지막버린비율;
2944
- say(` ${c.gray(`서버가 알려 준 값이 우리 추정과 너무 달라 ${session.보정버림}번은 안 썼습니다`
2945
- + (배 ? ` (마지막에 잰 것은 추정의 ${배.toFixed(1)}배)` : ''))}`);
2946
- say(` ${c.gray('이 창구에서는 위 숫자가 실제와 크게 다를 수 있습니다.')}`);
2947
- }
2948
- say('');
2949
- }
2950
-
2951
- /**
2952
- * 이번 대화에서 무엇이 바뀌었는지 보여 준다.
2953
- *
2954
- * /diff 바뀐 파일 목록과 늘고 준 줄 수
2955
- * /diff src/a.js 그 파일이 처음과 지금 사이에 어떻게 달라졌는지
2956
- *
2957
- * 파일 하나를 볼 때는 이번 대화의 '맨 처음' 모습과 견준다. 세 번 고쳤어도
2958
- * 사람이 알고 싶은 것은 '내가 시키기 전과 지금이 뭐가 다른가' 이지
2959
- * 마지막 한 번이 아니다. 되돌리기 이력이 그 맨 처음 모습을 들고 있다.
2960
- */
2961
- function 바뀐것보기(session, ctx, arg = '') {
2962
- const 말 = String(arg ?? '').trim();
2963
- say('');
2964
-
2965
- if (!말) {
2966
- const 목록 = [...session.changes.entries()];
2967
- if (!목록.length) {
2968
- say(` ${c.gray('이번 대화에서 바뀐 파일이 없습니다.')}`);
2969
- say(` ${c.gray('파일을 고치고 나면 여기에 무엇이 얼마나 바뀌었는지 모입니다.')}`);
2970
- say('');
2971
- return;
2972
- }
2973
- rule('이번 대화에서 바뀐 파일', 70);
2974
- let a = 0;
2975
- let r = 0;
2976
- for (const [p, v] of 목록) {
2977
- a += v.added;
2978
- r += v.removed;
2979
- const 몇번 = v.times > 1 ? c.gray(` ${v.times}번`) : '';
2980
- say(` ${pad(clip(ctx.scope.show(p), 46), 46)} ${c.hgreen(pad(`+${v.added}`, 6, 'right'))} ${c.hred(pad(`−${v.removed}`, 6, 'right'))}${몇번}`);
2981
- }
2982
- say(` ${c.gray('─'.repeat(60))}`);
2983
- say(` ${pad(`${목록.length}개 파일`, 46)} ${c.hgreen(pad(`+${a}`, 6, 'right'))} ${c.hred(pad(`−${r}`, 6, 'right'))}`);
2984
- say('');
2985
- say(` ${c.gray('한 파일을 자세히 보려면')} ${c.cyan('/diff <파일>')}${c.gray(', 되돌리려면')} ${c.cyan('/undo')}`);
2986
- say('');
2987
- return;
2988
- }
2989
-
2990
- let abs;
2991
- try { abs = ctx.scope.resolve(말); }
2992
- catch (err) { say(` ${mark.warn} ${err.message}`); say(''); return; }
2993
-
2994
- // 이번 대화에서 이 파일을 처음 건드리기 직전의 모습.
2995
- const 처음 = ctx.history.all().find((x) => x.path === abs);
2996
- if (!처음) {
2997
- say(` ${c.gray('이번 대화에서 안 바꾼 파일입니다:')} ${ctx.scope.show(abs)}`);
2998
- say(` ${c.gray('바뀐 것들을 보려면')} ${c.cyan('/diff')}`);
2999
- say('');
3000
- return;
3001
- }
3002
-
3003
- const 옛것 = 처음.before === null ? null
3004
- : (처음.enc === 'b64' ? Buffer.from(처음.before, 'base64').toString('utf8') : 처음.before);
3005
- let 지금 = null;
3006
- if (existsSync(abs)) {
3007
- try { 지금 = readTextFull(abs).text; }
3008
- catch (err) { say(` ${mark.warn} 지금 내용을 못 읽습니다: ${err.message}`); say(''); return; }
3009
- }
3010
-
3011
- const d = diffLines(옛것, 지금);
3012
- rule(ctx.scope.show(abs), 70);
3013
- if (!d.changed) {
3014
- say(` ${c.gray('고쳤다가 되돌아와서, 처음과 지금이 같습니다.')}`);
3015
- say('');
3016
- return;
3017
- }
3018
- if (d.isNew) say(` ${c.gray('이번 대화에서 새로 만든 파일입니다.')}`);
3019
- if (d.isGone) say(` ${c.gray('이번 대화에서 없어진 파일입니다.')}`);
3020
- say(` ${shortStat(d)}`);
3021
- say('');
3022
- for (const l of renderDiff(d, { maxLines: session.level === '개발자' ? 200 : 60 })) say(l);
3023
- say('');
3024
- say(` ${c.gray('되돌리려면')} ${c.cyan('/undo')}`);
3025
- say('');
3026
- }
3027
-
3028
- /**
3029
- * 한 번에 받을 답 길이 상한.
3030
- *
3031
- * /out 지금 값과 어디서 나온 값인지
3032
- * /out 32k 직접 지정
3033
- * /out auto 모른다고 두고 안전한 기본값으로 (16,384)
3034
- *
3035
- * 왜 따로 있나:
3036
- * 컨텍스트(/ctx)와 다른 축이다. 컨텍스트가 655k 여도 '한 번에 뱉을 수 있는 답'
3037
- * 은 대개 훨씬 작다. 그 두 값이 하나인 줄 알면 큰 파일이 왜 안 만들어지는지
3038
- * 영영 알 수 없다 — 컨텍스트는 넉넉한데 답이 잘리기 때문이다.
3039
- *
3040
- * 전에는 이 기능이 /ctx out 안에 숨어 있었고, 게다가 **먹지도 않았다**
3041
- * (effort.js 의 클램프가 다시 조였다). 있는데 안 먹는 것이 가장 나쁘다 —
3042
- * 문서에 적혀 있으니 사람이 그걸 믿고 쓴다.
3043
- */
3044
- async function 출력상한(session, arg = '') {
3045
- const { parseSize } = await import('./backend/ctxsize.js');
3046
- const 말 = String(arg ?? '').trim().toLowerCase();
3047
- const cfg = load();
3048
- const prof = 이연결의프로필(cfg, session);
3049
- const 알아낸것 = session.conn.maxOut ?? null;
3050
-
3051
- if (말 === 'auto' || 말 === '자동') {
3052
- session.conn.maxTokens = null;
3053
- const 지웠나 = prof ? (delete prof.maxTokens, 설정남기기(cfg)) : false;
3054
- say(` ${mark.ok} 직접 정한 값을 지웠습니다. ${c.gray('이번 대화에 바로 먹습니다.')}`);
3055
- // 프로필을 못 찾으면 다음에 켤 때 그 값이 되살아난다. 숨기면 안 된다.
3056
- if (!지웠나) say(` ${mark.warn} ${c.yellow('설정에서 이 연결을 못 찾아 파일에는 못 남겼습니다 — 다음에 켜면 옛 값입니다.')}`);
3057
- say(` ${c.gray(알아낸것 ? `서버에서 알아낸 ${알아낸것.toLocaleString()} 토큰을 씁니다.` : '모르는 값이라 16,384 토큰으로 갑니다.')}`);
3058
- say('');
3059
- return;
3060
- }
3061
-
3062
- if (말) {
3063
- const 값 = parseSize(말);
3064
- if (!값) {
3065
- say(` ${mark.no} 숫자를 못 읽었습니다: ${c.white(arg)}`);
3066
- say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/out 32k')} ${c.cyan('/out 65536')} ${c.cyan('/out auto')}`);
3067
- say('');
3068
- return;
3069
- }
3070
- session.conn.maxTokens = 값;
3071
- if (prof) { prof.maxTokens = 값; 설정남기기(cfg); }
3072
- say(` ${mark.ok} 답 길이 상한 ${c.bold(값.toLocaleString())} 토큰`);
3073
- say(` ${c.gray('모델이 못 내는 값을 넣으면 서버가 거절합니다. 거절당하면')} ${c.cyan('/out auto')} ${c.gray('로 되돌리세요.')}`);
3074
- say('');
3075
- return;
3076
- }
3077
-
3078
- const 쓰는값 = session.conn.maxTokens ?? 알아낸것 ?? 16384;
3079
- const 어디서 = session.conn.maxTokens ? '직접 정하신 값'
3080
- : 알아낸것 ? '서버에서 알아낸 값'
3081
- : '모르는 값이라 안전한 기본값';
3082
- say('');
3083
- rule('한 번에 받을 답 길이', 70);
3084
- say(` ${c.gray('지금 상한')} ${c.white(쓰는값.toLocaleString())} ${c.gray(`토큰 — ${어디서}`)}`);
3085
- say(` ${c.gray('컨텍스트')} ${c.white((session.conn.ctx ?? 0).toLocaleString())} ${c.gray('토큰')} ${c.gray('(다른 축입니다 — /ctx)')}`);
3086
- say('');
3087
- say(` ${c.gray('이 값이 한 번에 만들 수 있는 파일 크기를 정합니다.')}`);
3088
- say(` ${c.gray('1,000줄짜리 HTML 이 대략 12,000~18,000 토큰입니다.')}`);
3089
- say(` ${c.gray('여기서 잘려도 받은 데까지는 파일에 쓰고 이어 붙입니다 — 다만 몇 번 더 오갑니다.')}`);
3090
- say('');
3091
- say(` ${c.cyan('/out 32k')} ${c.gray('직접 지정 (k 는 1024)')}`);
3092
- say(` ${c.cyan('/out auto')} ${c.gray('직접 정한 값을 지우고 알아낸 값/기본값으로')}`);
3093
- say('');
3094
- }
3095
-
3096
- /**
3097
- * /grade — 모델 급.
3098
- *
3099
- * `/ctx` 와는 **다른 축**이다. 헷갈리기 쉬워서 화면에서도 나란히 보여 준다.
3100
- * /ctx 얼마나 담나 (창 크기)
3101
- * /grade 얼마나 알아서 하나 (능력)
3102
- *
3103
- * 창이 128k 인 3B 모델이 있고, 창이 32k 인 아주 좋은 모델도 있다. 둘을 같은
3104
- * 값으로 다루면 하나는 붙들려 있고 하나는 놓쳐진다.
3105
- *
3106
- * 평소에는 안 건드려도 된다 — 이름으로 짐작하고, 대화가 돌수록 실제로 본 것
3107
- * (인자 잘림·빈 답·편집 실패·되풀이)으로 고쳐 잡는다. 여기서 정하면 그것이
3108
- * 이기고, `auto` 로 되돌리면 다시 스스로 잡는다.
3109
- */
3110
- function 모델급(session, arg = '') {
3111
- const 값 = String(arg ?? '').trim().toLowerCase();
3112
- const 별명 = {
3113
- '작음': '작음', 'small': '작음', 's': '작음', '작': '작음',
3114
- '보통': '보통', 'medium': '보통', 'm': '보통', '중': '보통',
3115
- '큼': '큼', 'large': '큼', 'l': '큼', 'big': '큼', '대': '큼',
3116
- };
3117
-
3118
- if (값 === 'auto' || 값 === '자동') {
3119
- session.급정한것 = null;
3120
- // 급은 턴마다 한 번만 맨다(session.js 의 급 머리말). 사람이 여기서 정한
3121
- // 것은 다음 턴이 아니라 **지금** 먹어야 하니 빗장을 풀어 준다.
3122
- session.급다시재기?.();
3123
- const g = session.급();
3124
- say('');
3125
- say(` ${c.cyan('◈')} 모델 급을 다시 ${c.bold('스스로 잡게')} 했습니다 — 지금은 ${c.white(g.급)}`);
3126
- say(` ${c.gray(g.왜)}`);
3127
- return;
3128
- }
3129
-
3130
- if (값 && Object.hasOwn(별명, 값)) {
3131
- session.급정한것 = 별명[값];
3132
- session.급다시재기?.();
3133
- const v = session.급값();
3134
- say('');
3135
- say(` ${c.cyan('◈')} 모델 급을 ${c.bold(별명[값])} 으로 정했습니다.`);
3136
- say(` ${c.gray(`한 번에 만들 파일 ${v.한번에쓸파일}개 · ${v.나눠쓰기줄}줄 넘으면 나눠 쓰기`)}`);
3137
- say(` ${c.gray('/grade auto 로 되돌리면 다시 스스로 잡습니다.')}`);
3138
- return;
3139
- }
3140
-
3141
- /*
3142
- * 못 알아들은 값은 **못 알아들었다고 말한다.**
3143
- *
3144
- * 앞서는 `/grade 크게` 같은 오타가 오류 한 줄 없이 지금 상태 표로
3145
- * 떨어졌다. 사람은 정해진 줄로 읽고 그대로 쓴다. 바로 옆 `/work` 는
3146
- * 「그런 모드는 없습니다」 를 먼저 말한다 — 잣대를 같게 맞춘다.
3147
- */
3148
- if (값) {
3149
- say('');
3150
- say(` ${mark.no} ${c.gray('그런 급은 없습니다:')} ${c.white(값)}`);
3151
- say(` ${c.gray('고를 수 있는 것:')} ${c.cyan('작음')} ${c.gray('·')} ${c.cyan('보통')} ${c.gray('·')} ${c.cyan('큼')} ${c.gray('·')} ${c.cyan('auto')}`);
3152
- }
3153
-
3154
- // 인자가 없으면 지금 상태를 보여 준다.
3155
- const g = session.급();
3156
- const v = session.급값();
3157
- const 본 = session.본것;
3158
- say('');
3159
- say(` ${c.bold('모델 급')} ${c.hcyan(g.급)}${g.짐작 ? c.gray(' (짐작)') : ''}`);
3160
- say(` ${c.gray(g.왜)}`);
3161
- say('');
3162
- say(` ${c.gray('이 급에서 쓰는 값')}`);
3163
- say(` ${c.gray('한 번에 만들 파일')} ${c.white(String(v.한번에쓸파일))}개`);
3164
- say(` ${c.gray('나눠 쓰기 기준')} ${c.white(String(v.나눠쓰기줄))}줄`);
3165
- say(` ${c.gray('절차를 못 박나')} ${v.절차를못박나 ? c.white('예') : c.gray('아니오 — 목표만 준다')}`);
3166
- say(` ${c.gray('하위 작업 권함')} ${v.하위작업권함 ? c.white('예') : c.gray('아니오')}`);
3167
- if (본 && 본.걸음) {
3168
- say('');
3169
- say(` ${c.gray('이번 대화에서 실제로 본 것')} ${c.gray(`(${본.걸음}걸음)`)}`);
3170
- const 줄 = [
3171
- ['인자 잘림', 본.잘린인자], ['빈 답', 본.빈답],
3172
- ['편집 실패', 본.편집실패], ['되풀이', 본.되풀이], ['도구 성공', 본.도구성공],
3173
- ];
3174
- say(' ' + 줄.map(([이름, n]) => `${c.gray(이름)} ${n ? c.white(String(n)) : c.gray('0')}`).join(c.gray(' · ')));
3175
- }
3176
- say('');
3177
- say(` ${c.gray('/ctx 와는 다른 축입니다 — /ctx 는 얼마나 담나, /grade 는 얼마나 알아서 하나.')}`);
3178
- say(` ${c.gray('직접 정하려면 /grade 작음|보통|큼 · 되돌리려면 /grade auto')}`);
3179
- }
3180
-
3181
- /**
3182
- * 컨텍스트 길이를 보고·다시 재고·직접 지정한다.
3183
- *
3184
- * /ctx 지금 값과 어디서 나온 값인지
3185
- * /ctx auto 서버에 다시 물어 모델에 맞춘다
3186
- * /ctx 655360 직접 지정 (640k · 128k · 1m 도 받는다 — k 는 1024)
3187
- *
3188
- * 왜 필요한가: 이 숫자 하나가 프로그램 전체 크기를 정한다. 서버가 안 알려주면
3189
- * 32,768 로 깔고 앉는데, 요즘 로컬 모델은 262,144 · 655,360 이 흔하다.
3190
- * 그 상태로 쓰면 모델이 가진 것의 5% 만 쓰는 셈이다.
3191
- *
3192
- * 고른 값은 프로필에 남긴다 — 다음에 켤 때도 그대로여야 한다.
3193
- */
3194
- async function ctxLength(session, arg = '') {
3195
- const { probeCtx, parseSize, fmtSize } = await import('./backend/ctxsize.js');
3196
- const 말 = String(arg ?? '').trim().toLowerCase();
3197
- const 지금 = session.conn.ctx ?? 0;
3198
-
3199
- const 남기기 = (값, 어디서) => {
3200
- session.conn.ctx = 값;
3201
- const cfg = load();
3202
- const prof = 이연결의프로필(cfg, session);
3203
- // 못 남기면 아래 「프로필에 저장했습니다」 가 거짓말이 된다. 살아있는지 들고 간다.
3204
- const 남김 = prof ? (prof.ctx = 값, 설정남기기(cfg)) : false;
3205
- const b = session.breakdown();
3206
- say(` ${mark.ok} 컨텍스트 ${c.bold(값.toLocaleString())} 토큰 ${c.gray(`(${fmtSize(값)}) — ${어디서}`)}`);
3207
- say(` ${c.gray('지금 찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음')} ${c.white(b.left.toLocaleString())}`);
3208
- if (남김) say(` ${c.gray('프로필에 저장했습니다. 다음에 켤 때도 이 값입니다.')}`);
3209
- // 못 남긴 까닭이 '설정을 못 찾음' 이면 설정남기기() 가 아무 말도 안 한다.
3210
- else if (!prof) say(` ${mark.warn} ${c.yellow('설정에서 이 연결을 못 찾아 파일에는 못 남겼습니다 — 다음에 켜면 옛 값입니다.')}`);
3211
- say('');
3212
- };
3213
-
3214
- // 0) 답 길이 상한 — 이제 /out 이 본자리다. 여기서는 그리로 넘긴다.
3215
- // \b 가 아니라 공백·줄 끝으로 끊는다. 한글은 \w 가 아니라서
3216
- // `/^답\b/` 는 '답 32k' 에도 '답' 에도 안 맞는다(위 '배분' 과 같은 함정).
3217
- if (/^(out|답|출력)(\s|$)/.test(말)) return await 출력상한(session, 말.replace(/^(out|답|출력)\s*/, ''));
3218
-
3219
- // 1) 직접 지정
3220
- if (말 && !['auto', '자동', '다시', '자세히', 'detail', '-v'].includes(말)) {
3221
- const 값 = parseSize(말);
3222
- if (!값) {
3223
- say(` ${mark.no} 숫자를 못 읽었습니다: ${c.white(arg)}`);
3224
- say(` ${c.gray('이렇게 쓰세요 —')} ${c.cyan('/ctx 655360')} ${c.cyan('/ctx 640k')} ${c.cyan('/ctx 128k')} ${c.cyan('/ctx auto')}`);
3225
- say(` ${c.gray('k 는 1024 입니다. 655,360 은 640k 이지 655k 가 아닙니다 — 헷갈리면 그냥 숫자로 쓰세요.')}`);
3226
- say('');
3227
- return;
3228
- }
3229
- 남기기(값, '직접 지정');
3230
- say(` ${c.gray('서버가 실제로 올려 둔 길이보다 크게 잡으면 긴 대화에서 거절당합니다.')}`);
3231
- say(` ${c.gray('서버 쪽에서 올린 다음 맞추는 게 안전합니다 —')} ${c.cyan('/ctx auto')} ${c.gray('로 다시 잽니다.')}`);
3232
- say('');
3233
- return;
3234
- }
3235
-
3236
- // 2) 다시 재기 (auto · 다시 · 자세히)
3237
- if (말) {
3238
- const 자세히 = /자세히|detail|-v/.test(말);
3239
- const s = spin('모델에 걸린 길이를 서버에 묻는 중…');
3240
- let r;
3241
- try { r = await probeCtx(session.conn); }
3242
- catch (err) { s.stop(` ${mark.no} 못 물어봤습니다 — ${c.gray(String(err?.message ?? err))}`); say(''); return; }
3243
- s.stop('');
3244
-
3245
- // 어디를 두드렸고 무엇이 나왔는지. 값이 이상할 때 사람이 원인을 짚을 수 있어야 한다.
3246
- if (자세히) {
3247
- say('');
3248
- say(` ${c.gray('두드린 자리')}`);
3249
- for (const t of r.tried) {
3250
- const 표 = t.ok ? c.green('응답함') : c.gray(`${t.status || '연결 실패'}`);
3251
- say(` ${c.gray(pad(t.label, 20))} ${표} ${c.gray(clip(t.url, 60))}`);
3252
- }
3253
- say('');
3254
- say(` ${c.gray('읽어 낸 값')}`);
3255
- say(` ${c.gray(pad('모델 최대', 20))} ${r.max ? c.white(r.max.toLocaleString()) : c.gray('못 찾음')}`
3256
- + (r.maxKey ? c.gray(` ← ${r.maxKey}`) : ''));
3257
- say(` ${c.gray(pad('지금 올린 길이', 20))} ${r.loaded ? c.white(r.loaded.toLocaleString()) : c.gray('못 찾음')}`
3258
- + (r.loadedKey ? c.gray(` ← ${r.loadedKey}`) : ''));
3259
- say(` ${c.gray(pad('답 길이 상한', 20))} ${r.out ? c.white(r.out.toLocaleString()) : c.gray('못 찾음')}`
3260
- + (r.outSource ? c.gray(` ← ${r.outSource}`) : ''));
3261
- say('');
3262
- }
3263
-
3264
- // 답 길이 상한도 같이 알아냈으면 받아 둔다. 사람이 정한 값은 안 덮는다.
3265
- if (r.out) session.conn.maxOut = r.out;
3266
-
3267
- if (!r.value) {
3268
- // 못 알아낸 것을 아는 척하지 않는다. 조용히 32,768 로 깔고 앉으면
3269
- // 655k 모델을 5% 만 쓰거나, 8k 서버에 128k 를 보내 조용히 잘린다.
3270
- say(` ${mark.warn} 서버가 컨텍스트 길이를 안 알려줍니다. ${c.gray(r.why ?? '')}`);
3271
- if (!자세히) for (const t of r.tried) say(` ${c.gray(pad(t.label, 20))} ${c.gray(t.ok ? '응답함(값 없음)' : `${t.status || '연결 실패'}`)}`);
3272
- say(` ${c.gray(`지금은 ${(session.conn.ctx ?? 0).toLocaleString()} 으로 잡혀 있습니다 — 이건 알아낸 값이 아니라 기본값입니다.`)}`);
3273
- say(` ${c.gray('직접 넣어 주세요 —')} ${c.cyan('/ctx 655360')} ${c.gray('어디를 두드렸는지 보려면')} ${c.cyan('/ctx 자세히')}`);
3274
- say('');
3275
- return;
3276
- }
3277
- 남기기(r.value, `${r.source ?? '서버'}에서 읽음`);
3278
- if (r.max && r.loaded && r.max > r.loaded) {
3279
- say(` ${c.yellow('이 모델은')} ${c.bold(r.max.toLocaleString())} ${c.yellow('까지 되는데 지금')} ${c.bold(r.loaded.toLocaleString())} ${c.yellow('로 올려 두셨습니다.')}`);
3280
- say(` ${c.gray('서버(LM Studio 등)에서 컨텍스트를 더 올려 다시 올린 뒤')} ${c.cyan('/ctx auto')} ${c.gray('를 하시면 그만큼 씁니다.')}`);
3281
- say('');
3282
- }
3283
- return;
3284
- }
3285
-
3286
- // 3) 그냥 보기
3287
- const b = session.breakdown();
3288
- say('');
3289
- rule('컨텍스트 길이', 70);
3290
- say(` ${c.bold(session.conn.model)}`);
3291
- say(` ${c.gray('지금 잡은 길이')} ${c.white(지금.toLocaleString())} ${c.gray('토큰 (' + fmtSize(지금) + ')')}`);
3292
- say(` ${c.gray('찬 양')} ${c.white(b.used.toLocaleString())} ${c.gray('· 남음 ' + b.left.toLocaleString())}`);
3293
- say('');
3294
- say(` ${c.gray('이 값 하나가 프로그램 전체 크기를 정합니다 — 한 번에 읽힐 수 있는 파일 수,')}`);
3295
- say(` ${c.gray('대화가 접히는 시점, 한 번에 쓸 수 있는 답 길이가 모두 여기서 나옵니다.')}`);
3296
- say('');
3297
- say(` ${c.cyan('/ctx auto')} ${c.gray('서버에 다시 물어 모델에 맞춥니다')}`);
3298
- say(` ${c.cyan('/ctx 655360')} ${c.gray('직접 지정 (640k · 128k · 1m 도 됩니다 — k 는 1024)')}`);
3299
- say(` ${c.cyan('/out 32k')} ${c.gray('한 번에 받을 답 길이 상한 (지금 ' + (session.conn.maxTokens ?? session.conn.maxOut ?? 16384).toLocaleString() + ') — 다른 축입니다')}`);
3300
- say('');
3301
- }
3302
-
3303
- /** 지금 연결을 이 프로필로 갈아끼운다. 대화는 그대로 둔다. */
3304
- function 연결적용(session, p, ctx = null) {
3305
- Object.assign(session.conn, {
3306
- kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
3307
- ctx: p.ctx, maxTokens: p.maxTokens ?? null,
3308
- streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
3309
- // 프로필마다 다를 수 있다 — 사내 게이트웨이는 잠잠 상한이 크고 로컬은 작다.
3310
- 잠잠: p.잠잠 ?? p.streamIdleMs ?? 잠잠기본,
3311
- // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
3312
- 무소식: p.무소식 ?? p.streamNoNewsMs ?? 무소식기본,
3313
- // 프로필마다 인증서가 다르다. 안 갈아 끼우면 옛 프로필의 신원으로 붙는다.
3314
- 인증서: 인증서설정(p),
3315
- /*
3316
- * ── 이 두 줄이 없어서 회사 토큰이 남의 창구로 나갔다 ────────────────
3317
- *
3318
- * conn 을 짓는 자리는 넷이다 — repl.js · oneshot.js · acp/serve.js, 그리고
3319
- * 여기. 앞의 셋은 이 둘을 넣는데 여기만 빠져 있었다. 그래서 `/model` 로
3320
- * 갈아타면 **옛 프로필의 것이 그대로 남았다.**
3321
- *
3322
- * 회사 프로필(열쇠받기로 SSO 토큰을 받아 온다) → /model 남의창구
3323
- * · 살아 있는 회사 토큰이 `Authorization` 에 실려 남의 host 로 간다
3324
- * · 새 프로필의 제 열쇠(conn.key)는 **쓰이지도 않는다** —
3325
- * adapter.js 의 머리말짓기 는 열쇠받기가 있으면 그쪽을 쓴다
3326
- * · 남의 창구는 그 토큰을 모르니 401, 그러면 로그인 명령을 다시 돌린다
3327
- *
3328
- * 반대쪽도 같다. 맨 프로필에서 회사 프로필로 옮기면 열쇠받기가 null 로
3329
- * 남아 401 뒤에 열쇠를 다시 받는 길이 잠긴다(adapter.js 의 열쇠다시받을까).
3330
- *
3331
- * vision 도 같은 값이다 — 안 옮기면 그림을 못 보는 모델에 그림을 보낸다.
3332
- */
3333
- 열쇠받기: 받기설정(p, { 정책값: 정책읽기().값 }),
3334
- vision: p.vision ?? false,
3335
- });
3336
- /*
3337
- * 들고 있던 토큰도 버린다.
3338
- *
3339
- * 열쇠받기 설정을 새것으로 갈아도, authcmd.js 가 **모듈 전역 하나**에
3340
- * 받은 토큰을 들고 있다(받은것). 어느 프로필 것인지는 안 적혀 있다. 그래서
3341
- * 안 버리면 새 창구의 첫 요청에 옛 회사 토큰이 그대로 실려 나간다 — 위에서
3342
- * 고친 것을 캐시가 도로 되돌리는 셈이다.
3343
- *
3344
- * 이 함수의 머리말이 「검사와 `/model` 갈아타기가 부른다」 라고 적혀 있었는데
3345
- * 부르는 곳이 검사밖에 없었다. 이제 적힌 대로가 된다.
3346
- */
3347
- 받은열쇠잊기();
3348
- /*
3349
- * 급도 다시 맨다.
3350
- *
3351
- * 급은 conn 을 보고 매긴다(agent/grade.js 의 매김). 모델을 갈아탔는데 앞
3352
- * 모델로 매긴 급을 턴 끝까지 들고 있으면, 화면에 적힌 급과 프롬프트에 실린
3353
- * 급이 서로 다른 모델 이야기가 된다.
3354
- */
3355
- session.급다시재기?.();
3356
- // 자물쇠도 같이 옮긴다. 이걸 빼먹으면 옛 주소가 열린 채로 남고 새 주소는 막혀
3357
- // 다음 한마디에서 바로 "허용되지 않은 주소" 가 난다.
3358
- allowEndpoint(p.baseUrl);
3359
- // 어디 것인지도 같이 옮긴다. 요금표 주소 같은 안내가 옛 회사 것으로 남으면
3360
- // 사람이 엉뚱한 요금표를 보러 간다.
3361
- session.제공자 = p.제공자 ?? null;
3362
-
3363
- /*
3364
- * 전선 카드도 **다시 달아 준다** (backend/wire.js).
3365
- *
3366
- * 안 다시 달면 옛 창구의 카드가 그대로 붙어 있다. 그것만으로 이런 일이
3367
- * 난다 — Anthropic 으로 옮겼는데 카드는 `생각형식:'effort'` 라 생각이 안
3368
- * 켜지고, `캐시:'열쇠'` 라 **캐시 표식이 한 자리도 안 붙는다.** 반대로
3369
- * OpenAI 호환으로 옮기면 `스트림usage:false` 가 남아 usage 가 영영 안 와서
3370
- * 상태줄의 ↑↓ 가 멈춰 선다. 그러면서 `/status` 는 옛 카드를 그대로 적는다 —
3371
- * 화면과 전선이 어긋나는 것, 이 모듈이 없애겠다고 만든 바로 그 고장이다.
3372
- *
3373
- * 지우고 새로 단다. 안 지우면 `배운칸` 이 따라붙어서, 옛 창구가 거절한
3374
- * 칸이 새 창구에서 「배운 것」 으로 남는다.
3375
- */
3376
- session.conn.전선 = null;
3377
- 전선붙이기(session.conn, ctx?.배움 ?? null);
3378
- }
3379
-
3380
- /**
3381
- * 지금 붙어 있는 서버가 내주는 모델 목록.
3382
- *
3383
- * 저장된 프로필에는 등록할 때 고른 모델 하나만 있다. 그런데 서버 한 대가
3384
- * 모델을 여럿 내주는 경우가 대부분이다 — 특히 프록시나 게이트웨이가 그렇다.
3385
- * 그래서 서버에 직접 물어본다. 자리를 새로 여는 게 아니라 이미 열린 자리다.
3386
- */
3387
- async function 서버모델들(conn) {
3388
- const { req, headersFor } = await import('./backend/http.js');
3389
- if (conn.kind === 'ollama') {
3390
- const r = await req(`${conn.base.replace(/\/v1\/?$/, '')}/api/tags`, { timeout: 4000 });
3391
- /*
3392
- * **못 받은 것**과 **안 내주는 것**은 다르다.
3393
- *
3394
- * req 는 통신 실패에 안 던지고 `{ok:false, status, error}` 를 준다. 여기만
3395
- * 그 ok 를 안 보고 빈 배열을 돌려줘서, 404·500·시간초과·프록시 거절이
3396
- * 전부 「이 서버는 모델 목록을 내주지 않습니다」 가 됐다. 다른 갈래는
3397
- * 아래처럼 null 을 준다 — 같은 함수가 갈래마다 다른 약속을 하고 있었다.
3398
- */
3399
- if (!r.ok) return null;
3400
- return (r.json?.models ?? []).map((m) => m.name ?? m.model).filter(Boolean);
3401
- }
3402
- /*
3403
- * Azure 는 모델 목록이 `/models` 가 아니라 `/openai/deployments` 에 있다.
3404
- * 여기를 안 고쳐 두면 `/model` 로 배포를 바꾸려는 순간
3405
- * `.../deployments/gpt-4o?api-version=2024-10-21/models` 를 두드리고 404 다 —
3406
- * 붙는 길만 고치고 바꾸는 길을 안 고치면 반쪽이다.
3407
- */
3408
- const { 애저인가, 애저풀기, 배포목록 } = await import('./backend/azure.js');
3409
- if (애저인가(conn.base)) {
3410
- const 푼것 = 애저풀기(conn.base);
3411
- const a = await req(푼것.목록주소,
3412
- { headers: headersFor(conn.auth ?? 'none', conn.key, 더할머리(conn.kind)), timeout: 4000 });
3413
- if (!a.ok) return null;
3414
- return 배포목록(a.json).map((m) => m.id);
3415
- }
3416
- /*
3417
- * 판 머리를 같이 얹는다 (adapter.js 의 더할머리).
3418
- *
3419
- * Anthropic 은 `anthropic-version` 이 없으면 400 이다. 이 자리가 그것을
3420
- * 빠뜨리고 있어서, 그 창구에서는 `/model` 이 목록을 못 받아 왔다 — 그리고
3421
- * 목록을 못 받으면 화면은 「모델이 없습니다」 라고 적는다. 있는데.
3422
- */
3423
- const r = await req(`${conn.base.replace(/\/$/, '')}/models`, {
3424
- headers: headersFor(conn.auth ?? 'none', conn.key, 더할머리(conn.kind)), timeout: 4000,
3425
- });
3426
- if (!r.ok) return null;
3427
- const list = r.json?.data ?? r.json?.models ?? [];
3428
- return Array.isArray(list)
3429
- ? list.map((m) => (typeof m === 'string' ? m : m.id ?? m.name ?? m.model)).filter(Boolean)
3430
- : null;
3431
- }
3432
-
3433
- /**
3434
- * /model — 연결·모델 바꾸기.
3435
- *
3436
- * /model 골라 바꾸기 (연결 목록 + '이 서버의 다른 모델')
3437
- * /model <이름> 바로 바꾸기. 연결 이름이든 모델 이름이든 일부만 쳐도 된다
3438
- * /model list 무엇이 등록돼 있는지만 보기
3439
- * /model models 지금 서버가 내주는 모델을 물어보기
3440
- */
3441
- async function switchModel(session, ctx, arg = '') {
3442
- const cfg = load();
3443
- if (!cfg.profiles.length) {
3444
- say(` ${c.gray('저장된 연결이 없습니다.')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')}`);
3445
- say('');
3446
- return;
3447
- }
3448
- const 말 = arg.trim();
3449
-
3450
- if (말 === 'list' || 말 === '목록') return 연결목록(cfg, session);
3451
- if (말 === 'models' || 말 === '모델') return await 서버모델고르기(session, ctx, cfg);
3452
-
3453
- // 이름으로 바로 바꾸기 — 메뉴를 안 거친다.
3454
- if (말) {
3455
- const 찾은 = 이름으로찾기(cfg.profiles, 말);
3456
- if (찾은.length === 1) return await 골라적용(session, cfg, 찾은[0], ctx);
3457
- if (찾은.length > 1) {
3458
- say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 것이 여럿입니다.')}`);
3459
- for (const p of 찾은.slice(0, 12)) say(` ${c.cyan(p.name)} ${c.gray(p.model)}`);
3460
- say('');
3461
- return;
3462
- }
3463
- // 등록된 것에 없으면, 지금 서버가 내주는 모델 중에 있는지 본다.
3464
- const 있는것 = await 서버모델들(session.conn);
3465
- const 맞는것 = (있는것 ?? []).filter((m) => m.toLowerCase().includes(말.toLowerCase()));
3466
- if (맞는것.length === 1) return await 모델만바꾸기(session, cfg, 맞는것[0], ctx);
3467
- if (맞는것.length > 1) {
3468
- say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 모델이 여럿입니다.')}`);
3469
- for (const m of 맞는것.slice(0, 12)) say(` ${c.cyan(m)}`);
3470
- say('');
3471
- return;
3472
- }
3473
- // 목록을 **못 받은** 것을 「없다」 로 적으면, 사람은 있는 모델을 찾아 헤맨다.
3474
- if (있는것 == null) {
3475
- say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결이 없고, 서버에는 물어봤지만 목록을 못 받았습니다.')}`);
3476
- } else {
3477
- say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 연결도 모델도 없습니다.')}`);
3478
- }
3479
- say(` ${c.gray('무엇이 있는지 보려면')} ${c.cyan('/model list')}${c.gray(', 서버에 물어보려면')} ${c.cyan('/model models')}`);
3480
- say('');
3481
- return;
3482
- }
3483
-
3484
- // 인자 없이 — 골라 바꾸기. 물어볼 수 없는 자리면 목록만 보여준다.
3485
- if (!ctx?.ask) return 연결목록(cfg, session);
3486
-
3487
- const items = cfg.profiles.map((p) => ({
3488
- label: `${pad(p.name, 22)} ${c.gray(p.model)}`,
3489
- note: p.id === cfg.active ? '지금' : '',
3490
- }));
3491
- items.push({ label: c.cyan('이 서버의 다른 모델 고르기'), note: '서버에 물어봅니다' });
3492
-
3493
- const i = await pick('연결·모델 고르기', items, {
3494
- def: Math.max(0, cfg.profiles.findIndex((p) => p.id === cfg.active)),
3495
- ask: ctx?.ask,
3496
- });
3497
- if (i === cfg.profiles.length) return await 서버모델고르기(session, ctx, cfg);
3498
- return await 골라적용(session, cfg, cfg.profiles[i], ctx);
3499
- }
3500
-
3501
- function 이름으로찾기(profiles, 말) {
3502
- const q = 말.toLowerCase();
3503
- const 정확 = profiles.filter((p) => p.name.toLowerCase() === q || p.model.toLowerCase() === q || p.id.toLowerCase() === q);
3504
- if (정확.length) return 정확;
3505
- return profiles.filter((p) => `${p.name} ${p.model} ${p.id}`.toLowerCase().includes(q));
3506
- }
3507
-
3508
- /*
3509
- * 이 프로필로 갈아타면 바깥으로 나가는가. 나가면 한 번 묻는다.
3510
- *
3511
- * 켤 때 지나는 문(repl.js)과 **같은 문**이다. 여기를 안 지키면 자물쇠가
3512
- * 반쪽이 된다 — 로컬로 켜서 물음을 지나친 다음, /model 로 바깥 프로필에
3513
- * 갈아타는 순간 아무것도 안 묻고 나간다. 실제로 그렇게 쓴다: 한 시간쯤
3514
- * 로컬로 하다가 「이건 큰 모델이 낫겠다」 하고 옮긴다.
3515
- *
3516
- * 허락은 프로필에 적힌다. 갈아탈 때마다 묻지 않는다.
3517
- */
3518
- async function 나가도되나묻기(session, p) {
3519
- const 모드 = session.실행모드 ?? 지금모드({});
3520
- const 나감 = 나갈수있나(모드, { 바깥: 바깥인가(p.baseUrl), 허가: p.online === true });
3521
- if (나감.되나) return true;
3522
-
3523
- const 어디 = 주소가리기((() => { try { return new URL(p.baseUrl).host; } catch { return String(p.baseUrl); } })());
3524
- if (!나감.물어볼까) {
3525
- // 봉인이다. 여기서 바꿔 주면 다음 한마디에서 막히는데, 그 화면만 보고는
3526
- // 왜 막혔는지 알 수 없다 — 바꾸기 전에 말하는 편이 언제나 낫다.
3527
- say(` ${mark.warn} ${c.gray(`${어디} 는 이 컴퓨터 밖입니다. 지금은`)} ${c.white('봉인(--offline)')} ${c.gray('이라 안 바꿉니다.')}`);
3528
- say('');
3529
- return false;
3530
- }
3531
- say('');
3532
- say(` ${c.yellow('↗')} ${c.bold(어디)} ${c.gray('는 이 컴퓨터 밖입니다.')}`);
3533
- say(` ${c.gray('바꾸면 시킨 말과, 모델이 읽은 파일의 내용이 그리로 갑니다.')}`);
3534
- const 예 = await confirm('나가도 될까요? (한 번 허락하면 이 연결은 다음부터 안 묻습니다)', true);
3535
- if (!예) {
3536
- say(` ${c.gray('안 바꿨습니다. 아무것도 안 보냈습니다.')}`);
3537
- say('');
3538
- return false;
3539
- }
3540
- p.online = true;
3541
- return true;
3542
- }
3543
-
3544
- async function 골라적용(session, cfg, p, ctx = null) {
3545
- if (!await 나가도되나묻기(session, p)) return;
3546
- cfg.active = p.id;
3547
- 설정남기기(cfg);
3548
- 연결적용(session, p, ctx);
3549
- say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
3550
- say('');
3551
- }
3552
-
3553
- /**
3554
- * 같은 서버에서 모델만 바꾼다.
3555
- *
3556
- * 등록된 연결이 아니어도 된다 — 서버가 내준다면 쓸 수 있어야 한다.
3557
- * 다음에도 쓰도록 프로필로 남겨 둔다. 그래야 /model 목록에서 다시 보인다.
3558
- */
3559
- async function 모델만바꾸기(session, cfg, 모델, ctx = null) {
3560
- const 지금 = cfg.profiles.find((p) => p.id === cfg.active) ?? cfg.profiles[0];
3561
- const 이미 = cfg.profiles.find((p) => p.baseUrl === session.conn.base && p.model === 모델);
3562
- const p = 이미 ?? {
3563
- ...지금,
3564
- id: `${(지금?.id ?? 'conn').replace(/-[^-]*$/, '')}-${String(모델).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase(),
3565
- name: `${(지금?.name ?? '연결').split(' · ')[0]} · ${모델}`,
3566
- baseUrl: session.conn.base,
3567
- model: 모델,
3568
- // 컨텍스트 길이는 물려받지 않는다. 모델마다 다르다 —
3569
- // 32k 짜리에서 655k 짜리로 옮겼는데 32k 로 깔고 앉으면 새 모델의 5% 만 쓴다.
3570
- // 반대로 큰 데서 작은 데로 옮기면 긴 대화에서 서버가 거절한다. 아래에서 다시 잰다.
3571
- ctx: null,
3572
- };
3573
- // 서버는 그대로지만 문은 같이 지난다. 등록 안 된 서버로 옮겨 가는 길도
3574
- // 여기라서, 여기를 열어 두면 자물쇠에 구멍이 하나 남는다.
3575
- if (!await 나가도되나묻기(session, p)) return;
3576
- /*
3577
- * 새로 만든 프로필도 **지금 쓰는 것**으로 못 박는다.
3578
- *
3579
- * upsert 는 active 가 비어 있을 때만 채운다. 여기서는 이미 차 있으므로,
3580
- * 새 프로필을 넣기만 하고 active 는 옛 모델에 그대로 남았다 — 화면에는
3581
- * 「모델을 X 로 바꿨습니다」 라고 적히고, 다음에 켜면 옛 모델로 돌아온다.
3582
- * 이미 있는 프로필로 갈아탈 때(아래 갈래)는 제대로 하고 있었다.
3583
- */
3584
- if (!이미) upsert(cfg, p);
3585
- cfg.active = p.id;
3586
- 설정남기기(cfg);
3587
- 연결적용(session, p, ctx);
3588
- say(` ${mark.ok} 모델을 ${c.bold(모델)} 로 바꿨습니다. ${c.gray('서버는 그대로입니다.')}`);
3589
- await 길이맞추기(session, cfg, p);
3590
- say('');
3591
- }
3592
-
3593
- /**
3594
- * 바뀐 모델에 맞춰 컨텍스트 길이를 다시 잰다.
3595
- *
3596
- * 모델을 바꾸는 순간은 이미 서버와 이야기하는 중이라 한 번 더 물어봐도 티가 안 난다.
3597
- * 여기서 안 재면 새 모델을 옛 모델의 길이로 쓰게 된다 — 화면에는 아무 표시도 안 나고
3598
- * 그냥 조용히 작아진다. 그런 고장이 가장 늦게 발견된다.
3599
- */
3600
- async function 길이맞추기(session, cfg, prof) {
3601
- const { probeCtx, fmtSize, 기본값 } = await import('./backend/ctxsize.js');
3602
- let r = null;
3603
- // 물어보다 **터진 것**과 서버가 값을 **안 준 것**은 다르다. 아래에서 갈라 적는다.
3604
- let 못물어본까닭 = null;
3605
- try { r = await probeCtx(session.conn, { timeout: 8000 }); }
3606
- catch (e) { 못물어본까닭 = String(e?.message ?? e); }
3607
- const 값 = r?.value ?? prof.ctx ?? 기본값;
3608
- session.conn.ctx = 값;
3609
- if (prof) {
3610
- prof.ctx = 값;
3611
- 설정남기기(cfg);
3612
- }
3613
- /*
3614
- * 어디서 온 값인지 **세 가지를 갈라** 적는다.
3615
- *
3616
- * 앞서는 물어보다 터진 것도, 서버가 안 준 것도, 프로필에 적혀 있던 값을
3617
- * 물려받은 것도 전부 「서버가 안 알려줘 기본값」 이었다. 값이 655,360 인데
3618
- * 출처는 「기본값」 이라고 적히는 판이 실제로 난다 — /ctx 화면은 출처를
3619
- * 정성껏 가르는데 이 줄만 뭉갰다.
3620
- */
3621
- const 어디서 = r?.source ? r.source + '에서 읽음'
3622
- : 못물어본까닭 ? '못 물어봤습니다 — ' + clip(못물어본까닭, 50)
3623
- : r?.value == null && prof?.ctx ? '이 프로필에 적혀 있던 값'
3624
- : '서버가 안 알려줘 기본값';
3625
- say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + 어디서)}`);
3626
- if (r?.max && r?.loaded && r.max > r.loaded) {
3627
- say(` ${c.yellow('이 모델은 ' + r.max.toLocaleString() + ' 까지 됩니다.')} ${c.gray('서버에서 더 올린 뒤')} ${c.cyan('/ctx auto')}`);
3628
- } else if (!r?.value) {
3629
- say(` ${c.gray('맞지 않으면')} ${c.cyan('/ctx 655360')} ${c.gray('처럼 직접 지정하세요.')}`);
3630
- }
3631
- }
3632
-
3633
- function 연결목록(cfg, session) {
3634
- rule('등록된 연결', 70);
3635
- for (const p of cfg.profiles) {
3636
- const 지금 = p.id === cfg.active;
3637
- say(` ${지금 ? c.hgreen('●') : c.gray('·')} ${지금 ? c.bold(c.white(pad(p.name, 26))) : pad(p.name, 26)}${c.gray(p.model)}`);
3638
- say(` ${c.gray(p.baseUrl)}`);
3639
- }
3640
- say('');
3641
- say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}${c.gray(' — 연결 이름이든 모델 이름이든 됩니다.')}`);
3642
- say(` ${c.gray('이 서버가 내주는 다른 모델을 보려면')} ${c.cyan('/model models')}`);
3643
- say('');
3644
- }
3645
-
3646
- async function 서버모델고르기(session, ctx, cfg) {
3647
- const s = spin('서버에 모델 목록을 물어보는 중…');
3648
- let 있는것;
3649
- /*
3650
- * 무엇 때문에 못 받았는지를 **버리지 않는다.**
3651
- *
3652
- * 여기 catch 는 오프라인 잠금(NetBlocked)까지 삼켰다. 그러면 자물쇠가 막은
3653
- * 것을 「이 서버는 목록을 안 내줍니다」 라고 서버 탓으로 돌리게 된다 —
3654
- * 잠금이 화면에서 사라지는 것은 이 저장소가 여러 군데서 막으려던 그 꼴이다.
3655
- */
3656
- let 못받은까닭 = null;
3657
- try { 있는것 = await 서버모델들(session.conn); }
3658
- catch (e) { 있는것 = null; 못받은까닭 = String(e?.message ?? e); }
3659
- s.stop('');
3660
- if (!있는것 || !있는것.length) {
3661
- if (못받은까닭) say(` ${mark.warn} 모델 목록을 못 받았습니다 ${c.gray('— ' + clip(못받은까닭, 70))}`);
3662
- else if (있는것 == null) say(` ${mark.warn} 모델 목록을 못 받았습니다 ${c.gray('— 서버가 답을 안 주거나 주소·열쇠가 안 맞습니다.')}`);
3663
- else say(` ${mark.warn} 이 서버는 모델 목록을 내주지 않습니다.`);
3664
- say(` ${c.gray('목록이 없는 게이트웨이도 있습니다. 그때는')} ${c.cyan('deel setup')} ${c.gray('에서 모델 이름을 직접 넣으세요.')}`);
3665
- say('');
3666
- return;
3667
- }
3668
- // 물어볼 수 없는 자리면 목록만 보여주고 끝낸다.
3669
- //
3670
- // 여기서 그냥 pick 을 부르면 표준입력을 붙잡고 영영 안 끝난다.
3671
- // 파이프로 넣거나 검사에서 돌릴 때가 그렇다 — 멈춘 것처럼 보이고 끊는 수밖에 없다.
3672
- if (!ctx?.ask) {
3673
- rule(`이 서버의 모델 (${있는것.length}개)`, 70);
3674
- for (const m of 있는것) say(` ${m === session.conn.model ? c.hgreen('●') : c.gray('·')} ${m === session.conn.model ? c.bold(m) : m}`);
3675
- say('');
3676
- say(` ${c.gray('바꾸려면')} ${c.cyan('/model <이름 일부>')}`);
3677
- say('');
3678
- return;
3679
- }
3680
-
3681
- const i = await pick(`이 서버의 모델 (${있는것.length}개)`, 있는것.map((m) => ({
3682
- label: m, note: m === session.conn.model ? '지금' : '',
3683
- })), {
3684
- def: Math.max(0, 있는것.indexOf(session.conn.model)),
3685
- ask: ctx.ask,
3686
- });
3687
- const 고른것 = 있는것[i];
3688
- if (고른것 === session.conn.model) {
3689
- say(` ${c.gray('그대로 둡니다.')}`);
3690
- say('');
3691
- return;
3692
- }
3693
- await 모델만바꾸기(session, cfg, 고른것, ctx);
3694
- }
3695
-
3696
1840
  const INIT_TEMPLATE = `# DEEL.md
3697
1841
 
3698
1842
  이 폴더에서 일할 때 지킬 규칙을 적습니다. deel 가 매번 읽습니다.
@@ -3712,36 +1856,3 @@ const INIT_TEMPLATE = `# DEEL.md
3712
1856
  - 고치기 전에 관련 파일을 먼저 읽는다
3713
1857
  - (프로젝트에 맞는 규칙을 적으세요)
3714
1858
  `;
3715
-
3716
- // /work 를 인자 없이 부르면 지금 모드와 고를 수 있는 것들을 보여 준다.
3717
- function showWork(session) {
3718
- rule(말('work.title'), 70);
3719
- /*
3720
- * 이름 칸을 **제일 긴 이름에 맞춰** 잡는다.
3721
- *
3722
- * 8칸으로 못 박아 뒀더니 Orchestrator(12자)가 칸을 넘어 다음 칸을 밀었다.
3723
- * 화면에 `OrchestratorOrchestrator` 라고 붙어 나왔다 — 사진을 찍어 보고서야
3724
- * 알았다. 목록의 값은 칸이 맞아야 목록이다.
3725
- */
3726
- const 이름들 = WORK_ORDER.map((k) => 보일이름(WORK_MODES[k].id));
3727
- const 칸너비 = Math.max(...이름들.map((n) => width(n))) + 2;
3728
- // 영어 화면에서는 이름과 영문 이름이 같은 글자다. 같은 것을 두 번 적지 않는다.
3729
- const 영문칸 = 언어() === 'en' ? 0 : 14;
3730
-
3731
- for (const k of WORK_ORDER) {
3732
- const w = WORK_MODES[k];
3733
- const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
3734
- const 표 = 지금 ? c.hgreen('●') : c.gray('·');
3735
- const 보임 = pad(보일이름(w.id), 칸너비);
3736
- const 이름 = 지금 ? c.bold(c.white(보임)) : c.gray(보임);
3737
- say(` ${표} ${c.hcyan(w.glyph)} ${이름}${영문칸 ? c.gray(pad(w.en, 영문칸)) : ''}${c.gray(보일한줄(w.id))}`);
3738
- const 강도 = `${w.think ?? '—'}·${w.effort}`;
3739
- const 걸음 = 말('work.steps', { n: 걸음수(k, session.conn?.ctx) });
3740
- say(` ${canWrite(k) ? c.gray(말('work.canEdit')) : c.green(말('work.readOnly'))}`
3741
- + `${c.gray(` · ${말('work.think')} ${강도} · ${걸음}`)}`);
3742
- }
3743
- say('');
3744
- say(` ${c.gray(말('work.howTo', { a: c.cyan('/plan'), b: c.cyan('/code'), c: c.cyan('/debug'), 키: c.cyan('Ctrl+O') }))}`);
3745
- say(` ${c.gray(말('work.axis', { 명령: c.cyan('/mode') }))}`);
3746
- say('');
3747
- }