deel-local-cli 0.5.0 → 0.9.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,118 @@
1
+ // 엑셀 파일을 읽어 표로 돌려준다. 두 갈래를 여기서 고른다.
2
+ //
3
+ // 보통 xlsx/xlsm → 직접 푼다. 의존성도 엑셀도 필요 없다. 빠르다.
4
+ // 암호 걸린 것·옛 xls → 엑셀을 시킨다. 그것 말고는 방법이 없다.
5
+ //
6
+ // 부르는 쪽은 어느 갈래인지 몰라도 된다. 다만 암호가 필요할 때는 물어볼 수
7
+ // 있어야 하므로, 물어보는 방법을 받아 온다(askPassword).
8
+ import { readFileSync, statSync } from 'node:fs';
9
+ import { extname } from 'node:path';
10
+ import { readXlsx, toCsv, looksXlsx, looksOle } from './xlsx.js';
11
+ import { excelToTables, canUseExcel } from './excel-com.js';
12
+
13
+ const 엑셀확장자 = new Set(['.xlsx', '.xlsm', '.xltx', '.xltm', '.xls', '.xlt']);
14
+
15
+ /** 이 경로가 엑셀 파일인가. 확장자로만 본다 — 내용은 열어 봐야 안다. */
16
+ export function isExcelPath(p) {
17
+ return 엑셀확장자.has(extname(String(p ?? '')).toLowerCase());
18
+ }
19
+
20
+ /**
21
+ * 엑셀 파일을 시트별 표로 읽는다.
22
+ *
23
+ * @param {string} abs 절대 경로
24
+ * @param {{ askPassword?: (안내:string)=>Promise<string|null>, maxTries?: number }} opt
25
+ * askPassword 는 사용자에게 암호를 물어보는 함수다. 안 주면 못 묻는다.
26
+ * 받은 암호는 이 함수 안에서만 산다 — 어디에도 안 적고, 돌려주지도 않는다.
27
+ * @returns {Promise<{ ok:boolean, sheets?:Array, how?:string, notes?:string[], error?:string }>}
28
+ */
29
+ export async function readExcel(abs, { askPassword = null, maxTries = 3 } = {}) {
30
+ const buf = readFileSync(abs);
31
+
32
+ if (looksXlsx(buf)) {
33
+ try {
34
+ const { sheets, notes } = readXlsx(buf);
35
+ return { ok: true, sheets, notes, how: '직접 풀었습니다' };
36
+ } catch (err) {
37
+ // zip 이긴 한데 엑셀이 아니거나 모양이 다르다. 엑셀이 있으면 맡겨 본다.
38
+ if (!canUseExcel()) return { ok: false, error: `${err.message}` };
39
+ const r = await excelToTables(abs, { password: '' });
40
+ if (r.ok) return { ok: true, sheets: r.sheets, notes: [], how: '엑셀에게 맡겼습니다' };
41
+ return { ok: false, error: `${err.message} (엑셀에게도 맡겨 봤지만: ${r.message})` };
42
+ }
43
+ }
44
+
45
+ if (!looksOle(buf)) {
46
+ return { ok: false, error: '엑셀 파일이 아닙니다 — 앞머리가 xlsx(zip) 도 xls(OLE) 도 아닙니다' };
47
+ }
48
+
49
+ // 여기부터는 엑셀이 있어야 한다.
50
+ if (!canUseExcel()) {
51
+ return {
52
+ ok: false,
53
+ error: '암호가 걸렸거나 옛 형식(.xls) 인 엑셀 파일입니다. 이건 엑셀이 설치된 윈도우에서만 읽을 수 있습니다.',
54
+ };
55
+ }
56
+
57
+ // 암호 없이 먼저 해 본다. 옛 .xls 는 암호가 없는 경우가 대부분이다.
58
+ let r = await excelToTables(abs, { password: '' });
59
+ if (r.ok) return { ok: true, sheets: r.sheets, notes: [], how: '엑셀에게 맡겼습니다' };
60
+
61
+ if (r.reason !== 'password') {
62
+ return { ok: false, error: 붙임(r) };
63
+ }
64
+
65
+ if (!askPassword) {
66
+ return { ok: false, error: '암호가 걸린 엑셀 파일입니다. 암호를 물어볼 수 없는 자리라 못 엽니다 — 대화창에서 다시 시도해 주세요.' };
67
+ }
68
+
69
+ for (let i = 1; i <= maxTries; i++) {
70
+ const 안내 = i === 1
71
+ ? '이 엑셀 파일은 암호가 걸려 있습니다. 암호를 넣어 주세요'
72
+ : `암호가 맞지 않습니다. 다시 넣어 주세요 (${i}/${maxTries})`;
73
+ // 받은 즉시 쓰고 버린다. 변수 밖으로 안 나간다.
74
+ const pw = await askPassword(안내);
75
+ if (pw === null || pw === '') return { ok: false, error: '암호를 넣지 않아 열지 않았습니다.' };
76
+ r = await excelToTables(abs, { password: pw });
77
+ if (r.ok) return { ok: true, sheets: r.sheets, notes: [], how: '엑셀에게 맡겼습니다 (암호 씀)' };
78
+ if (r.reason !== 'password') return { ok: false, error: 붙임(r) };
79
+ }
80
+ return { ok: false, error: `암호가 ${maxTries}번 다 맞지 않았습니다.` };
81
+ }
82
+
83
+ function 붙임(r) {
84
+ if (r.reason === 'busy') return `${r.message}`;
85
+ if (r.reason === 'timeout') return `${r.message}`;
86
+ if (r.reason === 'no-excel') return `${r.message}`;
87
+ return r.message ?? '엑셀 파일을 읽지 못했습니다';
88
+ }
89
+
90
+ /**
91
+ * 읽은 표를 모델에게 줄 글로 만든다.
92
+ *
93
+ * CSV 로 준다. 표를 그리는 것보다 글자를 덜 먹고, 모델이 이미 잘 아는 모양이다.
94
+ * 시트가 여럿이면 어디서 어디까지가 어느 시트인지 표시를 넣는다.
95
+ */
96
+ export function toText(sheets, { maxRows = 500, maxChars = 60000 } = {}) {
97
+ const 조각 = [];
98
+ const 잘림 = [];
99
+ for (const s of sheets) {
100
+ const 넘침 = s.rows.length > maxRows;
101
+ const rows = 넘침 ? s.rows.slice(0, maxRows) : s.rows;
102
+ if (넘침) 잘림.push(`${s.name}: ${s.rows.length}줄 중 ${maxRows}줄`);
103
+ const 머리 = `### 시트: ${s.name}${s.hidden ? ' (숨김)' : ''} — ${s.rows.length}줄 × ${s.rows[0]?.length ?? 0}칸`;
104
+ 조각.push(`${머리}\n${toCsv(rows)}`);
105
+ }
106
+ let text = 조각.join('\n\n');
107
+ if (text.length > maxChars) {
108
+ text = `${text.slice(0, maxChars)}\n… (너무 길어 여기서 자릅니다)`;
109
+ 잘림.push(`전체 길이 ${maxChars}자에서 자름`);
110
+ }
111
+ return { text, 잘림 };
112
+ }
113
+
114
+ /** 사람에게 보여줄 한 줄 요약. */
115
+ export function summarize(sheets, how) {
116
+ const 줄 = sheets.reduce((a, s) => a + s.rows.length, 0);
117
+ return `시트 ${sheets.length}개 · ${줄}줄 · ${how}`;
118
+ }
@@ -1,6 +1,36 @@
1
1
  // 파일 훑기와 glob 매칭. 외부 패키지 없이 직접 구현한다.
2
- import { readdirSync, statSync, readFileSync } from 'node:fs';
2
+ import { readdirSync, statSync, readFileSync, mkdirSync, copyFileSync } from 'node:fs';
3
3
  import { join, relative, sep } from 'node:path';
4
+ import { decode, looksBinary } from './encoding.js';
5
+
6
+ /**
7
+ * 폴더를 통째로 옮겨 담는다.
8
+ *
9
+ * fs.cpSync 를 안 쓰는 이유가 둘이다.
10
+ *
11
+ * 1) Node 가 실험 기능으로 표시한 API 다. 판마다 동작이 다르고, 윈도우에서
12
+ * 프로세스가 통째로 죽는 것을 실제로 겪었다 — 검사가 아무 말도 없이
13
+ * 0xC0000409 로 끝났다. 배포되는 코드가 실험 API 에 매달려 있으면 안 된다.
14
+ *
15
+ * 2) cpSync 는 심볼릭 링크를 따라간다. 남이 준 플러그인 폴더에 바깥을 가리키는
16
+ * 링크가 하나 있으면 그것까지 딸려 들어온다. 여기서는 링크를 건너뛴다 —
17
+ * 플러그인은 제 폴더 안의 글 파일이면 충분하다.
18
+ *
19
+ * 하는 일이 뻔해서 읽으면 다 보인다. 그게 이 프로젝트가 원하는 것이다.
20
+ */
21
+ export function copyDir(from, to, { skipped = [] } = {}) {
22
+ mkdirSync(to, { recursive: true });
23
+ for (const e of readdirSync(from, { withFileTypes: true })) {
24
+ const s = join(from, e.name);
25
+ const d = join(to, e.name);
26
+ if (e.isSymbolicLink()) { skipped.push(s); continue; }
27
+ if (e.isDirectory()) copyDir(s, d, { skipped });
28
+ else if (e.isFile()) copyFileSync(s, d);
29
+ // 그 밖(장치·소켓 같은 것)은 건너뛴다. 플러그인에 있을 이유가 없다.
30
+ else skipped.push(s);
31
+ }
32
+ return { skipped };
33
+ }
4
34
 
5
35
  export const SKIP_DIRS = new Set([
6
36
  'node_modules', '.git', '.deel', '.svn', '.hg', 'dist', 'build',
@@ -79,12 +109,25 @@ export function isText(path) {
79
109
  } catch { return false; }
80
110
  }
81
111
 
82
- export function readText(path) {
112
+ /**
113
+ * 글 파일을 읽는다. 무엇으로 쓰여 있든 알아보고 읽는다.
114
+ *
115
+ * 두 번째 값으로 '무엇으로 읽었는지' 를 같이 준다. 부르는 쪽이 그걸 기억해 뒀다가
116
+ * 되돌려 쓸 때 같은 인코딩으로 넣어야 한다. 안 그러면 사내 CP949 문서를 한 번
117
+ * 고치는 것만으로 UTF-8 로 바뀌어 버린다.
118
+ */
119
+ export function readTextFull(path) {
83
120
  const buf = readFileSync(path);
84
- if (buf.subarray(0, 8000).includes(0)) {
121
+ if (looksBinary(buf)) {
85
122
  const err = new Error('바이너리 파일입니다 — 텍스트로 읽을 수 없습니다');
86
123
  err.binary = true;
87
124
  throw err;
88
125
  }
89
- return buf.toString('utf8');
126
+ const r = decode(buf);
127
+ return { text: r.text, encoding: r.encoding, sure: r.sure, bom: r.bom ?? 0 };
128
+ }
129
+
130
+ /** 글만 필요할 때. 예전 부르던 자리를 그대로 두기 위해 남긴다. */
131
+ export function readText(path) {
132
+ return readTextFull(path).text;
90
133
  }
@@ -3,10 +3,15 @@
3
3
  import { writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
- import { globToRegex, walk, readText, SKIP_DIRS } from './fsutil.js';
6
+ import { globToRegex, walk, readText, readTextFull, SKIP_DIRS } from './fsutil.js';
7
+ import { encode, label as encLabel, decode as decodeBytes, consoleCodepage } from './encoding.js';
7
8
  import { checkCommand } from '../safety/guard.js';
8
9
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
9
10
  import { loadSkill } from '../skills/discover.js';
11
+ import { WEB_FETCH_TOOL } from './webfetch.js';
12
+ import { TODO_TOOL } from './todo.js';
13
+ import { allow as allowedIn } from '../agent/modes.js';
14
+ import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
10
15
 
11
16
  const MAX_READ_LINES = 2000;
12
17
  const MAX_OUT = 30000;
@@ -16,11 +21,43 @@ function clip(s, n = MAX_OUT) {
16
21
  return t.length > n ? t.slice(0, n) + `\n… (${t.length - n}자 잘림)` : t;
17
22
  }
18
23
 
24
+ // 엑셀 파일에 쓰려 할 때 하는 말. 왜 안 되는지와, 그럼 어떻게 하는지를 같이 준다.
25
+ function 엑셀은못고침(보인이름) {
26
+ return `엑셀 파일은 이 도구로 고칠 수 없습니다: ${보인이름}\n`
27
+ + ' 읽기만 됩니다 (CSV 로 바꿔서 보여줍니다). 서식·수식·차트가 든 파일을\n'
28
+ + ' CSV 로 왕복시키면 반드시 뭔가 잃기 때문입니다.\n'
29
+ + ' 값을 바꿔야 한다면 CSV 로 따로 내보내 작업하거나, 엑셀에서 직접 고치세요.';
30
+ }
31
+
32
+ /**
33
+ * 엑셀 파일을 표로 읽어 돌려준다.
34
+ *
35
+ * 되돌려 쓰지 않으므로 ctx.enc 에 인코딩을 적지 않는다 — 적어 두면 나중에
36
+ * Edit 이 '이 파일 고칠 수 있다' 고 오해한다. ctx.seen 에도 안 넣는 이유가 같다.
37
+ * 엑셀 파일은 이 도구로 고치는 물건이 아니다.
38
+ */
39
+ async function 엑셀읽기(abs, args, ctx) {
40
+ const r = await readExcel(abs, { askPassword: ctx.askPassword ?? null });
41
+ if (!r.ok) return { error: r.error };
42
+
43
+ const { text, 잘림 } = excelText(r.sheets);
44
+ const 말 = [...(r.notes ?? []), ...잘림];
45
+ return {
46
+ content: clip(
47
+ `${text}\n\n(엑셀 파일을 CSV 로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
48
+ + (말.length ? `\n(${말.join(' · ')})` : ''),
49
+ ),
50
+ summary: excelSummary(r.sheets, r.how) + (잘림.length ? ` · 일부만` : ''),
51
+ };
52
+ }
53
+
19
54
  export const TOOLS = {
20
55
  Read: {
21
56
  schema: {
22
57
  name: 'Read',
23
- description: '파일 하나를 읽는다. 줄 번호가 붙어 돌아온다. 고치기 전에는 반드시 먼저 읽어야 한다.',
58
+ description: '파일 하나를 읽는다. 줄 번호가 붙어 돌아온다. 고치기 전에는 반드시 먼저 읽어야 한다.'
59
+ + ' 엑셀 파일(.xlsx/.xlsm/.xls)도 그대로 읽을 수 있다 — 시트별 CSV 로 바꿔서 돌려준다.'
60
+ + ' 사용자에게 CSV 로 내보내 달라고 할 필요가 없다. 다만 엑셀 파일은 읽기만 되고 고칠 수는 없다.',
24
61
  parameters: {
25
62
  type: 'object',
26
63
  properties: {
@@ -35,7 +72,17 @@ export const TOOLS = {
35
72
  const abs = ctx.scope.resolve(args.file_path);
36
73
  if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
37
74
  if (statSync(abs).isDirectory()) return { error: `폴더입니다. Glob 을 쓰세요: ${args.file_path}` };
38
- const text = readText(abs);
75
+
76
+ // 엑셀 파일은 글이 아니라 압축 꾸러미다. 그냥 읽으면 '바이너리' 로 끝난다.
77
+ // 여기서 표로 바꿔 돌려준다 — 사람이 손으로 CSV 로 내보낼 일이 없게.
78
+ if (isExcelPath(abs)) return 엑셀읽기(abs, args, ctx);
79
+
80
+ const 읽음 = readTextFull(abs);
81
+ // 무엇으로 읽었는지 기억해 둔다. 나중에 고칠 때 같은 것으로 되돌려 써야 한다.
82
+ // 안 그러면 사내 CP949 문서가 한 번 고치는 것만으로 UTF-8 이 되어 버린다.
83
+ ctx.enc = ctx.enc ?? new Map();
84
+ ctx.enc.set(abs, 읽음.encoding);
85
+ const text = 읽음.text;
39
86
  const lines = text.split('\n');
40
87
  const start = Math.max(0, (args.offset ?? 1) - 1);
41
88
  const count = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES);
@@ -43,7 +90,11 @@ export const TOOLS = {
43
90
  const body = slice.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
44
91
  const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
45
92
  ctx.seen.add(abs);
46
- return { content: clip(body + more), summary: `${lines.length}줄` };
93
+ const 별난인코딩 = 읽음.encoding !== 'utf-8';
94
+ return {
95
+ content: clip(body + more),
96
+ summary: `${lines.length}줄` + (별난인코딩 ? ` · ${encLabel(읽음.encoding)}` : ''),
97
+ };
47
98
  },
48
99
  },
49
100
 
@@ -63,13 +114,33 @@ export const TOOLS = {
63
114
  run(args, ctx) {
64
115
  const abs = ctx.scope.resolve(args.file_path);
65
116
  if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
117
+ // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
118
+ // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
119
+ if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
66
120
  ctx.history.snapshot(abs, 'Write');
67
121
  mkdirSync(dirname(abs), { recursive: true });
68
122
  const existed = existsSync(abs);
69
- writeFileSync(abs, args.content, 'utf8');
123
+
124
+ // 원래 있던 파일이면 그 파일이 쓰던 인코딩으로 되돌려 쓴다.
125
+ // 새 파일이면 UTF-8 이다 — 요즘 만드는 파일까지 옛 인코딩으로 둘 이유가 없다.
126
+ const 원래 = existed ? (ctx.enc?.get(abs) ?? 'utf-8') : 'utf-8';
127
+ const 만든것 = encode(args.content, 원래);
128
+ if (만든것.lost.length) {
129
+ return {
130
+ error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 그 인코딩에 없는 글자가 있습니다: `
131
+ + `${만든것.lost.slice(0, 8).join(' ')}\n`
132
+ + ` 그대로 쓰면 그 글자들이 뭉개집니다. 해당 글자를 빼거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
133
+ };
134
+ }
135
+ writeFileSync(abs, 만든것.buf);
70
136
  ctx.seen.add(abs);
71
137
  const n = args.content.split('\n').length;
72
- return { content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄)`, summary: `${n}줄`, changed: abs };
138
+ const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
139
+ return {
140
+ content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄${표기})`,
141
+ summary: `${n}줄${표기}`,
142
+ changed: abs,
143
+ };
73
144
  },
74
145
  },
75
146
 
@@ -91,10 +162,14 @@ export const TOOLS = {
91
162
  run(args, ctx) {
92
163
  const abs = ctx.scope.resolve(args.file_path);
93
164
  if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
165
+ // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
166
+ // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
167
+ if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
94
168
  if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
95
169
  if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
96
170
 
97
- const text = readText(abs);
171
+ const 읽음 = readTextFull(abs);
172
+ const text = 읽음.text;
98
173
  const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
99
174
 
100
175
  if (!m.ok) {
@@ -110,13 +185,24 @@ export const TOOLS = {
110
185
  ctx.history.snapshot(abs, 'Edit');
111
186
  const next = applySpans(text, m.spans, (matched) =>
112
187
  m.tier === 'exact' ? args.new_string : reindent(args.new_string, matched, args.old_string));
113
- writeFileSync(abs, next, 'utf8');
188
+
189
+ // 읽은 그 인코딩으로 되돌려 쓴다.
190
+ const 만든것 = encode(next, 읽음.encoding);
191
+ if (만든것.lost.length) {
192
+ return {
193
+ error: `이 파일은 ${encLabel(읽음.encoding)} 로 되어 있는데, 그 인코딩에 없는 글자를 넣으려 합니다: `
194
+ + `${만든것.lost.slice(0, 8).join(' ')}\n`
195
+ + ` 그대로 쓰면 그 글자들이 뭉개집니다. 다른 표현을 쓰거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
196
+ };
197
+ }
198
+ writeFileSync(abs, 만든것.buf);
114
199
 
115
200
  const n = m.spans.length;
116
201
  const how = m.tier === 'exact' ? '' : ` · ${TIER_LABELS[m.tier]}`;
202
+ const 표기 = 읽음.encoding !== 'utf-8' ? ` · ${encLabel(읽음.encoding)}` : '';
117
203
  return {
118
- content: `고침: ${ctx.scope.show(abs)} (${n}군데${how})`,
119
- summary: `${n}군데${how}`,
204
+ content: `고침: ${ctx.scope.show(abs)} (${n}군데${how}${표기})`,
205
+ summary: `${n}군데${how}${표기}`,
120
206
  changed: abs,
121
207
  tier: m.tier,
122
208
  };
@@ -274,13 +360,26 @@ export const TOOLS = {
274
360
  : { file: '/bin/sh', args: ['-c', cmd] };
275
361
 
276
362
  return new Promise((done) => {
363
+ // 출력은 글자가 아니라 바이트로 받는다.
364
+ //
365
+ // 윈도우 명령창은 UTF-8 이 아니다. 한국어 윈도우는 CP949 로 뱉는다.
366
+ // 이걸 utf8 이라고 하고 받으면 한글이 통째로 깨진다 — '파싱 성공' 이
367
+ // '�Ľ� ����' 이 된다. 바이트로 받아 이 컴퓨터가 쓰는 것으로 해독한다.
277
368
  execFile(shell.file, shell.args, {
278
369
  cwd: ctx.scope.root,
279
370
  timeout: args.timeout ?? 120000,
280
371
  maxBuffer: 8 * 1024 * 1024,
281
372
  windowsHide: true,
282
- encoding: 'utf8',
283
- }, (err, stdout, stderr) => {
373
+ encoding: 'buffer',
374
+ }, (err, stdoutBuf, stderrBuf) => {
375
+ const 콘솔 = consoleCodepage() === 65001 ? 'utf-8' : null;
376
+ const 풀기 = (b) => {
377
+ if (!b || !b.length) return '';
378
+ // UTF-8 로 말이 되면 UTF-8 이다. 아니면 이 컴퓨터 콘솔 인코딩으로 본다.
379
+ return decodeBytes(Buffer.from(b), { fallback: 콘솔 }).text;
380
+ };
381
+ const stdout = 풀기(stdoutBuf);
382
+ const stderr = 풀기(stderrBuf);
284
383
  const out = [stdout, stderr].filter(Boolean).join('\n').trim();
285
384
  if (err && err.killed) return done({ error: `시간 초과로 중단됨 (${args.timeout ?? 120000}ms)`, content: clip(out) });
286
385
  const code = err?.code ?? 0;
@@ -293,12 +392,27 @@ export const TOOLS = {
293
392
  });
294
393
  },
295
394
  },
395
+
396
+ // 웹 읽기는 '데이터가 나가는 길' 과 분리돼 있다 — webfetch.js 머리말 참고.
397
+ WebFetch: WEB_FETCH_TOOL,
398
+
399
+ // 긴 작업에서 시킨 것을 빠뜨리지 않게 붙잡아 두는 목록.
400
+ TodoWrite: TODO_TOOL,
296
401
  };
297
402
 
298
403
  // 모델에게 넘길 도구 정의 목록.
299
404
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
300
- export function toolSchemas(names = null, { hasSkills = false } = {}) {
301
- const list = names ?? Object.keys(TOOLS).filter((n) => n !== 'Skill' || hasSkills);
405
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null } = {}) {
406
+ let list = names ?? Object.keys(TOOLS).filter((n) => {
407
+ if (n === 'Skill') return hasSkills;
408
+ if (n === 'WebFetch') return web;
409
+ return true;
410
+ });
411
+ // 작업 모드가 정해져 있으면 그 모드가 쓰는 것만 남긴다.
412
+ //
413
+ // 설계·계획·묻기 모드에서 파일을 바꾸면 안 된다고 프롬프트로 부탁할 수도 있다.
414
+ // 그런데 모델은 부탁을 잊는다. 목록에서 아예 빼면 잊을 것이 없다.
415
+ if (work) list = allowedIn(work, list);
302
416
  return list.map((n) => ({ type: 'function', function: TOOLS[n].schema }));
303
417
  }
304
418
 
@@ -0,0 +1,92 @@
1
+ // 할 일 목록. 긴 작업에서 모델이 길을 잃지 않게 붙잡아 준다.
2
+ //
3
+ // 왜 필요한가:
4
+ // "이거 세 군데 고치고 테스트 돌려줘" 같은 일을 시키면, 도구를 열댓 번 부르는 사이
5
+ // 모델이 처음 시킨 것 중 하나를 슬그머니 빠뜨린다. 컨텍스트가 접히면 더 심해진다.
6
+ // 목록을 눈에 보이게 들고 있으면 그 일이 크게 준다.
7
+ //
8
+ // 규칙 하나: 진행 중은 한 번에 하나.
9
+ // 여러 개를 한꺼번에 '하는 중' 으로 두면 결국 아무것도 안 끝난다.
10
+ // 여기서 막아 두면 모델이 순서를 정하고 하나씩 닫는다.
11
+
12
+ export const STATES = ['todo', 'doing', 'done'];
13
+
14
+ const 표시 = { todo: '☐', doing: '▶', done: '☑' };
15
+
16
+ function 정리(items) {
17
+ const out = [];
18
+ for (const [i, x] of (items ?? []).entries()) {
19
+ const text = String(x?.text ?? x?.content ?? '').trim();
20
+ if (!text) continue;
21
+ let state = String(x?.state ?? x?.status ?? 'todo').toLowerCase();
22
+ if (state === 'in_progress' || state === 'in-progress') state = 'doing';
23
+ if (state === 'completed' || state === 'complete') state = 'done';
24
+ if (state === 'pending') state = 'todo';
25
+ if (!STATES.includes(state)) state = 'todo';
26
+ out.push({ id: i + 1, text: text.slice(0, 200), state });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ export function render(items) {
32
+ if (!items.length) return '할 일이 없습니다.';
33
+ const 남은 = items.filter((x) => x.state !== 'done').length;
34
+ const lines = items.map((x) => `${표시[x.state]} ${x.text}`);
35
+ lines.push('', `${items.length}개 중 ${items.length - 남은}개 완료`);
36
+ return lines.join('\n');
37
+ }
38
+
39
+ export const TODO_TOOL = {
40
+ schema: {
41
+ name: 'TodoWrite',
42
+ description:
43
+ '할 일 목록을 만들고 갱신한다. 세 단계 이상 걸리는 일이면 먼저 목록을 만들고, '
44
+ + '하나를 끝낼 때마다 바로 갱신한다. 목록 전체를 매번 통째로 보낸다. '
45
+ + 'state 는 todo(아직) / doing(하는 중) / done(끝) 셋 중 하나이고, doing 은 한 번에 하나만 둔다.',
46
+ parameters: {
47
+ type: 'object',
48
+ properties: {
49
+ todos: {
50
+ type: 'array',
51
+ description: '할 일 전체 목록',
52
+ items: {
53
+ type: 'object',
54
+ properties: {
55
+ text: { type: 'string', description: '무엇을 할지 한 줄' },
56
+ state: { type: 'string', enum: STATES, description: 'todo / doing / done' },
57
+ },
58
+ required: ['text', 'state'],
59
+ },
60
+ },
61
+ },
62
+ required: ['todos'],
63
+ },
64
+ },
65
+
66
+ run(args, ctx) {
67
+ const items = 정리(args?.todos);
68
+ if (!items.length) return { error: '할 일이 비어 있습니다.' };
69
+
70
+ const 하는중 = items.filter((x) => x.state === 'doing');
71
+ if (하는중.length > 1) {
72
+ return {
73
+ error: `'하는 중' 은 한 번에 하나만 둡니다. 지금 ${하는중.length}개입니다: `
74
+ + 하는중.map((x) => x.text).join(', ')
75
+ + '\n 하나만 doing 으로 두고 나머지는 todo 로 되돌리세요.',
76
+ };
77
+ }
78
+
79
+ const 이전 = ctx.todos ?? [];
80
+ ctx.todos = items;
81
+
82
+ const 끝난것 = items.filter((x) => x.state === 'done').length;
83
+ const 새로끝난 = items.filter((x) =>
84
+ x.state === 'done' && !이전.some((y) => y.text === x.text && y.state === 'done'));
85
+
86
+ return {
87
+ content: render(items),
88
+ summary: `${끝난것}/${items.length} 완료${새로끝난.length ? ` · 방금 ${새로끝난.length}개` : ''}`,
89
+ todos: items,
90
+ };
91
+ },
92
+ };
@@ -0,0 +1,110 @@
1
+ // 웹 읽기. 읽기 전용이고, 나가는 것은 주소뿐이다.
2
+ //
3
+ // 이 도구가 다른 길로 다니는 이유:
4
+ // 모델 게이트웨이로는 소스 코드가 통째로 나간다. 그래서 그 길은 딱 한 자리로 묶어 뒀다.
5
+ // 웹 읽기는 성격이 다르다 — 받아 오기만 하고 보내지 않는다. 두 길을 한 목록에
6
+ // 같이 두면 "코드가 어디로 갈 수 있나" 를 더 이상 한 줄로 답할 수 없게 된다.
7
+ // 그래서 여기서만 잠깐 열고, 끝나면 바로 닫고, 다녀온 곳은 전부 기록에 남긴다.
8
+ //
9
+ // 지키는 것:
10
+ // · GET 만. 본문을 실어 보내지 않는다.
11
+ // · 사설·로컬 주소는 거절. 사내 서버를 모델이 긁어 오게 두지 않는다.
12
+ // · 오프라인이면 아예 거절.
13
+ // · 받은 것은 글자만 뽑고 길이를 자른다.
14
+ import { allowTemporarily, isOffline, isLocalHost } from '../safety/network.js';
15
+
16
+ export const 방문기록 = [];
17
+
18
+ const MAX_BYTES = 2 * 1024 * 1024; // 2MB 넘게 받지 않는다
19
+
20
+ function 태그벗기기(html) {
21
+ return html
22
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
23
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
24
+ .replace(/<!--[\s\S]*?-->/g, ' ')
25
+ .replace(/<\/(p|div|section|article|li|tr|h[1-6]|br)>/gi, '\n')
26
+ .replace(/<br\s*\/?>/gi, '\n')
27
+ .replace(/<[^>]+>/g, ' ')
28
+ .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
29
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
30
+ .replace(/[ \t]+/g, ' ')
31
+ .replace(/\n\s*\n\s*\n+/g, '\n\n')
32
+ .trim();
33
+ }
34
+
35
+ /**
36
+ * @param {object} args 모델이 주는 값 — url, max_chars
37
+ * @param {object} opts 프로그램 내부에서만 주는 값.
38
+ * allowPrivate 는 검사용이다. 도구 스키마에 없으므로 모델은 이 값을 줄 수 없다.
39
+ * (환경변수로 열어 두면 실제 사용 중에도 열려 버린다 — 그래서 인자로만 둔다)
40
+ */
41
+ export async function webFetch(args, { allowPrivate = false } = {}) {
42
+ const raw = String(args?.url ?? '').trim();
43
+ const max = Math.min(Math.max(parseInt(args?.max_chars, 10) || 20000, 1000), 100000);
44
+
45
+ if (isOffline()) return { error: '오프라인 모드입니다 — 웹을 읽지 않습니다.' };
46
+
47
+ let u;
48
+ try { u = new URL(raw); } catch { return { error: `주소 형식이 아닙니다: ${raw}` }; }
49
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
50
+ return { error: `${u.protocol} 는 읽지 않습니다. http/https 만 됩니다.` };
51
+ }
52
+ // 사내망·로컬을 모델이 훑게 두지 않는다. 웹을 읽는 도구지 내부 정찰 도구가 아니다.
53
+ if (isLocalHost(u.hostname) && !allowPrivate) {
54
+ return { error: `이 컴퓨터·사내망 주소는 이 도구로 읽지 않습니다: ${u.hostname}\n 파일은 Read, 사내 서버는 사람이 직접 확인하세요.` };
55
+ }
56
+
57
+ const close = allowTemporarily(u.origin);
58
+ try {
59
+ const res = await fetch(u.href, {
60
+ method: 'GET', // 보내는 건 없다
61
+ redirect: 'follow',
62
+ headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
63
+ signal: AbortSignal.timeout(30000),
64
+ });
65
+ 방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
66
+
67
+ if (!res.ok) return { error: `HTTP ${res.status} — ${u.href}` };
68
+
69
+ const type = (res.headers.get('content-type') ?? '').toLowerCase();
70
+ if (!/text|json|xml|javascript/.test(type)) {
71
+ return { error: `글이 아닌 내용입니다 (${type || '알 수 없음'}). 이 도구는 글만 읽습니다.` };
72
+ }
73
+
74
+ const buf = Buffer.from(await res.arrayBuffer());
75
+ if (buf.length > MAX_BYTES) return { error: `너무 큽니다 (${(buf.length / 1024 / 1024).toFixed(1)}MB).` };
76
+
77
+ let text = buf.toString('utf8');
78
+ if (/html/.test(type)) text = 태그벗기기(text);
79
+ const cut = text.length > max;
80
+ if (cut) text = text.slice(0, max);
81
+
82
+ return {
83
+ content: `${u.href}\n${'─'.repeat(60)}\n${text}${cut ? `\n\n(뒤쪽 ${'약 ' + (buf.length - max).toLocaleString()}자는 잘렸습니다)` : ''}`,
84
+ summary: `${text.length.toLocaleString()}자${cut ? ' (잘림)' : ''}`,
85
+ };
86
+ } catch (err) {
87
+ const m = String(err?.message ?? err);
88
+ if (err?.name === 'TimeoutError') return { error: '시간 초과 — 응답이 없습니다.' };
89
+ if (/ENOTFOUND|getaddrinfo/i.test(m)) return { error: '주소를 찾을 수 없습니다 (DNS).' };
90
+ return { error: m };
91
+ } finally {
92
+ close(); // 반드시 닫는다. 열어 둔 채로 두면 자물쇠가 아니게 된다.
93
+ }
94
+ }
95
+
96
+ export const WEB_FETCH_TOOL = {
97
+ schema: {
98
+ name: 'WebFetch',
99
+ description: '웹 페이지를 읽는다. 읽기만 하고 아무것도 보내지 않는다. 문서·오류 메시지·라이브러리 사용법을 확인할 때 쓴다. 이 컴퓨터·사내망 주소는 읽지 않는다.',
100
+ parameters: {
101
+ type: 'object',
102
+ properties: {
103
+ url: { type: 'string', description: '읽을 주소 (http/https)' },
104
+ max_chars: { type: 'number', description: '가져올 최대 글자 수. 기본 20000' },
105
+ },
106
+ required: ['url'],
107
+ },
108
+ },
109
+ run: (args) => webFetch(args),
110
+ };