deel-local-cli 1.0.1 → 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
  };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 승인 방식을 사람 말로.
3
+ *
4
+ * ── 왜 따로 두나 ────────────────────────────────────────────────────────
5
+ *
6
+ * 상태줄에 `auto` 라고만 떠 있었다. 그 옆에는 `종합` 과 `medium·절약` 이 나란히
7
+ * 있어서, 셋 다 그냥 '모드' 처럼 보였다. 그중 하나가 **묻지 않고 파일을 고친다**
8
+ * 는 뜻이라는 것은 화면 어디에도 없었다.
9
+ *
10
+ * 이건 꾸미기가 아니라 안전 표시다. 지금 이 순간 내 파일이 물어보고 바뀌는지
11
+ * 안 물어보고 바뀌는지는, 화면을 흘깃 봐서 **바로** 알아야 하는 한 가지다.
12
+ *
13
+ * 그래서 세 가지를 준다 — 눈에 띄는 글자, 사람 말 이름, 무엇을 묻는지 한 줄.
14
+ */
15
+ import { c } from './ansi.js';
16
+
17
+ export const 승인 = {
18
+ auto: {
19
+ 글자: '⏵⏵',
20
+ 이름: '자동 승인',
21
+ 짧은이름: '자동',
22
+ 한줄: '안 묻고 고칩니다. 되돌리기(/undo)가 안전망입니다',
23
+ 색: c.hyellow,
24
+ },
25
+ confirm: {
26
+ 글자: '⏵',
27
+ 이름: '위험만 확인',
28
+ 짧은이름: '위험만',
29
+ 한줄: '되돌릴 수 없는 명령만 물어봅니다. 파일은 안 묻고 고칩니다',
30
+ 색: c.white,
31
+ },
32
+ strict: {
33
+ 글자: '⏸',
34
+ 이름: '모두 확인',
35
+ 짧은이름: '모두',
36
+ 한줄: '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
37
+ 색: c.hgreen,
38
+ },
39
+ };
40
+
41
+ export const 차례 = ['auto', 'confirm', 'strict'];
42
+ export const 기본 = 'auto';
43
+
44
+ export function 고르기(이름) {
45
+ return 승인[이름] ?? 승인[기본];
46
+ }
47
+
48
+ /**
49
+ * 다음 승인 방식. Shift+Tab 이 이걸로 돈다.
50
+ *
51
+ * 자동 → 위험만 → 모두 → 자동. **느슨한 쪽에서 조이는 쪽으로** 도는 차례다 —
52
+ * 한 번 누를 때마다 더 많이 물어보게 되니, 잘못 눌러도 위험해지지 않는다.
53
+ * 반대로 돌면 Shift+Tab 한 번에 '안 묻고 고침' 으로 떨어진다.
54
+ */
55
+ export function 다음(이름) {
56
+ // 모르는 값이면 화면에 떠 있는 것(고르기가 내주는 기본)을 기준으로 삼는다.
57
+ // 안 그러면 보이는 것과 한 칸 어긋나서, 눌러도 안 바뀐 것처럼 보인다.
58
+ const i = 차례.indexOf(승인[이름] ? 이름 : 기본);
59
+ return 차례[(i + 1) % 차례.length];
60
+ }
61
+
62
+ /** 상태줄에 넣을 조각. 짧게=true 면 좁은 창용. */
63
+ export function 표시(이름, { 짧게 = false } = {}) {
64
+ const m = 고르기(이름);
65
+ return `${m.색(m.글자)} ${m.색(짧게 ? m.짧은이름 : m.이름)}`;
66
+ }