deel-local-cli 1.0.2 → 1.1.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Verify — 만든 것이 **실제로 되는지** 본다.
3
+ *
4
+ * ── 왜 이게 필요한가 ───────────────────────────────────────────────────
5
+ *
6
+ * 턴 끝에 이렇게 뜬다.
7
+ * ✓ index.html · 410줄 · 18.2KB
8
+ *
9
+ * 이건 파일이 **있다**는 증명이지 **된다**는 증명이 아니다. `<div>` 를 안 닫아
10
+ * 놨어도, `src="app.js"` 가 없는 파일을 가리켜도, JS 에 괄호가 하나 모자라도
11
+ * 똑같이 초록으로 뜬다. 사람은 그 초록을 믿고 다음 일로 넘어간다.
12
+ *
13
+ * 프롬프트에 "다 했으면 확인한다" 는 이미 넣어 뒀다. 그런데 확인할 **길**을
14
+ * 안 줬다. 그러면 그건 부탁이지 규칙이 아니다.
15
+ *
16
+ * ── 무엇을 확인하나 ────────────────────────────────────────────────────
17
+ *
18
+ * 돌려 볼 수 있는 것은 돌려 본다 (node --check · py_compile).
19
+ * 못 돌리는 것은 **읽어서** 본다 (HTML 태그 짝, 빠진 참조, CSS 중괄호 짝).
20
+ *
21
+ * 임의의 명령은 **여기서 안 돌린다.** 검사 스크립트는 무슨 짓이든 할 수 있어서,
22
+ * 그걸 돌리는 길은 Bash 하나여야 한다 — 승인 관문과 안전 검사가 거기에만 있다.
23
+ * 이 도구가 몰래 돌리면 strict 모드의 약속이 이 자리에서만 깨진다.
24
+ * 여기서는 "이 프로젝트엔 npm test 가 있다" 고 알려 주기만 한다.
25
+ *
26
+ * 그리고 제일 중요한 것 — **못 확인한 것은 못 확인했다고 말한다.**
27
+ * 조용히 넘기면 사람이 받는 신호는 '확인함' 과 구별되지 않는다. 그럴 바에는
28
+ * 이 도구가 없는 편이 낫다. 확인 못 한 것을 확인했다고 하는 것이 제일 나쁘다.
29
+ */
30
+ import { existsSync, statSync, readFileSync } from 'node:fs';
31
+ import { execFile } from 'node:child_process';
32
+ import { extname, dirname, join, resolve } from 'node:path';
33
+ import { walk, SKIP_DIRS, 내부살림 } from './fsutil.js';
34
+ import { decode, looksBinary } from './encoding.js';
35
+ import { checkCommand } from '../safety/guard.js';
36
+
37
+ /** 짝이 맞아야 하는 HTML 태그. 안 닫아도 되는 것(void)은 뺀다. */
38
+ const 안닫아도되는것 = new Set([
39
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
40
+ 'meta', 'param', 'source', 'track', 'wbr', '!doctype', '!--',
41
+ ]);
42
+
43
+ /**
44
+ * HTML 을 읽어 본다.
45
+ *
46
+ * 파서를 쓰지 않는다(의존성 0개). 대신 **틀렸다고 확신할 수 있는 것만** 잡는다 —
47
+ * 애매한 것은 넘긴다. 잘못된 경고를 내면 모델이 멀쩡한 파일을 고치기 시작하고,
48
+ * 그게 확인 안 하는 것보다 나쁘다.
49
+ */
50
+ export function html보기(글, { 있는파일 = () => true } = {}) {
51
+ const 탈 = [];
52
+ const 쌓임 = [];
53
+
54
+ // 주석과 script/style 안엣것은 빼고 본다 — 그 안의 `<` 는 태그가 아니다.
55
+ const 뼈 = String(글)
56
+ .replace(/<!--[\s\S]*?-->/g, '')
57
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '<$1></$1>');
58
+
59
+ for (const m of 뼈.matchAll(/<(\/?)([a-zA-Z!][a-zA-Z0-9-]*)([^>]*)>/g)) {
60
+ const 닫는가 = m[1] === '/';
61
+ const 이름 = m[2].toLowerCase();
62
+ const 뒤 = m[3] ?? '';
63
+ if (안닫아도되는것.has(이름) || 뒤.trimEnd().endsWith('/')) continue;
64
+ if (!닫는가) { 쌓임.push(이름); continue; }
65
+ // 닫는 태그. 짝이 맞는 자리를 찾는다.
66
+ const i = 쌓임.lastIndexOf(이름);
67
+ if (i < 0) { 탈.push(`</${이름}> 를 닫는데 여는 <${이름}> 가 없습니다`); continue; }
68
+ const 안닫힌것 = 쌓임.splice(i).slice(1);
69
+ for (const x of 안닫힌것) 탈.push(`<${x}> 를 안 닫았습니다 (</${이름}> 앞에서 끊겼습니다)`);
70
+ }
71
+ for (const x of 쌓임) 탈.push(`<${x}> 를 안 닫았습니다`);
72
+
73
+ // 가리키는 파일이 실제로 있나. 이게 "열었는데 아무것도 안 보인다" 의 첫째 원인이다.
74
+ for (const m of String(글).matchAll(/(?:src|href)\s*=\s*"([^"]+)"/g)) {
75
+ const 곳 = m[1];
76
+ if (/^(?:https?:|data:|mailto:|tel:|#|\/\/)/i.test(곳)) continue; // 바깥 것은 못 본다
77
+ if (!있는파일(곳.split(/[?#]/)[0])) 탈.push(`${곳} 을 가리키는데 그 파일이 없습니다`);
78
+ }
79
+
80
+ return 탈;
81
+ }
82
+
83
+ /** CSS 중괄호 짝. 이것만으로도 '스타일이 통째로 안 먹는' 경우는 거의 다 잡힌다. */
84
+ export function css보기(글) {
85
+ const 뼈 = String(글).replace(/\/\*[\s\S]*?\*\//g, '');
86
+ const 연것 = (뼈.match(/\{/g) ?? []).length;
87
+ const 닫은것 = (뼈.match(/\}/g) ?? []).length;
88
+ if (연것 === 닫은것) return [];
89
+ return [연것 > 닫은것
90
+ ? `중괄호를 ${연것 - 닫은것}개 안 닫았습니다 (여는 것 ${연것} · 닫는 것 ${닫은것})`
91
+ : `닫는 중괄호가 ${닫은것 - 연것}개 더 많습니다 (여는 것 ${연것} · 닫는 것 ${닫은것})`];
92
+ }
93
+
94
+ /** JSON 은 그냥 파싱해 본다 — 되면 되는 것이다. */
95
+ export function json보기(글) {
96
+ try { JSON.parse(글); return []; }
97
+ catch (e) { return [String(e.message)]; }
98
+ }
99
+
100
+ /** 명령 하나를 돌린다. Bash 도구와 같은 안전 검사를 거친다. */
101
+ function 명령돌리기(cmd, cwd, 제한 = 60000) {
102
+ return new Promise((끝) => {
103
+ const shell = process.platform === 'win32'
104
+ ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}"`], verbatim: true }
105
+ : { file: '/bin/sh', args: ['-c', cmd] };
106
+ execFile(shell.file, shell.args, {
107
+ cwd, timeout: 제한, maxBuffer: 4 * 1024 * 1024,
108
+ windowsHide: true, windowsVerbatimArguments: shell.verbatim === true,
109
+ encoding: 'buffer',
110
+ }, (err, so, se) => {
111
+ const 풀기 = (b) => (b && b.length ? decode(Buffer.from(b)).text : '');
112
+ const out = [풀기(so), 풀기(se)].filter(Boolean).join('\n').trim();
113
+ const 시그널 = err?.signal ?? null;
114
+ const code = 시그널 ? null : (err?.code ?? 0);
115
+ 끝({ ok: !시그널 && code === 0, code, 시그널, out });
116
+ });
117
+ });
118
+ }
119
+
120
+ /** 이 확장자를 돌려 볼 명령이 있나. 없으면 null — 그러면 '못 돌려 봤다' 고 말한다. */
121
+ function 돌릴명령(확장) {
122
+ switch (확장) {
123
+ case '.js': case '.mjs': case '.cjs': return (p) => `node --check "${p}"`;
124
+ case '.py': return (p) => `python -m py_compile "${p}"`;
125
+ default: return null;
126
+ }
127
+ }
128
+
129
+ const 짧게 = (s, n = 300) => {
130
+ const t = String(s ?? '').trim();
131
+ return t.length > n ? `${t.slice(0, n)}…` : t;
132
+ };
133
+
134
+ export const VERIFY_TOOL = {
135
+ schema: {
136
+ name: 'Verify',
137
+ description:
138
+ '만든 것이 실제로 되는지 확인한다. 일을 끝내기 전에 **반드시** 한 번 불러라.'
139
+ + ' 파일이 있다는 것과 그 파일이 된다는 것은 다르다 — 안 닫힌 태그, 없는 파일을'
140
+ + ' 가리키는 src, 괄호 하나 모자란 JS 는 파일 목록만 봐서는 안 보인다.'
141
+ + ' 돌려 볼 수 있는 것은 돌려 보고(node --check · py_compile),'
142
+ + ' 못 돌리는 것은 읽어서 본다(HTML 태그 짝 · 빠진 참조 · CSS 중괄호 · JSON).'
143
+ + ' 확인 못 한 종류는 못 했다고 그대로 말해 준다.'
144
+ + ' 검사·빌드를 돌리는 것은 Bash 로 해라 — 그건 사용자 승인을 거쳐야 하는 일이다.',
145
+ parameters: {
146
+ type: 'object',
147
+ properties: {
148
+ paths: {
149
+ type: 'array',
150
+ items: { type: 'string' },
151
+ description: '확인할 파일들. 없으면 작업 폴더에서 확인할 수 있는 것을 다 본다.',
152
+ },
153
+ },
154
+ required: [],
155
+ },
156
+ },
157
+
158
+ async run(args, ctx) {
159
+ const 뿌리 = ctx.scope.root;
160
+
161
+ // 볼 파일 고르기. 안 주면 작업 폴더에서 확인할 수 있는 것을 찾는다.
162
+ let 볼것 = [];
163
+ if (Array.isArray(args.paths) && args.paths.length) {
164
+ for (const p of args.paths) {
165
+ let abs;
166
+ try { abs = ctx.scope.resolve(p); } catch (e) { 볼것.push({ 없음: p, 왜: e.message }); continue; }
167
+ if (!existsSync(abs)) { 볼것.push({ 없음: p, 왜: '파일이 없습니다' }); continue; }
168
+ if (statSync(abs).isDirectory()) {
169
+ for (const f of walk(abs, { skipDirs: SKIP_DIRS })) 볼것.push({ path: f.path });
170
+ } else 볼것.push({ path: abs });
171
+ }
172
+ } else {
173
+ 볼것 = walk(뿌리, { skipDirs: SKIP_DIRS })
174
+ .filter((f) => ['.html', '.htm', '.css', '.json', '.js', '.mjs', '.cjs', '.py'].includes(extname(f.path).toLowerCase()))
175
+ .slice(0, 40)
176
+ .map((f) => ({ path: f.path }));
177
+ }
178
+
179
+ const 된것 = [];
180
+ const 탈난것 = [];
181
+ const 못한것 = [];
182
+
183
+ for (const x of 볼것) {
184
+ if (x.없음) { 탈난것.push({ 이름: x.없음, 탈: [x.왜] }); continue; }
185
+ const abs = x.path;
186
+ const 이름 = ctx.scope.show(abs);
187
+ const 확장 = extname(abs).toLowerCase();
188
+
189
+ if (내부살림(abs)) continue;
190
+ let buf;
191
+ try { buf = readFileSync(abs); } catch (e) { 탈난것.push({ 이름, 탈: [e.message] }); continue; }
192
+ if (looksBinary(buf)) { 못한것.push({ 이름, 왜: '바이너리 — 돌려 볼 수 없습니다' }); continue; }
193
+ const 글 = decode(buf).text;
194
+
195
+ // ── 돌려 볼 수 있는 것 ──────────────────────────────────────────
196
+ const 만들기 = 돌릴명령(확장);
197
+ if (만들기) {
198
+ const cmd = 만들기(abs);
199
+ try { checkCommand(cmd); }
200
+ catch { 못한것.push({ 이름, 왜: '안전 검사에 걸려 안 돌렸습니다' }); continue; }
201
+ const r = await 명령돌리기(cmd, 뿌리, 30000);
202
+ // 도구 자체가 이 컴퓨터에 없으면 '틀렸다' 가 아니라 '못 봤다' 이다.
203
+ // 파이썬이 안 깔린 PC 에서 py 파일을 전부 빨갛게 칠하면 아무도 안 믿는다.
204
+ if (!r.ok && /not recognized|command not found|찾을 수 없|No such file/i.test(r.out)) {
205
+ 못한것.push({ 이름, 왜: `${확장} 을 확인할 도구가 이 컴퓨터에 없습니다` });
206
+ } else if (r.ok) 된것.push({ 이름, 어떻게: 확장 === '.py' ? 'py_compile' : 'node --check' });
207
+ else 탈난것.push({ 이름, 탈: [짧게(r.out) || `종료코드 ${r.code}`] });
208
+ continue;
209
+ }
210
+
211
+ // ── 읽어서 보는 것 ──────────────────────────────────────────────
212
+ let 탈 = null;
213
+ if (확장 === '.html' || 확장 === '.htm') {
214
+ 탈 = html보기(글, { 있는파일: (곳) => existsSync(resolve(dirname(abs), 곳)) });
215
+ } else if (['.css', '.scss', '.less'].includes(확장)) {
216
+ 탈 = css보기(글);
217
+ } else if (확장 === '.json') {
218
+ 탈 = json보기(글);
219
+ }
220
+
221
+ if (탈 === null) { 못한것.push({ 이름, 왜: `${확장 || '확장자 없음'} 은 아직 확인할 줄 모릅니다` }); continue; }
222
+ if (탈.length) 탈난것.push({ 이름, 탈 });
223
+ else 된것.push({ 이름, 어떻게: 확장 === '.json' ? 'JSON 파싱' : '읽어서 확인' });
224
+ }
225
+
226
+ // ── 사실대로 적는다 ───────────────────────────────────────────────
227
+ const 줄 = [];
228
+
229
+ if (탈난것.length) {
230
+ 줄.push(`탈난 것 ${탈난것.length}개 — 여기부터 고쳐라:`);
231
+ for (const x of 탈난것) {
232
+ 줄.push(` ✗ ${x.이름}`);
233
+ for (const t of x.탈.slice(0, 5)) 줄.push(` ${t}`);
234
+ if (x.탈.length > 5) 줄.push(` … 그 밖에 ${x.탈.length - 5}개`);
235
+ }
236
+ 줄.push('');
237
+ }
238
+
239
+ if (된것.length) {
240
+ 줄.push(`확인된 것 ${된것.length}개:`);
241
+ for (const x of 된것.slice(0, 20)) 줄.push(` ✓ ${x.이름} (${x.어떻게})`);
242
+ if (된것.length > 20) 줄.push(` … 그 밖에 ${된것.length - 20}개`);
243
+ 줄.push('');
244
+ }
245
+
246
+ /*
247
+ * 못 확인한 것을 **반드시** 적는다.
248
+ *
249
+ * 이걸 빼면 "확인된 것 3개" 만 보이고, 사람도 모델도 그게 전부인 줄 안다.
250
+ * 그러면 이 도구는 확인해 주는 물건이 아니라 안심시켜 주는 물건이 된다.
251
+ */
252
+ if (못한것.length) {
253
+ 줄.push(`확인 못 한 것 ${못한것.length}개 (**됐다고 말하면 안 된다**):`);
254
+ for (const x of 못한것.slice(0, 15)) 줄.push(` ? ${x.이름} — ${x.왜}`);
255
+ if (못한것.length > 15) 줄.push(` … 그 밖에 ${못한것.length - 15}개`);
256
+ }
257
+
258
+ if (!줄.length) 줄.push('확인할 수 있는 파일이 없었습니다. 무엇을 확인할지 paths 로 알려 주세요.');
259
+
260
+ /*
261
+ * 이 프로젝트가 스스로 정해 둔 확인 방법이 있으면 **알려만 준다.**
262
+ *
263
+ * 여기서 직접 돌리지 않는다. 검사 스크립트는 무슨 짓이든 할 수 있고,
264
+ * 그걸 돌리는 길은 Bash 하나뿐이어야 한다 — 거기에만 승인 관문과 안전
265
+ * 검사가 걸려 있기 때문이다. 이 도구가 몰래 돌리면 strict 모드에서
266
+ * "물어보고 실행한다" 는 약속이 이 자리에서만 깨진다.
267
+ */
268
+ const 있는명령 = 프로젝트확인법(뿌리);
269
+ if (있는명령) {
270
+ 줄.push('');
271
+ 줄.push(`이 프로젝트에는 ${있는명령} 가 있습니다. 진짜로 도는지 보려면 Bash 로 돌려라.`);
272
+ }
273
+
274
+ const 다됐나 = !탈난것.length;
275
+ return {
276
+ content: 줄.join('\n').trim(),
277
+ summary: 탈난것.length
278
+ ? `탈 ${탈난것.length}개 · 확인 ${된것.length}개`
279
+ : `확인 ${된것.length}개` + (못한것.length ? ` · 못 확인 ${못한것.length}개` : ''),
280
+ // 루프가 '아직 안 끝났다' 고 알 수 있게. 탈이 났는데 성공으로 넘기면
281
+ // 다음 걸음에서 모델이 "다 됐습니다" 로 답을 맺는다.
282
+ failed: !다됐나,
283
+ 확인됨: 된것.length,
284
+ 탈: 탈난것.length,
285
+ 못확인: 못한것.length,
286
+ };
287
+ },
288
+ };
289
+
290
+ /**
291
+ * 이 프로젝트가 스스로 정해 둔 확인 방법.
292
+ *
293
+ * package.json 에 test 가 있으면 그것이 답이다. 없는 것을 지어내지는 않는다 —
294
+ * 없으면 null 이고, 그러면 파일별 확인만 한 것이 전부다.
295
+ * 돌리지는 않는다. 알려 주기만 한다 (위 머리말 참고).
296
+ */
297
+ function 프로젝트확인법(뿌리) {
298
+ const pkg = join(뿌리, 'package.json');
299
+ if (!existsSync(pkg)) return null;
300
+ try {
301
+ const j = JSON.parse(readFileSync(pkg, 'utf8'));
302
+ if (j.scripts?.test) return 'npm test';
303
+ if (j.scripts?.build) return 'npm run build';
304
+ } catch { /* 망가진 package.json 은 아래 파일 확인에서 잡힌다 */ }
305
+ return null;
306
+ }
@@ -13,6 +13,7 @@
13
13
  // · 받은 것은 글자만 뽑고 길이를 자른다.
14
14
  import { allowTemporarily, isOffline, isLocalHost } from '../safety/network.js';
15
15
  import { decode as decodeBytes } from './encoding.js';
16
+ import { 웹글자수 } from '../agent/budget.js';
16
17
 
17
18
  /**
18
19
  * 받아 온 바이트를 글로. 머리글에 적힌 인코딩이 있으면 그것부터 믿는다.
@@ -33,6 +34,62 @@ export const 방문기록 = [];
33
34
 
34
35
  const MAX_BYTES = 2 * 1024 * 1024; // 2MB 넘게 받지 않는다
35
36
 
37
+ /*
38
+ * ── 한 집에는 한 번에 하나씩 ────────────────────────────────────────────
39
+ *
40
+ * 모델은 도구를 **한꺼번에** 부른다. 화면에 `5개를 함께 돌립니다` 가 뜨는
41
+ * 그 자리다. 그런데 그 다섯이 전부 같은 API 면, 상대 쪽에서는 우리가
42
+ * 한순간에 다섯 번 두드린 것으로 보인다. 그래서 이런 게 나왔다:
43
+ *
44
+ * ◍ WebFetch(api.coingecko.com/…/volume_chart?…)
45
+ * └ HTTP 429 — api.coingecko.com/…/volume_chart?…
46
+ *
47
+ * 429 는 "틀렸다" 가 아니라 "천천히 해라" 다. 그런데 그냥 오류로 끝내 버리니
48
+ * 모델은 그 자료를 영영 못 받고, 사람은 왜 못 받았는지 모른다.
49
+ *
50
+ * 고치는 방법은 안 두드리는 것이 아니라 **줄을 세우는 것**이다. 집(origin)
51
+ * 마다 줄이 하나씩 있고, 다른 집끼리는 그대로 동시에 간다. 조금 느려지지만
52
+ * 받아 오기는 받아 온다 — 못 받는 것보다 늦게 받는 것이 낫다.
53
+ */
54
+ const 집줄 = new Map(); // origin → 그 집의 마지막 차례가 끝나는 약속
55
+ const 집간격 = 400; // 같은 집을 다시 두드리기 전에 쉬는 시간
56
+
57
+ const 잠깐 = (ms) => new Promise((r) => setTimeout(r, ms));
58
+
59
+ function 한집씩(origin, 일) {
60
+ const 앞사람 = 집줄.get(origin);
61
+ const 내차례 = (앞사람 ?? Promise.resolve())
62
+ .then(async () => { if (앞사람) await 잠깐(집간격); return 일(); });
63
+ // 다음 사람이 기다리는 것은 '내가 끝났다' 뿐이다. 내가 실패해도 줄은 넘어간다.
64
+ 집줄.set(origin, 내차례.then(() => {}, () => {}));
65
+ return 내차례;
66
+ }
67
+
68
+ /*
69
+ * 잠시 뒤에 다시 하면 되는 것들.
70
+ *
71
+ * 429 는 너무 자주, 502·503·504 는 상대가 잠깐 힘든 것이다. 셋 다 우리가
72
+ * 뭘 잘못한 게 아니라서, 조금 쉬었다 다시 부르면 대개 된다.
73
+ * 400·401·403·404 는 다시 불러도 같은 답이 온다 — 안 다시 한다.
74
+ */
75
+ const 다시할것 = new Set([429, 502, 503, 504]);
76
+ const 다시횟수 = 2;
77
+
78
+ /** `Retry-After` 를 초로. 초로 적히기도 하고 날짜로 적히기도 한다. */
79
+ function 얼마나쉬라나(머리, 회차) {
80
+ const v = String(머리 ?? '').trim();
81
+ let 초 = null;
82
+ if (/^\d+$/.test(v)) 초 = parseInt(v, 10);
83
+ else if (v) {
84
+ const t = Date.parse(v);
85
+ if (!Number.isNaN(t)) 초 = Math.ceil((t - Date.now()) / 1000);
86
+ }
87
+ // 상대가 안 알려 주면 우리가 정한다 — 1초, 2초.
88
+ if (초 == null || 초 < 0) 초 = 회차 + 1;
89
+ // 너무 오래 붙잡고 있지 않는다. 그건 멈춘 것과 화면상 구분이 안 된다.
90
+ return Math.min(초, 10);
91
+ }
92
+
36
93
  function 태그벗기기(html) {
37
94
  return html
38
95
  .replace(/<script[\s\S]*?<\/script>/gi, ' ')
@@ -54,9 +111,17 @@ function 태그벗기기(html) {
54
111
  * allowPrivate 는 검사용이다. 도구 스키마에 없으므로 모델은 이 값을 줄 수 없다.
55
112
  * (환경변수로 열어 두면 실제 사용 중에도 열려 버린다 — 그래서 인자로만 둔다)
56
113
  */
57
- export async function webFetch(args, { allowPrivate = false } = {}) {
114
+ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트 = null } = {}) {
58
115
  const raw = String(args?.url ?? '').trim();
59
- const max = Math.min(Math.max(parseInt(args?.max_chars, 10) || 20000, 1000), 100000);
116
+ /*
117
+ * 얼마나 가져올지는 **모델에 맞춰** 정한다 (agent/budget.js).
118
+ *
119
+ * 전에는 누구에게나 20,000자였다. 8k 모델에 20,000자를 부어 넣으면 그 한
120
+ * 번으로 창이 넘치고, 넘치면 접히고, 접히면 앞엣말을 잊는다 — 사람 눈에는
121
+ * "모델이 멍청해졌다" 로 보인다. 655k 모델에는 반대로 턱없이 적다.
122
+ */
123
+ const 기본 = 웹글자수(모델컨텍스트);
124
+ const max = Math.min(Math.max(parseInt(args?.max_chars, 10) || 기본, 1000), 120000);
60
125
 
61
126
  if (isOffline()) return { error: '오프라인 모드입니다 — 웹을 읽지 않습니다.' };
62
127
 
@@ -72,15 +137,45 @@ export async function webFetch(args, { allowPrivate = false } = {}) {
72
137
 
73
138
  const close = allowTemporarily(u.origin);
74
139
  try {
75
- const res = await fetch(u.href, {
76
- method: 'GET', // 보내는 건 없다
77
- redirect: 'follow',
78
- headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
79
- signal: AbortSignal.timeout(30000),
80
- });
81
- 방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
140
+ // 같은 집이면 줄을 서고, 잠시 뒤 되는 오류면 쉬었다 다시 부른다.
141
+ let res = null;
142
+ let 쉰시간 = 0;
143
+ for (let 회차 = 0; ; 회차++) {
144
+ res = await 한집씩(u.origin, () => fetch(u.href, {
145
+ method: 'GET', // 보내는 건 없다
146
+ redirect: 'follow',
147
+ headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
148
+ signal: AbortSignal.timeout(30000),
149
+ }));
150
+ 방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
151
+ if (res.ok || !다시할것.has(res.status) || 회차 >= 다시횟수) break;
152
+ const 초 = 얼마나쉬라나(res.headers.get('retry-after'), 회차);
153
+ 쉰시간 += 초;
154
+ await 잠깐(초 * 1000);
155
+ }
82
156
 
83
- if (!res.ok) return { error: `HTTP ${res.status} — ${u.href}` };
157
+ if (!res.ok) {
158
+ /*
159
+ * 오류를 그냥 번호로만 던지면 모델은 할 수 있는 게 없다. 실제로 그랬다 —
160
+ * `✗ HTTP 429` 만 보고 그 자료를 포기했다. 무엇을 하면 되는지 같이 준다.
161
+ */
162
+ const 집 = u.hostname;
163
+ if (res.status === 429) {
164
+ return { error: `HTTP 429 — ${집} 가 "너무 자주 부른다" 고 합니다.`
165
+ + `\n ${다시횟수}번 쉬었다 다시 불러 봤습니다(${쉰시간}초). 그래도 같습니다.`
166
+ + '\n 한꺼번에 여러 개를 부르지 말고 하나씩 부르거나, 잠시 뒤에 다시 해 보세요.'
167
+ + '\n 키가 있는 API 면 키를 붙인 주소를 쓰면 한도가 늘어납니다.' };
168
+ }
169
+ if (res.status === 404) return { error: `HTTP 404 — 그런 쪽이 없습니다: ${u.href}\n 주소를 다시 확인하세요.` };
170
+ if (res.status === 401 || res.status === 403) {
171
+ return { error: `HTTP ${res.status} — ${집} 가 접근을 막았습니다.\n 로그인이나 키가 있어야 하는 쪽입니다. 이 도구로는 못 읽습니다.` };
172
+ }
173
+ if (다시할것.has(res.status)) {
174
+ return { error: `HTTP ${res.status} — ${집} 가 지금 힘들어합니다.`
175
+ + `\n ${다시횟수}번 다시 불러 봤습니다(${쉰시간}초). 잠시 뒤에 다시 해 보세요.` };
176
+ }
177
+ return { error: `HTTP ${res.status} — ${u.href}` };
178
+ }
84
179
 
85
180
  const type = (res.headers.get('content-type') ?? '').toLowerCase();
86
181
  if (!/text|json|xml|javascript/.test(type)) {
@@ -112,12 +207,51 @@ export async function webFetch(args, { allowPrivate = false } = {}) {
112
207
  if (/html/.test(type)) text = 태그벗기기(text);
113
208
  }
114
209
  }
115
- const cut = text.length > max;
210
+ /*
211
+ * ── 자르기 ────────────────────────────────────────────────────────────
212
+ *
213
+ * JSON 을 글자 수로 자르면 **JSON 이 아니게 된다.** 모델은 `{"a":1,"b":[{"c"`
214
+ * 같은 것을 받고, 읽을 수 없으니 아무것도 못 한다. 그런데 화면에는
215
+ * `20,000자 (잘림)` 이라고만 떠서, 사람은 자료를 받은 줄 안다.
216
+ *
217
+ * 그래서 두 가지를 한다.
218
+ * 1) 자르기 전에 **눌러 본다.** API 응답은 대개 보기 좋게 들여쓰기가
219
+ * 돼 있는데, 그 공백이 절반을 먹는 일이 흔하다. 눌러서 들어가면
220
+ * 자를 필요가 아예 없어진다.
221
+ * 2) 그래도 넘치면 **깨진 JSON 이라고 분명히 말한다.** 그리고 무엇을
222
+ * 하면 되는지 — 범위를 좁히거나 max_chars 를 올리거나 — 같이 준다.
223
+ */
224
+ const json쪽 = /json/.test(type);
225
+ let 눌렀나 = false;
226
+ if (json쪽 && text.length > max) {
227
+ try {
228
+ const 눌린것 = JSON.stringify(JSON.parse(text));
229
+ if (눌린것.length < text.length) { text = 눌린것; 눌렀나 = true; }
230
+ } catch { /* JSON 이 아니거나 이미 잘려 온 것이다. 그냥 둔다 */ }
231
+ }
232
+
233
+ const 원래길이 = text.length;
234
+ const cut = 원래길이 > max;
116
235
  if (cut) text = text.slice(0, max);
117
236
 
237
+ let 꼬리 = '';
238
+ if (cut) {
239
+ const 남은것 = (원래길이 - max).toLocaleString();
240
+ 꼬리 = json쪽
241
+ ? `\n\n(여기서 잘렸습니다 — ${남은것}자가 더 있습니다.`
242
+ + '\n 잘린 JSON 은 그대로 읽을 수 없습니다. 다음 중 하나를 하세요:'
243
+ + '\n · 범위를 좁혀 다시 부른다 (per_page·ids·days 같은 조건을 붙인다)'
244
+ + `\n · 같은 주소를 max_chars 를 올려 다시 부른다 (지금 ${max.toLocaleString()}, 최대 100,000))`
245
+ : `\n\n(뒤쪽 ${남은것}자는 잘렸습니다. 더 필요하면 max_chars 를 올려 다시 부르세요.)`;
246
+ }
247
+
118
248
  return {
119
- content: `${u.href}\n${'─'.repeat(60)}\n${text}${cut ? `\n\n(뒤쪽 ${'약 ' + (buf.length - max).toLocaleString()}자는 잘렸습니다)` : ''}`,
120
- summary: `${text.length.toLocaleString()}자${cut ? ' (잘림)' : ''}`,
249
+ content: `${u.href}\n${'─'.repeat(60)}\n${text}${꼬리}`,
250
+ // 요약은 화면에 그대로 뜬다. 잘렸으면 **얼마나** 잘렸는지까지 보여야
251
+ // 사람이 "받은 줄 알았는데 아니었다" 를 안 겪는다.
252
+ summary: `${text.length.toLocaleString()}자`
253
+ + (눌렀나 ? ' (눌러 담음)' : '')
254
+ + (cut ? ` (잘림 — ${원래길이.toLocaleString()}자 중)` : ''),
121
255
  };
122
256
  } catch (err) {
123
257
  const m = String(err?.message ?? err);
@@ -132,15 +266,17 @@ export async function webFetch(args, { allowPrivate = false } = {}) {
132
266
  export const WEB_FETCH_TOOL = {
133
267
  schema: {
134
268
  name: 'WebFetch',
135
- description: '웹 페이지를 읽는다. 읽기만 하고 아무것도 보내지 않는다. 문서·오류 메시지·라이브러리 사용법을 확인할 때 쓴다. 이 컴퓨터·사내망 주소는 읽지 않는다.',
269
+ description: '웹 페이지를 읽는다. 읽기만 하고 아무것도 보내지 않는다. 문서·오류 메시지·라이브러리 사용법을 확인할 때 쓴다. 이 컴퓨터·사내망 주소는 읽지 않는다.'
270
+ + ' 같은 사이트를 한 번에 여러 개 부르면 차례로 나가므로 그만큼 느려진다 — 꼭 필요한 것만 부를 것.'
271
+ + ' JSON 이 잘리면 읽을 수 없으니, 잘렸다고 하면 조건을 붙여 범위를 좁히거나 max_chars 를 올려 다시 부를 것.',
136
272
  parameters: {
137
273
  type: 'object',
138
274
  properties: {
139
275
  url: { type: 'string', description: '읽을 주소 (http/https)' },
140
- max_chars: { type: 'number', description: '가져올 최대 글자 수. 기본 20000' },
276
+ max_chars: { type: 'number', description: '가져올 최대 글자 수. 주면 모델 크기에 맞춰 정해진다. 자료가 잘리면 여기를 올린다 (최대 120000)' },
141
277
  },
142
278
  required: ['url'],
143
279
  },
144
280
  },
145
- run: (args) => webFetch(args),
281
+ run: (args, ctx) => webFetch(args, { 모델컨텍스트: ctx?.모델컨텍스트 ?? null }),
146
282
  };
@@ -98,9 +98,34 @@ export function 프레임({ 글 = '', 커서: 커서자리 = null, 폭 = 80, 상
98
98
  // 자리가 모자라면 오른쪽(걸린 시간·중단 안내)을 버린다. 왼쪽이 본문이다.
99
99
  const 안 = 남 >= 2 ? `${왼쪽}${' '.repeat(남)}${오른쪽}` : 왼쪽;
100
100
  줄들.push(` ${c.gray('│')} ${안}${' '.repeat(Math.max(0, 일안쪽 - width(안)))} ${c.gray('│')}`);
101
+
102
+ /*
103
+ * 일하는 도중에 미리 쳐 두는 자리.
104
+ *
105
+ * 로컬 모델은 한 턴에 몇 분이 걸린다. 그 동안 생각난 것을 못 적어 두고
106
+ * 끝나기를 지켜보고 있어야 한다면, 기다리는 시간이 통째로 버려진다.
107
+ * 사실 readline 은 그 동안에도 글을 받아 두고 있었다 — 다만 화면에
108
+ * 아무것도 안 보여서, 쳐도 안 먹는 줄 알았을 뿐이다.
109
+ *
110
+ * 그래서 보여만 준다. 새로 받는 게 아니라 **이미 받고 있던 것**이다.
111
+ */
112
+ if (일감.대기) {
113
+ const 대기줄들 = 접어쓰기(일감.대기, 안쪽).slice(0, 3);
114
+ for (const [i, 한줄] of 대기줄들.entries()) {
115
+ const 표 = i === 0 ? c.hcyan('❯') : ' ';
116
+ 줄들.push(` ${c.gray('│')} ${표} ${한줄}${' '.repeat(Math.max(0, 안쪽 - width(한줄)))} ${c.gray('│')}`);
117
+ }
118
+ }
119
+
101
120
  줄들.push(c.gray(` ╰${가로}╯`));
102
- if (곁말) 줄들.push(` ${c.gray(곁말)}`);
103
- // 일하는 중에는 커서를 안 보여 준다 — 칠 자리가 아니다.
121
+ if (일감.대기) 줄들.push(` ${c.gray('Enter 를 치면 지금 일이 끝난 뒤에 보냅니다')}`);
122
+ else if (곁말) 줄들.push(` ${c.gray(곁말)}`);
123
+
124
+ // 커서는 미리 치는 중일 때만 보여 준다. 아니면 칠 자리가 아니다.
125
+ if (일감.대기) {
126
+ const 마지막 = 접어쓰기(일감.대기, 안쪽).slice(0, 3).at(-1) ?? '';
127
+ return { 줄들, 커서: { 위: 2, 열: Math.min(앞머리 + 1 + width(마지막), 칸 - 1) } };
128
+ }
104
129
  return { 줄들, 커서: { 위: (곁말 ? 1 : 0), 열: 1 } };
105
130
  }
106
131
 
@@ -204,8 +229,38 @@ export class InputBox {
204
229
  this.일감 = null;
205
230
  this.박자 = null; // 돌아가는 표시를 돌리는 시계
206
231
  this.틱 = 0;
232
+
233
+ /*
234
+ * 대화가 **줄 중간**에 멈춰 있나.
235
+ *
236
+ * 이걸 안 들고 있으면 답이 잘려 보인다. 실제로 나갔던 결함이다.
237
+ *
238
+ * 모델이 답을 흘리는 동안 글은 토막토막 온다. 한 토막을 찍으면 커서는
239
+ * 그 줄 한가운데에 선다. 그런데 돌아가는 표시는 90ms 마다 제 시계로
240
+ * 상자를 다시 그리므로, 그 순간 **상자가 답 줄에 얹혀 그려진다.**
241
+ *
242
+ * ▌ **계획표** ▏myproject · qwen… ▏ ▰▱▱▱▱ 13% ▏ ◎ 종합
243
+ *
244
+ * 그 다음 지우기는 `\r` 로 1칸째에 간 뒤 `\x1b[K` 로 줄을 지운다.
245
+ * 상자만 지우려던 것인데 **같은 줄에 있던 답까지 같이 지워진다.**
246
+ * 사람 눈에는 답의 앞부분이 사라진 것으로 보인다 — `**계획표**` 가
247
+ * `표**` 만 남는 식이다. 글자가 안 온 게 아니라 지워진 것이라,
248
+ * 모델을 아무리 바꿔도 똑같이 나온다.
249
+ *
250
+ * 그래서 규칙을 하나로 못 박는다 — **줄 중간에서는 아무것도 안 그린다.**
251
+ */
252
+ this.줄중간 = false;
207
253
  }
208
254
 
255
+ /** 대화 글이 줄 중간까지 쓰였다. 줄이 끝날 때까지 상자를 안 세운다. */
256
+ 줄중간표시() {
257
+ this.지우기();
258
+ this.줄중간 = true;
259
+ }
260
+
261
+ /** 줄이 끝났다. 이제 아래에 상자를 세워도 안전하다. */
262
+ 줄끝남() { this.줄중간 = false; }
263
+
209
264
  /**
210
265
  * 일하기 시작. 상자 안엣것이 입력칸에서 '돌아가는 중' 으로 바뀐다.
211
266
  *
@@ -214,7 +269,8 @@ export class InputBox {
214
269
  */
215
270
  일시작(session, 갈래 = '기본') {
216
271
  this.session = session ?? this.session;
217
- this.일감 = { 갈래, 시작: Date.now(), 곁정보: '' };
272
+ // 대기 = 일하는 도중에 미리 치고 있는 글, 예약수 = 이미 Enter 를 친 것들.
273
+ this.일감 = { 갈래, 시작: Date.now(), 곁정보: '', 대기: '', 예약수: 0 };
218
274
  this.틱 = 0;
219
275
  this.일그리기();
220
276
  if (this.박자) clearInterval(this.박자);
@@ -239,15 +295,39 @@ export class InputBox {
239
295
  this.지우기();
240
296
  }
241
297
 
242
- 일그리기() {
298
+ /**
299
+ * 일하는 도중에 사람이 치고 있는 글 · 예약해 둔 개수.
300
+ *
301
+ * 답이 흘러나오는 동안(줄 중간)에는 안 그려진다 — 그건 일그리기() 가
302
+ * 알아서 넘긴다. 글은 readline 이 계속 들고 있으므로 잃지 않는다.
303
+ */
304
+ 대기갱신(글 = '', 예약수 = 0) {
243
305
  if (!this.일감) return;
306
+ if (this.일감.대기 === 글 && this.일감.예약수 === 예약수) return;
307
+ this.일감.대기 = 글;
308
+ this.일감.예약수 = 예약수;
309
+ this.일그리기();
310
+ }
311
+
312
+ 일그리기() {
313
+ // 줄 중간이면 안 그린다. 여기가 시계로 저절로 도는 유일한 자리라,
314
+ // 답이 흘러나오는 동안 상자가 답 줄에 얹히는 것을 여기서 막는다.
315
+ if (!this.일감 || this.줄중간) return;
244
316
  const 지난 = Date.now() - this.일감.시작;
245
317
  const 갈래 = 지난 >= 느긋해질때 ? '느긋' : this.일감.갈래;
246
318
  const 회차 = Math.floor((this.틱 * 돌림주기) / 문구주기);
247
319
  this.그리기(this.session, '', 0, {
248
320
  돌림: 돌림틀[this.틱 % 돌림틀.length],
249
321
  말: 문구고르기(갈래, 회차),
250
- 곁: [걸린시간(지난), this.일감.곁정보, 'Ctrl+C 중단'].filter(Boolean).join(' · '),
322
+ 곁: [
323
+ 걸린시간(지난),
324
+ this.일감.곁정보,
325
+ // 예약해 둔 것이 있으면 잊지 않게 세어 준다. 몇 분짜리 일이 끝난 뒤
326
+ // 엉뚱한 말이 저절로 나가는 것처럼 보이면 안 된다.
327
+ this.일감.예약수 ? `${this.일감.예약수}건 예약됨` : '',
328
+ 'Ctrl+C 중단',
329
+ ].filter(Boolean).join(' · '),
330
+ 대기: this.일감.대기,
251
331
  });
252
332
  }
253
333
 
@@ -277,6 +357,18 @@ export class InputBox {
277
357
  */
278
358
  그리기(session, 글 = '', 커서자리 = null, 일감 = null, 추천 = []) {
279
359
  this.session = session ?? this.session;
360
+ /*
361
+ * 줄 중간에서는 절대 안 그린다 — 여기가 그 규칙을 지키는 마지막 자리다.
362
+ *
363
+ * 시계로 도는 일그리기() 는 아예 안 그리고 넘어가지만, 사람이 입력을
364
+ * 기다리는 자리(입력자리·입력갱신)는 그리긴 그려야 한다. 그때는 줄을
365
+ * 먼저 끝내고 그린다. 안 그러면 상자가 대화 줄에 얹히고, 다음 지우기가
366
+ * 그 줄을 통째로 지운다.
367
+ */
368
+ if (this.줄중간) {
369
+ process.stdout.write('\n');
370
+ this.줄중간 = false;
371
+ }
280
372
  this.지우기();
281
373
 
282
374
  const { 줄들, 커서: 자리 } = 프레임({