deel-local-cli 1.1.1 → 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.
Files changed (60) hide show
  1. package/README.en.md +826 -19
  2. package/README.md +689 -14
  3. package/bin/deel.js +73 -4
  4. package/package.json +7 -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/budget.js +0 -14
  9. package/src/agent/card.js +110 -0
  10. package/src/agent/compact.js +95 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/evolve.js +213 -0
  13. package/src/agent/grade.js +20 -0
  14. package/src/agent/loop.js +172 -14
  15. package/src/agent/models.js +169 -0
  16. package/src/agent/modes.js +49 -1
  17. package/src/agent/pins.js +140 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +359 -11
  20. package/src/agent/store.js +31 -0
  21. package/src/agent/threads.js +154 -0
  22. package/src/commands.js +751 -55
  23. package/src/config.js +10 -1
  24. package/src/i18n/en.js +244 -0
  25. package/src/i18n/index.js +126 -0
  26. package/src/i18n/ko.js +231 -0
  27. package/src/lsp/client.js +459 -0
  28. package/src/lsp/diag.js +112 -0
  29. package/src/lsp/rpc.js +84 -0
  30. package/src/lsp/servers.js +218 -0
  31. package/src/oneshot.js +23 -0
  32. package/src/pack/sbom.js +218 -0
  33. package/src/pack/selfpack.js +19 -3
  34. package/src/repl.js +273 -22
  35. package/src/safety/guard.js +37 -2
  36. package/src/safety/secrets.js +205 -0
  37. package/src/safety/undo.js +11 -4
  38. package/src/setup.js +2 -2
  39. package/src/skills/discover.js +1 -1
  40. package/src/tools/desc.en.js +219 -0
  41. package/src/tools/edit-match.js +16 -5
  42. package/src/tools/excel-com.js +1 -1
  43. package/src/tools/excel.js +1 -1
  44. package/src/tools/fsutil.js +0 -8
  45. package/src/tools/index.js +149 -6
  46. package/src/tools/lsp.js +327 -0
  47. package/src/tools/task.js +30 -2
  48. package/src/tools/todo.js +19 -3
  49. package/src/ui/ansi.js +42 -2
  50. package/src/ui/approve.js +25 -21
  51. package/src/ui/inputbox.js +69 -13
  52. package/src/ui/intro.js +174 -0
  53. package/src/ui/level.js +11 -5
  54. package/src/ui/md.js +227 -0
  55. package/src/ui/notify.js +101 -0
  56. package/src/ui/prompt.js +1 -1
  57. package/src/ui/screen.js +1 -1
  58. package/src/ui/status.js +190 -34
  59. package/src/ui/working.js +0 -3
  60. package/src/ui/wrap.js +1 -8
@@ -15,6 +15,101 @@ export const COMPACT_AT = 0.8;
15
15
  export const KEEP_TAIL_RATIO = 0.3; // 최근 대화 중 남길 비율
16
16
  export const KEEP_HEAD = 2; // 맨 처음 요청은 언제나 남긴다 (목표가 거기 있다)
17
17
 
18
+ /*
19
+ * ── 그 전에, 도구 결과부터 접는다 ──────────────────────────────────────
20
+ *
21
+ * 요약 압축은 잃는 것이 크다. 사람이 한 말도, 모델이 왜 그렇게 정했는지도
22
+ * 세 줄로 줄어든다. 그런데 자리를 실제로 먹고 있는 것은 대개 그쪽이 아니다.
23
+ *
24
+ * Read 로 600줄짜리 파일을 한 번 열면 32k 창의 15% 가 그 자리에서 사라지고,
25
+ * 그 사본은 대화가 끝날 때까지 이력에 그대로 눌러앉는다. 이미 다 읽고 고친
26
+ * 파일인데도 그렇다. 세 번 읽으면 창의 절반이 옛날 파일 내용이다.
27
+ *
28
+ * 그래서 80% 에서 통째로 요약하기 전에, 먼저 **오래된 도구 결과만** 한 줄로
29
+ * 접는다. 사람 말과 모델의 판단은 한 글자도 안 건드린다. 무엇을 읽었는지는
30
+ * 남기므로 모델은 필요하면 그 파일을 다시 읽으면 된다 — 읽는 값은 싸고,
31
+ * 하던 일을 잊는 값은 비싸다.
32
+ *
33
+ * 최근 것은 안 접는다. 방금 읽은 것을 접으면 그 자리에서 다시 읽게 되고,
34
+ * 그러면 접은 보람이 없다.
35
+ */
36
+ export const FOLD_AT = 0.55; // 이 아래로는 접을 이유가 없다
37
+ export const KEEP_RECENT = 4; // 최근 도구 결과 이만큼은 원문 그대로 둔다
38
+ export const FOLD_MIN = 300; // 이보다 작으면 접어도 자리가 안 준다 (글자 수)
39
+ export const 접힘표 = '(접힘)';
40
+
41
+ /** 인자에서 사람이 알아볼 한 조각만 뽑는다. 경로가 제일 쓸모 있다. */
42
+ function 어디(args) {
43
+ if (!args || typeof args !== 'object') return '';
44
+ for (const k of ['file_path', 'path', 'pattern', 'command', 'query', 'url', '목적']) {
45
+ const v = args[k];
46
+ if (typeof v === 'string' && v.trim()) return v.trim().replace(/\s+/g, ' ').slice(0, 60);
47
+ }
48
+ return '';
49
+ }
50
+
51
+ export function shouldFold(session, at = FOLD_AT) {
52
+ const b = session.breakdown();
53
+ return b.total > 0 && b.used / b.total >= at;
54
+ }
55
+
56
+ /**
57
+ * 오래된 도구 결과를 한 줄로 접는다.
58
+ *
59
+ * 짝은 안 건드린다 — 메시지를 지우는 게 아니라 **내용만** 바꾸므로 호출과
60
+ * 결과의 짝이 그대로다. 규격이 깨질 자리가 아예 없다.
61
+ *
62
+ * @returns {{접은것: number, 아낀토큰: number}}
63
+ */
64
+ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN } = {}) {
65
+ const ms = session.messages ?? [];
66
+
67
+ // 호출 쪽에서 이름과 인자를 가져온다. 결과 메시지에는 그게 안 실려 있다.
68
+ const 이름표 = new Map();
69
+ for (const m of ms) {
70
+ for (const c of m?.tool_calls ?? []) {
71
+ let args = {};
72
+ try { args = JSON.parse(c?.function?.arguments ?? '{}'); } catch { /* 못 읽으면 그만 */ }
73
+ if (c?.id) 이름표.set(c.id, { name: c?.function?.name ?? '도구', args });
74
+ }
75
+ }
76
+
77
+ const 자리 = [];
78
+ ms.forEach((m, i) => {
79
+ if (m?.role !== 'tool') return;
80
+ const 글 = typeof m.content === 'string' ? m.content : '';
81
+ if (글.startsWith(접힘표)) return; // 이미 접은 것
82
+ 자리.push({ i, 글 });
83
+ });
84
+
85
+ const 접을것 = 자리.slice(0, Math.max(0, 자리.length - keep)).filter((x) => x.글.length >= min);
86
+ let 아낀토큰 = 0;
87
+ // 무엇을 버렸는지 이름으로 남긴다.
88
+ //
89
+ // 조용히 버리면 사람이 모른다 — "아까 그 파일 내용 어디 갔지" 를 스스로 답할
90
+ // 길이 없다. 접힌 자리에 표시가 남긴 하지만 그건 모델이 보는 쪽이고,
91
+ // 사람 화면에는 여기서 돌려주는 목록으로 알려 준다.
92
+ const 접은것들 = [];
93
+
94
+ for (const { i, 글 } of 접을것) {
95
+ const m = ms[i];
96
+ const 아는것 = 이름표.get(m.tool_call_id) ?? { name: m.tool_name ?? '도구', args: {} };
97
+ const 곳 = 어디(아는것.args);
98
+ const 줄수 = 글.split('\n').length;
99
+ const 전 = estimateTokens(글);
100
+ ms[i] = {
101
+ ...m,
102
+ content: `${접힘표} ${아는것.name}${곳 ? `(${곳})` : ''} — ${줄수}줄. `
103
+ + '자리를 비우려고 내용을 접었습니다. 필요하면 다시 읽으세요.',
104
+ };
105
+ const 아낀것 = 전 - estimateTokens(ms[i].content);
106
+ 아낀토큰 += 아낀것;
107
+ 접은것들.push({ 도구: 아는것.name, 곳, 줄수, 토큰: Math.max(0, 아낀것) });
108
+ }
109
+
110
+ return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들 };
111
+ }
112
+
18
113
  const 요약지시 = `지금까지의 대화를 다음 형식으로 요약하세요. 이어서 일할 사람이 이것만 보고도 계속할 수 있어야 합니다.
19
114
  추측하지 말고 실제로 오간 내용만 쓰세요. 한국어로, 각 항목 3줄 이내로 쓰세요.
20
115
 
@@ -0,0 +1,186 @@
1
+ // 증거 — 「다 됐습니다」 대신 검토할 수 있는 것을 내놓는다.
2
+ //
3
+ // ── 왜 만드나 ───────────────────────────────────────────────────────────
4
+ //
5
+ // 2026년 조사로는 개발자의 **96%가 AI 가 쓴 코드를 온전히 믿지 않는데, 매번
6
+ // 확인하는 사람은 48%** 다. 38%는 "사람 코드보다 리뷰가 더 힘들다"고 답한다.
7
+ //
8
+ // 왜 더 힘든가. 사람이 낸 코드는 "왜 이렇게 했나" 를 물으면 답이 온다. 에이전트가
9
+ // 낸 코드는 **"끝났습니다" 한 줄**과 함께 온다. 그 한 줄은 검토할 수가 없다.
10
+ // 무엇을 근거로 끝났다고 하는지가 그 안에 없기 때문이다.
11
+ //
12
+ // 그래서 이 파일이 하는 일은 하나다 — **말을 증거로 바꾼다.**
13
+ //
14
+ // 무엇을 바꿨나 파일과 줄 수
15
+ // 무엇을 돌렸나 명령과 그 결과
16
+ // 무엇이 증명됐나 고친 **뒤에** 돌아서 **성공한** 것이 있는가
17
+ //
18
+ // ── 제일 중요한 칸 ──────────────────────────────────────────────────────
19
+ //
20
+ // 바꾼 것을 늘어놓는 일은 /diff 도 한다. 여기서만 하는 것은 **증명 안 된 것**을
21
+ // 증명 안 됐다고 말하는 것이다.
22
+ //
23
+ // 고쳐 놓고 아무것도 안 돌렸으면 아무것도 증명되지 않았다. 검사가 빨간데
24
+ // 돌리기는 했다고 초록으로 치면 그건 **빨간 검사를 증거로 내미는** 셈이다.
25
+ // 고치기 **전에** 돌린 검사는 고친 것을 증명하지 못한다 — "아까 돌렸으니 됐다" 가
26
+ // 제일 흔한 자기기만이다. 셋 다 여기서 걸러진다.
27
+ //
28
+ // ── 안 하는 것 ──────────────────────────────────────────────────────────
29
+ //
30
+ // 프롬프트에 안 싣는다. 이건 사람이 보는 것이지 모델에게 먹이는 것이 아니다.
31
+ // 실으면 컨텍스트만 먹고, 모델이 제가 만든 증거를 다시 읽는 이상한 고리가 생긴다.
32
+ import { writeFileSync, mkdirSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+
35
+ /** 이 명령들은 '확인했다' 로 친다. 돌렸다고 다 증거가 되는 것은 아니다. */
36
+ const 확인하는명령 = /(^|[\s|;&])(npm|pnpm|yarn|bun)\s+(test|run\s+(test|check|lint|build|verify|typecheck))|(^|[\s|;&])(pytest|jest|vitest|mocha|go\s+test|cargo\s+test|mvn\s+test|gradle\s+test|dotnet\s+test|tsc|eslint|ruff|mypy)\b/i;
37
+
38
+ /** 우리 쪽 확인 도구. Bash 를 안 거치고 확인하는 길이다. */
39
+ const 확인하는도구 = new Set(['Verify', 'Test']);
40
+
41
+ /**
42
+ * 이번 대화의 증거를 모은다.
43
+ *
44
+ * @param {object} session
45
+ * @param {object} o
46
+ * @param {object} o.audit safety/audit.js 의 Audit
47
+ * @param {number} o.최근 감사기록을 몇 줄까지 볼까
48
+ * @param {Date} o.바꾼때 파일을 언제 고쳤다고 볼까 (검사가 고정할 수 있게)
49
+ */
50
+ export function 증거모으기(session, { audit = null, 최근 = 400, 바꾼때 = null } = {}) {
51
+ /*
52
+ * 감사기록은 폴더에 하나뿐이고 계속 덧붙는다. 그대로 읽으면 **지난주에 돌린
53
+ * 검사**가 오늘 고친 파일의 증거로 붙는다 — 증거를 모으는 도구가 거짓 증거를
54
+ * 만드는 셈이다. 이번 세션 것만 본다.
55
+ */
56
+ const 이번세션 = audit?.session ?? null;
57
+ const 기록 = (() => {
58
+ try {
59
+ const 전부 = audit?.recent?.(최근) ?? [];
60
+ return 이번세션 ? 전부.filter((r) => r?.session === 이번세션) : 전부;
61
+ } catch { return []; }
62
+ })();
63
+
64
+ // 돌린 것 — Bash 와 확인 도구만. 파일을 읽은 것까지 증거라고 하면 목록이
65
+ // 길어지기만 하고, 읽은 것은 아무것도 증명하지 않는다.
66
+ const 돌린것 = [];
67
+ for (const r of 기록) {
68
+ if (r?.kind !== 'tool') continue;
69
+ const 도구 = String(r.tool ?? '');
70
+ if (도구 !== 'Bash' && !확인하는도구.has(도구)) continue;
71
+ 돌린것.push({
72
+ 도구,
73
+ 무엇: String(r.target ?? ''),
74
+ 됐나: r.ok !== false,
75
+ 남긴말: r.ok === false ? `실패 — ${String(r.note ?? '').slice(0, 120)}` : String(r.note ?? '').slice(0, 120),
76
+ 때: r.at ?? null,
77
+ 확인인가: 확인하는도구.has(도구) || 확인하는명령.test(String(r.target ?? '')),
78
+ });
79
+ }
80
+
81
+ const 확인시도 = 돌린것.filter((x) => x.확인인가);
82
+ const 언제고쳤나 = 바꾼때 ? new Date(바꾼때).getTime() : null;
83
+
84
+ const 바꾼것 = [];
85
+ const 증명안된것 = [];
86
+ for (const [파일, d] of session?.changes ?? new Map()) {
87
+ // 고친 **뒤에** 돌린 확인만 본다.
88
+ const 쓸것 = 확인시도.filter((x) => {
89
+ if (언제고쳤나 == null || !x.때) return true; // 시각을 모르면 순서를 안 따진다
90
+ return new Date(x.때).getTime() >= 언제고쳤나;
91
+ });
92
+ /*
93
+ * **마지막** 확인이 증거다. 성공한 것 중 마지막이 아니다.
94
+ *
95
+ * 빌드가 통과하고 그 뒤에 검사가 깨졌는데 앞의 초록을 들고 와서
96
+ * "확인됐습니다" 하면 그게 거짓 증거다. 사람이 보는 순서로 생각하면
97
+ * 답이 분명하다 — 마지막에 돌린 것이 빨간데 확인됐을 리가 없다.
98
+ */
99
+ const 마지막 = 쓸것.length ? 쓸것[쓸것.length - 1] : null;
100
+ const 증명 = 마지막?.됐나 ? 마지막.무엇 : null;
101
+ const 한줄 = { 파일, 더한줄: d.added ?? 0, 뺀줄: d.removed ?? 0, 고친횟수: d.times ?? 0, 증명 };
102
+ 바꾼것.push(한줄);
103
+ if (!증명) {
104
+ 증명안된것.push({
105
+ 파일,
106
+ 왜: 왜못믿나(돌린것, 마지막, 언제고쳤나),
107
+ });
108
+ }
109
+ }
110
+
111
+ return {
112
+ 바꾼것,
113
+ 돌린것,
114
+ 증명안된것,
115
+ // 화면이 한눈에 쓸 수 있게 미리 세어 둔다.
116
+ 셈: {
117
+ 파일: 바꾼것.length,
118
+ 더한줄: 바꾼것.reduce((a, x) => a + x.더한줄, 0),
119
+ 뺀줄: 바꾼것.reduce((a, x) => a + x.뺀줄, 0),
120
+ 돌린것: 돌린것.length,
121
+ 실패한것: 돌린것.filter((x) => !x.됐나).length,
122
+ 증명안됨: 증명안된것.length,
123
+ },
124
+ };
125
+ }
126
+
127
+ function 왜못믿나(돌린것, 마지막, 언제고쳤나) {
128
+ if (!돌린것.length) return '고친 뒤에 아무것도 안 돌렸습니다 — 무엇도 확인되지 않았습니다.';
129
+ const 확인시도 = 돌린것.filter((x) => x.확인인가);
130
+ if (!확인시도.length) return '명령은 돌렸지만 검사·빌드는 안 돌렸습니다.';
131
+ if (마지막 && !마지막.됐나) {
132
+ return `마지막으로 돌린 \`${마지막.무엇}\` 이 실패했습니다 — 그 앞이 통과했어도 지금 상태를 증명하지 못합니다.`;
133
+ }
134
+ if (!마지막 && 언제고쳤나 != null) return '검사가 이 파일을 고치기 전에 돌았습니다 — 그 뒤 바뀐 것은 확인되지 않았습니다.';
135
+ return '확인되지 않았습니다.';
136
+ }
137
+
138
+ /** 사람이 읽고 남길 수 있는 글. 마크다운. */
139
+ export function 증거글(증거, { 제목 = '작업 증거' } = {}) {
140
+ const e = 증거 ?? {};
141
+ const 줄 = [`# ${제목}`, ''];
142
+
143
+ 줄.push('## 바꾼 것', '');
144
+ if (!e.바꾼것?.length) 줄.push('바꾼 파일이 없습니다.', '');
145
+ else {
146
+ 줄.push('| 파일 | 더함 | 뺌 | 확인한 것 |', '|---|---|---|---|');
147
+ for (const x of e.바꾼것) {
148
+ 줄.push(`| ${x.파일} | +${x.더한줄} | -${x.뺀줄} | ${x.증명 ? `\`${x.증명}\`` : '**없음**'} |`);
149
+ }
150
+ 줄.push('');
151
+ }
152
+
153
+ 줄.push('## 돌린 것', '');
154
+ if (!e.돌린것?.length) 줄.push('돌린 명령이 없습니다.', '');
155
+ else {
156
+ for (const x of e.돌린것) {
157
+ 줄.push(`- ${x.됐나 ? '✓' : '✗'} \`${x.무엇}\`${x.남긴말 ? ` — ${x.남긴말}` : ''}`);
158
+ }
159
+ 줄.push('');
160
+ }
161
+
162
+ // 이 절이 이 글의 요점이다. 비어 있으면 비어 있다고 분명히 적는다.
163
+ 줄.push('## 증명 안 된 것', '');
164
+ if (!e.증명안된것?.length) 줄.push('없습니다 — 바꾼 것마다 뒤에 돌린 확인이 있습니다.', '');
165
+ else {
166
+ for (const x of e.증명안된것) 줄.push(`- **${x.파일}** — ${x.왜}`);
167
+ 줄.push('');
168
+ }
169
+
170
+ return 줄.join('\n');
171
+ }
172
+
173
+ /**
174
+ * 파일로 남긴다. 화면은 스크롤로 사라지고, 검토는 나중에 다른 사람이 한다.
175
+ *
176
+ * @returns {string|null} 적은 자리. 못 적었으면 null — 못 적어도 대화는 계속된다.
177
+ */
178
+ export function 증거적기(root, 증거, 이름 = '증거') {
179
+ try {
180
+ const dir = join(root, '.deel', '증거');
181
+ mkdirSync(dir, { recursive: true });
182
+ const 자리 = join(dir, `${String(이름).replace(/[^\w가-힣.-]/g, '_')}.md`);
183
+ writeFileSync(자리, 증거글(증거), 'utf8');
184
+ return 자리;
185
+ } catch { return null; }
186
+ }
@@ -0,0 +1,213 @@
1
+ // 쓸수록 이 PC 에 맞춰 나아진다.
2
+ //
3
+ // ── 왜 만드나 ───────────────────────────────────────────────────────────
4
+ //
5
+ // deel 은 이미 세 군데서 뭔가를 배우고 있었다. 그런데 셋 다 **끄면 사라졌다.**
6
+ //
7
+ // backend/learn.js 서버가 거절하면 그 말에서 상한을 배운다 → 그 세션에서만
8
+ // agent/grade.js 모델이 인자를 자주 잘라 먹는지 지켜본다 → 그 세션에서만
9
+ // session.배운다() 토큰 추정을 실제값에 맞춘다 → 그 세션에서만
10
+ //
11
+ // 그래서 켤 때마다 처음부터 다시 겪는다. 어제 `pnpm` 이 이 PC 에 없다는 걸
12
+ // 알아냈어도 오늘 또 부르고, 또 실패하고, 또 우회한다. 같은 실수를 매일 한다.
13
+ //
14
+ // 여기서는 **겪은 것을 조금씩 쌓아 두고**, 다음에 켤 때 몇 줄로 요약해 넘긴다.
15
+ //
16
+ // ── 학습이 아니다 ───────────────────────────────────────────────────────
17
+ //
18
+ // 모델을 고치는 것이 아니고, 대화를 통째로 쌓아 두는 것도 아니다. 그러면
19
+ // 컨텍스트만 먹는다. 여기 쌓는 것은 **확인된 사실 몇 줄**이다.
20
+ //
21
+ // · 세어 두기만 한다 — 무엇이 몇 번 됐고 몇 번 안 됐나
22
+ // · 확신이 설 때만 말한다 — 한 번 겪은 것은 우연일 수 있다
23
+ // · 상한을 못 박는다 — 프롬프트에 실리는 것은 220토큰까지다
24
+ //
25
+ // 자리를 아끼는 쪽이 오히려 값이 크다. 여기 한 줄이 모델이 헛도는 걸음
26
+ // 서너 개를 없앤다 — 그 걸음 하나가 파일 내용 한 벌씩이다.
27
+ //
28
+ // ── 무엇을 어디에 쌓나 ──────────────────────────────────────────────────
29
+ //
30
+ // <작업폴더>/.deel/배운것.json 이 프로젝트에서만 맞는 것 (명령·인코딩)
31
+ // <설정폴더>/배운것.json 이 PC·이 모델에 붙는 것 (버릇·토큰 보정)
32
+ //
33
+ // 나눠 두는 이유는 하나다. "`npm test` 가 된다" 는 이 폴더의 사실이고,
34
+ // "이 모델은 인자를 잘라 먹는다" 는 모델의 사실이다. 섞어 두면 폴더를 옮길
35
+ // 때마다 모델에 대해 알아낸 것을 통째로 잃는다.
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
37
+ import { join, dirname } from 'node:path';
38
+ import { estimateTokens } from './session.js';
39
+
40
+ export const 최대토큰 = 220; // 프롬프트에 실을 상한
41
+ const 명령최대 = 40; // 표가 끝없이 자라지 않게
42
+ const 모델최대 = 10;
43
+
44
+ const 빈것 = () => ({ 판: 1, 명령: {}, 모델: {} });
45
+
46
+ function 읽기(파일) {
47
+ try {
48
+ if (!existsSync(파일)) return 빈것();
49
+ const j = JSON.parse(readFileSync(파일, 'utf8'));
50
+ return { ...빈것(), ...(j && typeof j === 'object' ? j : {}) };
51
+ } catch { return 빈것(); }
52
+ }
53
+
54
+ function 쓰기(파일, 값) {
55
+ try {
56
+ mkdirSync(dirname(파일), { recursive: true });
57
+ writeFileSync(파일, JSON.stringify(값, null, 2), 'utf8');
58
+ } catch { /* 못 적어도 대화는 계속돼야 한다 */ }
59
+ }
60
+
61
+ /** 오래된 것부터 버려서 표를 상한 안에 둔다. */
62
+ function 줄이기(표, 상한) {
63
+ const 열쇠 = Object.keys(표);
64
+ if (열쇠.length <= 상한) return 표;
65
+ 열쇠.sort((a, b) => String(표[a]?.at ?? '').localeCompare(String(표[b]?.at ?? '')));
66
+ for (const k of 열쇠.slice(0, 열쇠.length - 상한)) delete 표[k];
67
+ return 표;
68
+ }
69
+
70
+ export class 배움 {
71
+ /**
72
+ * @param {string} root 작업 폴더
73
+ * @param {string} home 설정 폴더 (~/.deel 또는 DEEL_HOME)
74
+ * @param {string} 지금 시각. 검사가 고정할 수 있게 밖에서 받는다
75
+ */
76
+ constructor(root, home, 지금 = new Date().toISOString()) {
77
+ this.지금 = 지금;
78
+ this.폴더파일 = root ? join(root, '.deel', '배운것.json') : null;
79
+ this.집파일 = home ? join(home, '배운것.json') : null;
80
+ this.폴더 = this.폴더파일 ? 읽기(this.폴더파일) : 빈것();
81
+ this.집 = this.집파일 ? 읽기(this.집파일) : 빈것();
82
+ }
83
+
84
+ /**
85
+ * 명령 하나가 됐는지 안 됐는지.
86
+ *
87
+ * 명령줄 전체가 아니라 **앞머리 두 낱말**만 센다. `npm test -- --watch` 와
88
+ * `npm test` 는 같은 사실이고, 경로·인자까지 세면 표가 금세 쓸모없어진다.
89
+ *
90
+ * 다만 **프로그램이 아예 없어서** 실패한 것은 첫 낱말만 센다. `pnpm install`
91
+ * 이 안 되고 `pnpm add` 도 안 됐다면 그건 두 가지 사실이 아니라 "이 PC 에
92
+ * pnpm 이 없다" 는 한 가지 사실이다. 두 낱말로 세면 서로 다른 칸에 하나씩
93
+ * 쌓여서 영영 확신에 못 이른다 — 매번 다시 겪는다.
94
+ */
95
+ 명령본것(명령, 됐나, 이유 = '') {
96
+ const 낱말 = String(명령 ?? '').trim().split(/\s+/);
97
+ const 없는프로그램 = !됐나
98
+ && /not found|not recognized|ENOENT|찾을 수 없|없는 명령/i.test(String(이유 ?? ''));
99
+ const 열쇠 = (없는프로그램 ? 낱말.slice(0, 1) : 낱말.slice(0, 2)).join(' ').slice(0, 40);
100
+ if (!열쇠) return this;
101
+ const 표 = this.폴더.명령;
102
+ const r = 표[열쇠] ?? { ok: 0, no: 0, at: this.지금 };
103
+ if (됐나) r.ok++; else r.no++;
104
+ r.at = this.지금;
105
+ 표[열쇠] = r;
106
+ 줄이기(표, 명령최대);
107
+ this.#저장폴더();
108
+ return this;
109
+ }
110
+
111
+ /** 모델이 보인 버릇 하나. grade.js 의 지켜본것 과 같은 이름을 쓴다. */
112
+ 모델본것(모델, 무엇, n = 1) {
113
+ const r = this.#모델칸(모델);
114
+ if (!r) return this;
115
+ r[무엇] = (r[무엇] ?? 0) + n;
116
+ r.at = this.지금;
117
+ this.#저장집();
118
+ return this;
119
+ }
120
+
121
+ /** 토큰 추정 배수. 다음에 켤 때 이 값으로 시작한다. */
122
+ 보정본것(모델, 배수) {
123
+ const n = Number(배수);
124
+ if (!Number.isFinite(n) || n < 0.5 || n > 2) return this;
125
+ const r = this.#모델칸(모델);
126
+ if (!r) return this;
127
+ // 갑자기 튀지 않게 지난 값과 섞는다. 대화마다 조금씩 옮겨 간다.
128
+ r.보정 = r.보정 ? r.보정 + (n - r.보정) * 0.5 : n;
129
+ r.at = this.지금;
130
+ this.#저장집();
131
+ return this;
132
+ }
133
+
134
+ /** 지난번에 알아낸 토큰 배수. 없으면 null. */
135
+ 아는보정(모델) {
136
+ const r = this.집.모델?.[String(모델 ?? '')];
137
+ return r?.보정 && r.보정 >= 0.5 && r.보정 <= 2 ? r.보정 : null;
138
+ }
139
+
140
+ #모델칸(모델) {
141
+ const 이름 = String(모델 ?? '').trim();
142
+ if (!이름) return null;
143
+ const 표 = this.집.모델;
144
+ 표[이름] = 표[이름] ?? { 걸음: 0, at: this.지금 };
145
+ 줄이기(표, 모델최대);
146
+ return 표[이름];
147
+ }
148
+
149
+ #저장폴더() { if (this.폴더파일) 쓰기(this.폴더파일, this.폴더); }
150
+ #저장집() { if (this.집파일) 쓰기(this.집파일, this.집); }
151
+
152
+ /**
153
+ * 프롬프트에 실을 몇 줄.
154
+ *
155
+ * 확신이 서는 것만 말한다. 두 번 겪지 않은 것은 우연일 수 있고, 우연을
156
+ * 사실처럼 적으면 모델이 그 위에서 엉뚱한 판단을 한다 — 안 배우느니만 못하다.
157
+ *
158
+ * @returns {string|null} 없으면 null (그때는 프롬프트에 아무것도 안 붙는다)
159
+ */
160
+ 요약(모델 = '', 상한 = 최대토큰) {
161
+ const 줄 = [];
162
+
163
+ // 되는 명령 / 안 되는 명령. 이 폴더의 사실이다.
164
+ const 되는것 = [];
165
+ const 안되는것 = [];
166
+ for (const [열쇠, r] of Object.entries(this.폴더.명령 ?? {})) {
167
+ if (r.ok >= 2 && r.no === 0) 되는것.push(열쇠);
168
+ else if (r.no >= 2 && r.ok === 0) 안되는것.push(열쇠);
169
+ }
170
+ if (되는것.length) 줄.push(`- 여기서 되는 명령: ${되는것.slice(0, 6).map((x) => `\`${x}\``).join(' · ')}`);
171
+ if (안되는것.length) 줄.push(`- 이 PC 에서 안 되는 명령(다시 부르지 마라): ${안되는것.slice(0, 6).map((x) => `\`${x}\``).join(' · ')}`);
172
+
173
+ // 모델 버릇. 걸음이 쌓여야 말이 된다.
174
+ const m = this.집.모델?.[String(모델 ?? '')];
175
+ if (m && (m.걸음 ?? 0) >= 10) {
176
+ if ((m.잘린인자 ?? 0) / m.걸음 >= 0.15) {
177
+ 줄.push('- 이 모델은 큰 인자를 자주 잘라 먹었다. 파일은 Write 한 번에 다 쓰지 말고 Append 로 나눠 써라.');
178
+ }
179
+ if ((m.편집실패 ?? 0) / m.걸음 >= 0.15) {
180
+ 줄.push('- Edit 이 자주 빗나갔다. old_string 은 Read 로 본 그대로, 공백까지 옮겨 담아라.');
181
+ }
182
+ if ((m.되풀이 ?? 0) / m.걸음 >= 0.15) {
183
+ 줄.push('- 같은 호출을 되풀이한 적이 잦다. 한 번 실패한 방법은 바꿔서 시도해라.');
184
+ }
185
+ }
186
+
187
+ if (!줄.length) return null;
188
+
189
+ const 머리 = '## 여기서 겪어 본 것 (자동으로 쌓임)';
190
+ const out = [머리];
191
+ for (const l of 줄) {
192
+ if (estimateTokens([...out, l].join('\n')) > 상한) break;
193
+ out.push(l);
194
+ }
195
+ return out.length > 1 ? out.join('\n') : null;
196
+ }
197
+
198
+ /** 사람이 지우라고 한 것. 파일째 비운다. */
199
+ 지우기(어디 = '전부') {
200
+ if (어디 === '전부' || 어디 === '폴더') { this.폴더 = 빈것(); this.#저장폴더(); }
201
+ if (어디 === '전부' || 어디 === '모델') { this.집 = 빈것(); this.#저장집(); }
202
+ return this;
203
+ }
204
+
205
+ /** 화면에 뿌릴 것. */
206
+ 현황(모델 = '') {
207
+ const 명령 = Object.entries(this.폴더.명령 ?? {})
208
+ .map(([이름, r]) => ({ 이름, ...r }))
209
+ .sort((a, b) => (b.ok + b.no) - (a.ok + a.no));
210
+ const m = this.집.모델?.[String(모델 ?? '')] ?? null;
211
+ return { 명령, 모델: m, 모델이름: String(모델 ?? '') };
212
+ }
213
+ }
@@ -1,3 +1,4 @@
1
+ import { 언어 } from '../i18n/index.js';
1
2
  /**
2
3
  * 모델 급 — 지금 붙어 있는 모델이 얼마나 하는가.
3
4
  *
@@ -194,7 +195,26 @@ export function 값(급) {
194
195
  export function 급말(급) {
195
196
  const v = 값(급);
196
197
  if (급 === '큼') return '';
198
+ /*
199
+ * 이 글도 모델이 읽는 것이라 화면 말을 따라간다.
200
+ *
201
+ * 안 따라가면 영어로 켠 사람의 작은 모델만 한국어 지시를 받는데, 하필
202
+ * 그 조합이 제일 흔들리기 쉽다 — 작은 모델은 못 읽는 말이 섞이면 그 줄을
203
+ * 통째로 흘린다. 지시가 있는 척하면서 실제로는 없는 상태가 된다.
204
+ */
205
+ const 영 = 언어() === 'en';
197
206
  const 줄 = [];
207
+ if (영) {
208
+ 줄.push('Sized to this model:');
209
+ 줄.push(`- Create at most ${v.한번에쓸파일} files in one Write call. More than that, split it across calls.`);
210
+ 줄.push(`- For a file over ${v.나눠쓰기줄} lines, Write the first part and Append the rest.`);
211
+ if (급 === '작음') {
212
+ 줄.push('- One thing per step. Do not call several tools at once.');
213
+ 줄.push('- Edit a file on the **very next step** after you Read it. Do not put anything in between.');
214
+ }
215
+ 줄.push('- Call Verify before you finish. Fix what comes back and call it again.');
216
+ return 줄.join('\n');
217
+ }
198
218
  줄.push('이 모델에 맞춘 것:');
199
219
  줄.push(`- 파일을 한 번에 만들 때는 ${v.한번에쓸파일}개까지만. 더 있으면 나눠서 여러 번 불러라.`);
200
220
  줄.push(`- ${v.나눠쓰기줄}줄이 넘는 파일은 Write 로 앞부분만 만들고 Append 로 이어 붙여라.`);