deel-local-cli 1.15.1 → 1.16.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.
- package/README.ko.md +68 -12
- package/README.md +69 -12
- package/bin/deel.js +45 -1
- package/package.json +1 -1
- package/src/agent/asks.js +58 -2
- package/src/agent/budget.js +33 -0
- package/src/agent/effort.js +69 -0
- package/src/agent/loop.js +10 -2
- package/src/agent/modes.js +179 -17
- package/src/agent/route.js +119 -8
- package/src/backend/adapter.js +56 -5
- package/src/cmdnames.js +1 -0
- package/src/commands.js +31 -13
- package/src/completion.js +1 -0
- package/src/i18n/en.js +13 -0
- package/src/i18n/ja.js +13 -0
- package/src/i18n/ko.js +13 -0
- package/src/i18n/zh.js +13 -0
- package/src/tools/doc2md.js +266 -0
- package/src/tools/docs.js +14 -4
- package/src/tools/fig.js +393 -0
- package/src/tools/index.js +49 -8
- package/src/tools/kiwi.js +293 -0
package/src/tools/fig.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma `.fig` 읽기 — 화면 하나가 어떤 조각으로 짜였는지.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 읽나 ────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 「이 시안대로 만들어 줘」 하면서 `.fig` 를 건네는 일이 실제로 잦다. 그런데
|
|
7
|
+
* 지금까지 그 파일은 여기서 끝났다 — 「이진 파일이라 못 읽습니다」. 그러면
|
|
8
|
+
* 사람이 화면을 캡처해서 다시 붙이거나, 글자를 하나씩 옮겨 적는다.
|
|
9
|
+
*
|
|
10
|
+
* 안에 있는 것은 그림만이 아니다. **틀 이름 · 글 · 크기 · 겹친 차례**가 다
|
|
11
|
+
* 글자로 들어 있다. 「로그인」 이라는 틀 밑에 「이메일」 「비밀번호」 라는
|
|
12
|
+
* 글상자가 있고 폭이 375 라는 것 — 코드를 짜는 데 필요한 것은 대부분 그거다.
|
|
13
|
+
*
|
|
14
|
+
* ── 무엇을 안 하나 ─────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* 그림은 안 그린다. 색·그림자·글꼴 굵기도 안 낸다. 낼 수야 있지만 그걸 다
|
|
17
|
+
* 실으면 창이 그것만으로 찬다. **이름과 글과 짜임**까지가 이 읽개의 몫이다.
|
|
18
|
+
*
|
|
19
|
+
* 쓰기는 아예 없다. 글로 왕복시킨 시안은 시안이 아니다.
|
|
20
|
+
*
|
|
21
|
+
* ── 어떻게 생겼나 ──────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* .fig = zip
|
|
24
|
+
* ├ canvas.fig ← 알맹이. 이게 Kiwi 다
|
|
25
|
+
* ├ meta.json ← 파일 이름 · 만든 판
|
|
26
|
+
* ├ thumbnail.png ← 미리보기
|
|
27
|
+
* └ images/… ← 쓰인 그림들
|
|
28
|
+
*
|
|
29
|
+
* canvas.fig = "fig-kiwi"(8바이트) 판:u32
|
|
30
|
+
* 덩이길이:u32 덩이 ← 스키마
|
|
31
|
+
* 덩이길이:u32 덩이 ← 알맹이
|
|
32
|
+
*
|
|
33
|
+
* 두 덩이는 눌려 있다. 예전 파일은 deflate, 요즘 파일은 zstd 다. 앞 네
|
|
34
|
+
* 바이트를 보고 가른다 — 확장자나 판 번호로 짐작하지 않는다. 짐작이 틀리면
|
|
35
|
+
* 「파일이 깨졌다」 는 엉뚱한 말이 나간다.
|
|
36
|
+
*
|
|
37
|
+
* ── zstd 와 Node 판 ────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* Node 가 zstd 를 들고 온 것은 22.15 부터다. 이 프로그램의 바닥은 20 이다.
|
|
40
|
+
* 그래서 zstd 로 눌린 파일을 Node 20 에서 열면 **못 연다.** 그때 「깨진
|
|
41
|
+
* 파일입니다」 라고 하면 사람은 파일을 의심하며 시간을 버린다. 무엇이
|
|
42
|
+
* 없어서 못 하는지, 그럼 무엇을 하면 되는지까지 말한다.
|
|
43
|
+
*/
|
|
44
|
+
import { readFileSync } from 'node:fs';
|
|
45
|
+
import { basename, extname } from 'node:path';
|
|
46
|
+
import { inflateRawSync, inflateSync } from 'node:zlib';
|
|
47
|
+
import * as zlib from 'node:zlib';
|
|
48
|
+
|
|
49
|
+
import { readZip, looksZip } from '../pack/zip.js';
|
|
50
|
+
import { 스키마읽기, 알맹이읽기 } from './kiwi.js';
|
|
51
|
+
|
|
52
|
+
const 머리들 = ['fig-kiwi', 'fig-jam.'];
|
|
53
|
+
|
|
54
|
+
/** 이 파일이 `.fig` 인가. */
|
|
55
|
+
export function isFigPath(p) {
|
|
56
|
+
return extname(String(p ?? '')).toLowerCase() === '.fig';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 우리가 담아 두는 칸 이름.
|
|
61
|
+
*
|
|
62
|
+
* Figma 스키마에는 칸이 수백 개 있고 그중 대부분이 도형 좌표다. 전부
|
|
63
|
+
* 객체로 만들면 큰 파일 하나에 몇백 MB 가 된다. 바이트는 그대로 지나가되
|
|
64
|
+
* **담기는 것만 고른다** — 이름으로 고르므로 새 판이 칸을 늘려도 안 깨진다.
|
|
65
|
+
*/
|
|
66
|
+
const 담을칸 = new Set([
|
|
67
|
+
'guid', 'sessionID', 'localID',
|
|
68
|
+
'parentIndex', 'position',
|
|
69
|
+
'type', 'name', 'visible',
|
|
70
|
+
'size', 'x', 'y',
|
|
71
|
+
'textData', 'characters',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 뿌리에서 「도형 목록」 칸을 찾는다.
|
|
76
|
+
*
|
|
77
|
+
* 이름으로 `nodeChanges` 를 찾되, **못 찾으면 생김새로 찾는다** — 배열이고,
|
|
78
|
+
* 그 알맹이 정의에 `guid` 칸이 있는 것. 이름은 Figma 가 바꿀 수 있지만
|
|
79
|
+
* 「도형마다 고유 번호가 있다」 는 것은 이 형식의 뼈대라 안 바뀐다.
|
|
80
|
+
*
|
|
81
|
+
* 이름만 믿었다가 못 찾으면 「도형이 하나도 없습니다」 라는 거짓말이 나간다.
|
|
82
|
+
*/
|
|
83
|
+
function 도형칸찾기(스키마, 뿌리이름) {
|
|
84
|
+
const d = 스키마.정의들[스키마.이름표.get(뿌리이름)];
|
|
85
|
+
const 배열칸 = (d?.칸들 ?? []).filter((f) => f.배열 && f.형 >= 0);
|
|
86
|
+
const 이름난것 = 배열칸.find((f) => f.이름 === 'nodeChanges');
|
|
87
|
+
if (이름난것) return 이름난것.이름;
|
|
88
|
+
const 닮은것 = 배열칸.find((f) => 스키마.정의들[f.형]?.칸들?.some((x) => x.이름 === 'guid'));
|
|
89
|
+
return 닮은것?.이름 ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 이보다 많으면 그림에 다 못 싣는다. 자르되 잘랐다고 말한다. */
|
|
93
|
+
const 노드최대 = 20000;
|
|
94
|
+
|
|
95
|
+
/** 앞 네 바이트로 무엇에 눌렸는지 본다. 판 번호로 짐작하지 않는다. */
|
|
96
|
+
function 눌린방식(buf) {
|
|
97
|
+
if (buf.length >= 4 && buf.readUInt32LE(0) === 0xfd2fb528) return 'zstd';
|
|
98
|
+
// zlib 은 첫 바이트가 0x78 이고 두 바이트를 31 로 나누면 떨어진다.
|
|
99
|
+
if (buf.length >= 2 && buf[0] === 0x78 && ((buf[0] << 8) + buf[1]) % 31 === 0) return 'zlib';
|
|
100
|
+
return 'deflate';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const zstd있나 = () => typeof zlib.zstdDecompressSync === 'function';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* zstd 가 없을 때 하는 말.
|
|
107
|
+
*
|
|
108
|
+
* 「깨진 파일입니다」 라고 하면 사람은 파일을 의심하며 시간을 버린다. 파일은
|
|
109
|
+
* 멀쩡하고 **여기 Node 가 못 푸는 것**이다. 그 둘은 하는 일이 완전히 다르다.
|
|
110
|
+
*/
|
|
111
|
+
export function zstd안내() {
|
|
112
|
+
return 'zstd 로 눌린 .fig 입니다. 이 Node 에는 zstd 가 없습니다'
|
|
113
|
+
+ ` (지금 ${process.version} · Node 22.15 부터 들어왔습니다).\n`
|
|
114
|
+
+ ' 파일이 깨진 것이 아닙니다 — 여기서 못 푸는 것뿐입니다.\n'
|
|
115
|
+
+ ' 고치는 법: Node 를 22.15 이상으로 올리면 그대로 읽힙니다.';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 덩이 하나를 편다.
|
|
120
|
+
*
|
|
121
|
+
* 상한을 **펴기 전에** 건다. 다 펴 놓고 재면 이미 늦다 — 작은 조각이 몇 GB
|
|
122
|
+
* 로 부푸는 파일을 남이 만들어 줄 수 있다(zip.js 가 같은 이유로 같은 일을
|
|
123
|
+
* 한다).
|
|
124
|
+
*/
|
|
125
|
+
function 펴기(덩이, 상한) {
|
|
126
|
+
const 방식 = 눌린방식(덩이);
|
|
127
|
+
if (방식 === 'zstd') {
|
|
128
|
+
if (!zstd있나()) {
|
|
129
|
+
const err = new Error(zstd안내());
|
|
130
|
+
err.끝났다 = true;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
return zlib.zstdDecompressSync(덩이, { maxOutputLength: 상한 });
|
|
134
|
+
}
|
|
135
|
+
if (방식 === 'zlib') return inflateSync(덩이, { maxOutputLength: 상한 });
|
|
136
|
+
return inflateRawSync(덩이, { maxOutputLength: 상한 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `canvas.fig` 를 덩이 목록으로 가른다.
|
|
141
|
+
*
|
|
142
|
+
* @returns {{판:number, 덩이들:Buffer[]}}
|
|
143
|
+
*/
|
|
144
|
+
export function 통가르기(buf) {
|
|
145
|
+
if (buf.length < 12) throw new Error('canvas.fig 가 너무 짧습니다');
|
|
146
|
+
const 머리 = buf.toString('latin1', 0, 8);
|
|
147
|
+
if (!머리들.includes(머리)) {
|
|
148
|
+
throw new Error(`Figma 알맹이가 아닙니다 — 앞머리가 ${JSON.stringify(머리)} 입니다`);
|
|
149
|
+
}
|
|
150
|
+
const 판 = buf.readUInt32LE(8);
|
|
151
|
+
|
|
152
|
+
const 덩이들 = [];
|
|
153
|
+
let p = 12;
|
|
154
|
+
while (p + 4 <= buf.length) {
|
|
155
|
+
const 길이 = buf.readUInt32LE(p);
|
|
156
|
+
p += 4;
|
|
157
|
+
// 남은 것보다 길다고 적혀 있으면 그 자리에서 거짓이다. 그대로 잘라
|
|
158
|
+
// 가면 뒤 덩이가 통째로 헛것이 된다.
|
|
159
|
+
if (길이 > buf.length - p) throw new Error(`덩이 길이가 파일보다 깁니다 (${길이})`);
|
|
160
|
+
덩이들.push(buf.subarray(p, p + 길이));
|
|
161
|
+
p += 길이;
|
|
162
|
+
}
|
|
163
|
+
if (덩이들.length < 2) throw new Error(`덩이가 ${덩이들.length}개뿐입니다 — 스키마와 알맹이가 둘 다 있어야 합니다`);
|
|
164
|
+
return { 판, 덩이들 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** guid 를 한 줄 열쇠로. 없으면 null — 뿌리 노드는 부모가 없다. */
|
|
168
|
+
function 열쇠(g) {
|
|
169
|
+
if (!g || typeof g !== 'object') return null;
|
|
170
|
+
return `${g.sessionID ?? 0}:${g.localID ?? 0}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 크기를 `375×812` 로. 소수점은 버린다 — 시안 치수는 정수로 읽는 게 맞다. */
|
|
174
|
+
function 크기말(size) {
|
|
175
|
+
if (!size || typeof size !== 'object') return '';
|
|
176
|
+
const w = Number(size.x);
|
|
177
|
+
const h = Number(size.y);
|
|
178
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || (!w && !h)) return '';
|
|
179
|
+
return ` ${Math.round(w)}×${Math.round(h)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 글은 한 줄로 접는다. 시안의 글상자에는 줄바꿈이 흔하다. */
|
|
183
|
+
function 글말(node) {
|
|
184
|
+
const s = node?.textData?.characters;
|
|
185
|
+
if (typeof s !== 'string' || !s.trim()) return '';
|
|
186
|
+
const 한줄 = s.replace(/\s+/g, ' ').trim();
|
|
187
|
+
return ` "${한줄.length > 120 ? `${한줄.slice(0, 120)}…` : 한줄}"`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 노드 목록을 부모-자식으로 엮는다.
|
|
192
|
+
*
|
|
193
|
+
* 차례는 `position` 이라는 글자열이 정한다 — 사이에 끼워 넣을 수 있게 만든
|
|
194
|
+
* 값이라 **글자 순서가 곧 화면 순서**다. 숫자로 바꾸려 들면 안 된다.
|
|
195
|
+
*/
|
|
196
|
+
function 나무엮기(노드들) {
|
|
197
|
+
const 자리표 = new Map();
|
|
198
|
+
for (const n of 노드들) {
|
|
199
|
+
const k = 열쇠(n.guid);
|
|
200
|
+
if (k && !자리표.has(k)) 자리표.set(k, n);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const 자식표 = new Map();
|
|
204
|
+
const 뿌리들 = [];
|
|
205
|
+
for (const n of 노드들) {
|
|
206
|
+
const 부모 = 열쇠(n.parentIndex?.guid);
|
|
207
|
+
if (부모 && 자리표.has(부모)) {
|
|
208
|
+
if (!자식표.has(부모)) 자식표.set(부모, []);
|
|
209
|
+
자식표.get(부모).push(n);
|
|
210
|
+
} else {
|
|
211
|
+
뿌리들.push(n);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const 목록 of 자식표.values()) {
|
|
215
|
+
목록.sort((a, b) => String(a.parentIndex?.position ?? '').localeCompare(String(b.parentIndex?.position ?? '')));
|
|
216
|
+
}
|
|
217
|
+
return { 자리표, 자식표, 뿌리들 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 한 갈래를 줄들로 편다. 깊이는 두 칸씩. */
|
|
221
|
+
function 가지풀기(뿌리, 자식표, 줄들, 깊이, 셈) {
|
|
222
|
+
if (셈.남은 <= 0) return;
|
|
223
|
+
const 들여 = ' '.repeat(Math.min(깊이, 12));
|
|
224
|
+
const 갈래 = typeof 뿌리.type === 'string' ? 뿌리.type : String(뿌리.type ?? '?');
|
|
225
|
+
const 이름 = String(뿌리.name ?? '').trim();
|
|
226
|
+
const 숨김 = 뿌리.visible === false ? ' (숨김)' : '';
|
|
227
|
+
줄들.push(`${들여}- ${갈래}${이름 ? ` · ${이름}` : ''}${크기말(뿌리.size)}${글말(뿌리)}${숨김}`);
|
|
228
|
+
셈.남은 -= 1;
|
|
229
|
+
for (const 아이 of 자식표.get(열쇠(뿌리.guid)) ?? []) {
|
|
230
|
+
가지풀기(아이, 자식표, 줄들, 깊이 + 1, 셈);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `.fig` 하나를 읽는다.
|
|
236
|
+
*
|
|
237
|
+
* 던지지 않는다 — 깨진 파일은 도구 실행 한가운데서 만나는 것이라, 예외가
|
|
238
|
+
* 나면 「파일이 깨졌다」 가 「도구가 터졌다」 로 보고된다. 다른 읽개들과
|
|
239
|
+
* 같은 규칙이고, 돌려주는 모양도 같다(`덩이들`).
|
|
240
|
+
*
|
|
241
|
+
* @returns {{ok:true, 갈래:'fig', 덩이들:[{이름,문단들}], 말:string[], 노드수:number}
|
|
242
|
+
* |{ok:false, error:string, 끝났다?:boolean}}
|
|
243
|
+
*/
|
|
244
|
+
export function readFig(경로또는버퍼) {
|
|
245
|
+
let buf;
|
|
246
|
+
try {
|
|
247
|
+
buf = Buffer.isBuffer(경로또는버퍼) ? 경로또는버퍼 : readFileSync(경로또는버퍼);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { ok: false, error: `못 읽었습니다: ${err.message}` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const 말 = [];
|
|
253
|
+
let 알맹이;
|
|
254
|
+
let 메타 = null;
|
|
255
|
+
let 그림수 = 0;
|
|
256
|
+
let 미리보기 = false;
|
|
257
|
+
|
|
258
|
+
// 꾸러미로 온 것이 보통이지만, `canvas.fig` 만 따로 떨어져 오기도 한다.
|
|
259
|
+
if (looksZip(buf)) {
|
|
260
|
+
let 꾸러미;
|
|
261
|
+
try {
|
|
262
|
+
꾸러미 = readZip(buf);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return { ok: false, error: `.fig 꾸러미를 풀지 못했습니다 — ${err.message}` };
|
|
265
|
+
}
|
|
266
|
+
for (const [이름] of 꾸러미.files) {
|
|
267
|
+
if (/^images\//i.test(이름)) 그림수 += 1;
|
|
268
|
+
if (/^thumbnail\.png$/i.test(이름)) 미리보기 = true;
|
|
269
|
+
}
|
|
270
|
+
const 메타몸 = 꾸러미.files.get('meta.json');
|
|
271
|
+
if (메타몸) { try { 메타 = JSON.parse(메타몸.toString('utf8')); } catch { 메타 = null; } }
|
|
272
|
+
알맹이 = 꾸러미.files.get('canvas.fig');
|
|
273
|
+
if (!알맹이) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
끝났다: true,
|
|
277
|
+
error: '.fig 꾸러미 안에 canvas.fig 가 없습니다 — '
|
|
278
|
+
+ `든 것: ${[...꾸러미.files.keys()].slice(0, 8).join(' · ') || '(빈 꾸러미)'}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
알맹이 = buf;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let 통;
|
|
286
|
+
try {
|
|
287
|
+
통 = 통가르기(알맹이);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { ok: false, error: err.message, 끝났다: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const 상한 = 256 * 1024 * 1024;
|
|
293
|
+
let 스키마;
|
|
294
|
+
let 몸;
|
|
295
|
+
try {
|
|
296
|
+
스키마 = 스키마읽기(펴기(통.덩이들[0], 16 * 1024 * 1024));
|
|
297
|
+
몸 = 펴기(통.덩이들[1], 상한);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return { ok: false, error: `.fig 알맹이를 풀지 못했습니다 — ${err.message}`, 끝났다: !!err.끝났다 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const 뿌리이름 = 스키마.이름표.has('Message')
|
|
303
|
+
? 'Message'
|
|
304
|
+
: 스키마.정의들.find((d) => d.갈래 === 'MESSAGE')?.이름;
|
|
305
|
+
if (!뿌리이름) return { ok: false, error: '.fig 스키마에 읽을 시작점이 없습니다', 끝났다: true };
|
|
306
|
+
|
|
307
|
+
const 도형칸 = 도형칸찾기(스키마, 뿌리이름);
|
|
308
|
+
if (!도형칸) {
|
|
309
|
+
const 있는칸 = (스키마.정의들[스키마.이름표.get(뿌리이름)]?.칸들 ?? [])
|
|
310
|
+
.map((f) => f.이름).slice(0, 10).join(' · ');
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
끝났다: true,
|
|
314
|
+
error: `.fig 를 열었는데 도형 목록 칸을 못 찾았습니다. ${뿌리이름} 에 있는 칸: ${있는칸 || '(없음)'}`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let 것;
|
|
319
|
+
try {
|
|
320
|
+
것 = 알맹이읽기(스키마, 몸, 뿌리이름, (_정의, 칸) => 칸 === 도형칸 || 담을칸.has(칸));
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// 여기까지 왔는데 못 읽었으면 스키마와 알맹이가 안 맞는 것이다. 반쯤
|
|
323
|
+
// 읽은 것을 내놓지 않는다 — 반만 맞는 시안은 틀린 시안보다 나쁘다.
|
|
324
|
+
return { ok: false, error: `.fig 를 스키마대로 읽지 못했습니다 — ${err.message}`, 끝났다: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const 노드들 = Array.isArray(것?.[도형칸]) ? 것[도형칸].filter(Boolean) : [];
|
|
328
|
+
if (!노드들.length) {
|
|
329
|
+
return { ok: false, error: '.fig 를 열었는데 안에 도형이 하나도 없습니다', 끝났다: true };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { 자식표, 뿌리들 } = 나무엮기(노드들);
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
* 쪽은 CANVAS 다.
|
|
336
|
+
*
|
|
337
|
+
* Figma 에서 「페이지」 라고 부르는 것이 파일 안에서는 CANVAS 이고,
|
|
338
|
+
* 그 위가 DOCUMENT 하나다. 쪽마다 한 덩이로 내면 마크다운에서 `## 쪽이름`
|
|
339
|
+
* 이 되고, 글로 낼 때도 `--- 쪽이름 ---` 으로 갈린다.
|
|
340
|
+
*/
|
|
341
|
+
const 문서 = 뿌리들.find((n) => n.type === 'DOCUMENT') ?? 뿌리들[0];
|
|
342
|
+
const 쪽들 = (자식표.get(열쇠(문서?.guid)) ?? []).filter((n) => n.type === 'CANVAS');
|
|
343
|
+
|
|
344
|
+
const 셈 = { 남은: 노드최대 };
|
|
345
|
+
const 덩이들 = [];
|
|
346
|
+
|
|
347
|
+
const 개요 = [];
|
|
348
|
+
const 파일이름 = 메타?.file_name ?? 메타?.fileName ?? 메타?.name;
|
|
349
|
+
if (파일이름) 개요.push(`파일 이름: ${파일이름}`);
|
|
350
|
+
개요.push(`쪽 ${쪽들.length || 1}개 · 도형 ${노드들.length.toLocaleString('en-US')}개`);
|
|
351
|
+
if (그림수) 개요.push(`쓰인 그림 ${그림수}개 (images/ 안에 있습니다 — 이 읽개는 그림을 안 풉니다)`);
|
|
352
|
+
if (미리보기) 개요.push('미리보기 그림 thumbnail.png 가 들어 있습니다');
|
|
353
|
+
개요.push('색·그림자·글꼴은 안 냅니다. 이름 · 글 · 크기 · 짜임까지입니다.');
|
|
354
|
+
덩이들.push({ 이름: '개요', 문단들: 개요 });
|
|
355
|
+
|
|
356
|
+
if (쪽들.length) {
|
|
357
|
+
for (const 쪽 of 쪽들) {
|
|
358
|
+
const 줄들 = [];
|
|
359
|
+
for (const 아이 of 자식표.get(열쇠(쪽.guid)) ?? []) 가지풀기(아이, 자식표, 줄들, 0, 셈);
|
|
360
|
+
덩이들.push({
|
|
361
|
+
이름: String(쪽.name ?? '').trim() || '이름 없는 쪽',
|
|
362
|
+
문단들: 줄들.length ? 줄들 : ['(이 쪽은 비어 있습니다.)'],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
// DOCUMENT/CANVAS 짜임이 아닌 파일도 있다 — 그때는 있는 대로 편다.
|
|
367
|
+
const 줄들 = [];
|
|
368
|
+
for (const n of 뿌리들) 가지풀기(n, 자식표, 줄들, 0, 셈);
|
|
369
|
+
덩이들.push({ 이름: '도형', 문단들: 줄들 });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (셈.남은 <= 0) {
|
|
373
|
+
말.push(`도형이 ${노드최대.toLocaleString('en-US')}개를 넘어 거기까지만 폈습니다 — 뒷부분은 안 실렸습니다`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { ok: true, 갈래: 'fig', 덩이들, 말, 노드수: 노드들.length };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 한 줄 요약. Read 의 summary 자리로 간다. */
|
|
380
|
+
export function summarize(r) {
|
|
381
|
+
if (!r?.ok) return '';
|
|
382
|
+
const 쪽 = Math.max(0, r.덩이들.length - 1);
|
|
383
|
+
return `fig · 쪽 ${쪽}개 · 도형 ${r.노드수.toLocaleString('en-US')}개`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 고치려 들 때 하는 말. 문서·PDF 와 같은 꼴 — 왜 안 되는지와 그럼 어떻게 하는지. */
|
|
387
|
+
export function fig는못고침(보인이름) {
|
|
388
|
+
return `.fig 시안은 이 도구로 고칠 수 없습니다: ${보인이름}\n`
|
|
389
|
+
+ ' 읽기만 됩니다 (이름 · 글 · 크기 · 짜임을 글로 냅니다). 시안을 글로\n'
|
|
390
|
+
+ ' 왕복시키면 그 자리에서 시안이 아니게 되기 때문입니다.\n'
|
|
391
|
+
+ ` 고칠 것이 있으면 Figma 에서 고치고 다시 내보내 주세요.\n`
|
|
392
|
+
+ ` ${basename(보인이름)} 자체는 안 건드렸습니다.`;
|
|
393
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -27,8 +27,9 @@ import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOl
|
|
|
27
27
|
import { 바꿔볼까, 직접못읽나, 변환기찾기, 글로바꾸기, 못바꿈말 } from './convert.js';
|
|
28
28
|
import { 물음검사 } from '../agent/askcheck.js';
|
|
29
29
|
import { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침, 한쪽도못읽음말 } from './pdf.js';
|
|
30
|
+
import { isFigPath, readFig, summarize as figSummary, fig는못고침 } from './fig.js';
|
|
30
31
|
import { diffLines } from '../ui/diff.js';
|
|
31
|
-
import { 읽을줄수, 찾을개수, 찾을줄수,
|
|
32
|
+
import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이, 한번에낼글자수 } from '../agent/budget.js';
|
|
32
33
|
import { 도구설명EN } from './desc.en.js';
|
|
33
34
|
import { 그림인가, 그림읽기, 크기말, 기본한도 } from '../backend/vision.js';
|
|
34
35
|
import { 빠르게찾기, 엔진말, 안볼정규식 } from './fastgrep.js';
|
|
@@ -95,6 +96,15 @@ function clip(s, n = MAX_OUT) {
|
|
|
95
96
|
return t.length > n ? t.slice(0, n) + `\n… (${t.length - n}자 잘림)` : t;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* 이 창에서 도구 하나가 한 번에 실어 보낼 만큼 (agent/budget.js).
|
|
101
|
+
*
|
|
102
|
+
* MAX_OUT 은 창을 모를 때(검사·일회성 호출) 서는 자리로만 남는다. 아는
|
|
103
|
+
* 창에서는 이 함수가 정한다 — 30,000자는 200k 짜리에는 맞고 8k 짜리에는
|
|
104
|
+
* 한 번으로 창을 넘기는 양이다.
|
|
105
|
+
*/
|
|
106
|
+
const 실을만큼 = (ctx) => 한번에낼글자수(ctx?.모델컨텍스트);
|
|
107
|
+
|
|
98
108
|
const 몇KB = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)}MB` : `${Math.round(n / 1024)}KB`);
|
|
99
109
|
|
|
100
110
|
/*
|
|
@@ -475,6 +485,7 @@ async function 엑셀읽기(abs, args, ctx) {
|
|
|
475
485
|
content: clip(
|
|
476
486
|
`${text}\n\n(엑셀 파일을 CSV 로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
|
|
477
487
|
+ (말.length ? `\n(${말.join(' · ')})` : ''),
|
|
488
|
+
실을만큼(ctx),
|
|
478
489
|
),
|
|
479
490
|
summary: excelSummary(r.sheets, r.how) + (잘림.length ? ` · 일부만` : ''),
|
|
480
491
|
};
|
|
@@ -523,11 +534,35 @@ async function 빌려읽기(abs, ctx, 원래오류) {
|
|
|
523
534
|
`${r.text}\n\n(deel 이 직접 못 읽는 형식이라 이 PC 의 ${r.쓴것} 로 글만 뽑아 보여준 것입니다.`
|
|
524
535
|
+ ' 원본은 한 글자도 안 바뀌었습니다. 이 파일은 Edit/Write 로 고칠 수 없습니다 —'
|
|
525
536
|
+ ` 고쳐야 하면 새 파일에 쓰세요.)\n(원래 못 읽은 까닭: ${String(원래오류).split('\n')[0]})`,
|
|
537
|
+
실을만큼(ctx),
|
|
526
538
|
),
|
|
527
539
|
summary: `${r.쓴것} 로 바꿔 읽음 · ${줄수}줄`,
|
|
528
540
|
};
|
|
529
541
|
}
|
|
530
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Figma 시안을 글로 읽어 돌려준다.
|
|
545
|
+
*
|
|
546
|
+
* 문서·PDF 와 같은 규칙이다. 다른 점은 **무엇을 안 냈는지 먼저 말한다**는
|
|
547
|
+
* 것 — 색도 그림자도 안 나오므로, 그것까지 다 봤다고 여기고 답하면 안 된다.
|
|
548
|
+
*/
|
|
549
|
+
function fig읽기(abs, ctx) {
|
|
550
|
+
const r = readFig(abs);
|
|
551
|
+
if (!r.ok) return { error: r.error, ...(r.끝났다 ? { 끝났다: true } : {}) };
|
|
552
|
+
const { text, 잘림 } = docText(r.덩이들);
|
|
553
|
+
return {
|
|
554
|
+
content: clip(
|
|
555
|
+
`${text}
|
|
556
|
+
|
|
557
|
+
(.fig 시안을 짜임과 글로 바꿔서 보여준 것입니다. 그림·색·글꼴은 안 나옵니다.
|
|
558
|
+
이 파일은 Edit/Write 로 고칠 수 없습니다.)`
|
|
559
|
+
+ ([...(r.말 ?? []), ...잘림].length ? `\n(${[...(r.말 ?? []), ...잘림].join(' · ')})` : ''),
|
|
560
|
+
실을만큼(ctx),
|
|
561
|
+
),
|
|
562
|
+
summary: figSummary(r) + (잘림.length ? ' · 일부만' : ''),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
531
566
|
async function 문서읽기(abs, ctx) {
|
|
532
567
|
const r = readDoc(abs);
|
|
533
568
|
if (!r.ok) {
|
|
@@ -546,6 +581,7 @@ async function 문서읽기(abs, ctx) {
|
|
|
546
581
|
(${r.갈래} 문서를 글로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
|
|
547
582
|
+ (잘림.length ? `
|
|
548
583
|
(${잘림.join(' · ')})` : ''),
|
|
584
|
+
실을만큼(ctx),
|
|
549
585
|
),
|
|
550
586
|
summary: docSummary(r) + (잘림.length ? ' · 일부만' : ''),
|
|
551
587
|
};
|
|
@@ -584,6 +620,7 @@ function pdf읽기(abs, ctx) {
|
|
|
584
620
|
(PDF 를 쪽마다 글로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
|
|
585
621
|
+ (못 ? `\n${못}` : '')
|
|
586
622
|
+ (잘림.length ? `\n(${잘림.join(' · ')})` : ''),
|
|
623
|
+
실을만큼(ctx),
|
|
587
624
|
),
|
|
588
625
|
summary: pdfSummary(r) + (잘림.length ? ' · 일부만' : ''),
|
|
589
626
|
};
|
|
@@ -799,6 +836,7 @@ function 한파일쓰기(args, ctx) {
|
|
|
799
836
|
// 오류로 넘기면 왜 안 되는지가 안 실려서, 모델이 우회로를 찾는다.
|
|
800
837
|
if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
|
|
801
838
|
if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
|
|
839
|
+
if (isFigPath(abs)) return { error: fig는못고침(args.file_path) };
|
|
802
840
|
// 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
|
|
803
841
|
// 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
|
|
804
842
|
// 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
|
|
@@ -932,6 +970,7 @@ function 한군데고치기(args, ctx) {
|
|
|
932
970
|
if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
|
|
933
971
|
if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
|
|
934
972
|
if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
|
|
973
|
+
if (isFigPath(abs)) return { error: fig는못고침(args.file_path) };
|
|
935
974
|
if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
|
|
936
975
|
if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
|
|
937
976
|
// 새로 넣을 글에만 본다. old_string 쪽은 **찾는 말**이라 표가 들어 있어도
|
|
@@ -1136,6 +1175,8 @@ export const TOOLS = {
|
|
|
1136
1175
|
if (isDocPath(abs)) return 문서읽기(abs, ctx);
|
|
1137
1176
|
// PDF 도 같다. 속은 사전+흐름이라 zlib 만으로 쪽마다 글을 꺼낸다 (pdf.js).
|
|
1138
1177
|
if (isPdfPath(abs)) return pdf읽기(abs, ctx);
|
|
1178
|
+
// Figma 시안도 같다. 속이 zip + Kiwi 라 이름·글·크기를 꺼낸다 (fig.js).
|
|
1179
|
+
if (isFigPath(abs)) return fig읽기(abs, ctx);
|
|
1139
1180
|
/*
|
|
1140
1181
|
* 구형 hwp 는 '바이너리' 로 끝내지 않는다. 그 오류에는 길이 없어서
|
|
1141
1182
|
* 모델이 우회로(새로 쓰기)를 찾는다 — 실제로 그렇게 원본이 죽은 적이
|
|
@@ -1203,7 +1244,7 @@ export const TOOLS = {
|
|
|
1203
1244
|
const 준줄수 = slice.length;
|
|
1204
1245
|
const 다못줌 = start + count < lines.length || start > 0;
|
|
1205
1246
|
const 통째로 = body + more + 줄인것.알림;
|
|
1206
|
-
const 실린것 = clip(
|
|
1247
|
+
const 실린것 = clip(통째로, 실을만큼(ctx));
|
|
1207
1248
|
return {
|
|
1208
1249
|
content: 실린것,
|
|
1209
1250
|
/*
|
|
@@ -1608,7 +1649,7 @@ export const TOOLS = {
|
|
|
1608
1649
|
].filter(Boolean).join(' ');
|
|
1609
1650
|
const 붙이기2 = (t) => (꼬리2 ? [t, '', 꼬리2].join('\n') : t);
|
|
1610
1651
|
if (!total) return { content: 붙이기2(`일치 없음: ${args.pattern}`), summary: 이어(세말('hits', 0), 빠른것.엔진) };
|
|
1611
|
-
if (mode === 'content') return { content: 붙이기2(clip(lines.join('\n'))), summary: 이어(세말('hits', total), 빠른것.엔진) };
|
|
1652
|
+
if (mode === 'content') return { content: 붙이기2(clip(lines.join('\n'), 실을만큼(ctx))), summary: 이어(세말('hits', total), 빠른것.엔진) };
|
|
1612
1653
|
if (mode === 'count') {
|
|
1613
1654
|
return {
|
|
1614
1655
|
content: 붙이기2(hitFiles.slice(0, limit).map((f) => `${f.n}\t${f.rel}`).join('\n')),
|
|
@@ -1713,7 +1754,7 @@ export const TOOLS = {
|
|
|
1713
1754
|
summary: 다못봄 ? `${세말('hits', 0)} (${말('sum.notAllSeen')})` : 세말('hits', 0),
|
|
1714
1755
|
};
|
|
1715
1756
|
}
|
|
1716
|
-
if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'))), summary: 세말('hits', total) };
|
|
1757
|
+
if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'), 실을만큼(ctx))), summary: 세말('hits', total) };
|
|
1717
1758
|
if (mode === 'count') {
|
|
1718
1759
|
return { content: 붙이기(hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n')), summary: 세말('files', hitFiles.length) };
|
|
1719
1760
|
}
|
|
@@ -1889,9 +1930,9 @@ export const TOOLS = {
|
|
|
1889
1930
|
const stderr = 풀기(stderrBuf);
|
|
1890
1931
|
const out = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
1891
1932
|
|
|
1892
|
-
if (끊겼나) return done({ error: '사용자가 중단했습니다', content: clip(out) });
|
|
1933
|
+
if (끊겼나) return done({ error: '사용자가 중단했습니다', content: clip(out, 실을만큼(ctx)) });
|
|
1893
1934
|
if (시간초과 || (err && err.killed)) {
|
|
1894
|
-
return done({ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out) });
|
|
1935
|
+
return done({ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out, 실을만큼(ctx)) });
|
|
1895
1936
|
}
|
|
1896
1937
|
|
|
1897
1938
|
/*
|
|
@@ -1931,7 +1972,7 @@ export const TOOLS = {
|
|
|
1931
1972
|
: 숫자코드 === null ? `\n\n[${글자코드} — 명령을 아예 못 돌렸습니다]`
|
|
1932
1973
|
: `\n\n[종료코드 ${code}]`;
|
|
1933
1974
|
done({
|
|
1934
|
-
content: clip(out || '(출력 없음)') + 꼬리,
|
|
1975
|
+
content: clip(out || '(출력 없음)', 실을만큼(ctx)) + 꼬리,
|
|
1935
1976
|
summary: 잘됨 ? 말('sum.ok')
|
|
1936
1977
|
: 시그널 ? 말('sum.killedBy', { 시그널 })
|
|
1937
1978
|
: 넘침 ? 말('sum.tooMuchOut')
|
|
@@ -2683,7 +2724,7 @@ async function runMcpTool(name, args, ctx) {
|
|
|
2683
2724
|
const 줄 = 글 ? 글.split(/\r?\n/).length : 0;
|
|
2684
2725
|
return {
|
|
2685
2726
|
summary: 글 ? 이어(세말('lines', 줄), 말('unit.chars', { n: 글.length.toLocaleString() })) : 말('sum.emptyAnswer'),
|
|
2686
|
-
content: clip(
|
|
2727
|
+
content: clip(글, 실을만큼(ctx)),
|
|
2687
2728
|
};
|
|
2688
2729
|
} catch (e) {
|
|
2689
2730
|
return { error: e.message };
|