deel-local-cli 1.2.0 → 1.4.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.
@@ -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
+ }
@@ -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
  }
@@ -7,6 +7,8 @@ import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js
7
7
  import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
8
8
  import { 지문 } from './project.js';
9
9
  import { 프롬프트토막 as 기억토막 } from './memory.js';
10
+ import { 못박기 } from './pins.js';
11
+ import { 언어 } from '../i18n/index.js';
10
12
 
11
13
  // 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
12
14
  // 한글은 글자당 약 1토큰, 영문·코드는 약 4글자당 1토큰으로 본다.
@@ -74,6 +76,71 @@ const BASE_RULES_짧게 = `너는 deel 다. 사용자의 작업 폴더에서 코
74
76
  - 답은 한국어로 짧게. 코드를 통째로 붙여넣지 마라.
75
77
  - 사용자가 정한 규칙은 Remember 로 한 줄 남긴다. "저번에" 라고 하면 Recall 로 찾는다.`;
76
78
 
79
+ /*
80
+ * ── 영어판 ──────────────────────────────────────────────────────────────
81
+ *
82
+ * /lang en 일 때 **모델이 읽는 글도** 영어로 간다. 화면 말만 바꾸는 1단계와
83
+ * 여기가 다른 점이고, 다르게 한 데는 두 가지 이유가 있다.
84
+ *
85
+ * 1) 안 바꾸면 답이 한국어로 온다. 위 규칙에 "사용자에게 답할 때는 한국어로"
86
+ * 가 박혀 있어서다. 화면 글자만 영어로 갈아 끼워 놓고 모델은 계속 한국어로
87
+ * 답하면, 영어권 사람에게는 아무것도 안 고친 것과 같다.
88
+ *
89
+ * 2) 토큰이 눈에 띄게 싸다. 한글은 글자당 약 1토큰이고 영문·코드는 약 3.6자당
90
+ * 1토큰이다(estimateTokens 를 볼 것). 32k 창에서 고정 몫이 15% 를 먹고
91
+ * 있었는데, 그 몫이 줄면 그만큼 대화가 쓸 자리가 는다. 작은 창에서는
92
+ * 이게 '파일 한 개를 더 읽을 수 있나' 를 가르는 크기다.
93
+ *
94
+ * 규칙 자체는 한 줄도 안 뺐다. 옮기면서 규칙이 느슨해지면 영어로 켠 사람만
95
+ * 다른 프로그램을 쓰는 셈이 된다 — 특히 "확인 못 했으면 확인 못 했다고 말해라"
96
+ * 같은 줄은 이 프로그램이 거짓말을 안 하게 하는 자리라 글자 그대로 옮겼다.
97
+ */
98
+ const BASE_RULES_EN = `You are deel, a tool that reads and edits code inside the user's working folder.
99
+
100
+ **Finish the job.** Do not stop at a plan.
101
+
102
+ - Start now. Create missing files and folders — that is part of the job.
103
+ - Ask back **only when no tool can tell you**. If you can decide, decide, and say what you decided it from.
104
+ - If the job needs several files, make them all. Do not touch one and stop.
105
+ - When you are done, check. Run it, and fix it if it fails. If you could not check, say "I could not verify this."
106
+
107
+ Rules:
108
+ - Do not guess — confirm with a tool. Always Read an existing file before editing it.
109
+ - Edit's old_string must match the file exactly, whitespace and indentation included. Do not trim it short; include plenty of surrounding context.
110
+ - Do not put a large file in one call. Write the first ~300 lines, then call Append repeatedly until the rest is in place.
111
+ Append needs no Read first. Do not resend the earlier part when appending — it will just get cut at the same place again.
112
+ - Do not call the same tool with the same arguments twice. The result will be the same. Remember what you saw and move on.
113
+ - If the user names the scope to look at, stay inside it. Otherwise look as far as you need.
114
+ - Use Bash when you need to run a command. Commands that cannot be undone are blocked, so find another way.
115
+ - Answer the user in English, briefly. Do not paste whole files back — say what changed.
116
+
117
+ Keep what will be needed again:
118
+ - When the user sets a rule or tells you not to do something, leave one line with Remember. That line rides on every
119
+ later request, so keep it to one sentence. Do not record anything that only applies to this one job.
120
+ - When the user points back ("last time", "as we decided"), search with Recall before asking again.
121
+ - When you finish a procedure you will do again, write it to .deel/skills/<name>/SKILL.md.
122
+ Put name and description in the front matter (fenced with ---) and the steps below. If you find a mistake in a
123
+ skill you used, fix that file.`;
124
+
125
+ /*
126
+ * 작은 창을 위한 짧은 영어판. 위 짧은 판과 같은 생각이다 —
127
+ * 빠진 규칙은 없고 설득하는 문장만 없다.
128
+ */
129
+ const BASE_RULES_짧게_EN = `You are deel. You read and edit code in the user's working folder.
130
+
131
+ Finish the job. Do not stop at a plan.
132
+ - Start now. Create missing files and folders.
133
+ - If a tool can tell you, decide instead of asking. Say what you decided it from.
134
+ - If there are several files, make them all. Do not do one and stop.
135
+ - Verify before you finish. If you could not verify, say so.
136
+
137
+ - Read a file before you edit it.
138
+ - Edit's old_string must match the file exactly, whitespace included. Include plenty of context.
139
+ - For a long file, Write the first part and Append the rest. Do not resend the earlier part.
140
+ - Do not call the same tool with the same arguments twice. The result will be the same.
141
+ - Answer in English, briefly. Do not paste whole files.
142
+ - Record rules the user sets with Remember. When they say "last time", search with Recall.`;
143
+
77
144
  /**
78
145
  * 이 창 크기에 맞는 기본 규칙.
79
146
  *
@@ -82,7 +149,9 @@ const BASE_RULES_짧게 = `너는 deel 다. 사용자의 작업 폴더에서 코
82
149
  * 같이 움직여야 '작은 창에서는 고정 몫을 줄인다' 가 한 가지 결정이 된다.
83
150
  */
84
151
  function 기본규칙(ctx) {
85
- return Number(ctx) > 0 && Number(ctx) < 24000 ? BASE_RULES_짧게 : BASE_RULES;
152
+ const 짧게 = Number(ctx) > 0 && Number(ctx) < 24000;
153
+ if (언어() === 'en') return 짧게 ? BASE_RULES_짧게_EN : BASE_RULES_EN;
154
+ return 짧게 ? BASE_RULES_짧게 : BASE_RULES;
86
155
  }
87
156
 
88
157
  export class Session {
@@ -118,9 +187,35 @@ export class Session {
118
187
  this.보정잰것 = 0;
119
188
  // 겪어 본 것 요약 (agent/evolve.js). 켤 때 repl 이 채운다.
120
189
  this.배움요약 = null;
190
+ /*
191
+ * 못 박은 것 (agent/pins.js).
192
+ *
193
+ * 여기에 두는 것이 핵심이다. messages 안에 넣으면 접기와 요약이 언젠가
194
+ * 가져간다 — 그래서 아예 그 바깥, 시스템 프롬프트 쪽에 둔다.
195
+ */
196
+ this.못박은것 = new 못박기();
121
197
  this.filesRead = new Map(); // 경로 → 추정 토큰
122
198
  this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
199
+ /*
200
+ * 상태줄이 보는 두 숫자.
201
+ *
202
+ * 여기 들고 있는 이유는 **화면을 그릴 때마다 디스크를 읽지 않기 위해서**다.
203
+ * 상태줄은 사람이 글자 하나 칠 때마다 다시 그려진다. 거기서 되돌리기
204
+ * 이력 파일을 열면 타이핑이 끊긴다 — 화면 꾸미기가 입력을 느리게 만드는
205
+ * 것만큼 나쁜 것이 없다. repl 이 턴이 끝날 때 한 번씩 채워 준다.
206
+ */
207
+ this.되돌릴턴 = 0;
208
+ this.검증 = { 돈횟수: 0, 확인: 0, 탈: 0 };
123
209
  this.skills = []; // 켜질 때 이 PC 에서 찾은 것들
210
+ /*
211
+ * 이 자리에 언어 서버가 있나 (Def·Refs 를 목록에 넣을지).
212
+ *
213
+ * 켤 때 repl 이 한 번 재서 넣어 준다. 여기서 직접 안 재는 이유는 폴더를
214
+ * 훑어야 알 수 있어서다 — 세션은 시험에서도 수없이 만들어지는데, 그때마다
215
+ * 폴더를 훑으면 시험이 느려지고 그 자리에 뭐가 깔렸는지에 따라 결과가
216
+ * 달라진다. 기본은 꺼짐이고, 켜 주는 자리가 딱 하나다.
217
+ */
218
+ this.lsp = false;
124
219
  this.commands = [];
125
220
  this.plugins = [];
126
221
  this.maxSkillsListed = 40; // 프롬프트에 올릴 최대 개수
@@ -186,13 +281,19 @@ export class Session {
186
281
  급값() { return 급값(this.급().급); }
187
282
 
188
283
  systemPrompt() {
284
+ const 영 = 언어() === 'en';
189
285
  const parts = [기본규칙(this.conn?.ctx)];
190
- parts.push(`\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
286
+ // 범위를 박는 줄. 이건 모델이 읽는 글이라 화면 말을 따라간다.
287
+ parts.push(영
288
+ ? `\nWorking folder: ${this.root}\nYou can neither read nor write files outside this folder.`
289
+ : `\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
191
290
 
192
291
  // 지금 무슨 일을 하는 중인지. 도구 목록도 이 모드에 맞춰 이미 걸러져 있다.
193
292
  const w = workMode(this.effectiveWork());
194
293
  // 창이 좁으면 짧은 판을 쓴다 (modes.js 의 말()). 규칙은 같고 설득하는 문장만 빠진다.
195
- parts.push(`\n--- 지금 모드: ${w.name} (${w.en}) ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`);
294
+ parts.push(
295
+ ? `\n--- current mode: ${w.en} ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`
296
+ : `\n--- 지금 모드: ${w.name} (${w.en}) ---\n${모드말(this.effectiveWork(), this.conn?.ctx)}`);
196
297
  /*
197
298
  * 모델 급에 맞춘 한 문단 (grade.js).
198
299
  *
@@ -211,7 +312,13 @@ export class Session {
211
312
  */
212
313
  if (this.프로젝트) parts.push(this.프로젝트);
213
314
 
214
- if (this.rules) parts.push(`\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
315
+ if (this.rules) {
316
+ // 사용자 규칙 파일의 **내용은 안 건드린다.** 사람이 쓴 글이고, 그 사람의
317
+ // 말로 모델에게 가야 한다. 여기서 바뀌는 것은 그것을 소개하는 머리말뿐이다.
318
+ parts.push(영
319
+ ? `\n--- ${this.rules.name} (user rules — these win over the principles above) ---\n${this.rules.text}`
320
+ : `\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
321
+ }
215
322
 
216
323
  /*
217
324
  * 지난 대화에서 정한 것.
@@ -237,16 +344,30 @@ export class Session {
237
344
  const listed = this.listedSkills();
238
345
  if (listed.length) {
239
346
  parts.push(
240
- '\n--- 쓸 수 있는 스킬 ---\n' +
241
- '필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n' +
347
+ (영
348
+ ? '\n--- skills available ---\nCall the Skill tool with a name to get its body. If none fits, just carry on.\n'
349
+ : '\n--- 쓸 수 있는 스킬 ---\n필요한 것이 있으면 Skill 도구로 이름을 불러 본문을 받아라. 없으면 그냥 진행해라.\n') +
242
350
  // 설명이 없는 스킬이 섞일 수 있다 — 남의 폴더에서 오는 파일이라
243
351
  // 앞머리(frontmatter)가 빠지곤 한다. 여기서 터지면 시스템 프롬프트를
244
352
  // 못 만들어 **매 턴** 죽는다. 목록 명령 하나가 아니라 대화 전체가 막힌다.
245
353
  listed.map((s) => `- ${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n')
246
354
  );
247
355
  const rest = this.skills.filter((s) => s.enabled).length - listed.length;
248
- if (rest > 0) parts.push(`(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
356
+ if (rest > 0) {
357
+ parts.push(영
358
+ ? `(${rest} more exist but did not fit.)`
359
+ : `(그 밖에 ${rest}개가 더 있으나 자리가 모자라 안 실었다.)`);
360
+ }
249
361
  }
362
+ /*
363
+ * 못 박은 것은 **맨 끝**에 붙인다 (agent/pins.js).
364
+ *
365
+ * 긴 글의 가운데는 흘려 읽힌다 — 'lost in the middle' 이라 부르는 것이고,
366
+ * 어느 모델에서나 잰다. 사람이 직접 못 박은 말은 그 가운데에 묻히면 안 되므로
367
+ * 가장 마지막, 대화 바로 앞에 둔다.
368
+ */
369
+ const 못박은글 = this.못박은것?.요약();
370
+ if (못박은글) parts.push(못박은글);
250
371
  return parts.join('\n');
251
372
  }
252
373
 
@@ -260,8 +381,90 @@ export class Session {
260
381
  .slice(0, this.maxSkillsListed);
261
382
  }
262
383
 
263
- push(msg) { this.messages.push(msg); return this; }
264
- clear() { this.messages = []; this.filesRead.clear(); return this; }
384
+ /*
385
+ * ── 턴이 어디서 시작했는지 ────────────────────────────────────────────
386
+ *
387
+ * 되돌리기(/undo)는 파일만 되돌렸다. 대화에는 "src/runner.js 를 고쳤습니다" 가
388
+ * 그대로 남아 있어서, 되돌린 다음 턴에 모델은 **이미 고쳐 놓은 줄 알고** 그
389
+ * 위에 이어 일했다 — 없는 코드를 고치려 들고, 없는 함수를 부른다. 사람 눈에는
390
+ * 모델이 헛소리하는 것으로 보이지만, 사실은 우리가 모델에게 거짓말을 남겨 둔
391
+ * 것이다. 그러니 파일을 되감을 때 말도 같이 걷어내야 한다.
392
+ *
393
+ * 자리를 **숫자로 적어 두지 않는다.** 접기(compact)와 줄이기(trim)가 messages
394
+ * 를 통째로 갈아 끼우기 때문에, 적어 둔 3번은 다음 순간 엉뚱한 말을 가리킨다.
395
+ * 잘못된 자리에서 자르는 되돌리기는 안 하느니만 못하다. 그래서 **메시지 객체
396
+ * 자체**를 들고 있다가 그때그때 indexOf 로 찾는다. 접혀서 사라졌으면 못 찾고,
397
+ * 못 찾으면 그 턴은 되감을 수 없다고 정직하게 말한다.
398
+ */
399
+ #턴표 = [];
400
+ #다음턴 = null;
401
+ static #표최대 = 200;
402
+
403
+ /** 새 턴을 연다. 바로 다음에 push 되는 말이 이 턴의 첫 말이 된다. */
404
+ 턴시작(턴) {
405
+ if (턴 == null) return this;
406
+ // 접혀 없어진 표는 여기서 턴다 — 안 그러면 긴 대화에서 끝없이 쌓인다.
407
+ this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
408
+ if (this.#턴표.length > Session.#표최대) this.#턴표 = this.#턴표.slice(-Session.#표최대);
409
+ this.#다음턴 = 턴;
410
+ return this;
411
+ }
412
+
413
+ /** 살아 있는 턴 표시들. 접혀 사라진 것은 빠진다. @returns {{턴:number, 자리:number}[]} */
414
+ 턴자리() {
415
+ const out = [];
416
+ for (const x of this.#턴표) {
417
+ const i = this.messages.indexOf(x.표);
418
+ if (i >= 0) out.push({ 턴: x.턴, 자리: i });
419
+ }
420
+ return out.sort((a, b) => a.자리 - b.자리);
421
+ }
422
+
423
+ /**
424
+ * 주어진 턴들의 말을 걷어낸다.
425
+ *
426
+ * 여러 턴이면 그중 **제일 이른** 자리까지 간다 — 그 뒤는 어차피 되돌린 파일
427
+ * 위에서 나눈 이야기라 남겨 둘 이유가 없다. 사람이 쳤던 말은 돌려준다,
428
+ * 다시 치기 쉽게.
429
+ *
430
+ * 자르고 나서 repairToolPairs 를 반드시 한 번 돌린다. 도구를 부른 assistant
431
+ * 만 남고 그 결과가 없으면 그 뒤 모든 요청이 400 으로 튕긴다 — 되돌리기가
432
+ * 대화를 아예 못 쓰게 만드는 셈이다. 턴 경계는 보통 깨끗하지만, 여기서만은
433
+ * '보통' 에 기대지 않는다.
434
+ */
435
+ 되감기(턴들) {
436
+ const 찾을것 = new Set((Array.isArray(턴들) ? 턴들 : []).filter((t) => t != null));
437
+ const 빈것 = { 걷은것: 0, 고친것: 0, 사람말: null, 턴: [] };
438
+ if (!찾을것.size) return 빈것;
439
+
440
+ const 표들 = this.턴자리().filter((x) => 찾을것.has(x.턴));
441
+ if (!표들.length) return 빈것;
442
+
443
+ const 자리 = 표들[0].자리;
444
+ const 첫말 = this.messages[자리];
445
+ const 사람말 = 첫말?.role === 'user' && typeof 첫말.content === 'string' ? 첫말.content : null;
446
+
447
+ const 전 = this.messages.length;
448
+ const 고침 = repairToolPairs(this.messages.slice(0, 자리));
449
+ this.messages = 고침.messages;
450
+ this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
451
+ this.#다음턴 = null;
452
+ return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
453
+ }
454
+
455
+ push(msg) {
456
+ if (this.#다음턴 != null) { this.#턴표.push({ 턴: this.#다음턴, 표: msg }); this.#다음턴 = null; }
457
+ this.messages.push(msg);
458
+ return this;
459
+ }
460
+
461
+ clear() {
462
+ this.messages = [];
463
+ this.filesRead.clear();
464
+ this.#턴표 = [];
465
+ this.#다음턴 = null;
466
+ return this;
467
+ }
265
468
 
266
469
  noteRead(path, text) { this.filesRead.set(path, estimateTokens(text)); }
267
470
 
@@ -408,7 +611,7 @@ export class Session {
408
611
  const mcp수 = (this.mcp ?? []).reduce((n, s) => n + (s.도구?.length ?? 0), 0);
409
612
  // 창 크기도 열쇠에 넣는다. 설명을 창에 맞춰 줄여 싣기 때문에(budget.js),
410
613
  // /ctx 로 창을 다시 잡으면 이 값도 달라져야 한다. 안 넣으면 옛 값이 남는다.
411
- const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
614
+ const 열쇠 = `${this.effectiveWork()}|${this.skills?.length ? 'skill' : ''}|${this.web !== false ? 'web' : ''}|${this.lsp ? 'lsp' : ''}|mcp${mcp수}|c${this.conn?.ctx ?? 0}`;
412
615
  if (this.#도구잰것.has(열쇠)) return this.#도구잰것.get(열쇠);
413
616
  let n = 0;
414
617
  try {
@@ -417,6 +620,7 @@ export class Session {
417
620
  web: this.web !== false,
418
621
  work: this.effectiveWork(),
419
622
  mcp: this.mcp ?? null,
623
+ lsp: this.lsp === true,
420
624
  // 실제로 나가는 것과 **같은 것**을 재야 한다. 안 넘기면 안 줄인 것을
421
625
  // 재게 되고, 그러면 /context 가 실제보다 크게 말한다 — 그 값으로
422
626
  // effort.js 가 출력 상한을 잡으므로 답이 이유 없이 짧아진다.
@@ -98,12 +98,43 @@ export class Store {
98
98
  this.#write({ t: 'msg', m: msg });
99
99
  }
100
100
 
101
+ /**
102
+ * 못 박은 것을 적어 둔다. 통째로 한 줄 — 마지막 것이 이긴다.
103
+ *
104
+ * 메시지와 따로 두는 이유는 pins.js 에 적은 것과 같다. 접기·요약은 메시지를
105
+ * 손보는데, 못 박은 것은 그 손질에 닿으면 안 된다.
106
+ */
107
+ 못박기목록(목록) {
108
+ this.#open();
109
+ this.#write({ t: 'pins', at: new Date().toISOString(), 목록: 목록 ?? [] });
110
+ }
111
+
112
+ /** 마지막으로 적힌 못 박은 것. 없으면 빈 배열. */
113
+ 못박은것읽기() {
114
+ if (!existsSync(this.file)) return [];
115
+ let 마지막 = [];
116
+ try {
117
+ for (const line of readFileSync(this.file, 'utf8').split('\n')) {
118
+ if (!line.includes('"t":"pins"')) continue;
119
+ try {
120
+ const j = JSON.parse(line);
121
+ if (j.t === 'pins' && Array.isArray(j.목록)) 마지막 = j.목록;
122
+ } catch { /* 깨진 줄은 건너뛴다 */ }
123
+ }
124
+ } catch { return []; }
125
+ return 마지막;
126
+ }
127
+
101
128
  // 압축이 일어나면 이력이 통째로 바뀐다. 그때는 새로 적는다.
102
129
  replace(messages, note = '압축') {
103
130
  this.#open();
104
131
  const meta = this.readMeta() ?? {};
132
+ // 여기가 놓치기 쉬운 자리다. 파일을 새로 쓰면서 못 박은 것을 안 옮기면,
133
+ // '요약해도 안 지워진다' 는 말이 바로 그 요약에서 거짓이 된다.
134
+ const 못박은것 = this.못박은것읽기();
105
135
  const lines = [JSON.stringify({ t: 'meta', at: new Date().toISOString(), ...meta })];
106
136
  lines.push(JSON.stringify({ t: 'note', at: new Date().toISOString(), note }));
137
+ if (못박은것.length) lines.push(JSON.stringify({ t: 'pins', at: new Date().toISOString(), 목록: 못박은것 }));
107
138
  for (const m of messages) lines.push(JSON.stringify({ t: 'msg', m }));
108
139
  try { writeFileSync(this.file, lines.join('\n') + '\n', 'utf8'); } catch {}
109
140
  }