claude-token-saver 3.27.0 → 3.28.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.en.md +51 -9
- package/README.md +57 -9
- package/bin/cli.js +11 -0
- package/package.json +1 -1
- package/presets/doc2md/convert.py +55 -3
- package/src/commands/doc2md.js +15 -4
- package/src/doc2md-ledger.cjs +208 -0
- package/src/doc2md.cjs +199 -5
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +182 -0
- package/src/formatters/statusline.js +58 -3
- package/src/installer.js +40 -10
package/src/doc2md.cjs
CHANGED
|
@@ -27,13 +27,16 @@ const os = require('node:os');
|
|
|
27
27
|
const path = require('node:path');
|
|
28
28
|
const crypto = require('node:crypto');
|
|
29
29
|
const { spawnSync } = require('node:child_process');
|
|
30
|
+
const ledger = require('./doc2md-ledger.cjs');
|
|
30
31
|
|
|
31
32
|
// Formats where the original is of no use to the model. Images are absent
|
|
32
33
|
// deliberately: markitdown returns nothing for them, and OCR misread resource
|
|
33
34
|
// names in testing (`c5.xlarge` as `c.xlarge`), which is worse than no text at
|
|
34
35
|
// all in a document where those names are the content. The model reads images
|
|
35
36
|
// natively anyway.
|
|
36
|
-
|
|
37
|
+
// `.fig` converts through openfig-core in Node rather than markitdown; see
|
|
38
|
+
// fig2md.cjs for why it cannot go through the Python path.
|
|
39
|
+
const TARGET_EXTENSIONS = ['.pptx', '.xlsx', '.xls', '.pdf', '.docx', '.fig'];
|
|
37
40
|
|
|
38
41
|
// Big enough for real decks and reports, small enough that a hostile file
|
|
39
42
|
// cannot make the converter the expensive part of the turn.
|
|
@@ -107,7 +110,13 @@ function isSensitivePath(filePath) {
|
|
|
107
110
|
function cachePathFor(filePath) {
|
|
108
111
|
const abs = path.resolve(filePath);
|
|
109
112
|
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 12);
|
|
110
|
-
|
|
113
|
+
// Keep letters and digits of any script, so a Korean or Japanese file name
|
|
114
|
+
// stays readable in the cache directory instead of collapsing into a row of
|
|
115
|
+
// underscores. Only characters that are awkward in a path are replaced.
|
|
116
|
+
const base = path.basename(abs)
|
|
117
|
+
.replace(/[^\p{L}\p{N}._-]/gu, '_')
|
|
118
|
+
.replace(/_{2,}/g, '_')
|
|
119
|
+
.slice(0, 80);
|
|
111
120
|
return path.join(cacheDir(), `${base}.${hash}.md`);
|
|
112
121
|
}
|
|
113
122
|
|
|
@@ -129,16 +138,81 @@ function readCache(filePath) {
|
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
/** `31854740` → `30.4MB`, for the banner and the hook's one-line summary. */
|
|
142
|
+
function humanBytes(n) {
|
|
143
|
+
const bytes = Number(n) || 0;
|
|
144
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
145
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
146
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The header stamped onto every conversion.
|
|
151
|
+
*
|
|
152
|
+
* Without it a conversion is an anonymous .md in a directory nobody opened on
|
|
153
|
+
* purpose, and the reader has no way to tell that this tool produced it, from
|
|
154
|
+
* what, or when. Four lines of provenance answer all of that at the top of the
|
|
155
|
+
* file the model is about to read, and cost about 60 tokens.
|
|
156
|
+
*/
|
|
157
|
+
function banner(sourcePath, saving) {
|
|
158
|
+
const src = fs.statSync(sourcePath);
|
|
159
|
+
const lines = [
|
|
160
|
+
'<!--',
|
|
161
|
+
'claude-token-saver doc2md 가 변환한 파일입니다. 직접 편집하지 마십시오.',
|
|
162
|
+
`원본: ${path.resolve(sourcePath)} (${humanBytes(src.size)})`,
|
|
163
|
+
`변환: ${new Date().toISOString()} · Markdown ${humanBytes(Buffer.byteLength(saving.markdown, 'utf8'))} · 약 ${saving.tokens.toLocaleString('en-US')} 토큰`,
|
|
164
|
+
];
|
|
165
|
+
if (saving.usd > 0) {
|
|
166
|
+
const savedTokens = Math.max(0, saving.baseline - saving.tokens);
|
|
167
|
+
// The alternative differs by format, so the sentence names it: a PDF
|
|
168
|
+
// would have been attached, a zip document would have been unpacked.
|
|
169
|
+
const alternative = path.extname(sourcePath).toLowerCase() === '.pdf'
|
|
170
|
+
? '원본을 첨부하면'
|
|
171
|
+
: '압축을 풀어 본문 XML을 읽으면';
|
|
172
|
+
lines.push(
|
|
173
|
+
`${alternative} 약 ${saving.baseline.toLocaleString('en-US')} 토큰이 드는데, `
|
|
174
|
+
+ `변환본은 ${saving.tokens.toLocaleString('en-US')} 토큰입니다. `
|
|
175
|
+
+ `약 ${savedTokens.toLocaleString('en-US')} 토큰, $${saving.usd.toFixed(2)} 을 아꼈습니다(추정).`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
lines.push('-->', '');
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
132
182
|
function writeCache(filePath, markdown, extra) {
|
|
133
183
|
ensureCacheDir();
|
|
134
184
|
const cacheFile = cachePathFor(filePath);
|
|
135
185
|
const src = fs.statSync(filePath);
|
|
136
|
-
|
|
186
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
187
|
+
const saving = Object.assign(
|
|
188
|
+
ledger.estimateSaving({
|
|
189
|
+
ext,
|
|
190
|
+
pages: (extra && extra.pages) || 0,
|
|
191
|
+
markupBytes: (extra && extra.markupBytes) || 0,
|
|
192
|
+
markdown,
|
|
193
|
+
}),
|
|
194
|
+
{ markdown },
|
|
195
|
+
);
|
|
196
|
+
fs.writeFileSync(cacheFile, banner(filePath, saving) + markdown, { encoding: 'utf8', mode: 0o600 });
|
|
197
|
+
ledger.recordConversion(userDataDir(), {
|
|
198
|
+
key: path.resolve(filePath),
|
|
199
|
+
ts: Date.now(),
|
|
200
|
+
usd: saving.usd,
|
|
201
|
+
ext,
|
|
202
|
+
tokens: saving.tokens,
|
|
203
|
+
baseline: saving.baseline,
|
|
204
|
+
});
|
|
137
205
|
const meta = Object.assign({
|
|
138
206
|
source: path.resolve(filePath),
|
|
139
207
|
size: src.size,
|
|
140
208
|
mtimeMs: src.mtimeMs,
|
|
141
209
|
convertedAt: new Date().toISOString(),
|
|
210
|
+
// Carried in the metadata so the hook can report what the conversion cost
|
|
211
|
+
// and saved without re-reading and re-measuring the markdown.
|
|
212
|
+
markdownBytes: Buffer.byteLength(markdown, 'utf8'),
|
|
213
|
+
tokens: saving.tokens,
|
|
214
|
+
baselineTokens: saving.baseline,
|
|
215
|
+
savedUsd: saving.usd,
|
|
142
216
|
}, extra || {});
|
|
143
217
|
fs.writeFileSync(metaPathFor(cacheFile), JSON.stringify(meta, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
144
218
|
return { cacheFile, meta };
|
|
@@ -193,6 +267,12 @@ function managedPython() {
|
|
|
193
267
|
|
|
194
268
|
const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]';
|
|
195
269
|
|
|
270
|
+
// Editing libraries, installed alongside the converter. Reading is doc2md's
|
|
271
|
+
// own job; editing is the agent's, done per-request with a short script
|
|
272
|
+
// against a COPY of the document. These are the libraries those scripts need,
|
|
273
|
+
// pre-installed so "swap the chart on slide 23" does not stall on pip.
|
|
274
|
+
const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
|
|
275
|
+
|
|
196
276
|
/**
|
|
197
277
|
* Build the managed venv and install markitdown into it.
|
|
198
278
|
*
|
|
@@ -217,8 +297,8 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
217
297
|
}
|
|
218
298
|
}
|
|
219
299
|
|
|
220
|
-
onProgress(`installing ${MARKITDOWN_SPEC}`);
|
|
221
|
-
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC], {
|
|
300
|
+
onProgress(`installing ${MARKITDOWN_SPEC} + ${EDIT_LIBS.join(', ')}`);
|
|
301
|
+
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC, ...EDIT_LIBS], {
|
|
222
302
|
encoding: 'utf8',
|
|
223
303
|
timeout: 900_000,
|
|
224
304
|
});
|
|
@@ -246,6 +326,30 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
246
326
|
* Every failure is a reason to leave the original Read alone, never to break
|
|
247
327
|
* it. That is the whole contract with the hook.
|
|
248
328
|
*/
|
|
329
|
+
/**
|
|
330
|
+
* Bridge from this synchronous pipeline to the async fig converter: run it in
|
|
331
|
+
* a child process and read one JSON object from stdout, exactly the contract
|
|
332
|
+
* the Python converter already speaks. Costs a process spawn, buys the same
|
|
333
|
+
* timeout and isolation the other formats get.
|
|
334
|
+
*/
|
|
335
|
+
function spawnFigConvert(fig2md, filePath) {
|
|
336
|
+
const runner = path.join(__dirname, 'fig2md-runner.cjs');
|
|
337
|
+
const run = spawnSync(process.execPath, [runner, filePath, userDataDir()], {
|
|
338
|
+
encoding: 'utf8',
|
|
339
|
+
timeout: CONVERT_TIMEOUT_MS,
|
|
340
|
+
maxBuffer: MAX_MARKDOWN_BYTES * 4,
|
|
341
|
+
});
|
|
342
|
+
if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
|
|
343
|
+
if (run.status !== 0) {
|
|
344
|
+
return { ok: false, reason: 'convert-failed', detail: (run.stderr || '').slice(0, 300) };
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(run.stdout);
|
|
348
|
+
} catch {
|
|
349
|
+
return { ok: false, reason: 'convert-failed', detail: 'fig converter produced no JSON' };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
249
353
|
function convert(filePath, { converter = CONVERTER, python: pythonOverride = null } = {}) {
|
|
250
354
|
if (!isTargetPath(filePath)) return { ok: false, reason: 'not-target' };
|
|
251
355
|
if (isSensitivePath(filePath)) return { ok: false, reason: 'sensitive' };
|
|
@@ -263,6 +367,17 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
263
367
|
const cached = readCache(filePath);
|
|
264
368
|
if (cached) return { ok: true, cached: true, cacheFile: cached.cacheFile, meta: cached.meta };
|
|
265
369
|
|
|
370
|
+
// Figma files take the Node converter; everything else goes to markitdown.
|
|
371
|
+
if (path.extname(filePath).toLowerCase() === '.fig') {
|
|
372
|
+
const fig2md = require('./fig2md.cjs');
|
|
373
|
+
const result = spawnFigConvert(fig2md, filePath);
|
|
374
|
+
if (!result.ok) return result;
|
|
375
|
+
const written = writeCache(filePath, result.markdown, {
|
|
376
|
+
note: result.note, truncated: false, rows: 0, pages: 0, markupBytes: 0,
|
|
377
|
+
});
|
|
378
|
+
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
379
|
+
}
|
|
380
|
+
|
|
266
381
|
// The override exists so tests can drive a stub converter with any Python at
|
|
267
382
|
// all: the normal search insists the interpreter can import markitdown,
|
|
268
383
|
// which would make the whole path untestable without the real package.
|
|
@@ -299,6 +414,8 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
299
414
|
note: payload.note || null,
|
|
300
415
|
truncated: !!payload.truncated || clipped,
|
|
301
416
|
rows: payload.rows || 0,
|
|
417
|
+
pages: payload.pages || 0,
|
|
418
|
+
markupBytes: payload.markup_bytes || 0,
|
|
302
419
|
clipped,
|
|
303
420
|
});
|
|
304
421
|
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
@@ -449,6 +566,24 @@ function documentPathsIn(text) {
|
|
|
449
566
|
// conversion. The rest are named in the note so nothing disappears quietly.
|
|
450
567
|
const MAX_PROMPT_CONVERSIONS = 3;
|
|
451
568
|
|
|
569
|
+
/**
|
|
570
|
+
* The one line that makes a conversion visible: what came in, what went out,
|
|
571
|
+
* and what not attaching the original was worth.
|
|
572
|
+
*
|
|
573
|
+
* The sizes matter more than they look. A 30MB deck that becomes 90KB of
|
|
574
|
+
* Markdown is the whole argument for this feature, and without the figures
|
|
575
|
+
* the model has nothing concrete to tell the user it happened.
|
|
576
|
+
*/
|
|
577
|
+
function conversionSummary(sourcePath, meta = {}) {
|
|
578
|
+
const parts = [];
|
|
579
|
+
try {
|
|
580
|
+
parts.push(`${humanBytes(fs.statSync(sourcePath).size)} → ${humanBytes(meta.markdownBytes || 0)}`);
|
|
581
|
+
} catch { /* the size is a nicety, not the point */ }
|
|
582
|
+
if (meta.tokens) parts.push(`약 ${Number(meta.tokens).toLocaleString('en-US')} 토큰`);
|
|
583
|
+
if (meta.savedUsd > 0) parts.push(`첨부 대비 약 $${Number(meta.savedUsd).toFixed(2)} 절감(추정)`);
|
|
584
|
+
return parts.join(', ');
|
|
585
|
+
}
|
|
586
|
+
|
|
452
587
|
/**
|
|
453
588
|
* Context to inject for a UserPromptSubmit payload, or null.
|
|
454
589
|
*
|
|
@@ -471,6 +606,7 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
471
606
|
const result = convert(p, opts);
|
|
472
607
|
if (result.ok) {
|
|
473
608
|
lines.push(` ${name} → ${result.cacheFile}`);
|
|
609
|
+
lines.push(` ${conversionSummary(p, result.meta)}`);
|
|
474
610
|
if (result.meta && result.meta.note) lines.push(` ${result.meta.note}`);
|
|
475
611
|
if (result.meta && result.meta.clipped) {
|
|
476
612
|
lines.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
@@ -479,6 +615,10 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
479
615
|
lines.push(lang === 'ko'
|
|
480
616
|
? ` ${name}: 변환기가 없어 변환하지 못했습니다. 설치: ${INSTALL_HINT}`
|
|
481
617
|
: ` ${name}: no converter installed. Install it with: ${INSTALL_HINT}`);
|
|
618
|
+
} else if (result.reason === 'no-figparser') {
|
|
619
|
+
lines.push(lang === 'ko'
|
|
620
|
+
? ` ${name}: .fig 파서가 없어 변환하지 못했습니다. 설치: claude-token-saver doc2md install-converter`
|
|
621
|
+
: ` ${name}: no .fig parser installed. Install it with: claude-token-saver doc2md install-converter`);
|
|
482
622
|
} else if (result.reason === 'sensitive') {
|
|
483
623
|
lines.push(lang === 'ko'
|
|
484
624
|
? ` ${name}: 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다.`
|
|
@@ -500,12 +640,14 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
500
640
|
'[doc2md] 이 프롬프트에 문서 경로가 있어 Markdown 으로 변환해 두었습니다.',
|
|
501
641
|
...lines,
|
|
502
642
|
'원본 대신 위 변환본을 Read 하십시오. pptx·xlsx·docx 는 Read 도구가 이진 파일이라며 거부하므로 원본을 직접 열 수 없습니다.',
|
|
643
|
+
'변환이 일어났다는 사실은 사용자에게도 보여야 하므로, 답변 첫 줄에 변환한 문서마다 다음 형식으로 한 줄씩 출력하십시오: `📄 [claude-token-saver] doc2md: <파일명> (<원본 크기> → <Markdown 크기>, 약 <토큰> 토큰)`. 절감액이 함께 안내됐다면 그 금액도 같은 줄 끝에 덧붙이십시오.',
|
|
503
644
|
'경로가 주어지지 않은 문서를 열어야 할 때는 `claude-token-saver doc2md <경로>` 를 실행해 변환본 경로를 얻으십시오.',
|
|
504
645
|
].join('\n')
|
|
505
646
|
: [
|
|
506
647
|
'[doc2md] This prompt names document paths, which have been converted to Markdown:',
|
|
507
648
|
...lines,
|
|
508
649
|
'Read the conversions above rather than the originals: Read refuses pptx/xlsx/docx as binary files, so the originals cannot be opened directly.',
|
|
650
|
+
'The user cannot see that any of this happened, so open your reply with one line per converted document: `📄 [claude-token-saver] doc2md: <name> (<source size> → <markdown size>, ~<tokens> tokens)`, adding the saving above if one was reported.',
|
|
509
651
|
'For a document whose path was not given here, run `claude-token-saver doc2md <path>` to get its conversion.',
|
|
510
652
|
].join('\n');
|
|
511
653
|
}
|
|
@@ -523,15 +665,66 @@ function sessionNote(lang = 'en') {
|
|
|
523
665
|
'[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
|
|
524
666
|
' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `claude-token-saver doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
|
|
525
667
|
' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
|
|
668
|
+
' 변환은 단방향입니다. 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 문서 수정 요청을 받으면: ① 원본을 복사하고 ② 복사본을 스크립트로 수정하십시오. pptx·docx·xlsx 는 도구 venv 파이썬에 python-pptx·python-docx·openpyxl 이 준비되어 있고(`claude-token-saver doc2md` 상태 출력에 경로가 나옵니다), .fig 는 openfig-core 로 편집·재인코드합니다. ③ 수정한 복사본을 doc2md 로 재변환해 변경이 들어갔는지 검증하십시오. 차트·이미지 같은 시각 요소는 변환본에 안 잡히므로 텍스트 검증만으로 완료를 단정하지 마십시오.',
|
|
526
669
|
].join('\n');
|
|
527
670
|
}
|
|
528
671
|
return [
|
|
529
672
|
'[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
|
|
530
673
|
' If Read refuses a document as a binary file, run `claude-token-saver doc2md <path>` and Read the .md it prints.',
|
|
531
674
|
' If the user attached a document to their message, its full contents were billed into the context. Tell them that naming the file path instead is far cheaper, since only the converted Markdown gets read.',
|
|
675
|
+
' Conversions are one-way: editing the cached .md changes nothing in the source. When asked to modify a document: ① copy the original, ② edit the COPY with a script — the managed venv python has python-pptx/python-docx/openpyxl, and .fig edits go through openfig-core — then ③ re-convert the copy with doc2md to verify the change landed. Charts and images do not appear in conversions, so text verification alone does not prove visual edits.',
|
|
532
676
|
].join('\n');
|
|
533
677
|
}
|
|
534
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Guard for Edit/Write. Two writes are refused, for different reasons.
|
|
681
|
+
*
|
|
682
|
+
* A write to a conversion in the cache: the conversion is one-way, so an edit
|
|
683
|
+
* there changes nothing the user cares about — and worse, the cache still
|
|
684
|
+
* matches the source's mtime, so the corrupted copy would be served on every
|
|
685
|
+
* later read while looking exactly like the document. The deny explains where
|
|
686
|
+
* the work should go instead.
|
|
687
|
+
*
|
|
688
|
+
* A write to the original document: Edit and Write emit text, and a pptx or
|
|
689
|
+
* .fig overwritten with text is destroyed, not edited. Nothing this tool
|
|
690
|
+
* ships can write those formats back.
|
|
691
|
+
*/
|
|
692
|
+
function decideForWrite(context) {
|
|
693
|
+
if (!context || (context.tool_name !== 'Edit' && context.tool_name !== 'Write')) return null;
|
|
694
|
+
const toolInput = context.tool_input;
|
|
695
|
+
const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
696
|
+
if (!filePath) return null;
|
|
697
|
+
const abs = path.resolve(filePath);
|
|
698
|
+
|
|
699
|
+
if (abs.startsWith(cacheDir() + path.sep)) {
|
|
700
|
+
let source = null;
|
|
701
|
+
try {
|
|
702
|
+
source = JSON.parse(fs.readFileSync(metaPathFor(abs), 'utf8')).source;
|
|
703
|
+
} catch { /* the deny stands on its own */ }
|
|
704
|
+
return {
|
|
705
|
+
deny: true,
|
|
706
|
+
reason: '[doc2md] 이 파일은 변환 캐시입니다. 여기를 고쳐도 원본 문서에는 아무것도 반영되지 않고, '
|
|
707
|
+
+ '캐시만 오염된 채 다음 읽기부터 계속 서빙됩니다.\n'
|
|
708
|
+
+ (source ? ` 원본: ${source}\n` : '')
|
|
709
|
+
+ ' 문서 수정이 목적이라면: 원본을 복사한 뒤(cp) 복사본을 스크립트로 수정하십시오. '
|
|
710
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 에 python-pptx·python-docx·openpyxl 이 설치되어 있고, `
|
|
711
|
+
+ '.fig 는 doc2md-fig 의 openfig-core 로 편집·재인코드할 수 있습니다. '
|
|
712
|
+
+ '수정 후 복사본을 `claude-token-saver doc2md <복사본>` 으로 재변환해 의도한 변경이 들어갔는지 확인하십시오. 원본은 절대 직접 수정하지 마십시오.',
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (isTargetPath(abs)) {
|
|
717
|
+
return {
|
|
718
|
+
deny: true,
|
|
719
|
+
reason: `[doc2md] ${path.basename(abs)} 는 이진 문서입니다. Edit/Write 는 텍스트를 쓰므로 이 파일을 파괴합니다. `
|
|
720
|
+
+ '수정하려면 원본을 복사한 뒤(cp) 복사본을 스크립트로 고치십시오. '
|
|
721
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 의 python-pptx·python-docx·openpyxl, .fig 는 openfig-core 를 쓰고, `
|
|
722
|
+
+ '수정 후 `claude-token-saver doc2md <복사본>` 재변환으로 결과를 검증하십시오.',
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
535
728
|
/** The decision rendered as the JSON Claude Code expects on stdout. */
|
|
536
729
|
function formatHookOutput(decision) {
|
|
537
730
|
if (!decision) return null;
|
|
@@ -565,5 +758,6 @@ module.exports = {
|
|
|
565
758
|
writeCache,
|
|
566
759
|
convert,
|
|
567
760
|
decideForRead,
|
|
761
|
+
decideForWrite,
|
|
568
762
|
formatHookOutput,
|
|
569
763
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Child-process shim between the synchronous doc2md pipeline and the async
|
|
4
|
+
* fig converter. Prints the conversion result as one JSON object on stdout,
|
|
5
|
+
* exactly like the Python converter does, so the caller treats both the same.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node fig2md-runner.cjs <file.fig> <userDataDir>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { convertFig } = require('./fig2md.cjs');
|
|
13
|
+
|
|
14
|
+
const [file, userDataDir] = process.argv.slice(2);
|
|
15
|
+
convertFig(file, userDataDir)
|
|
16
|
+
.then((result) => { process.stdout.write(JSON.stringify(result)); })
|
|
17
|
+
.catch((e) => {
|
|
18
|
+
process.stdout.write(JSON.stringify({
|
|
19
|
+
ok: false, reason: 'convert-failed', detail: String(e && e.message || e).slice(0, 300),
|
|
20
|
+
}));
|
|
21
|
+
});
|
package/src/fig2md.cjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fig2md — Markdown rendering of a Figma `.fig` export.
|
|
3
|
+
*
|
|
4
|
+
* Planning documents increasingly live in Figma rather than PowerPoint, and a
|
|
5
|
+
* `.fig` handed to the model is even more opaque than a pptx: the container
|
|
6
|
+
* is a zip, but the payload inside (`canvas.fig`) is Figma's binary kiwi
|
|
7
|
+
* format, so there is no XML to fall back on. Without a converter the file is
|
|
8
|
+
* simply unreadable.
|
|
9
|
+
*
|
|
10
|
+
* markitdown does not speak this format, so the conversion runs in Node with
|
|
11
|
+
* [openfig-core] (MIT, three small pure-JS dependencies). The parser is not
|
|
12
|
+
* bundled: this package deliberately ships with zero dependencies, so
|
|
13
|
+
* openfig-core is installed on demand into the tool's own state directory —
|
|
14
|
+
* the same arrangement as the markitdown venv, for the same reason.
|
|
15
|
+
*
|
|
16
|
+
* Verified 2026-09-06 against real files: a community Bootstrap UI kit
|
|
17
|
+
* (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each
|
|
18
|
+
* converting in under a second, plus a round-trip fixture whose Korean text
|
|
19
|
+
* nodes came back byte-identical. Both .fig vintages parse — the current
|
|
20
|
+
* zip container and the older bare fig-kiwi stream.
|
|
21
|
+
*
|
|
22
|
+
* [openfig-core]: https://github.com/OpenFig-org/openfig-core
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
const FIG_PARSER_SPEC = 'openfig-core@^0.4.1';
|
|
32
|
+
|
|
33
|
+
/** Where the on-demand parser install lives, under the tool's state dir. */
|
|
34
|
+
function managedFigDir(userDataDir) {
|
|
35
|
+
return path.join(userDataDir, 'doc2md-fig');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadParser(userDataDir) {
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line import/no-dynamic-require
|
|
41
|
+
return require(path.join(managedFigDir(userDataDir), 'node_modules', 'openfig-core', 'dist', 'index.cjs'));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Install openfig-core into the managed directory. Mirrors the markitdown
|
|
49
|
+
* venv install: network once, probe as the acceptance test.
|
|
50
|
+
*/
|
|
51
|
+
function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
|
|
52
|
+
const dir = managedFigDir(userDataDir);
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
const pkgJson = path.join(dir, 'package.json');
|
|
55
|
+
if (!fs.existsSync(pkgJson)) {
|
|
56
|
+
fs.writeFileSync(pkgJson, JSON.stringify({ name: 'doc2md-fig', private: true }) + '\n');
|
|
57
|
+
}
|
|
58
|
+
onProgress(`installing ${FIG_PARSER_SPEC}`);
|
|
59
|
+
const r = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', FIG_PARSER_SPEC], {
|
|
60
|
+
cwd: dir,
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
timeout: 300_000,
|
|
63
|
+
});
|
|
64
|
+
if (r.status !== 0) {
|
|
65
|
+
return { ok: false, reason: 'npm-failed', detail: (r.stderr || '').slice(0, 400) };
|
|
66
|
+
}
|
|
67
|
+
if (!loadParser(userDataDir)) {
|
|
68
|
+
return { ok: false, reason: 'import-failed', detail: 'installed, but openfig-core does not load' };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, dir };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Node name plus type, indented by depth: the skeleton lines of the outline.
|
|
75
|
+
*/
|
|
76
|
+
function heading(node, depth) {
|
|
77
|
+
const name = String(node.name || '').trim() || '(이름 없음)';
|
|
78
|
+
return `${'#'.repeat(Math.min(depth + 2, 6))} ${name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Walk the parsed document and render an outline.
|
|
83
|
+
*
|
|
84
|
+
* The traversal follows `childrenMap` (guid → ordered children), which is how
|
|
85
|
+
* the parser exposes hierarchy. Containers become headings, TEXT nodes become
|
|
86
|
+
* body lines, and everything visual (vectors, rectangles, images) is counted
|
|
87
|
+
* rather than listed: in a planning document the words are the content, and
|
|
88
|
+
* two hundred `Rectangle 173` lines would drown them.
|
|
89
|
+
*/
|
|
90
|
+
function renderMarkdown(doc, sourceName) {
|
|
91
|
+
const guidKey = (g) => `${g.sessionID}:${g.localID}`;
|
|
92
|
+
const lines = [];
|
|
93
|
+
const skipped = Object.create(null);
|
|
94
|
+
let textNodes = 0;
|
|
95
|
+
|
|
96
|
+
const CONTAINERS = new Set(['DOCUMENT', 'CANVAS', 'FRAME', 'GROUP', 'SECTION', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'SLIDE', 'SYMBOL']);
|
|
97
|
+
|
|
98
|
+
function walk(node, depth) {
|
|
99
|
+
if (!node || node.phase === 'REMOVED' || node.visible === false) return;
|
|
100
|
+
const type = node.type || '?';
|
|
101
|
+
if (type === 'TEXT') {
|
|
102
|
+
textNodes += 1;
|
|
103
|
+
const text = String(node.textData?.characters || '').trim();
|
|
104
|
+
const name = String(node.name || '').trim();
|
|
105
|
+
// The layer name usually repeats the text's first line; only show it
|
|
106
|
+
// when it says something the text does not.
|
|
107
|
+
if (name && text && !text.startsWith(name) && !name.startsWith(text.slice(0, 20))) {
|
|
108
|
+
lines.push(`- **${name}**: ${text.replace(/\n/g, ' / ')}`);
|
|
109
|
+
} else if (text) {
|
|
110
|
+
lines.push(`- ${text.replace(/\n/g, ' / ')}`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (CONTAINERS.has(type)) {
|
|
115
|
+
if (type !== 'DOCUMENT') {
|
|
116
|
+
lines.push('', heading(node, depth), '');
|
|
117
|
+
}
|
|
118
|
+
const children = doc.childrenMap?.get?.(guidKey(node.guid))
|
|
119
|
+
|| doc.childrenMap?.[guidKey(node.guid)]
|
|
120
|
+
|| [];
|
|
121
|
+
for (const child of children) walk(child, depth + (type === 'DOCUMENT' ? 0 : 1));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
skipped[type] = (skipped[type] || 0) + 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const root = (doc.nodes || []).find((n) => n.type === 'DOCUMENT');
|
|
128
|
+
if (root) walk(root, 0);
|
|
129
|
+
|
|
130
|
+
const title = doc.meta?.file_name || sourceName;
|
|
131
|
+
const head = [`# ${title}`];
|
|
132
|
+
const skippedText = Object.entries(skipped)
|
|
133
|
+
.sort((a, b) => b[1] - a[1])
|
|
134
|
+
.map(([t, n]) => `${t} ${n}개`)
|
|
135
|
+
.join(', ');
|
|
136
|
+
if (skippedText) {
|
|
137
|
+
head.push('', `(텍스트 외 시각 요소는 생략했습니다: ${skippedText}. 시각 확인이 필요하면 Figma에서 원본을 여십시오.)`);
|
|
138
|
+
}
|
|
139
|
+
return { markdown: [...head, ...lines, ''].join('\n'), textNodes };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Convert one `.fig` file. Same result contract as the Python converter:
|
|
144
|
+
* `{ ok, markdown, note, truncated, rows, pages, markup_bytes }` or
|
|
145
|
+
* `{ ok: false, reason, detail }`.
|
|
146
|
+
*
|
|
147
|
+
* `markup_bytes` is 0 on purpose. The office formats price their alternative
|
|
148
|
+
* as "unzip and wade through the XML", but a .fig unzips to another binary —
|
|
149
|
+
* there is no readable fallback, so there is no honest baseline to claim and
|
|
150
|
+
* these conversions count as documents handled rather than money saved.
|
|
151
|
+
*/
|
|
152
|
+
async function convertFig(filePath, userDataDir) {
|
|
153
|
+
const parser = loadParser(userDataDir);
|
|
154
|
+
if (!parser) return { ok: false, reason: 'no-figparser' };
|
|
155
|
+
let doc;
|
|
156
|
+
try {
|
|
157
|
+
const buf = fs.readFileSync(filePath);
|
|
158
|
+
// Two vintages of the same extension: current exports are a zip wrapping
|
|
159
|
+
// canvas.fig, older ones are the bare fig-kiwi stream (magic "fig-kiwi").
|
|
160
|
+
// Verified on a real 8.4MB community kit that only the binary path reads.
|
|
161
|
+
doc = buf[0] === 0x50 && buf[1] === 0x4b
|
|
162
|
+
? await parser.parseFig(buf)
|
|
163
|
+
: await parser.parseFigBinary(buf);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { ok: false, reason: 'convert-failed', detail: String(e.message || e).slice(0, 300) };
|
|
166
|
+
}
|
|
167
|
+
const { markdown, textNodes } = renderMarkdown(doc, path.basename(filePath, '.fig'));
|
|
168
|
+
if (!textNodes) {
|
|
169
|
+
// A design file with no words converts to an empty outline, which would
|
|
170
|
+
// read as "the document says nothing" — a worse claim than "unreadable".
|
|
171
|
+
return { ok: false, reason: 'no-text', detail: 'the file has no text nodes' };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, markdown, note: null, truncated: false, rows: 0, pages: 0, markup_bytes: 0 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
FIG_PARSER_SPEC,
|
|
178
|
+
managedFigDir,
|
|
179
|
+
installFigParser,
|
|
180
|
+
convertFig,
|
|
181
|
+
renderMarkdown,
|
|
182
|
+
};
|
|
@@ -261,7 +261,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
261
261
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
262
262
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
263
263
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
264
|
-
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
264
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, doc2md, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
265
265
|
* @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
|
|
266
266
|
*/
|
|
267
267
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
@@ -350,6 +350,32 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
350
350
|
? `${c(YELLOW)}🔀 ${unresolvedRuns} unresolved${c(RESET)}`
|
|
351
351
|
: null);
|
|
352
352
|
|
|
353
|
+
// Document conversions — the same kind of number as "Routing saved", earned
|
|
354
|
+
// a different way: a document read as Markdown instead of attached whole.
|
|
355
|
+
// Kept as its own chip rather than folded into the routing total, because a
|
|
356
|
+
// single figure could not tell the reader which habit earned it.
|
|
357
|
+
//
|
|
358
|
+
// Conversions that saved nothing measurable still show as a count. For
|
|
359
|
+
// pptx/xlsx/docx the honest saving is zero — the client extracts much the
|
|
360
|
+
// same text — and a chip that disappeared on those would read as "doc2md
|
|
361
|
+
// did nothing" on the very formats it is the only way to open.
|
|
362
|
+
// icon: "📄 $0.42" verbose: "📄 Doc2md saved $0.42 · 12 docs"
|
|
363
|
+
// text: "Doc2md saved $0.42"
|
|
364
|
+
const doc2md = data.doc2mdTotals;
|
|
365
|
+
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
366
|
+
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
367
|
+
const doc2mdLabel = isIcon
|
|
368
|
+
? (verbose ? '📄 Doc2md saved' : '📄')
|
|
369
|
+
: 'Doc2md saved';
|
|
370
|
+
let doc2mdSeg = null;
|
|
371
|
+
if (doc2mdUsd > 0) {
|
|
372
|
+
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
|
|
373
|
+
+ (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
374
|
+
} else if (doc2mdDocs > 0) {
|
|
375
|
+
const label = isIcon ? '📄' : 'Doc2md';
|
|
376
|
+
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
353
379
|
// Routing-savings headline line (multi-line layout). The lifetime sum from
|
|
354
380
|
// the delegation ledger — the number the whole tool exists to grow, so it
|
|
355
381
|
// gets line 1 to itself while the diagnostics move to line 2.
|
|
@@ -388,6 +414,24 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
388
414
|
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
|
|
389
415
|
}
|
|
390
416
|
|
|
417
|
+
// Doc2md's own headline line, same anatomy as the routing one: a green
|
|
418
|
+
// lifetime total, then a gray per-format breakdown. Only built when there
|
|
419
|
+
// is money to report — a bare document count stays an inline chip, since a
|
|
420
|
+
// whole line for "3 docs" would be all frame and no figure.
|
|
421
|
+
// icon: "📄 Doc2md saved $1.8 | pptx 1× $1.55 · pdf 2× $0.27"
|
|
422
|
+
let doc2mdLine = null;
|
|
423
|
+
if (!singleLine && doc2mdUsd > 0) {
|
|
424
|
+
const head = isIcon ? '📄 Doc2md saved' : 'Doc2md saved';
|
|
425
|
+
const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
|
|
426
|
+
const extText = byExt
|
|
427
|
+
.map((r) => `${c(GRAY)}${r.ext} ${r.docs}× ${formatMoney(r.usd)}${c(RESET)}`)
|
|
428
|
+
.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
429
|
+
doc2mdLine =
|
|
430
|
+
`${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
431
|
+
`${c(GREEN)}${formatMoney(doc2mdUsd)}${c(RESET)}` +
|
|
432
|
+
(extText ? ` ${c(GRAY)}|${c(RESET)} ${extText}` : '');
|
|
433
|
+
}
|
|
434
|
+
|
|
391
435
|
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
|
392
436
|
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
393
437
|
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
@@ -628,6 +672,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
628
672
|
// When the totals headline owns line 1, the inline session chip would
|
|
629
673
|
// repeat the same story on line 2 — drop it there.
|
|
630
674
|
if (delegateSeg && want('delegated') && !totalsLine) segs.push(delegateSeg);
|
|
675
|
+
// Only when it did not already earn a headline line above.
|
|
676
|
+
if (doc2mdSeg && want('doc2md') && !doc2mdLine) segs.push(doc2mdSeg);
|
|
631
677
|
if (want('hit')) segs.push(hitSeg);
|
|
632
678
|
if (want('ttl')) segs.push(ttlSeg);
|
|
633
679
|
for (const { key, seg } of usageSegs) {
|
|
@@ -646,8 +692,17 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
646
692
|
// allowed — --no-color/NO_COLOR consumers expect escape-free output.
|
|
647
693
|
const eol = color ? '\x1b[K' : '';
|
|
648
694
|
const rest = segs.join(' · ') + eol;
|
|
695
|
+
// Each savings source that earned real money gets a headline line, ordered
|
|
696
|
+
// biggest saver first — the top line is the one the eye lands on, so it
|
|
697
|
+
// goes to whichever habit is actually paying for the tool. The diagnostics
|
|
698
|
+
// line always closes.
|
|
699
|
+
const headlines = [];
|
|
649
700
|
if (totalsLine && want('delegated')) {
|
|
650
|
-
|
|
701
|
+
headlines.push({ usd: Number(totals && totals.total) || 0, line: totalsLine });
|
|
702
|
+
}
|
|
703
|
+
if (doc2mdLine && want('doc2md')) {
|
|
704
|
+
headlines.push({ usd: doc2mdUsd, line: doc2mdLine });
|
|
651
705
|
}
|
|
652
|
-
|
|
706
|
+
headlines.sort((a, b) => b.usd - a.usd);
|
|
707
|
+
return headlines.map((h) => h.line + eol + '\n').join('') + rest;
|
|
653
708
|
}
|