deel-local-cli 1.2.0 → 1.4.1

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 (50) hide show
  1. package/README.en.md +301 -1537
  2. package/README.md +272 -1582
  3. package/bin/deel.js +73 -4
  4. package/package.json +8 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/card.js +129 -0
  9. package/src/agent/compact.js +10 -2
  10. package/src/agent/effort.js +5 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/grade.js +20 -0
  13. package/src/agent/loop.js +134 -13
  14. package/src/agent/models.js +169 -0
  15. package/src/agent/modes.js +47 -1
  16. package/src/agent/pins.js +140 -0
  17. package/src/agent/preset.js +113 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +233 -13
  20. package/src/agent/store.js +31 -0
  21. package/src/backend/adapter.js +14 -0
  22. package/src/commands.js +616 -52
  23. package/src/i18n/en.js +265 -0
  24. package/src/i18n/index.js +126 -0
  25. package/src/i18n/ko.js +252 -0
  26. package/src/lsp/client.js +459 -0
  27. package/src/lsp/diag.js +112 -0
  28. package/src/lsp/rpc.js +84 -0
  29. package/src/lsp/servers.js +218 -0
  30. package/src/oneshot.js +19 -0
  31. package/src/pack/sbom.js +218 -0
  32. package/src/pack/selfpack.js +19 -3
  33. package/src/preview/serve.js +19 -2
  34. package/src/repl.js +193 -8
  35. package/src/safety/secrets.js +205 -0
  36. package/src/safety/undo.js +10 -3
  37. package/src/tools/desc.en.js +221 -0
  38. package/src/tools/docs.js +252 -0
  39. package/src/tools/index.js +190 -6
  40. package/src/tools/lsp.js +327 -0
  41. package/src/tools/task.js +30 -2
  42. package/src/ui/ansi.js +45 -0
  43. package/src/ui/approve.js +25 -21
  44. package/src/ui/banner.js +245 -0
  45. package/src/ui/export.js +217 -0
  46. package/src/ui/inputbox.js +37 -7
  47. package/src/ui/intro.js +206 -0
  48. package/src/ui/level.js +11 -5
  49. package/src/ui/notify.js +101 -0
  50. package/src/ui/status.js +181 -35
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 한 세션 안에서 여러 모델 쓰기.
3
+ *
4
+ * ── 왜 필요한가 ────────────────────────────────────────────────────────
5
+ *
6
+ * 로컬에서는 모델 하나를 골라도 늘 어딘가 아쉽다. 7B 는 계획을 잘 세우는데
7
+ * 파일 열 개를 고치다 보면 창이 차고, 1.5B 는 창은 넉넉한데 무엇을 할지를
8
+ * 못 정한다. 클라우드였으면 큰 것 하나로 끝날 일인데, 8GB 램에서는 둘 다
9
+ * 올려 두고 **나눠 쓰는 것**이 실제로 가능한 유일한 길이다.
10
+ * 7B q4 (약 4.4GB) + 1.5B q4 (약 1.3GB) ≒ 5.7GB — 8GB 안에 들어간다.
11
+ *
12
+ * 그래서 두 자리를 연다.
13
+ * Task 에 `모델` 큰 것이 계획을 쥐고, 잔일 한 덩이를 작은 것에 떼어 준다.
14
+ * /ask <프로필> 지금 쓰던 것을 안 바꾸고 다른 모델에게 한 번 물어본다.
15
+ *
16
+ * ── 안 하는 것 ─────────────────────────────────────────────────────────
17
+ *
18
+ * 요약(compact)을 작은 모델에게 안 넘긴다. 요약은 '무엇을 버려도 되는가' 를
19
+ * 정하는 일이라 대화 전체를 제일 잘 아는 쪽이 해야 하는데, 그게 작은 모델이면
20
+ * 버려선 안 될 것을 버린다. 그 손해는 몇 턴 뒤에야 드러나고, 그때는 원인을
21
+ * 못 찾는다. 토큰 몇 푼 아끼자고 낼 값이 아니다.
22
+ *
23
+ * ── 경계선은 어떻게 되나 ───────────────────────────────────────────────
24
+ *
25
+ * 이게 이 파일에서 제일 조심한 자리다. deel 은 나갈 수 있는 자리를 **하나만**
26
+ * 연다(safety/network.js). 둘째 모델을 쓴다는 것은 그 자리를 하나 더 연다는
27
+ * 뜻이고, 그 자리가 바깥 게이트웨이면 "이 컴퓨터 안" 이 조용히 거짓이 된다.
28
+ *
29
+ * 1) 모델이 주소를 지어낼 수 없다. **사람이 설정에 적어 둔 프로필**만 쓴다.
30
+ * 2) 그 일이 도는 동안만 열고 끝나면 닫는다 (allowTemporarily).
31
+ * 3) 주소가 지금 쓰는 것과 다르면 **화면과 감사기록에 그렇게 적는다.**
32
+ * 조용히 열면 그게 제일 나쁘다.
33
+ * 4) 오프라인 잠금이면 이 컴퓨터 밖 프로필은 아예 못 쓴다.
34
+ */
35
+ import { load, activeProfile, resolveKey } from '../config.js';
36
+ import { isLocalHost, isOffline } from '../safety/network.js';
37
+
38
+ // 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
39
+ export const CTX_DEFAULT = 32768;
40
+
41
+ /**
42
+ * 프로필 하나로 연결을 만든다.
43
+ *
44
+ * repl.js 도 이걸 쓴다. 두 군데서 따로 만들면 한쪽에만 손이 가고, 그러면
45
+ * `/ask` 로 부른 모델만 스트리밍이 꺼져 있다거나 하는 일이 생긴다 —
46
+ * 화면에는 "느리네" 로만 보이고 원인은 안 보인다.
47
+ */
48
+ export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
49
+ if (!prof) return null;
50
+ return {
51
+ kind: prof.kind,
52
+ base: prof.baseUrl,
53
+ auth: prof.auth,
54
+ key: resolveKey(prof),
55
+ model: prof.model,
56
+ ctx: ctx ?? prof.ctx ?? CTX_DEFAULT,
57
+ maxTokens: maxTokens ?? prof.maxTokens ?? null,
58
+ streaming: prof.streaming ?? false,
59
+ tools: prof.tools ?? false,
60
+ json: prof.json ?? false,
61
+ think: prof.think ?? false,
62
+ };
63
+ }
64
+
65
+ /** 설정에 있는 프로필들. */
66
+ export function 프로필들(cfg = load()) {
67
+ return Array.isArray(cfg?.profiles) ? cfg.profiles : [];
68
+ }
69
+
70
+ /**
71
+ * 이름으로 프로필 찾기.
72
+ *
73
+ * id → 이름 → 모델 이름 → 앞부분 일치 순으로 본다. 사람은 `/ask small ...`
74
+ * 처럼 기억나는 대로 치지, 설정에 적은 id 를 외우고 있지 않다.
75
+ *
76
+ * 앞부분 일치에서 **둘 이상 걸리면 고르지 않는다.** 아무거나 골라 주면
77
+ * 물어본 사람은 어느 모델이 답했는지 모른 채로 그 답을 믿게 된다.
78
+ */
79
+ export function 프로필찾기(이름, cfg = load()) {
80
+ const q = String(이름 ?? '').trim().toLowerCase();
81
+ if (!q) return { ok: false, why: '없음', 후보: [] };
82
+ const 목록 = 프로필들(cfg);
83
+ if (!목록.length) return { ok: false, why: '설정에 프로필이 하나도 없습니다', 후보: [] };
84
+
85
+ const 딱 = 목록.find((p) => String(p.id).toLowerCase() === q)
86
+ ?? 목록.find((p) => String(p.name ?? '').toLowerCase() === q)
87
+ ?? 목록.find((p) => String(p.model ?? '').toLowerCase() === q);
88
+ if (딱) return { ok: true, prof: 딱, 후보: [딱] };
89
+
90
+ const 걸린것 = 목록.filter((p) => [p.id, p.name, p.model]
91
+ .some((v) => String(v ?? '').toLowerCase().includes(q)));
92
+ if (걸린것.length === 1) return { ok: true, prof: 걸린것[0], 후보: 걸린것 };
93
+ if (걸린것.length > 1) {
94
+ return { ok: false, why: `'${이름}' 에 여러 개가 걸립니다`, 후보: 걸린것 };
95
+ }
96
+ return { ok: false, why: `'${이름}' 이라는 프로필이 없습니다`, 후보: [] };
97
+ }
98
+
99
+ /**
100
+ * 이 프로필로 나가도 되는가.
101
+ *
102
+ * 오프라인 잠금은 여기서도 지킨다. 잠갔는데 하위 작업만 밖으로 나가면,
103
+ * 잠금은 화면에만 있고 실제로는 안 잠긴 것이 된다.
104
+ */
105
+ export function 쓸수있나(prof) {
106
+ if (!prof?.baseUrl) return { ok: false, why: '주소가 없는 프로필입니다' };
107
+ let 로컬 = false;
108
+ try { 로컬 = isLocalHost(new URL(prof.baseUrl).hostname); } catch {
109
+ return { ok: false, why: `주소를 읽을 수 없습니다: ${prof.baseUrl}` };
110
+ }
111
+ if (isOffline() && !로컬) {
112
+ return { ok: false, why: '오프라인 잠금 중입니다 — 이 컴퓨터 밖 프로필은 못 씁니다' };
113
+ }
114
+ return { ok: true, 로컬 };
115
+ }
116
+
117
+ /** 두 주소가 같은 자리인가. 다르면 사람에게 알려야 한다. */
118
+ export function 같은자리(a, b) {
119
+ try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
120
+ }
121
+
122
+ /**
123
+ * 다른 모델을 쓸 때 화면과 감사기록에 남길 한 줄.
124
+ *
125
+ * 같은 자리면 모델 이름만, 다른 자리면 **어디로 나가는지**까지 적는다.
126
+ * 이 줄이 없으면 사람은 제 소스가 어느 서버로 갔는지 알 방법이 없다.
127
+ * @returns {{말: string, 밖으로: boolean, 다른자리: boolean}}
128
+ */
129
+ export function 알릴말(지금conn, 새conn) {
130
+ const 다른자리 = !같은자리(지금conn?.base, 새conn?.base);
131
+ let 밖으로 = false;
132
+ try { 밖으로 = !isLocalHost(new URL(새conn.base).hostname); } catch { 밖으로 = true; }
133
+ if (!다른자리) return { 말: `모델 ${새conn.model}`, 밖으로, 다른자리 };
134
+ let 어디 = 새conn.base;
135
+ try { 어디 = new URL(새conn.base).host; } catch { /* 못 읽으면 통째로 */ }
136
+ return {
137
+ 말: `모델 ${새conn.model} · ${밖으로 ? '바깥' : '이 컴퓨터 안'} ${어디}`,
138
+ 밖으로,
139
+ 다른자리,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * 지금 붙어 있는 프로필. `/ask` 가 '나 자신에게 묻기' 를 걸러낼 때 쓴다.
145
+ */
146
+ export function 지금프로필(cfg = load()) {
147
+ return activeProfile(cfg);
148
+ }
149
+
150
+ /**
151
+ * 화면에 낼 프로필 한 줄들.
152
+ * @returns {{id:string, name:string, model:string, 어디:string, 로컬:boolean, 지금:boolean}[]}
153
+ */
154
+ export function 목록보기(cfg = load()) {
155
+ const 지금 = 지금프로필(cfg);
156
+ return 프로필들(cfg).map((p) => {
157
+ let 어디 = String(p.baseUrl ?? '');
158
+ let 로컬 = false;
159
+ try { const u = new URL(p.baseUrl); 어디 = u.host; 로컬 = isLocalHost(u.hostname); } catch { /* 못 읽으면 통째로 */ }
160
+ return {
161
+ id: p.id,
162
+ name: p.name ?? p.id,
163
+ model: p.model ?? '',
164
+ 어디,
165
+ 로컬,
166
+ 지금: 지금?.id === p.id,
167
+ };
168
+ });
169
+ }
@@ -13,7 +13,9 @@
13
13
  // 읽기만 하는 도구. 무엇을 바꾸지 않는다.
14
14
  // Recall 은 지난 대화를 찾는다 — 파일은 안 건드리므로 읽기 쪽이다.
15
15
  // 묻기 모드에도 준다: "저번에 이거 어떻게 했더라" 가 딱 묻기 모드의 일이다.
16
- const 읽기 = ['Read', 'Outline', 'Glob', 'Grep', 'WebFetch', 'Skill', 'Recall'];
16
+ // Def·Refs 읽기다 아무것도 바꾼다. 언어 서버가 없는 자리에서는
17
+ // toolSchemas 가 알아서 빼므로 여기서는 갈래만 정한다.
18
+ const 읽기 = ['Read', 'Outline', 'Glob', 'Grep', 'Def', 'Refs', 'WebFetch', 'Skill', 'Recall'];
17
19
  // 계획을 적는 도구. 파일을 안 건드리므로 읽기 전용 모드에서도 준다.
18
20
  //
19
21
  // Remember 도 여기 있다. 기억은 사용자의 소스를 안 건드리고 .deel/memory.md
@@ -43,6 +45,8 @@ const 확인 = ['Verify'];
43
45
  */
44
46
  const 쪼개기 = ['Task'];
45
47
 
48
+ import { 언어 } from '../i18n/index.js';
49
+
46
50
  export const MODES = {
47
51
  // 처음에는 여기서 시작한다.
48
52
  //
@@ -54,6 +58,9 @@ export const MODES = {
54
58
  // 뒤집지 않는다. 다시 맡기려면 /work 종합 이다.
55
59
  auto: {
56
60
  id: 'auto',
61
+ hintEn: "picks the right mode for what you ask",
62
+ sayEn: "You are in **Auto** mode. What comes next is not fixed.\n\n- Work out what kind of job this is first, then do it that way.\n If it is an edit, read before editing. If it is a diagnosis, confirm before concluding.\n If it is an explanation, back it with the files.\n- For a large job, break it into steps with TodoWrite and **finish everything you wrote down.**\n Do not write the list and then ask. If there are several chunks, hand them off with Task —\n a subtask runs in its own window, so yours does not fill up.\n- For code you did not write, look at the shape with Outline first. Do not Read whole files.\n- Making several files: one Write call (files array). Do not call it once per file.\n Several places to edit: one Edit call the same way (edits array).\n- Commands that never end (dev servers, watch) need background: true on Bash. Called plainly\n they die on timeout. After starting one, read its output with Jobs, and end it with Jobs when done.\n- Verify what can be verified — call Verify. Do not call something done that you did not check.\n- Do what the job needs. Do not start work the job did not ask for.",
63
+ say짧게En: "**Auto** mode. What comes next is not fixed.\n- Work out what kind of job this is, then do it that way.\n- Large job: break it up with TodoWrite and **finish it all.** Several chunks: hand off with Task.\n- Code you did not write: Outline for the shape first. Do not Read whole files.\n- Several files: one Write (files array). Several edits: one Edit (edits array).\n- Commands that never end (dev server, watch): background: true on Bash. Read with Jobs, end with Jobs.\n- Verify before you finish. Do not call something done that you did not check.",
57
64
  name: '종합',
58
65
  en: 'Auto',
59
66
  glyph: '◎',
@@ -91,6 +98,9 @@ export const MODES = {
91
98
 
92
99
  code: {
93
100
  id: 'code',
101
+ hintEn: "edits and builds",
102
+ sayEn: "This is **implementation**. Follow this order.\n\n1. For code you did not write, look at the **shape first** with Outline. Do not Read whole\n files — a folder through Outline is a fraction of the size.\n Pick the places to change there, then Read **only those files**.\n2. Always Read a file before editing it. The tool refuses an edit to a file you have not read.\n3. Follow the conventions of the surrounding code — naming, error handling, comment density.\n Do not import a new convention. Do what this code already does.\n4. Make and change every file the job needs. Do not touch one file and stop.\n For something new, lay out the folder structure first and create **several files in one Write**\n (files array). Several places to change go in **one Edit** (edits array).\n One call per item adds that many round trips, and minutes go with them.\n5. If the work splits into separate strands, hand a chunk off with Task.\n A subtask runs in its own window and returns only a summary — your window does not fill up.\n6. **Call Verify before you finish.** A file existing and a file working are different things.\n Fix what comes back and call it again. Say \"I could not verify this\" for anything you did not check.\n For things you only learn by running (dev servers, watch), give Bash **background: true**.\n Called plainly they never end and die on timeout. Read output with Jobs, and always end it\n with Jobs when done — otherwise that server keeps holding the port.\n7. When done, say what you changed and why in a line or two. Do not paste the code back.",
103
+ say짧게En: "**Implementation.** Follow this order.\n1. Code you did not write: Outline for the shape. Pick the places, then Read **only those files**.\n2. Always Read a file before editing it.\n3. Follow the surrounding conventions. Do not import a new one.\n4. Several files: one Write (files array). Several edits: one Edit (edits array).\n5. Several strands: hand off with Task.\n6. **Verify before you finish.** Fix what comes back and call it again.\n If it must be run, Bash with background: true — called plainly it dies on timeout. Read with Jobs, end with Jobs.\n7. Say what changed and why in a line or two. Do not paste code.",
94
104
  name: '코드',
95
105
  en: 'Code',
96
106
  glyph: '◆',
@@ -137,6 +147,8 @@ export const MODES = {
137
147
 
138
148
  architect: {
139
149
  id: 'architect',
150
+ hintEn: "shapes the structure · touches no files",
151
+ sayEn: "This is **design**. You have not been given the tools that change files.\n\nRead first. Designing without knowing the current structure is imagining, not designing.\n - Start with Outline for the shape of the folder. Narrow with Glob/Grep,\n then actually Read only the files you need\n - Work out what depends on what, and in which direction\n\nThen answer in this order.\n 1. Current structure — files, their roles, where the boundaries are (point with path:line)\n 2. What is wrong — why the current shape does not hold\n 3. Two or three options — what each gains, what each costs, how much work it is\n 4. One recommendation with the reason, and why you dropped the others\n 5. The files this affects\n\nFind the answer inside the conventions this code already uses. Bringing in a new framework is\nthe last resort, and if you go there, first say why the existing conventions cannot do it.",
140
152
  name: '설계',
141
153
  en: 'Architect',
142
154
  glyph: '◈',
@@ -166,6 +178,8 @@ export const MODES = {
166
178
 
167
179
  ask: {
168
180
  id: 'ask',
181
+ hintEn: "explains only · changes nothing",
182
+ sayEn: "This is **explanation**. You change nothing.\n\n- Back it with the files. Give the path and line number (src/a.js:42).\n- If you do not know, say so. Do not invent a plausible answer.\n- Keep it short. Answer what was asked.\n- Do not propose fixes unless asked to fix something. Do not start work you were not asked for.",
169
183
  name: '묻기',
170
184
  en: 'Ask',
171
185
  glyph: '◇',
@@ -185,6 +199,8 @@ export const MODES = {
185
199
 
186
200
  debug: {
187
201
  id: 'debug',
202
+ hintEn: "finds the cause",
203
+ sayEn: "This is **finding the cause**. Do not fix by guessing.\n\nFollow this order.\n 1. Restate the symptom in one sentence — what happens when you do what.\n 2. Get a reproduction. If there is none, build one. Without it you cannot tell whether you fixed it.\n 3. Form two or three hypotheses. For each, write down what you should see if it is true.\n 4. Check them one at a time, for real — read the logs, run something small, print the value.\n Only what you checked is fact. What you did not check is still a hypothesis.\n 5. When you name the cause, bring the evidence. A cause that starts with \"probably\" is not a cause.\n 6. After fixing, run the reproduction from step 2 again. If it is not fixed, go back to step 3.\n\nDo not change several places at once. You will not know which one fixed it.",
188
204
  name: '디버그',
189
205
  en: 'Debug',
190
206
  glyph: '◉',
@@ -210,6 +226,8 @@ export const MODES = {
210
226
 
211
227
  plan: {
212
228
  id: 'plan',
229
+ hintEn: "plan first · run it after approval",
230
+ sayEn: "This is **planning**. You have not been given the tools that change files.\nDo not try to edit code. Produce a plan and stop.\n\nConfirm first — a plan built without knowing the current state is a wish, not a plan.\n Start with Outline for the shape, narrow with Glob/Grep, then Read only what you must.\n\nThen write it in this order.\n 1. Goal — what does \"done\" look like (as a sentence you can check)\n 2. Current state — the files involved and what they do now (point with path:line)\n 3. What changes — per file, what and why\n 4. Order — step by step. Each step small enough to check on its own\n 5. Risks — what could break, and how to get back if it does\n 6. How to check — what do you run to know it worked\n\nWrite the steps into TodoWrite as well. After approval you continue straight from them.\n The number of steps is not fixed — match it to the size of the job. Do not force it to three.\n A small job ends in two or three; a large one lists all ten or more.\nIf something is unknown, do not invent it — write \"this needs to be confirmed\".\n\nEnd with \"Shall I go ahead with this?\". Once approved, switch to /code and run it.",
213
231
  name: '계획',
214
232
  en: 'Plan',
215
233
  glyph: '☰',
@@ -243,6 +261,9 @@ export const MODES = {
243
261
 
244
262
  orchestrator: {
245
263
  id: 'orchestrator',
264
+ hintEn: "splits a big job and sees it through",
265
+ sayEn: "This is **carrying a large job through to the end**.\n\n 1. Right at the start, break the whole thing into steps with TodoWrite. Do not keep it in your head.\n Each step must be small enough to check on its own.\n 2. **Hand every single step off with Task.** This is the point of this mode —\n if you do it all yourself, every file's contents pile up in your window, and by the third or\n fourth step the earlier turns fold away and you forget what you were doing.\n Give the subtask the background, the decisions, and the file paths. It cannot see this conversation.\n 3. Keep only one step in progress at a time. Mark it done and move on immediately.\n 4. Verify at the end of each step. Skipping it means you cannot find where things went wrong.\n 5. If you get stuck, stop and report what you are stuck on. Do not quietly take a detour.\n 6. If you learn the plan was wrong, fix the list. Do not push a wrong plan to the end.\n\nWhen it is all done, summarise what you did **and** what you did not.\nDo not leave the unfinished parts out of the summary.",
266
+ say짧게En: "**Carrying a large job through.**\n1. Break the whole thing into steps with TodoWrite right at the start.\n2. **Hand every step off with Task.** Doing it all yourself fills your window and you forget the job.\n The subtask cannot see this conversation — give it the background, decisions, and file paths.\n3. One step in progress at a time. Mark it done and move on.\n4. Verify at the end of every step.\n5. If you get stuck, stop and report. Do not quietly take a detour.\nWhen done, summarise what you did and what you did not. Do not leave the unfinished parts out.",
246
267
  name: '총괄',
247
268
  en: 'Orchestrator',
248
269
  glyph: '❋',
@@ -316,9 +337,34 @@ export function get(id) {
316
337
  export function 말(id, ctx) {
317
338
  const m = get(id);
318
339
  const 좁은가 = Number(ctx) > 0 && Number(ctx) < 24000;
340
+ /*
341
+ * 화면 말이 영어면 **모델이 읽는 글도** 영어로 간다.
342
+ *
343
+ * 화면만 영어로 갈아 끼우고 이 글을 한국어로 두면, 모델은 계속 한국어로
344
+ * 답한다 — 영어권 사람에게는 아무것도 안 고친 것과 같다.
345
+ *
346
+ * 영어 글이 없는 모드는 한국어 글로 되돌아간다. 빈 글을 보내면 그 모드는
347
+ * 아무 지시도 없는 채로 도는데, 그게 화면 빈칸보다 훨씬 나쁘다 —
348
+ * 모드가 있는 것처럼 보이면서 실제로는 아무 일도 안 한다.
349
+ */
350
+ if (언어() === 'en') {
351
+ if (좁은가 && m.say짧게En) return m.say짧게En;
352
+ if (m.sayEn) return m.sayEn;
353
+ }
319
354
  return (좁은가 && m.say짧게) ? m.say짧게 : m.say;
320
355
  }
321
356
 
357
+ /** 화면에 낼 모드 이름·한 줄 설명. 영어 것이 없으면 한국어로 되돌아간다. */
358
+ export function 보일이름(id) {
359
+ const m = get(id);
360
+ return 언어() === 'en' ? (m.en ?? m.name) : m.name;
361
+ }
362
+
363
+ export function 보일한줄(id) {
364
+ const m = get(id);
365
+ return 언어() === 'en' ? (m.hintEn ?? m.hint) : m.hint;
366
+ }
367
+
322
368
  /** Ctrl+O 로 돌릴 때 다음 모드. (Shift+Tab 은 승인 방식이 가져갔다) */
323
369
  export function next(id) {
324
370
  const i = ORDER.indexOf(normalize(id) ?? DEFAULT);
@@ -0,0 +1,140 @@
1
+ // 못 박은 것 — 접혀도, 요약돼도, 비워도 남는 말.
2
+ //
3
+ // ── 왜 만드나 ───────────────────────────────────────────────────────────
4
+ //
5
+ // 긴 대화는 결국 접히고 요약된다. deel 도 그렇게 한다. 그런데 요약은 **되돌릴 수
6
+ // 없고**, 무엇을 버렸는지 아무도 안 알려 준다.
7
+ //
8
+ // 2026년에 나온 재기로는 요약 압축이 안전 제약의 **절반만** 남긴다. 132k 토큰을
9
+ // 2.3k 로 줄이면서(98%) 그 안에 섞여 있던 "이건 하지 마라" 가 같이 사라진다.
10
+ //
11
+ // 그러면 이런 일이 벌어진다. 대화 초반에 "운영 DB 는 건드리지 마라" 하고 말해
12
+ // 두었는데, 두 시간 뒤 요약이 한 번 돌고 나면 모델은 **그 말을 들은 적 없는
13
+ // 상태로** 계속 일한다. 사람은 이미 말했다고 믿고 있으니 다시 말하지 않는다.
14
+ // 둘 다 서로 다른 것을 알고 있는 채로 나아간다 — 제일 나쁜 모양이다.
15
+ //
16
+ // ── 어떻게 막나 ─────────────────────────────────────────────────────────
17
+ //
18
+ // 방법은 간단하다. **메시지로 두지 않는다.**
19
+ //
20
+ // 접기도 요약도 `session.messages` 를 손본다. 그러니 지켜야 할 말은 아예 그
21
+ // 바깥, **시스템 프롬프트** 쪽에 둔다. 그러면 접는 코드가 아무리 세게 돌아도
22
+ // 닿을 수가 없다. 지우는 길은 사람이 `/pin 지우기` 를 치는 것 하나뿐이다.
23
+ //
24
+ // ── 값을 치르는 자리 ────────────────────────────────────────────────────
25
+ //
26
+ // 공짜가 아니다. 이건 **매 턴** 프롬프트에 실린다. 백 줄을 박아 두면 그것만으로
27
+ // 창이 차고, 그러면 못 박은 것 때문에 대화가 못 가는 우스운 일이 된다.
28
+ // 그래서 개수와 토큰에 상한을 못 박는다 — 못 박는 것에도 상한이 있다.
29
+ import { estimateTokens } from './session.js';
30
+
31
+ export const 최대개수 = 12;
32
+ export const 최대토큰 = 240; // 프롬프트에 실을 상한
33
+ export const 한줄최대 = 200; // 한 줄이 문단이 되면 그건 규칙 파일에 적을 일이다
34
+
35
+ export class 못박기 {
36
+ /** @param {Array<{말:string}>} 처음 저장해 둔 것을 되살릴 때 */
37
+ constructor(처음 = []) {
38
+ this.것들 = [];
39
+ for (const x of Array.isArray(처음) ? 처음 : []) {
40
+ const 말 = 다듬기(typeof x === 'string' ? x : x?.말);
41
+ if (말 && !this.#이미있나(말)) this.것들.push(말);
42
+ }
43
+ this.것들 = this.것들.slice(0, 최대개수);
44
+ }
45
+
46
+ #이미있나(말) {
47
+ const 낮 = 말.toLowerCase();
48
+ return this.것들.some((x) => x.toLowerCase() === 낮);
49
+ }
50
+
51
+ /**
52
+ * 하나 박는다.
53
+ * @returns {{ok:true, 번호:number} | {ok:false, why:string}}
54
+ */
55
+ 더하기(말) {
56
+ const s = 다듬기(말);
57
+ if (!s) return { ok: false, why: '무엇을 못 박을지 적어 주세요.' };
58
+ // 같은 말을 두 번 박아도 늘어나기만 한다. 자리는 먹고 얻는 것은 없다.
59
+ if (this.#이미있나(s)) return { ok: false, why: '이미 못 박아 둔 말입니다.' };
60
+ if (this.것들.length >= 최대개수) {
61
+ return { ok: false, why: `못 박을 수 있는 것은 ${최대개수}개까지입니다 — 매 턴 실리는 글이라 상한을 둡니다.` };
62
+ }
63
+ this.것들.push(s);
64
+ return { ok: true, 번호: this.것들.length };
65
+ }
66
+
67
+ /**
68
+ * 뺀다. 번호(1부터)나 '전부'.
69
+ * @returns {{ok:true, 뺀것:string|number} | {ok:false, why:string}}
70
+ */
71
+ 지우기(말) {
72
+ const s = String(말 ?? '').trim();
73
+ if (/^(전부|다|all|\*)$/i.test(s)) {
74
+ const n = this.것들.length;
75
+ this.것들 = [];
76
+ return { ok: true, 뺀것: n };
77
+ }
78
+ if (!/^\d+$/.test(s)) return { ok: false, why: '번호를 적거나 `전부` 라고 하세요.' };
79
+ const i = Number(s) - 1;
80
+ if (i < 0 || i >= this.것들.length) return { ok: false, why: '그런 번호가 없습니다.' };
81
+ const [뺀것] = this.것들.splice(i, 1);
82
+ return { ok: true, 뺀것 };
83
+ }
84
+
85
+ /** 화면에 뿌릴 것. 번호는 볼 때마다 다시 매긴다 — 사람이 세는 번호와 같아야 한다. */
86
+ 목록() {
87
+ return this.것들.map((말, i) => ({ 번호: i + 1, 말 }));
88
+ }
89
+
90
+ 개수() { return this.것들.length; }
91
+
92
+ /** 저장해 둘 모양. */
93
+ 직렬화() { return this.목록(); }
94
+
95
+ /**
96
+ * 프롬프트에 실을 몇 줄. 없으면 null — 안 쓰는 사람 프롬프트는 그대로여야 한다.
97
+ *
98
+ * 상한을 넘으면 **뒤에서부터** 버린다. 먼저 박은 것이 대개 더 근본적인 제약이다.
99
+ */
100
+ 요약(상한 = 최대토큰) {
101
+ return this.#고른것(상한).글;
102
+ }
103
+
104
+ /**
105
+ * 실제로 프롬프트에 실리는 것이 몇 개이고 얼마나 먹는가.
106
+ *
107
+ * 화면 쪽에서 세지 않게 여기서 알려 준다. 부르는 쪽이 estimateTokens 를 또
108
+ * 들여오면 같은 셈이 두 군데로 갈라지고, 한쪽만 고치는 날이 온다.
109
+ */
110
+ 실린것(상한 = 최대토큰) {
111
+ const r = this.#고른것(상한);
112
+ return { 개수: r.개수, 토큰: r.글 ? estimateTokens(r.글) : 0, 다실렸나: r.개수 >= this.것들.length };
113
+ }
114
+
115
+ /**
116
+ * 상한 안에 드는 것만 골라 글로 만든다. 글과 **개수를 같이** 돌려준다.
117
+ *
118
+ * 글에서 줄 수를 다시 세면 안 된다 — 머리글이 몇 줄인지에 따라 답이 달라지고,
119
+ * 머리글 문장을 손보는 날 개수가 조용히 어긋난다. 세는 자리는 여기 하나다.
120
+ */
121
+ #고른것(상한) {
122
+ if (!this.것들.length) return { 글: null, 개수: 0 };
123
+ const 머리 = '\n--- 못 박은 것 (사람이 직접 박았다. 접거나 요약해도 안 지워진다) ---\n'
124
+ + '아래는 이 대화 내내 반드시 지킨다. 어길 것 같으면 먼저 물어봐라.';
125
+ const out = [머리];
126
+ let 개수 = 0;
127
+ for (const [i, 말] of this.것들.entries()) {
128
+ const 줄 = `${i + 1}. ${말}`;
129
+ if (estimateTokens([...out, 줄].join('\n')) > 상한) break;
130
+ out.push(줄);
131
+ 개수++;
132
+ }
133
+ return { 글: 개수 ? out.join('\n') : null, 개수 };
134
+ }
135
+ }
136
+
137
+ function 다듬기(말) {
138
+ const s = String(말 ?? '').replace(/\s+/g, ' ').trim();
139
+ return s ? s.slice(0, 한줄최대) : '';
140
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 내장 모델 카드 — 겪기 전에 이미 아는 버릇.
3
+ *
4
+ * ── 카드(card.js)와 무엇이 다른가 ───────────────────────────────────────
5
+ *
6
+ * 카드는 겪어 본 버릇으로 하네스를 조정한다. 좋은데, **겪어야** 나온다 —
7
+ * 최소걸음(12)을 걷기 전에는 아무것도 안 바꾼다. 그런데 어떤 버릇은 겪기
8
+ * 전에 이미 안다. 추론 모델은 생각(thinking)이 출력 예산을 먹어 답이 잘린다 —
9
+ * 공개 문서에 있는 사실이고, 열두 걸음 걸으며 다시 확인할 일이 아니다.
10
+ * 이 프로그램이 그 함정을 처음 밟은 것도 qwen3 진단 때였다: 토큰 상한이
11
+ * 낮으면 본문이 통째로 비고 전부 thinking 으로 간다.
12
+ *
13
+ * ── 왜 국산 모델인가 ────────────────────────────────────────────────────
14
+ *
15
+ * 이 표의 절반이 국산(EXAONE·HyperCLOVA X·Kanana·Midm·Solar)인 이유는 이
16
+ * 프로그램이 도는 자리가 그 모델들이 도는 자리라서다. 사내망 GPU 에 올라가는
17
+ * 것이 이 이름들이고, 해외 도구는 이 이름들을 모른다.
18
+ *
19
+ * ── 지키는 선 ───────────────────────────────────────────────────────────
20
+ *
21
+ * 미리 아는 것은 **공개 문서에서 오는 것만** 적는다. 안 겪은 것을 겪은
22
+ * 척하면 카드 전체가 거짓말이 된다. 그래서 여기 적는 조정은 문서로 확인
23
+ * 가능한 한 가지 — 추론형 여부 — 뿐이고, 나머지(잘림·되풀이·빗나감)는
24
+ * 지금까지처럼 겪어서만 잡는다. 겪어 본 것이 이 표를 이긴다.
25
+ */
26
+ import { 언어 } from '../i18n/index.js';
27
+
28
+ /*
29
+ * 표. 위에서부터 먼저 맞는 것을 쓴다 — Deep 이 EXAONE 보다 먼저 서는
30
+ * 이유다(이름이 겹친다).
31
+ *
32
+ * 골라 는 Ollama·HuggingFace 의 이름 버릇을 다 받아야 한다:
33
+ * `exaone-deep:7.8b` · `hf.co/LGAI-EXAONE/EXAONE-4.0-32B-GGUF` ·
34
+ * `hyperclovax-seed-text-instruct-1.5b` 처럼 제각각이다.
35
+ */
36
+ const 표 = [
37
+ {
38
+ 골라: /exaone[-_.\s]?deep|exaone.*r1/i,
39
+ 이름: 'EXAONE Deep (LG)',
40
+ 추론형: true,
41
+ 한줄ko: 'LG 의 추론 모델. 수학·코딩에 생각을 길게 쓴다 — 공개 문서 기준.',
42
+ 한줄en: "LG's reasoning model. Thinks long on math and code — per its public docs.",
43
+ },
44
+ {
45
+ 골라: /exaone/i,
46
+ 이름: 'EXAONE (LG)',
47
+ 추론형: false,
48
+ 한줄ko: 'LG 의 한국어·영어 모델. 32k 컨텍스트 세대가 흔하다.',
49
+ 한줄en: "LG's Korean-English model. The 32k-context generation is common.",
50
+ },
51
+ {
52
+ 골라: /hyperclova|clova[-_.\s]?x/i,
53
+ 이름: 'HyperCLOVA X SEED (네이버)',
54
+ 추론형: false,
55
+ 한줄ko: '네이버의 공개 소형 모델(0.5B~3B). 급 조정(grade.js)이 알아서 잡는다.',
56
+ 한줄en: "Naver's open small models (0.5B-3B). The grade system already sizes for them.",
57
+ },
58
+ {
59
+ 골라: /kanana/i,
60
+ 이름: 'Kanana (카카오)',
61
+ 추론형: false,
62
+ 한줄ko: '카카오의 공개 모델. nano(2.1B)부터 있다 — 작은 판은 급 조정이 잡는다.',
63
+ 한줄en: "Kakao's open models, from nano (2.1B) up. Small sizes are handled by the grade system.",
64
+ },
65
+ {
66
+ 골라: /mi[-_.:\s]?dm|midm/i,
67
+ 이름: 'Midm — 믿음 (KT)',
68
+ 추론형: false,
69
+ 한줄ko: 'KT 의 공개 한국어 모델.',
70
+ 한줄en: "KT's open Korean model.",
71
+ },
72
+ {
73
+ 골라: /solar/i,
74
+ 이름: 'Solar (업스테이지)',
75
+ 추론형: false,
76
+ 한줄ko: '업스테이지의 모델. 한국어가 강하다.',
77
+ 한줄en: "Upstage's model, strong on Korean.",
78
+ },
79
+ {
80
+ /*
81
+ * 국산은 아니지만 이 자리(로컬 한국어 사용자)에서 제일 흔한 이름이고,
82
+ * deel 이 이 함정을 실제로 밟았다 — 진단 때 답이 통째로 thinking 으로
83
+ * 새서 "기본 대화 안 됨" 으로 오판할 뻔했다.
84
+ */
85
+ 골라: /qwen3|qwq/i,
86
+ 이름: 'Qwen3 (알리바바)',
87
+ 추론형: true,
88
+ 한줄ko: '생각을 켜고 끌 수 있는 모델. 켜져 있으면 생각이 출력 예산을 먹는다.',
89
+ 한줄en: 'Hybrid thinking model. With thinking on, the thinking eats the output budget.',
90
+ },
91
+ ];
92
+
93
+ /**
94
+ * 이 모델을 미리 아는가.
95
+ * @returns {{이름, 추론형, 한줄}|null} 모르면 null — 모르는 것은 모른다고 한다.
96
+ */
97
+ export function 내장카드(모델) {
98
+ const 이름 = String(모델 ?? '');
99
+ if (!이름) return null;
100
+ for (const p of 표) {
101
+ if (p.골라.test(이름)) {
102
+ return { 이름: p.이름, 추론형: p.추론형, 한줄: 언어() === 'en' ? p.한줄en : p.한줄ko };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** 추론형에 붙는 왜 한 줄. card.js 가 쓴다. */
109
+ export function 추론형왜() {
110
+ return 언어() === 'en'
111
+ ? 'A reasoning model (public docs) — thinking eats the output budget and answers get cut, so the cap starts generous.'
112
+ : '추론 모델입니다(공개 문서) — 생각이 출력 예산을 먹어 답이 잘리기 쉬우므로, 출력 상한을 처음부터 넉넉히 줍니다.';
113
+ }
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
+ import { 언어 } from '../i18n/index.js';
34
35
 
35
36
  // 위쪽에 있어도 사람에게 아무 말도 안 해 주는 것들. 적어 봐야 자리만 먹는다.
36
37
  const 안적을것 = new Set([
@@ -150,11 +151,14 @@ export function 지문(root, 창 = null) {
150
151
  const 명 = 명령들(root, 갈래, 명령상한);
151
152
  // 명령은 제일 값이 크다. 좁은 창에서 무엇 하나를 남긴다면 이것이다 —
152
153
  // '이 프로젝트에서 검사를 어떻게 돌리나' 가 여기 다 들어 있다.
153
- if (명.length) 줄들.push(`돌릴 있는 것: ${명.join(' · ')}`);
154
+ // 토막도 모델이 읽는 글이라 화면 말을 따라간다. 값(명령 이름·파일 이름)
155
+ // 그대로 둔다 — 그건 이 폴더에 실제로 있는 것이라 옮길 것이 아니다.
156
+ const 영 = 언어() === 'en';
157
+ if (명.length) 줄들.push(`${영 ? 'Runnable here' : '돌릴 수 있는 것'}: ${명.join(' · ')}`);
154
158
 
155
159
  const w = 위쪽(root, 위쪽상한);
156
160
  if (w.목록.length) {
157
- 줄들.push(`위쪽: ${w.목록.join(' ')}${w.더 ? ` ( 밖에 ${w.더})` : ''}`);
161
+ 줄들.push(`${영 ? 'Top level' : '위쪽'}: ${w.목록.join(' ')}${w.더 ? ` (${영 ? `${w.더} more` : `그 밖에 ${w.더}개`})` : ''}`);
158
162
  }
159
163
 
160
164
  if (!줄들.length) return null;
@@ -166,6 +170,8 @@ export function 지문(root, 창 = null) {
166
170
  * 모델은 이걸 프로젝트 전체 지도로 여기고 Outline 을 안 부른다 —
167
171
  * 그러면 이 토막이 오히려 손해가 된다.
168
172
  */
169
- 줄들.push('위쪽 한 겹만 본 것이다. 안을 알아야 하면 Outline 을 불러라.');
170
- return `\n--- 폴더 ---\n${줄들.join('\n')}`;
173
+ 줄들.push(
174
+ ? 'This is the top level only. Call Outline when you need to know what is inside.'
175
+ : '위쪽 한 겹만 본 것이다. 안을 알아야 하면 Outline 을 불러라.');
176
+ return `\n--- ${영 ? 'this folder' : '이 폴더'} ---\n${줄들.join('\n')}`;
171
177
  }