claude-token-saver 3.28.0 → 3.28.1

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 CHANGED
@@ -397,12 +397,18 @@ Three situations, three different interception points:
397
397
  That second row is measured, not assumed: a `.pdf` Read fires the hook, and a `.pptx` Read in the same session leaves no hook log entry at all.
398
398
 
399
399
  ```bash
400
- claude-token-saver doc2md install-converter # markitdown + editing libs + the .fig parser
401
- claude-token-saver doc2md on # register the Read hook
400
+ claude-token-saver doc2md on # register the hooks (the converter installs itself)
402
401
  claude-token-saver doc2md # check converter + hook registration
403
402
  claude-token-saver doc2md report.pptx # convert by hand and see the result
403
+ claude-token-saver doc2md install-converter # only to get the install out of the way early
404
404
  ```
405
405
 
406
+ **The converter installs itself.** Any rollout step a person has to be told about is a step some of them skip, so the converter installs in the background the moment a document first shows up, and converts as soon as it is ready. Measured: about 30s for the first document (15s install plus markitdown's first import), then 3.7s for a new document and 0.1s on a cache hit. The `.fig` parser installs in half a second on the first Figma file.
407
+
408
+ It installs on first use rather than at `install` time: the venv is 47MB, and someone who never opens a document should not pay for it. Set `CTS_DOC2MD_NO_AUTOINSTALL=1` to turn the automatic install off.
409
+
410
+ **Python 3.10+ is required** — markitdown's own floor, and macOS still ships 3.9 as `/usr/bin/python3`. The venv is built on an interpreter chosen by version rather than by PATH order. Built on 3.9, pip resolves markitdown to a 2019 placeholder release (0.0.1a1): the install looks like it worked and every conversion then dies at import. This was found by walking into it. When nothing on the machine is new enough, the message points at `brew install python` instead of at an install command that cannot succeed.
411
+
406
412
  The converter goes into a venv this tool owns (`<state dir>/doc2md-venv`): no system interpreter is touched, and uninstalling the CLI takes it along. An existing markitdown on `uv tool` or `PATH` is preferred over building a new one.
407
413
 
408
414
  Conversion is [markitdown](https://github.com/microsoft/markitdown). Slide numbers, heading levels, tables, speaker notes and per-sheet headings all survive, and non-Latin text comes through intact.
package/README.md CHANGED
@@ -389,12 +389,18 @@ doc2md 는 그 파일을 한 번 변환해 두고 원본 대신 변환본을 읽
389
389
  두 번째 줄의 제약은 실측으로 확인한 것입니다. `.pdf` 를 Read 하면 훅이 실행되고, 같은 세션에서 `.pptx` 를 Read 하면 훅 로그에 아무 기록도 남지 않습니다.
390
390
 
391
391
  ```bash
392
- claude-token-saver doc2md install-converter # markitdown·편집 라이브러리·.fig 파서 설치
393
- claude-token-saver doc2md on # Read 훅 등록
392
+ claude-token-saver doc2md on # 훅 등록 (변환기는 첫 문서에서 자동 설치)
394
393
  claude-token-saver doc2md # 변환기·훅 등록 상태 확인
395
394
  claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
395
+ claude-token-saver doc2md install-converter # 설치를 미리 끝내 두고 싶을 때만
396
396
  ```
397
397
 
398
+ **변환기는 알아서 깔립니다.** 팀에 배포할 때 각자 설치 명령을 실행하게 만들면 그 단계에서 빠지는 사람이 생깁니다. 그래서 문서가 처음 등장하는 시점에 변환기가 백그라운드로 설치되고, 설치가 끝나는 대로 곧바로 변환합니다. 실측으로 첫 문서는 약 30초(설치 15초 + markitdown 최초 임포트), 이후로는 새 문서 3.7초, 캐시 적중 0.1초입니다. `.fig` 파서는 첫 Figma 파일에서 0.5초 만에 깔립니다.
399
+
400
+ 설치는 `install` 단계가 아니라 첫 사용 시점에 합니다. venv 가 47MB 라서, 문서를 다루지 않는 사람은 낼 이유가 없는 비용입니다. 자동 설치를 끄려면 `CTS_DOC2MD_NO_AUTOINSTALL=1` 을 설정하십시오.
401
+
402
+ **파이썬 3.10 이상이 필요합니다.** markitdown 의 요구 사항이고, macOS 기본 `/usr/bin/python3` 는 3.9 입니다. 이 도구는 PATH 순서를 따르지 않고 3.10 이상인 인터프리터를 골라 venv 를 만듭니다. 3.9 로 만들면 pip 가 markitdown 을 2019 년 자리표시자 릴리스(0.0.1a1)로 해석해서, 설치는 성공한 것처럼 보이지만 모든 변환이 임포트 단계에서 죽습니다. 실제로 이 함정을 밟고 잡았습니다. 3.10 이상이 아예 없으면 설치 명령을 안내하는 대신 `brew install python` 을 안내합니다.
403
+
398
404
  변환기는 도구 전용 venv(`<상태 디렉터리>/doc2md-venv`)에 설치합니다. 시스템 파이썬을 건드리지 않고, CLI를 지우면 함께 사라집니다. 이미 `uv tool` 이나 다른 경로에 markitdown 이 있으면 그쪽을 먼저 씁니다.
399
405
 
400
406
  변환은 [markitdown](https://github.com/microsoft/markitdown)이 담당하며, 슬라이드 번호와 제목 계층, 표, 발표자 노트, 시트 구분이 모두 남습니다. 한글도 깨지지 않습니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.28.0",
3
+ "version": "3.28.1",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/doc2md.cjs CHANGED
@@ -26,7 +26,7 @@ const fs = require('node:fs');
26
26
  const os = require('node:os');
27
27
  const path = require('node:path');
28
28
  const crypto = require('node:crypto');
29
- const { spawnSync } = require('node:child_process');
29
+ const { spawn, spawnSync } = require('node:child_process');
30
30
  const ledger = require('./doc2md-ledger.cjs');
31
31
 
32
32
  // Formats where the original is of no use to the model. Images are absent
@@ -226,6 +226,13 @@ function writeCache(filePath, markdown, extra) {
226
226
  * memoized for the life of this process, and the caller memoizes across
227
227
  * processes through the notice file.
228
228
  */
229
+ // The readiness test for an interpreter. Importing the *class* matters: a pip
230
+ // install creates the package directory long before it finishes writing into
231
+ // it, so a bare `import markitdown` reports success mid-install and the
232
+ // conversion then fails with "cannot import name 'MarkItDown'". Measured
233
+ // during the first-use auto-install, where the window is about a second wide.
234
+ const PROBE = 'from markitdown import MarkItDown';
235
+
229
236
  let interpreterCache;
230
237
  function findInterpreter() {
231
238
  if (interpreterCache !== undefined) return interpreterCache;
@@ -244,7 +251,7 @@ function findInterpreter() {
244
251
  );
245
252
  for (const bin of candidates) {
246
253
  try {
247
- const probe = spawnSync(bin, ['-c', 'import markitdown'], { timeout: 20_000, stdio: 'ignore' });
254
+ const probe = spawnSync(bin, ['-c', PROBE], { timeout: 20_000, stdio: 'ignore' });
248
255
  if (probe.status === 0) {
249
256
  interpreterCache = bin;
250
257
  return bin;
@@ -281,19 +288,53 @@ const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
281
288
  * for, it goes somewhere this tool owns, so uninstalling the CLI takes the
282
289
  * whole thing with it and no system interpreter is touched.
283
290
  */
291
+ /**
292
+ * A Python new enough to run markitdown (3.10+), or null.
293
+ *
294
+ * Explicit version names are tried before the bare `python3` so a modern
295
+ * Homebrew interpreter wins over the system one regardless of PATH order.
296
+ */
297
+ function findVenvBase() {
298
+ const candidates = [
299
+ 'python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10',
300
+ 'python3', 'python',
301
+ ];
302
+ for (const bin of candidates) {
303
+ const r = spawnSync(bin, ['-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], {
304
+ encoding: 'utf8',
305
+ timeout: 20_000,
306
+ });
307
+ if (r.status !== 0) continue;
308
+ const [major, minor] = String(r.stdout).trim().split('.').map(Number);
309
+ if (major > 3 || (major === 3 && minor >= 10)) return { bin, version: `python ${major}.${minor}` };
310
+ }
311
+ return null;
312
+ }
313
+
284
314
  function installConverter({ onProgress = () => {} } = {}) {
285
315
  const venv = path.join(userDataDir(), 'doc2md-venv');
286
316
  const target = managedPython();
287
317
 
288
318
  if (!fs.existsSync(target)) {
289
- onProgress(`creating ${venv}`);
290
- let created = false;
291
- for (const base of ['python3', 'python']) {
292
- const r = spawnSync(base, ['-m', 'venv', venv], { encoding: 'utf8', timeout: 180_000 });
293
- if (r.status === 0) { created = true; break; }
319
+ // The base interpreter is chosen by version, not by whichever `python3`
320
+ // comes first on PATH. markitdown needs 3.10+, and macOS still ships 3.9
321
+ // as /usr/bin/python3: building the venv on that one installs a
322
+ // seven-year-old placeholder release (0.0.1a1) that has no MarkItDown
323
+ // class in it, and every conversion then fails at import time. Measured
324
+ // on this machine, where /usr/bin/python3 precedes Homebrew's 3.14.
325
+ const base = findVenvBase();
326
+ if (!base) {
327
+ return {
328
+ ok: false,
329
+ reason: 'no-python',
330
+ detail: 'markitdown needs Python 3.10 or newer; none was found on PATH '
331
+ + '(macOS /usr/bin/python3 is 3.9 — install a newer one, e.g. `brew install python`)',
332
+ };
294
333
  }
295
- if (!created) {
296
- return { ok: false, reason: 'no-python', detail: 'no python3 with the venv module on PATH' };
334
+ onProgress(`creating ${venv} (${base.version})`);
335
+ const r = spawnSync(base.bin, ['-m', 'venv', venv], { encoding: 'utf8', timeout: 180_000 });
336
+ if (r.status !== 0) {
337
+ return { ok: false, reason: 'no-python', detail: (r.stderr || 'venv creation failed').slice(0, 300) };
297
338
  }
298
339
  }
299
340
 
@@ -308,7 +349,7 @@ function installConverter({ onProgress = () => {} } = {}) {
308
349
 
309
350
  // The probe is the actual acceptance test: pip can exit 0 and still leave an
310
351
  // interpreter that cannot import what was asked for.
311
- const probe = spawnSync(target, ['-c', 'import markitdown'], { timeout: 60_000, stdio: 'ignore' });
352
+ const probe = spawnSync(target, ['-c', PROBE], { timeout: 60_000, stdio: 'ignore' });
312
353
  if (probe.status !== 0) {
313
354
  return { ok: false, reason: 'import-failed', detail: 'installed, but markitdown does not import' };
314
355
  }
@@ -320,8 +361,8 @@ function installConverter({ onProgress = () => {} } = {}) {
320
361
  /**
321
362
  * Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
322
363
  * `{ ok: false, reason, detail }` where reason is one of:
323
- * no-markitdown | too-large | sensitive | unsafe-archive | no-text |
324
- * convert-failed | timeout
364
+ * no-markitdown | python-too-old | no-figparser | too-large | sensitive |
365
+ * unsafe-archive | no-text | convert-failed | timeout
325
366
  *
326
367
  * Every failure is a reason to leave the original Read alone, never to break
327
368
  * it. That is the whole contract with the hook.
@@ -370,7 +411,14 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
370
411
  // Figma files take the Node converter; everything else goes to markitdown.
371
412
  if (path.extname(filePath).toLowerCase() === '.fig') {
372
413
  const fig2md = require('./fig2md.cjs');
373
- const result = spawnFigConvert(fig2md, filePath);
414
+ let result = spawnFigConvert(fig2md, filePath);
415
+ // The .fig parser is an npm install of a few hundred KB, fast enough to
416
+ // wait for inline the first time a Figma file turns up.
417
+ if (!result.ok && result.reason === 'no-figparser'
418
+ && process.env.CTS_DOC2MD_NO_AUTOINSTALL !== '1') {
419
+ fig2md.installFigParser(userDataDir());
420
+ result = spawnFigConvert(fig2md, filePath);
421
+ }
374
422
  if (!result.ok) return result;
375
423
  const written = writeCache(filePath, result.markdown, {
376
424
  note: result.note, truncated: false, rows: 0, pages: 0, markupBytes: 0,
@@ -381,7 +429,21 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
381
429
  // The override exists so tests can drive a stub converter with any Python at
382
430
  // all: the normal search insists the interpreter can import markitdown,
383
431
  // which would make the whole path untestable without the real package.
384
- const python = pythonOverride || findInterpreter();
432
+ let python = pythonOverride || findInterpreter();
433
+ if (!python && !pythonOverride) {
434
+ // Telling someone to run the install command is wrong when the install
435
+ // cannot succeed on this machine. A too-old interpreter is a different
436
+ // problem with a different fix, so it gets its own reason rather than
437
+ // hiding behind "no converter".
438
+ if (!findVenvBase()) {
439
+ return {
440
+ ok: false,
441
+ reason: 'python-too-old',
442
+ detail: 'markitdown needs Python 3.10 or newer; none was found on PATH',
443
+ };
444
+ }
445
+ if (ensureConverterInstalled()) python = findInterpreter();
446
+ }
385
447
  if (!python) return { ok: false, reason: 'no-markitdown' };
386
448
 
387
449
  const run = spawnSync(python, [converter, filePath], {
@@ -421,6 +483,72 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
421
483
  return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
422
484
  }
423
485
 
486
+ /**
487
+ * First-use install, so nobody has to be told to run a setup command.
488
+ *
489
+ * A team rollout dies on any step a person has to be told about, so the
490
+ * converter installs itself the first time a document actually shows up. It
491
+ * is still lazy rather than part of `install`: the venv is 47MB, and someone
492
+ * who never opens a document should never pay for it.
493
+ *
494
+ * The install runs detached and the caller waits only briefly. A cold install
495
+ * measured 6 seconds on a fast connection, but a corporate network can be far
496
+ * slower, and a hook that blocks a prompt for a minute is worse than a
497
+ * document that converts on the next turn. So: start it, wait up to
498
+ * `waitMs`, and if it is still going, say so and let this turn proceed
499
+ * without the conversion.
500
+ *
501
+ * Returns true when a converter is ready to use right now.
502
+ */
503
+ function ensureConverterInstalled({ waitMs = 15_000 } = {}) {
504
+ if (process.env.CTS_DOC2MD_NO_AUTOINSTALL === '1') return false;
505
+ if (findInterpreter()) return true;
506
+
507
+ const lock = path.join(userDataDir(), 'doc2md-install.lock');
508
+ let running = false;
509
+ try {
510
+ const started = JSON.parse(fs.readFileSync(lock, 'utf8')).startedAt;
511
+ // A lock older than the pip timeout is a crashed run, not a live one.
512
+ running = Number.isFinite(started) && Date.now() - started < 900_000;
513
+ } catch { /* no lock, or an unreadable one: treat as not running */ }
514
+
515
+ if (!running) {
516
+ try {
517
+ fs.mkdirSync(userDataDir(), { recursive: true });
518
+ fs.writeFileSync(lock, JSON.stringify({ startedAt: Date.now() }));
519
+ const cli = path.join(__dirname, '..', 'bin', 'cli.js');
520
+ // Detached, so a session that ends mid-install does not take the
521
+ // install with it — the next session finds it finished.
522
+ const child = spawn(process.execPath, [cli, 'doc2md', 'install-converter'], {
523
+ detached: true,
524
+ stdio: 'ignore',
525
+ });
526
+ child.unref();
527
+ } catch {
528
+ try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ }
529
+ return false;
530
+ }
531
+ }
532
+
533
+ // Poll cheaply: the interpreter file appearing is the first sign, and the
534
+ // import probe is the acceptance test. interpreterCache has to be cleared
535
+ // or the memoized null from the top of this function would stick.
536
+ const deadline = Date.now() + waitMs;
537
+ while (Date.now() < deadline) {
538
+ if (fs.existsSync(managedPython())) {
539
+ interpreterCache = undefined;
540
+ if (findInterpreter()) {
541
+ try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ }
542
+ return true;
543
+ }
544
+ }
545
+ // A synchronous sleep, because every caller here is synchronous. 400ms
546
+ // keeps the poll count low over a 15s wait.
547
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 400);
548
+ }
549
+ return false;
550
+ }
551
+
424
552
  /** Where the "markitdown is not installed" notice records that it was shown. */
425
553
  function noticePath() {
426
554
  return path.join(userDataDir(), 'doc2md-notice.json');
@@ -490,9 +618,19 @@ function decideForRead(context, opts = {}) {
490
618
  markNoticeShown();
491
619
  return {
492
620
  deny: false,
493
- reason: `[doc2md] ${name} 를 변환하려 했으나 markitdown 이 설치되어 있지 않습니다.\n`
494
- + ` 설치: ${INSTALL_HINT}\n`
495
- + ' 설치 전까지는 원본을 그대로 읽습니다. 이 안내는 한 번만 표시됩니다.',
621
+ reason: `[doc2md] ${name} 를 변환할 변환기를 지금 설치하고 있습니다(첫 실행에만 걸립니다).\n`
622
+ + ' 설치가 끝나면 다음 요청부터 자동으로 변환합니다. 이번 turn 은 원본을 그대로 읽습니다.\n'
623
+ + ` 진행 상황: ${INSTALL_HINT} 를 직접 실행하면 설치 로그를 볼 수 있습니다.`,
624
+ };
625
+ }
626
+ if (result.reason === 'python-too-old') {
627
+ if (noticeAlreadyShown()) return null;
628
+ markNoticeShown();
629
+ return {
630
+ deny: false,
631
+ reason: `[doc2md] ${name} 를 변환하지 못했습니다. 변환기(markitdown)는 Python 3.10 이상이 필요한데 PATH 에서 찾지 못했습니다.\n`
632
+ + ' macOS 기본 /usr/bin/python3 는 3.9 입니다. `brew install python` 으로 새 버전을 설치한 뒤 다시 시도하십시오.\n'
633
+ + ' 그때까지는 원본을 그대로 읽습니다. 이 안내는 한 번만 표시됩니다.',
496
634
  };
497
635
  }
498
636
  if (result.reason === 'no-text') {
@@ -613,8 +751,12 @@ function contextForPrompt(payload, opts = {}) {
613
751
  }
614
752
  } else if (result.reason === 'no-markitdown') {
615
753
  lines.push(lang === 'ko'
616
- ? ` ${name}: 변환기가 없어 변환하지 못했습니다. 설치: ${INSTALL_HINT}`
617
- : ` ${name}: no converter installed. Install it with: ${INSTALL_HINT}`);
754
+ ? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
755
+ : ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
756
+ } else if (result.reason === 'python-too-old') {
757
+ lines.push(lang === 'ko'
758
+ ? ` ${name}: 변환기가 Python 3.10 이상을 요구하는데 PATH 에 없습니다(macOS 기본은 3.9). \`brew install python\` 후 다시 시도하도록 사용자에게 안내하십시오.`
759
+ : ` ${name}: the converter needs Python 3.10+, and none is on PATH (macOS ships 3.9). Tell the user to install a newer Python, e.g. \`brew install python\`.`);
618
760
  } else if (result.reason === 'no-figparser') {
619
761
  lines.push(lang === 'ko'
620
762
  ? ` ${name}: .fig 파서가 없어 변환하지 못했습니다. 설치: claude-token-saver doc2md install-converter`
@@ -744,6 +886,8 @@ module.exports = {
744
886
  MARKITDOWN_SPEC,
745
887
  managedPython,
746
888
  installConverter,
889
+ ensureConverterInstalled,
890
+ findVenvBase,
747
891
  clearNotice,
748
892
  cacheDir,
749
893
  cachePathFor,