deel-local-cli 1.8.0 → 1.10.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 (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
@@ -7,12 +7,19 @@
7
7
  // - 막힘: 도구가 오류를 냈다. 얕게 생각하면 같은 실수를 또 한다. → 세게
8
8
  // 전부 똑같이 세게 두면 느리고, 전부 얕게 두면 엉뚱한 길로 간다.
9
9
 
10
+ import { 언어 } from '../i18n/index.js';
11
+
10
12
  export const LEVELS = ['off', 'low', 'medium', 'high', 'max'];
11
13
 
14
+ /*
15
+ * 단계 이름은 화면에 그대로 나간다. 그래서 영어 이름을 여기 같이 둔다 —
16
+ * modes.js 의 en 과 같은 방식이다. i18n 표에 같은 말을 또 적어 두면
17
+ * 단계를 하나 늘릴 때 한쪽만 늘어나서 화면에 빈칸이 뜬다.
18
+ */
12
19
  export const STAGES = {
13
- plan: { label: '첫 판단', why: '무엇을 할지 정하는 자리' },
14
- work: { label: '이어가기', why: '도구 결과를 읽고 다음 한 수' },
15
- fix: { label: '막혔을 때', why: '직전 도구가 오류를 냄' },
20
+ plan: { label: '첫 판단', why: '무엇을 할지 정하는 자리', en: 'First call', whyEn: 'Deciding what to do' },
21
+ work: { label: '이어가기', why: '도구 결과를 읽고 다음 한 수', en: 'Continue', whyEn: 'Reading a tool result, picking the next move' },
22
+ fix: { label: '막혔을 때', why: '직전 도구가 오류를 냄', en: 'Stuck', whyEn: 'The last tool returned an error' },
16
23
  };
17
24
 
18
25
  // shift = 기준 강도에서 몇 칸 올리고 내릴지.
@@ -40,6 +47,7 @@ export const PROFILES = {
40
47
  // i18n 표에 같은 말을 또 적어 두면 언젠가 둘이 갈라진다.
41
48
  en: 'even',
42
49
  desc: '모든 단계 같은 강도 — 예측 가능한 대신 느립니다',
50
+ descEn: 'Same effort at every stage - predictable, but slower',
43
51
  shift: { plan: 0, work: 0, fix: 0 },
44
52
  share: { plan: 0.40, work: 0.40, fix: 0.40 },
45
53
  },
@@ -47,6 +55,7 @@ export const PROFILES = {
47
55
  name: '절약',
48
56
  en: 'save',
49
57
  desc: '첫 판단만 세게, 이어가기는 얕게 — 대개 이게 낫습니다',
58
+ descEn: 'Hard on the first call, shallow while continuing - usually the better trade',
50
59
  shift: { plan: 0, work: -1, fix: +1 },
51
60
  share: { plan: 0.40, work: 0.35, fix: 0.45 },
52
61
  },
@@ -54,6 +63,7 @@ export const PROFILES = {
54
63
  name: '깊게',
55
64
  en: 'deep',
56
65
  desc: '전 단계 한 칸씩 위로 — 어려운 일에만',
66
+ descEn: 'Every stage one notch up - for hard work only',
57
67
  shift: { plan: +1, work: 0, fix: +1 },
58
68
  share: { plan: 0.50, work: 0.45, fix: 0.50 },
59
69
  },
@@ -158,16 +168,26 @@ export function wasCut(msg) {
158
168
  export function table(base, profileKey, room = {}) {
159
169
  const key = normalizeProfile(profileKey) ?? 'save';
160
170
  const p = PROFILES[key];
171
+ /*
172
+ * 화면 말이 한국어가 아니면 영어 이름으로 낸다.
173
+ *
174
+ * 여태 이 표는 언제나 한국어였다. /lang en 으로 켠 사람은 「단계 강도
175
+ * 출력상한」 옆에 「첫 판단·이어가기·막혔을 때」 가 서 있는 화면을 봤다 —
176
+ * 표의 틀만 영어고 알맹이가 한국어면 아무것도 안 읽힌다.
177
+ *
178
+ * 일본어·중국어도 영어로 간다. i18n 의 물러날곳(ja→en→ko)과 같은 규칙이다.
179
+ */
180
+ const 한국어 = 언어() === 'ko';
161
181
  return {
162
182
  key,
163
- name: p.name,
164
- desc: p.desc,
183
+ name: 한국어 ? p.name : (p.en ?? p.name),
184
+ desc: 한국어 ? p.desc : (p.descEn ?? p.desc),
165
185
  ctx: room.ctx ?? 0,
166
186
  used: room.used ?? 0,
167
187
  rows: Object.entries(STAGES).map(([stage, s]) => ({
168
188
  stage,
169
- label: s.label,
170
- why: s.why,
189
+ label: 한국어 ? s.label : (s.en ?? s.label),
190
+ why: 한국어 ? s.why : (s.whyEn ?? s.why),
171
191
  level: effortFor(base, key, stage),
172
192
  cap: tokensFor(key, stage, room),
173
193
  share: p.share[stage],
@@ -112,6 +112,8 @@ export function 증거모으기(session, { audit = null, 최근 = 400, 바꾼때
112
112
  바꾼것,
113
113
  돌린것,
114
114
  증명안된것,
115
+ // 기록이 새고 있으면 이 보고서는 실제보다 짧다. 짧은 채로 내보내면 안 된다.
116
+ 기록못씀: audit?.못쓴것?.() ?? null,
115
117
  // 화면이 한눈에 쓸 수 있게 미리 세어 둔다.
116
118
  셈: {
117
119
  파일: 바꾼것.length,
@@ -0,0 +1,141 @@
1
+ // 읽은 파일을 들고 있다가, 다시 읽을 때 **바뀐 만큼만** 싣는다.
2
+ //
3
+ // ── 무엇이 아까웠나 ─────────────────────────────────────────────────────
4
+ //
5
+ // 「폴더/파일을 읽으면 그 값을 가져와있다가 수정되면 수정하고, 필요하면
6
+ // 필요할때마다 해당 값을 써야 토큰이 덜 발생하는거 아니야?」
7
+ //
8
+ // 맞는 말이다. 여태 이랬다.
9
+ //
10
+ // Read src/a.js 1,200줄이 대화에 들어간다
11
+ // Edit src/a.js 한 줄 고친다
12
+ // Read src/a.js **1,200줄이 또 들어간다**
13
+ //
14
+ // 두 번째 1,200줄 중 실제로 새로운 것은 한 줄이다. 나머지 1,199줄은 이미
15
+ // 대화 안에 그대로 있다. 그런데 창은 그만큼 밀리고, 밀린 만큼 앞엣것이
16
+ // 접혀 나간다 — 돈을 두 번 내고 기억을 잃는 셈이다.
17
+ //
18
+ // loop.js 에도 되풀이를 막는 자리가 있지만 거기는 **결과가 똑같을 때**만
19
+ // 본다. 그리고 그 표는 턴 하나짜리다. 고치고 다시 읽는 것 — 제일 흔한
20
+ // 모양 — 은 거기에 안 걸린다.
21
+ //
22
+ // ── 지키는 선 ───────────────────────────────────────────────────────────
23
+ //
24
+ // **줄이는 것과 잃는 것은 다르다.** 「앞에서 읽은 그대로입니다」 는 앞엣것이
25
+ // 대화에 살아 있을 때만 참이다. 그래서 요약해서 접을 때(compact.js) 이
26
+ // 기억도 같이 버린다. 안 버리면 모델은 있지도 않은 글을 가리키는 쪽지만
27
+ // 받는다 — 그게 통째로 다시 싣는 것보다 훨씬 나쁘다.
28
+ //
29
+ // **부분만 읽은 것은 안 기억한다.** offset·limit 로 잘라 읽었거나 창이 좁아
30
+ // 앞부분만 준 경우, 우리가 들고 있는 것은 파일이 아니라 조각이다. 그걸
31
+ // 「그대로」 라고 하면 모델은 안 본 부분을 봤다고 여긴다.
32
+ import { diffLines } from '../ui/diff.js';
33
+
34
+ /*
35
+ * 바뀐 만큼만 싣는 것이 언제 이득인가.
36
+ *
37
+ * 조금 바뀌었으면 확실히 이득이다. 그런데 절반이 바뀌었으면 diff 가 원본보다
38
+ * 오히려 길어진다(뺀 줄과 넣은 줄이 둘 다 실리니까). 그때는 그냥 통째로
39
+ * 싣는 편이 짧고, 무엇보다 **읽기 쉽다.**
40
+ */
41
+ const 이득선 = 0.6;
42
+
43
+ /** 한 줄이라도 남기려면 이만큼은 있어야 한다 — 너무 짧은 파일은 diff 가 무의미하다. */
44
+ const 최소줄 = 40;
45
+
46
+ export class 파일기억 {
47
+ constructor() {
48
+ /** 경로 → { 글, 언제 } — 마지막으로 대화에 통째로 실은 내용. */
49
+ this.것들 = new Map();
50
+ }
51
+
52
+ /** 요약해서 접었다 = 앞엣것이 대화에서 사라졌다. 기억도 같이 버린다. */
53
+ 잊기() { this.것들.clear(); }
54
+
55
+ /**
56
+ * 이번에 실을 글을 정한다.
57
+ *
58
+ * @param {string} 경로 파일의 절대 경로
59
+ * @param {string} 새글 이번에 읽은 것(줄 번호가 붙은 그대로)
60
+ * @param {object} [옵션]
61
+ * @param {boolean} [옵션.부분인가] offset·limit·창 상한으로 잘라 읽었나
62
+ * @returns {{글: string, 어떻게: 'full'|'same'|'diff'}}
63
+ */
64
+ 실을것(경로, 새글, { 부분인가 = false } = {}) {
65
+ const 글 = String(새글 ?? '');
66
+ if (!경로 || 부분인가) return { 글, 어떻게: 'full' };
67
+
68
+ const 앞것 = this.것들.get(경로);
69
+ this.것들.set(경로, { 글, 언제: Date.now() });
70
+ if (!앞것) return { 글, 어떻게: 'full' };
71
+
72
+ if (앞것.글 === 글) {
73
+ return {
74
+ 글: '앞에서 읽은 그대로입니다 — 파일이 바뀌지 않았습니다.'
75
+ + ' 앞에 실린 내용을 그대로 쓰세요. (같은 글을 두 번 싣지 않습니다)',
76
+ 어떻게: 'same',
77
+ };
78
+ }
79
+
80
+ const 줄인것 = 바뀐데만(앞것.글, 글);
81
+ if (줄인것) return { 글: 줄인것, 어떻게: 'diff' };
82
+ return { 글, 어떻게: 'full' };
83
+ }
84
+ }
85
+
86
+ /*
87
+ * 줄 번호를 떼고 견준다.
88
+ *
89
+ * Read 는 ` 12\t코드` 처럼 줄 번호를 붙여 돌려준다. 그걸 그대로 견주면
90
+ * **맨 앞에 한 줄만 끼어도 그 아래 전부가 바뀐 줄로 잡힌다** — 번호가 다
91
+ * 밀리기 때문이다. 그러면 diff 가 원본보다 길어져서 줄이는 뜻이 사라진다.
92
+ * 그래서 번호를 떼고 견주고, 적을 때 **새 파일의 번호**로 다시 붙인다.
93
+ */
94
+ function 번호떼기(글) {
95
+ const 몸 = [];
96
+ const 번호 = [];
97
+ for (const l of String(글).split('\n')) {
98
+ const m = l.match(/^(\s*)(\d+)\t([\s\S]*)$/);
99
+ if (m) { 번호.push(m[2]); 몸.push(m[3]); } else { 번호.push(null); 몸.push(l); }
100
+ }
101
+ return { 몸: 몸.join('\n'), 번호 };
102
+ }
103
+
104
+ /**
105
+ * 바뀐 자리만 뽑아 적는다. 이득이 없으면 null 을 돌려준다.
106
+ *
107
+ * 줄 번호는 **새 파일의 번호**로 적는다. 모델은 그 번호로 Edit 을 걸기
108
+ * 때문에, 여기서 1부터 다시 세면 엉뚱한 줄을 고친다.
109
+ */
110
+ export function 바뀐데만(앞글, 새글) {
111
+ const 새줄들 = String(새글).split('\n');
112
+ if (새줄들.length < 최소줄) return null;
113
+
114
+ const A = 번호떼기(앞글);
115
+ const B = 번호떼기(새글);
116
+ const d = diffLines(A.몸, B.몸, { context: 3 });
117
+ if (!d.changed || d.tooBig || !d.hunks?.length) return null;
118
+
119
+ const 자리 = (n) => (n == null ? ' ' : String(n).padStart(6));
120
+ const 조각들 = [];
121
+ for (const h of d.hunks) {
122
+ const 줄들 = [];
123
+ for (let p = h.from; p <= h.to; p += 1) {
124
+ const op = d.opAt(p);
125
+ // 지운 줄에는 새 파일의 번호가 없다. 자리만 비워 두고 뺐다고만 적는다.
126
+ if (op.t === '-') 줄들.push(`-${' '.repeat(6)}\t${d.linesA[op.a] ?? ''}`);
127
+ else if (op.t === '+') 줄들.push(`+${자리(B.번호[op.b])}\t${d.linesB[op.b] ?? ''}`);
128
+ else 줄들.push(` ${자리(B.번호[op.b])}\t${d.linesB[op.b] ?? d.linesA[op.a] ?? ''}`);
129
+ }
130
+ 조각들.push(줄들.join('\n'));
131
+ }
132
+
133
+ const 몸 = 조각들.join('\n …\n');
134
+ // 이득이 없으면 통째로 싣는 편이 짧고 읽기도 쉽다.
135
+ if (몸.length >= String(새글).length * 이득선) return null;
136
+
137
+ return '앞에서 읽은 것에서 **바뀐 자리만** 적습니다'
138
+ + ` (+${d.added} −${d.removed}). 나머지는 앞에 실린 그대로입니다.\n`
139
+ + '줄 번호는 **지금 파일의 번호**입니다 — 이 번호로 고치면 됩니다.\n\n'
140
+ + 몸;
141
+ }
@@ -92,6 +92,14 @@ export class 지켜본것 {
92
92
  this.편집실패 = 0; // Edit 의 old_string 이 파일과 안 맞음
93
93
  this.되풀이 = 0; // 같은 자리를 또 부름
94
94
  this.도구성공 = 0; // 도구가 탈 없이 끝난 횟수
95
+ /*
96
+ * 다시 읽었는데 통째로 안 실은 횟수 (agent/filemem.js).
97
+ *
98
+ * 사고가 아니다 — 사고율() 에 안 들어간다. 잘한 일을 세는 자리라서
99
+ * 급을 낮추면 안 된다. 여기 두는 까닭은 화면에서 「얼마나 아꼈나」 를
100
+ * 말할 수 있어야 하기 때문이다. 안 보이면 없는 기능이나 같다.
101
+ */
102
+ this.아낀읽기 = 0;
95
103
  }
96
104
 
97
105
  걸음셈() { this.걸음++; return this; }
@@ -118,12 +126,54 @@ export class 지켜본것 {
118
126
  * @param {지켜본것} 본것 이번 대화에서 겪은 것 (없어도 된다)
119
127
  * @param {string} 사람이정한 '작음'|'보통'|'큼' — 사람이 정했으면 그게 답이다
120
128
  */
129
+ /*
130
+ * ── 급은 이름이 아니라 **연결이 알려 준 값**으로 잡는다 ─────────────────
131
+ *
132
+ * 이름에서크기() 는 `70b` 같은 파라미터 수를 찾는다. 로컬 모델은 이름에 그걸
133
+ * 적지만, 벤더 모델은 안 적는다 — `anthropic.claude-opus-4-1` 에는 B 가 없다.
134
+ * 그래서 못 읽고 '보통' 으로 떨어졌고, 실제로 Bedrock 의 Claude Opus 가
135
+ * 「한 번에 파일 6개 · 400줄 넘으면 나눠 쓰기」 를 받았다. 있는 것 중 제일 큰
136
+ * 모델에게 중급용 보조바퀴를 달아 준 셈이다.
137
+ *
138
+ * 그렇다고 `opus` `gpt-5` 같은 **이름표**를 두는 것도 답이 아니다. 그런 표는
139
+ * 새 모델이 나올 때마다 어긋나고, 어긋난 줄도 모른다. 이 저장소가 값 박힌
140
+ * 요금표를 안 두는 것과 같은 까닭이다(backend/price.js).
141
+ *
142
+ * 대신 서버가 알려 준 것을 쓴다. probeCtx() 가 켤 때 이미 받아 오는 값이다.
143
+ * 컨텍스트 길이는 급의 완벽한 잣대는 아니지만, **우리가 실제로 아는 것** 중
144
+ * 제일 가까운 잣대다. 32k 짜리에게 열 파일을 맡기면 창이 먼저 터지고,
145
+ * 200k 짜리를 세 파일에 묶어 두면 좋은 모델을 붙들어 매는 것이다.
146
+ *
147
+ * 순서가 뜻이 있다.
148
+ * 1. 파라미터 수를 **이름에서 읽었으면** 그게 제일 세다. 8B 는 창이 128k
149
+ * 여도 8B 다 — 창 크기가 머리를 키워 주지는 않는다.
150
+ * 2. 못 읽었으면 서버가 말해 준 창 크기로 잡는다.
151
+ * 3. 둘 다 모르면 '보통'.
152
+ * 그리고 이 셋 다 **실제로 돌려 본 결과**(사고율)가 언제든 덮는다.
153
+ */
154
+ const 창으로급 = [
155
+ [128000, '큼'],
156
+ [32000, '보통'],
157
+ ];
158
+
159
+ export function 첫짐작(conn) {
160
+ const 이름것 = 이름으로짐작(conn?.model);
161
+ // 이름에서 파라미터 수를 읽었으면 그대로 간다.
162
+ if (이름에서크기(conn?.model) != null) return 이름것;
163
+
164
+ const 창 = Number(conn?.ctx);
165
+ if (!Number.isFinite(창) || 창 <= 0) return 이름것;
166
+ const 찾은것 = 창으로급.find(([선]) => 창 >= 선);
167
+ const 급 = 찾은것 ? 찾은것[1] : '작음';
168
+ return { 급, 왜: `창이 ${Math.round(창 / 1000)}k 라고 서버가 알려 줍니다`, 짐작: true };
169
+ }
170
+
121
171
  export function 매김(conn, 본것 = null, 사람이정한 = null) {
122
172
  if (급들.includes(사람이정한)) {
123
173
  return { 급: 사람이정한, 왜: '사용자가 직접 정했습니다', 짐작: false };
124
174
  }
125
175
 
126
- const 첫어림 = 이름으로짐작(conn?.model);
176
+ const 첫어림 = 첫짐작(conn);
127
177
  const 율 = 본것?.사고율?.() ?? null;
128
178
  if (율 == null) return 첫어림;
129
179
 
package/src/agent/loop.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
2
2
  // 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
3
- import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
3
+ import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남 } from '../backend/adapter.js';
4
4
  import { 그림메시지 } from '../backend/vision.js';
5
5
  import { 어떻게할까 } from '../safety/policy.js';
6
6
  import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
@@ -8,7 +8,7 @@ import { isMutating } from '../safety/guard.js';
8
8
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel } from './effort.js';
9
9
  import { 살린쓰기 } from './salvage.js';
10
10
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
11
- import { compact, shouldCompact, shouldFold, foldToolResults, foldImages } from './compact.js';
11
+ import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것 } from './compact.js';
12
12
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
13
13
  import { Session } from './session.js';
14
14
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
@@ -249,6 +249,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
249
249
  // 이번 턴에 서버에게서 한계를 배웠나. 배웠는데도 또 거절당하면 다른 문제다 —
250
250
  // 그때는 끝없이 다시 부르지 않고 오류를 그대로 보여 준다.
251
251
  let 배운적 = false;
252
+ // 이번 턴에 자리가 없어 기억을 비운 적이 있나. 한 턴에 한 번뿐이다 —
253
+ // 비우고도 또 막히면 자리 문제가 아니라 다른 탈이다.
254
+ let 비운적 = false;
252
255
  const 짧게말 = (s) => String(s ?? '').replace(/\s+/g, ' ').slice(0, 200);
253
256
 
254
257
  /**
@@ -455,6 +458,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
455
458
  });
456
459
  };
457
460
 
461
+ /*
462
+ * 걸음이 끊긴 자리에서 대조할 '마지막으로 한 말'.
463
+ *
464
+ * done 은 방금 받은 답(msg.content)을 자국으로 넘긴다. limit·stuck 에는
465
+ * 그 답이 없다 — 도구를 부르다 끊겼기 때문이다. 그래도 모델이 앞서 글로
466
+ * 적어 둔 것은 자국이 되므로 대화에서 마지막 assistant 글을 꺼내 쓴다.
467
+ */
468
+ const 마지막말 = () => String(
469
+ [...session.messages].reverse().find((m) => m.role === 'assistant' && m.content)?.content ?? '',
470
+ );
471
+
458
472
  const 되미는말 = () => {
459
473
  const 시킨 = String(userText ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
460
474
  return 지시말() === 'en'
@@ -466,9 +480,64 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
466
480
  + '정말 도구가 막고 있으면 무엇이 막았는지 한 줄로 말하세요.';
467
481
  };
468
482
 
483
+ /*
484
+ * ── 자리를 만든다 (접기 → 요약) ────────────────────────────────────────
485
+ *
486
+ * 여태 이 검사는 **도구를 한 번 돌린 뒤에만** 했다. 그래서 첫 부름 전에는
487
+ * 아무리 꽉 차 있어도 그대로 보냈다. 실제로 이렇게 죽는다 —
488
+ *
489
+ * 컨텍스트가 78% 인 대화에 긴 파일을 하나 붙인다
490
+ * → 첫 요청이 창을 넘겨 서버가 거절한다
491
+ * → 배운 값으로 다시 부른다(같은 이력 그대로) → 또 거절
492
+ * → 이번엔 배운 적이 있으니 오류로 끝난다
493
+ *
494
+ * 사람이 보는 것은 「턴이 죽었다」 이고, /clear 하고 시킨 말을 다시 쳐야
495
+ * 한다. 붙인 파일도 다시 붙여야 한다. 검사는 걸음마다 **부르기 전에**
496
+ * 해야 맞다 — 자리가 모자란 것은 부른 뒤가 아니라 부르기 전에 안다.
497
+ *
498
+ * 문턱은 안 건드린다(FOLD_AT 0.55 · COMPACT_AT 0.8). 여기서 바뀐 것은
499
+ * '언제 재느냐' 하나다.
500
+ *
501
+ * @returns {boolean} 멈추라는 것이 닿아서 턴을 접어야 하나
502
+ */
503
+ let 접다멈췄나 = false;
504
+ const 자리만들기 = async function* () {
505
+ 접다멈췄나 = false;
506
+ /*
507
+ * 요약해서 접기 **전에** 도구 결과부터 접는다.
508
+ *
509
+ * 자리를 먹는 것은 대개 사람 말이 아니라 옛날에 읽어 둔 파일이다. 그쪽을
510
+ * 먼저 비우면 요약 압축을 한참 미룰 수 있고, 미루는 동안 대화는 한 글자도
511
+ * 안 잃는다. 그래도 차면 아래에서 통째로 요약한다.
512
+ */
513
+ if (shouldFold(session)) {
514
+ const f = foldToolResults(session);
515
+ if (f.접은것) yield { type: 'folded', ...f };
516
+ // 그림은 사람 말 자리에 실려서 위 접기가 못 건드린다. 따로 뺀다.
517
+ const g = foldImages(session);
518
+ if (g.뺀것) yield { type: 'images_folded', ...g };
519
+ }
520
+
521
+ // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
522
+ if (shouldCompact(session)) {
523
+ yield { type: 'compacting' };
524
+ // 요약을 부르다 서버가 막으면 그 알림도 화면으로 — '접는 중' 뒤에 60초를 숨기지 않는다.
525
+ const 편지 = 우편함((onBackoff) => compact(session, { auto: true, signal, onBackoff }));
526
+ for await (const 알림 of 편지.소식()) yield 알림;
527
+ const r = await 편지.부름;
528
+ if (r.aborted) { 접다멈췄나 = true; yield { type: 'aborted', steps, kept: true }; return; }
529
+ if (r.ok) yield { type: 'compacted', ...r };
530
+ else yield { type: 'compact_failed', why: r.why };
531
+ }
532
+ };
533
+
469
534
  while (steps < maxSteps) {
470
535
  steps++;
471
536
 
537
+ // 부르기 **전에** 자리를 본다. 왜 여기인지는 자리만들기() 머리말에.
538
+ yield* 자리만들기();
539
+ if (접다멈췄나) return;
540
+
472
541
  /*
473
542
  * ── 도중에 한 말을 여기서 받는다 (끼어들기) ──────────────────────────
474
543
  *
@@ -639,6 +708,14 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
639
708
  cap: 마지막상한,
640
709
  정한값: conn.maxTokens ?? conn.maxOut ?? null,
641
710
  };
711
+ } else if (msg?.stopped === 말없이끝남) {
712
+ /*
713
+ * 상한 때문에 잘린 것과는 다른 사고다. 서버가 끝난 까닭을 한 번도 안
714
+ * 주고 흘려보내기를 멈췄다 — 중계 프록시가 몸통을 자르고 연결을 곱게
715
+ * 닫으면 이 모양이 된다. 상한을 올려 다시 부르는 것은 답이 아니라서
716
+ * capped 와 섞으면 엉뚱한 곳(/out)을 고치게 만든다.
717
+ */
718
+ yield { type: 'cutoff' };
642
719
  }
643
720
 
644
721
  /*
@@ -727,7 +804,82 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
727
804
  continue;
728
805
  }
729
806
 
730
- yield { type: 'error', text: err.message };
807
+ /*
808
+ * ── 자리가 다 찼으면, 턴을 죽이지 말고 비우고 이어 간다 ──────────────
809
+ *
810
+ * 여기가 사용자가 이름 대어 말한 자리다 — "current 가 다 차서 막힐 경우,
811
+ * 초기화되고 난 후에 다시 바로 작업할 수 있게".
812
+ *
813
+ * 여태 있었던 일: 서버가 「자리가 없다」고 거절한다 → 위에서 한계를
814
+ * 배우고 다시 부른다 → 접기가 걸음의 머리에서 도니 대개 여기서 낫는다.
815
+ * 그래도 안 맞으면(요약 자체가 크거나, 요약을 못 받았거나) 배운적이
816
+ * 이미 참이라 그대로 오류로 떨어졌다. 화면에는 붉은 줄 하나가 뜨고
817
+ * **턴이 죽는다.** 사람은 /clear 를 치고, 시킨 말을 다시 타이핑하고,
818
+ * 붙였던 파일을 다시 붙여야 한다. 한 걸음도 못 나아간 채로.
819
+ *
820
+ * 그래서 이 자리에서 **턴 안에서** 비우고 이어 간다. 버리는 것은 지나간
821
+ * 대화뿐이고, 잃으면 안 되는 것은 그대로 옮긴다 —
822
+ * · 시킨 말 원문과 남은 할 일 (못박을것)
823
+ * · 이번 턴에 이미 손댄 파일 이름
824
+ * 모델이 다음 걸음에서 보는 것은 「무엇을 시켰고 · 어디까지 했나」 다.
825
+ * 그거면 이어서 할 수 있다.
826
+ *
827
+ * 읽어 둔 파일 기억도 같이 지운다. 내용은 대화에서 사라졌는데 '이미
828
+ * 읽었다' 는 표만 남으면, 모델은 다시 안 읽고 기억에 없는 파일을
829
+ * 고치려 든다 — 비운 것보다 나쁘다.
830
+ *
831
+ * 한 턴에 한 번뿐이다. 비우고도 또 막히면 그건 자리 문제가 아니라
832
+ * 다른 탈이라, 끝없이 비우며 도는 대신 있는 그대로 오류로 말한다.
833
+ */
834
+ const 자리문제 = 배운?.kind === 'ctx' || 길이문제인가(err.serverMessage ?? err.message);
835
+ if (자리문제 && !비운적) {
836
+ 비운적 = true;
837
+ const 지운수 = session.messages.length;
838
+ const 손댄것 = [...손댄파일];
839
+ const 남은할일수 = (ctx.todos ?? []).filter((x) => x?.state !== 'done').length;
840
+ session.messages = [{
841
+ role: 'user',
842
+ content: `(자리가 모자라 앞선 대화 ${지운수}개를 비웠습니다. 필요한 파일은 다시 읽으세요.)\n`
843
+ + (손댄것.length ? `이번 턴에 이미 손댄 파일: ${손댄것.join(', ')}\n` : '')
844
+ + '\n' + 못박을것(session),
845
+ }];
846
+ // 내용은 없어졌는데 '읽었다' 는 표만 남으면 모델이 다시 안 읽는다.
847
+ try { session.filesRead?.clear?.(); session.파일기억?.잊기?.(); } catch { /* 없어도 그만 */ }
848
+ yield {
849
+ type: 'reset',
850
+ dropped: 지운수,
851
+ kept: { 요청: !!String(session.이번요청 ?? '').trim(), 할일: 남은할일수, 파일: 손댄것.length },
852
+ };
853
+ /*
854
+ * 배우기(위)와 달리 여기서는 걸음을 **안 돌려준다.**
855
+ *
856
+ * 돌려주면 걸음 수가 영영 안 올라간다. 그러면 위의 한 번 규칙이
857
+ * 어쩌다 무너졌을 때 걸음 상한도 못 잡아서 턴이 **영영 돈다** —
858
+ * 화면은 멈춘 채로 있고 사람은 왜 그런지 알 길이 없다. 붉은 줄
859
+ * 하나보다 훨씬 나쁘다. 한 걸음 값은 어차피 실제로 한 번 부르고
860
+ * 거절당한 값이라, 안 돌려주는 쪽이 사실에도 맞다.
861
+ */
862
+ continue;
863
+ }
864
+
865
+ /*
866
+ * 탈이 나도 **여기까지 흘러온 말은 남긴다.**
867
+ *
868
+ * 중단(위)에서는 이미 남기고 있었는데 오류에서는 안 남기고 있었다.
869
+ * 그런데 사람이 겪는 것은 오히려 이쪽이 잦다 — 200자를 받아 화면에
870
+ * 읽고 있는데 연결이 끊기면, 화면에는 그 200자가 그대로 보이는데
871
+ * 대화에는 한 글자도 안 들어간다. 그 상태에서 "이어서 해줘" 라고
872
+ * 하면 모델은 방금 제가 한 말을 모른다. 화면과 대화가 어긋나 있으면
873
+ * 사람은 그 뒤로 무엇을 믿어야 할지 알 수 없다.
874
+ *
875
+ * 반쪽인 것은 중단과 같은 표를 붙여 남긴다 — 온전한 답으로 읽히면
876
+ * 안 된다. 도구 호출만 있고 결과가 빈 자리도 같이 채운다(짝맞추기).
877
+ * 안 채우면 다음 요청에서 게이트웨이가 통째로 400 을 준다.
878
+ */
879
+ const 오류때남길것 = 흘린것.trim();
880
+ if (!msg && 오류때남길것) session.push({ role: 'assistant', content: 오류때남길것 });
881
+ 짝맞추기();
882
+ yield { type: 'error', text: err.message, kept: !!오류때남길것 || !!msg };
731
883
  return;
732
884
  }
733
885
 
@@ -849,7 +1001,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
849
1001
  + ` 다시 보내면 또 같은 자리에서 잘립니다. 한 번에 300줄 안쪽으로 끊어 보내세요.`;
850
1002
  session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
851
1003
  if (r.changed) { session.noteChange(r.changed, r.diff); 손댄파일.add(r.changed); }
852
- ctx.audit.tool(call.name, { file_path: 건진것.path }, `잘린 것을 살려 ${건진것.lines}줄 씀`);
1004
+ ctx.audit.tool(call.name, { file_path: 건진것.path }, { summary: `잘린 것을 살려 ${건진것.lines}줄 씀` });
853
1005
  yield {
854
1006
  type: 'tool', name: call.name, args: { file_path: 건진것.path },
855
1007
  result: { ...r, summary: `${건진것.lines}줄 · 잘린 데까지`, warn: `잘린 데까지만 썼습니다 — ${건진것.lines}줄` },
@@ -1186,11 +1338,52 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1186
1338
 
1187
1339
  // 앞에서 똑같이 부른 적이 있고 결과도 같으면, 결과를 다시 싣지 않는다.
1188
1340
  let 실을것 = 실을글(result);
1341
+ const 날것 = 실을것; // 되풀이는 도구가 진짜 돌려준 것으로 센다(아래 참고)
1342
+ /*
1343
+ * ── 읽은 파일은 **바뀐 만큼만** 싣는다 ────────────────────────────
1344
+ *
1345
+ * 아래 되풀이 막기는 「인자도 결과도 똑같을 때」만 걸리고, 그 표는
1346
+ * 이 턴 하나짜리다. 그래서 제일 흔한 모양이 그물을 빠져나갔다 —
1347
+ * 읽고, 한 줄 고치고, 확인하러 다시 읽는 것. 결과가 달라졌으니
1348
+ * 1,200줄이 통째로 한 벌 더 실렸다. 새로운 것은 한 줄뿐인데.
1349
+ *
1350
+ * 여기서는 앞서 실은 글과 견줘서, 그대로면 그렇다고만 적고 달라졌으면
1351
+ * 바뀐 자리만 적는다. 창이 밀리지 않으니 앞선 대화가 그만큼 오래 산다.
1352
+ */
1353
+ /*
1354
+ * ── 할 일 목록은 세션에도 들고 있는다 ────────────────────────────
1355
+ *
1356
+ * 여태 목록이 사는 자리는 도구 결과 하나뿐이었다. 그런데 도구 결과는
1357
+ * 55%에서 한 줄로 접히고 80%에서 요약에 뭉개진다. 접힘 문구는
1358
+ * 「필요하면 다시 읽으세요」인데 **할 일 목록은 다시 읽을 파일이 없다.**
1359
+ * 그래서 긴 작업일수록 남은 항목이 통째로 사라졌다 — 「대화를 잘
1360
+ * 기억 못 한다」로 제보받은 것의 큰 몫이 여기다.
1361
+ *
1362
+ * ctx.todos 는 우리 코드만 본다. 모델이 보는 자리에 남기려면 접거나
1363
+ * 줄일 때 다시 실어 줘야 하고, 그러려면 session 이 들고 있어야 한다.
1364
+ */
1365
+ if (!result.error && Array.isArray(result.todos)) session.할일 = result.todos;
1366
+
1367
+ if (!result.error && result.읽은것 && session.파일기억) {
1368
+ const 정한것 = session.파일기억.실을것(result.읽은것, 실을것, { 부분인가: !!result.부분인가 });
1369
+ if (정한것.어떻게 !== 'full') {
1370
+ session.본것?.본것('아낀읽기');
1371
+ 실을것 = 정한것.글;
1372
+ }
1373
+ }
1189
1374
  if (!result.error) {
1190
1375
  const 서명 = 서명만들기(call);
1191
- // 결과도 한 모양으로 맞춰 견준다. 여러 도구가 받은 경로를 답에 그대로
1192
- // 되돌려 쓰므로, 자모가 쪼개진 경로로 부르면 결과까지 달라 보인다.
1193
- const 같은꼴 = 실을것.normalize('NFC');
1376
+ /*
1377
+ * 결과도 모양으로 맞춰 견준다. 여러 도구가 받은 경로를 답에 그대로
1378
+ * 되돌려 쓰므로, 자모가 쪼개진 경로로 부르면 결과까지 달라 보인다.
1379
+ *
1380
+ * 견주는 것은 **바꿔치기 전** 글이다. 위에서 파일 기억이 실을것 을
1381
+ * 「앞에서 읽은 그대로입니다」 로 갈아 끼우는데, 그것까지 표에 넣으면
1382
+ * 같은 파일을 두 번째 읽을 때가 첫 번째와 달라 보인다. 그러면 되풀이가
1383
+ * 한 바퀴 늦게 걸린다 — 헛도는 것을 한 걸음 더 돌고 나서야 잡는 셈이다.
1384
+ * 도구가 실제로 무엇을 돌려줬나로 세는 것이 맞다.
1385
+ */
1386
+ const 같은꼴 = 날것.normalize('NFC');
1194
1387
  const 앞것 = 부른것.get(서명);
1195
1388
  if (앞것 !== undefined && 앞것 === 같은꼴) {
1196
1389
  session.본것?.본것('되풀이');
@@ -1304,36 +1497,10 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1304
1497
  // 컨텍스트만 채우고 사람은 기다리기만 한다.
1305
1498
  if (멈출까) {
1306
1499
  ctx.audit.blocked('같은 자리를 반복해 스스로 멈춤', 멈출까);
1307
- yield { type: 'stuck', why: 멈출까, steps, files: 마무리() };
1500
+ yield { type: 'stuck', why: 멈출까, steps, files: 마무리(), 빠진: 빠뜨린것(마지막말()) };
1308
1501
  return;
1309
1502
  }
1310
1503
 
1311
- /*
1312
- * 요약해서 접기 **전에** 도구 결과부터 접는다.
1313
- *
1314
- * 자리를 먹는 것은 대개 사람 말이 아니라 옛날에 읽어 둔 파일이다. 그쪽을
1315
- * 먼저 비우면 요약 압축을 한참 미룰 수 있고, 미루는 동안 대화는 한 글자도
1316
- * 안 잃는다. 그래도 차면 아래에서 통째로 요약한다.
1317
- */
1318
- if (shouldFold(session)) {
1319
- const f = foldToolResults(session);
1320
- if (f.접은것) yield { type: 'folded', ...f };
1321
- // 그림은 사람 말 자리에 실려서 위 접기가 못 건드린다. 따로 뺀다.
1322
- const g = foldImages(session);
1323
- if (g.뺀것) yield { type: 'images_folded', ...g };
1324
- }
1325
-
1326
- // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
1327
- if (shouldCompact(session)) {
1328
- yield { type: 'compacting' };
1329
- // 요약을 부르다 서버가 막으면 그 알림도 화면으로 — '접는 중' 뒤에 60초를 숨기지 않는다.
1330
- const 편지 = 우편함((onBackoff) => compact(session, { auto: true, signal, onBackoff }));
1331
- for await (const 알림 of 편지.소식()) yield 알림;
1332
- const r = await 편지.부름;
1333
- if (r.aborted) { yield { type: 'aborted', steps, kept: true }; return; }
1334
- if (r.ok) yield { type: 'compacted', ...r };
1335
- else yield { type: 'compact_failed', why: r.why };
1336
- }
1337
1504
  }
1338
1505
 
1339
1506
  /*
@@ -1345,5 +1512,5 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1345
1512
  * 할 때 무엇을 이어야 하는지가 화면에 있어야 한다.
1346
1513
  */
1347
1514
  const 남은할일 = (ctx.todos ?? []).filter((x) => x.state !== 'done');
1348
- yield { type: 'limit', steps, files: 마무리(), 남은할일 };
1515
+ yield { type: 'limit', steps, files: 마무리(), 남은할일, 빠진: 빠뜨린것(마지막말()) };
1349
1516
  }