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/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
- const TARGET_EXTENSIONS = ['.pptx', '.xlsx', '.xls', '.pdf', '.docx'];
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
- const base = path.basename(abs).replace(/[^\w.\-]/g, '_');
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
- fs.writeFileSync(cacheFile, markdown, { encoding: 'utf8', mode: 0o600 });
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
- return totalsLine + eol + '\n' + rest;
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
- return rest;
706
+ headlines.sort((a, b) => b.usd - a.usd);
707
+ return headlines.map((h) => h.line + eol + '\n').join('') + rest;
653
708
  }