sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
package/src/doc2md.cjs
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc2md — hand the model a Markdown rendering of an attached document
|
|
3
|
+
* instead of the binary.
|
|
4
|
+
*
|
|
5
|
+
* A pptx or xlsx read straight into the context window is close to the worst
|
|
6
|
+
* thing a token-saving tool can allow: the bytes are unreadable to the model,
|
|
7
|
+
* so it either gets nothing useful or spends a fortune finding that out. This
|
|
8
|
+
* intercepts the Read, converts the file once, caches the result, and points
|
|
9
|
+
* the model at the .md.
|
|
10
|
+
*
|
|
11
|
+
* CommonJS on purpose. It runs from ~/.claude/ through the copied hook script,
|
|
12
|
+
* where there is no package.json to declare `"type": "module"`, which is the
|
|
13
|
+
* same reason korean-lint.cjs is written this way.
|
|
14
|
+
*
|
|
15
|
+
* Conversion is markitdown, a Python package. It cannot be an npm dependency,
|
|
16
|
+
* so a missing install is an ordinary state rather than an error: say so once,
|
|
17
|
+
* then get out of the way and let the Read proceed untouched. Failing loudly
|
|
18
|
+
* on every Read would be worse than the problem being solved, and failing
|
|
19
|
+
* silently is how graphify's `except ImportError: return ""` hid a broken
|
|
20
|
+
* converter for months.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const crypto = require('node:crypto');
|
|
29
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
30
|
+
const ledger = require('./doc2md-ledger.cjs');
|
|
31
|
+
|
|
32
|
+
// Formats where the original is of no use to the model. Images are absent
|
|
33
|
+
// deliberately: markitdown returns nothing for them, and OCR misread resource
|
|
34
|
+
// names in testing (`c5.xlarge` as `c.xlarge`), which is worse than no text at
|
|
35
|
+
// all in a document where those names are the content. The model reads images
|
|
36
|
+
// natively anyway.
|
|
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'];
|
|
40
|
+
|
|
41
|
+
// Big enough for real decks and reports, small enough that a hostile file
|
|
42
|
+
// cannot make the converter the expensive part of the turn.
|
|
43
|
+
const MAX_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
44
|
+
// A conversion larger than this costs more to read than it saves.
|
|
45
|
+
const MAX_MARKDOWN_BYTES = 2 * 1024 * 1024;
|
|
46
|
+
// Cold `import markitdown` measured at ~12s; conversions after that are under
|
|
47
|
+
// two seconds except for very large workbooks, which the row cap handles.
|
|
48
|
+
const CONVERT_TIMEOUT_MS = 120_000;
|
|
49
|
+
|
|
50
|
+
// Names that suggest the file should not be left lying around as plain text.
|
|
51
|
+
// Deliberately blunt: the cost of skipping a payroll deck is one extra manual
|
|
52
|
+
// step, and the cost of caching it is not recoverable.
|
|
53
|
+
const SENSITIVE_PATTERNS = [
|
|
54
|
+
/secret/i, /password/i, /credential/i, /salary/i, /payroll/i, /confidential/i,
|
|
55
|
+
/개인정보/, /급여/, /계약/, /대외비/,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Mirrors src/paths.js userDataDir(). Duplicated rather than imported because
|
|
59
|
+
// this file is CommonJS and paths.js is ESM; the precedence order has to match
|
|
60
|
+
// it exactly, or conversions would land somewhere the rest of the tool does
|
|
61
|
+
// not look.
|
|
62
|
+
function userDataDir() {
|
|
63
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
64
|
+
return path.join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
|
|
65
|
+
}
|
|
66
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
67
|
+
return path.join(process.env.APPDATA, 'claude-token-saver');
|
|
68
|
+
}
|
|
69
|
+
if (process.platform === 'darwin') {
|
|
70
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
|
|
71
|
+
}
|
|
72
|
+
return path.join(os.homedir(), '.config', 'claude-token-saver');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where conversions live.
|
|
77
|
+
*
|
|
78
|
+
* Not next to the original, and not in the project's own `.claude/`: either
|
|
79
|
+
* one drops a plain-text copy of a possibly confidential attachment into a
|
|
80
|
+
* directory people commit. Keeping it in the tool's own state directory means
|
|
81
|
+
* there is nothing for the user to remember to gitignore.
|
|
82
|
+
*/
|
|
83
|
+
function cacheDir() {
|
|
84
|
+
return path.join(userDataDir(), 'doc2md-cache');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ensureCacheDir() {
|
|
88
|
+
const dir = cacheDir();
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
// mkdir honours the mode only on creation, so an older directory made with
|
|
91
|
+
// the default mask is tightened here.
|
|
92
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isTargetPath(filePath) {
|
|
97
|
+
if (typeof filePath !== 'string' || !filePath) return false;
|
|
98
|
+
return TARGET_EXTENSIONS.includes(path.extname(filePath).toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isSensitivePath(filePath) {
|
|
102
|
+
const base = path.basename(filePath || '');
|
|
103
|
+
return SENSITIVE_PATTERNS.some((re) => re.test(base));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Cache path for a source file. The hash covers the absolute path, so two
|
|
108
|
+
* `report.pptx` files in different projects do not overwrite each other.
|
|
109
|
+
*/
|
|
110
|
+
function cachePathFor(filePath) {
|
|
111
|
+
const abs = path.resolve(filePath);
|
|
112
|
+
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 12);
|
|
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);
|
|
120
|
+
return path.join(cacheDir(), `${base}.${hash}.md`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function metaPathFor(cacheFile) {
|
|
124
|
+
return cacheFile.replace(/\.md$/, '.meta.json');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A cached conversion still matching the source's mtime and size, or null. */
|
|
128
|
+
function readCache(filePath) {
|
|
129
|
+
const cacheFile = cachePathFor(filePath);
|
|
130
|
+
try {
|
|
131
|
+
const src = fs.statSync(filePath);
|
|
132
|
+
const meta = JSON.parse(fs.readFileSync(metaPathFor(cacheFile), 'utf8'));
|
|
133
|
+
if (meta.size !== src.size || meta.mtimeMs !== src.mtimeMs) return null;
|
|
134
|
+
fs.statSync(cacheFile);
|
|
135
|
+
return { cacheFile, meta };
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
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 unpacked, and a .fig read
|
|
169
|
+
// straight into the context window, which is the one Read does not
|
|
170
|
+
// refuse.
|
|
171
|
+
const ext = path.extname(sourcePath).toLowerCase();
|
|
172
|
+
const alternative = ext === '.pdf' ? '원본을 첨부하면'
|
|
173
|
+
: ext === '.fig' ? '원본을 그대로 Read 하면'
|
|
174
|
+
: '압축을 풀어 본문 XML을 읽으면';
|
|
175
|
+
lines.push(
|
|
176
|
+
`${alternative} 약 ${saving.baseline.toLocaleString('en-US')} 토큰이 드는데, `
|
|
177
|
+
+ `변환본은 ${saving.tokens.toLocaleString('en-US')} 토큰입니다. `
|
|
178
|
+
+ `약 ${savedTokens.toLocaleString('en-US')} 토큰, $${saving.usd.toFixed(2)} 을 아꼈습니다(추정).`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
lines.push('-->', '');
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeCache(filePath, markdown, extra) {
|
|
186
|
+
ensureCacheDir();
|
|
187
|
+
const cacheFile = cachePathFor(filePath);
|
|
188
|
+
const src = fs.statSync(filePath);
|
|
189
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
190
|
+
const saving = Object.assign(
|
|
191
|
+
ledger.estimateSaving({
|
|
192
|
+
ext,
|
|
193
|
+
pages: (extra && extra.pages) || 0,
|
|
194
|
+
markupBytes: (extra && extra.markupBytes) || 0,
|
|
195
|
+
markdown,
|
|
196
|
+
}),
|
|
197
|
+
{ markdown },
|
|
198
|
+
);
|
|
199
|
+
fs.writeFileSync(cacheFile, banner(filePath, saving) + markdown, { encoding: 'utf8', mode: 0o600 });
|
|
200
|
+
ledger.recordConversion(userDataDir(), {
|
|
201
|
+
key: path.resolve(filePath),
|
|
202
|
+
ts: Date.now(),
|
|
203
|
+
usd: saving.usd,
|
|
204
|
+
ext,
|
|
205
|
+
tokens: saving.tokens,
|
|
206
|
+
baseline: saving.baseline,
|
|
207
|
+
});
|
|
208
|
+
const meta = Object.assign({
|
|
209
|
+
source: path.resolve(filePath),
|
|
210
|
+
size: src.size,
|
|
211
|
+
mtimeMs: src.mtimeMs,
|
|
212
|
+
convertedAt: new Date().toISOString(),
|
|
213
|
+
// Carried in the metadata so the hook can report what the conversion cost
|
|
214
|
+
// and saved without re-reading and re-measuring the markdown.
|
|
215
|
+
markdownBytes: Buffer.byteLength(markdown, 'utf8'),
|
|
216
|
+
tokens: saving.tokens,
|
|
217
|
+
baselineTokens: saving.baseline,
|
|
218
|
+
savedUsd: saving.usd,
|
|
219
|
+
}, extra || {});
|
|
220
|
+
fs.writeFileSync(metaPathFor(cacheFile), JSON.stringify(meta, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
221
|
+
return { cacheFile, meta };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A Python that can import markitdown, or null.
|
|
226
|
+
*
|
|
227
|
+
* Order matters: an explicit override first, then a `uv tool` install, then
|
|
228
|
+
* whatever is on PATH. Probing costs a process spawn each, so the answer is
|
|
229
|
+
* memoized for the life of this process, and the caller memoizes across
|
|
230
|
+
* processes through the notice file.
|
|
231
|
+
*/
|
|
232
|
+
// The readiness test for an interpreter. Importing the *class* matters: a pip
|
|
233
|
+
// install creates the package directory long before it finishes writing into
|
|
234
|
+
// it, so a bare `import markitdown` reports success mid-install and the
|
|
235
|
+
// conversion then fails with "cannot import name 'MarkItDown'". Measured
|
|
236
|
+
// during the first-use auto-install, where the window is about a second wide.
|
|
237
|
+
const PROBE = 'from markitdown import MarkItDown';
|
|
238
|
+
|
|
239
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Interpreters to try, as `{ bin, args }` so the Windows launcher can carry
|
|
243
|
+
* its version flag. `py -3` is how a Windows box usually reaches Python at
|
|
244
|
+
* all: `python3` is rarely on PATH there, and a bare `python` may be the App
|
|
245
|
+
* Execution Alias stub that opens the Microsoft Store instead of running
|
|
246
|
+
* anything.
|
|
247
|
+
*/
|
|
248
|
+
function interpreterCandidates() {
|
|
249
|
+
const out = [];
|
|
250
|
+
if (process.env.CTS_DOC2MD_PYTHON) out.push({ bin: process.env.CTS_DOC2MD_PYTHON, args: [] });
|
|
251
|
+
// The tool's own venv, created by `doc2md install-converter`. First because
|
|
252
|
+
// it is the only one this tool controls: telling people to `pip install`
|
|
253
|
+
// into the system interpreter is how a token-saving CLI ends up owning a
|
|
254
|
+
// break in someone else's project.
|
|
255
|
+
out.push({ bin: managedPython(), args: [] });
|
|
256
|
+
if (IS_WINDOWS) {
|
|
257
|
+
out.push(
|
|
258
|
+
{ bin: path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'Scripts', 'python.exe'), args: [] },
|
|
259
|
+
{ bin: 'py', args: ['-3'] },
|
|
260
|
+
{ bin: 'python', args: [] },
|
|
261
|
+
);
|
|
262
|
+
} else {
|
|
263
|
+
out.push(
|
|
264
|
+
{ bin: path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'), args: [] },
|
|
265
|
+
{ bin: path.join(os.homedir(), '.local', 'bin', 'markitdown-python'), args: [] },
|
|
266
|
+
{ bin: 'python3', args: [] },
|
|
267
|
+
{ bin: 'python', args: [] },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let interpreterCache;
|
|
274
|
+
function findInterpreter() {
|
|
275
|
+
if (interpreterCache !== undefined) return interpreterCache;
|
|
276
|
+
for (const { bin, args } of interpreterCandidates()) {
|
|
277
|
+
try {
|
|
278
|
+
const probe = spawnSync(bin, [...args, '-c', PROBE], {
|
|
279
|
+
timeout: 20_000,
|
|
280
|
+
stdio: 'ignore',
|
|
281
|
+
windowsHide: true,
|
|
282
|
+
});
|
|
283
|
+
if (probe.status === 0) {
|
|
284
|
+
// Only a bare interpreter is cached as a string; the launcher form
|
|
285
|
+
// keeps its flag, since dropping it would run the wrong Python.
|
|
286
|
+
interpreterCache = args.length ? { bin, args } : bin;
|
|
287
|
+
return interpreterCache;
|
|
288
|
+
}
|
|
289
|
+
} catch { /* candidate unusable, try the next */ }
|
|
290
|
+
}
|
|
291
|
+
interpreterCache = null;
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Split a `findInterpreter()` result into the pair spawnSync wants. */
|
|
296
|
+
function interpreterParts(found) {
|
|
297
|
+
return typeof found === 'string' ? { bin: found, args: [] } : found;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const CONVERTER = path.join(__dirname, '..', 'presets', 'doc2md', 'convert.py');
|
|
301
|
+
|
|
302
|
+
/** Path to the interpreter inside the venv this tool manages. */
|
|
303
|
+
function managedPython() {
|
|
304
|
+
const dir = path.join(userDataDir(), 'doc2md-venv');
|
|
305
|
+
return process.platform === 'win32'
|
|
306
|
+
? path.join(dir, 'Scripts', 'python.exe')
|
|
307
|
+
: path.join(dir, 'bin', 'python');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]';
|
|
311
|
+
|
|
312
|
+
// Editing libraries, installed alongside the converter. Reading is doc2md's
|
|
313
|
+
// own job; editing is the agent's, done per-request with a short script
|
|
314
|
+
// against a COPY of the document. These are the libraries those scripts need,
|
|
315
|
+
// pre-installed so "swap the chart on slide 23" does not stall on pip.
|
|
316
|
+
const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Build the managed venv and install markitdown into it.
|
|
320
|
+
*
|
|
321
|
+
* Kept behind an explicit command: creating a 300MB virtualenv is not
|
|
322
|
+
* something to do because somebody opened a spreadsheet once. But once asked
|
|
323
|
+
* for, it goes somewhere this tool owns, so uninstalling the CLI takes the
|
|
324
|
+
* whole thing with it and no system interpreter is touched.
|
|
325
|
+
*/
|
|
326
|
+
/**
|
|
327
|
+
* A Python new enough to run markitdown (3.10+), or null.
|
|
328
|
+
*
|
|
329
|
+
* Explicit version names are tried before the bare `python3` so a modern
|
|
330
|
+
* Homebrew interpreter wins over the system one regardless of PATH order.
|
|
331
|
+
*/
|
|
332
|
+
function findVenvBase() {
|
|
333
|
+
const candidates = IS_WINDOWS
|
|
334
|
+
? [
|
|
335
|
+
// The launcher first, asked for each version in turn: it knows about
|
|
336
|
+
// installs that never touched PATH, which is the normal case on
|
|
337
|
+
// Windows.
|
|
338
|
+
{ bin: 'py', args: ['-3.14'] }, { bin: 'py', args: ['-3.13'] },
|
|
339
|
+
{ bin: 'py', args: ['-3.12'] }, { bin: 'py', args: ['-3.11'] },
|
|
340
|
+
{ bin: 'py', args: ['-3.10'] }, { bin: 'py', args: ['-3'] },
|
|
341
|
+
{ bin: 'python', args: [] },
|
|
342
|
+
]
|
|
343
|
+
: [
|
|
344
|
+
{ bin: 'python3.14', args: [] }, { bin: 'python3.13', args: [] },
|
|
345
|
+
{ bin: 'python3.12', args: [] }, { bin: 'python3.11', args: [] },
|
|
346
|
+
{ bin: 'python3.10', args: [] },
|
|
347
|
+
{ bin: 'python3', args: [] }, { bin: 'python', args: [] },
|
|
348
|
+
];
|
|
349
|
+
for (const { bin, args } of candidates) {
|
|
350
|
+
const r = spawnSync(bin, [...args, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], {
|
|
351
|
+
encoding: 'utf8',
|
|
352
|
+
timeout: 20_000,
|
|
353
|
+
windowsHide: true,
|
|
354
|
+
});
|
|
355
|
+
if (r.status !== 0) continue;
|
|
356
|
+
const [major, minor] = String(r.stdout).trim().split('.').map(Number);
|
|
357
|
+
if (major > 3 || (major === 3 && minor >= 10)) {
|
|
358
|
+
return { bin, args, version: `python ${major}.${minor}` };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function installConverter({ onProgress = () => {} } = {}) {
|
|
365
|
+
const venv = path.join(userDataDir(), 'doc2md-venv');
|
|
366
|
+
const target = managedPython();
|
|
367
|
+
|
|
368
|
+
if (!fs.existsSync(target)) {
|
|
369
|
+
// The base interpreter is chosen by version, not by whichever `python3`
|
|
370
|
+
// comes first on PATH. markitdown needs 3.10+, and macOS still ships 3.9
|
|
371
|
+
// as /usr/bin/python3: building the venv on that one installs a
|
|
372
|
+
// seven-year-old placeholder release (0.0.1a1) that has no MarkItDown
|
|
373
|
+
// class in it, and every conversion then fails at import time. Measured
|
|
374
|
+
// on this machine, where /usr/bin/python3 precedes Homebrew's 3.14.
|
|
375
|
+
const base = findVenvBase();
|
|
376
|
+
if (!base) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
reason: 'no-python',
|
|
380
|
+
detail: 'markitdown needs Python 3.10 or newer; none was found on PATH '
|
|
381
|
+
+ '(macOS /usr/bin/python3 is 3.9 — install a newer one, e.g. `brew install python`)',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
onProgress(`creating ${venv} (${base.version})`);
|
|
385
|
+
const r = spawnSync(base.bin, [...(base.args || []), '-m', 'venv', venv], {
|
|
386
|
+
encoding: 'utf8',
|
|
387
|
+
timeout: 180_000,
|
|
388
|
+
windowsHide: true,
|
|
389
|
+
});
|
|
390
|
+
if (r.status !== 0) {
|
|
391
|
+
return { ok: false, reason: 'no-python', detail: (r.stderr || 'venv creation failed').slice(0, 300) };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
onProgress(`installing ${MARKITDOWN_SPEC} + ${EDIT_LIBS.join(', ')}`);
|
|
396
|
+
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC, ...EDIT_LIBS], {
|
|
397
|
+
encoding: 'utf8',
|
|
398
|
+
timeout: 900_000,
|
|
399
|
+
windowsHide: true,
|
|
400
|
+
});
|
|
401
|
+
if (install.status !== 0) {
|
|
402
|
+
return { ok: false, reason: 'pip-failed', detail: (install.stderr || '').slice(0, 400) };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The probe is the actual acceptance test: pip can exit 0 and still leave an
|
|
406
|
+
// interpreter that cannot import what was asked for.
|
|
407
|
+
const probe = spawnSync(target, ['-c', PROBE], { timeout: 60_000, stdio: 'ignore', windowsHide: true });
|
|
408
|
+
if (probe.status !== 0) {
|
|
409
|
+
return { ok: false, reason: 'import-failed', detail: 'installed, but markitdown does not import' };
|
|
410
|
+
}
|
|
411
|
+
interpreterCache = target;
|
|
412
|
+
clearNotice();
|
|
413
|
+
return { ok: true, python: target };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
|
|
418
|
+
* `{ ok: false, reason, detail }` where reason is one of:
|
|
419
|
+
* no-markitdown | python-too-old | no-figparser | encrypted |
|
|
420
|
+
* drm-protected | too-large | sensitive | unsafe-archive | no-text |
|
|
421
|
+
* convert-failed | timeout
|
|
422
|
+
*
|
|
423
|
+
* Every failure is a reason to leave the original Read alone, never to break
|
|
424
|
+
* it. That is the whole contract with the hook.
|
|
425
|
+
*/
|
|
426
|
+
/**
|
|
427
|
+
* Bridge from this synchronous pipeline to the async fig converter: run it in
|
|
428
|
+
* a child process and read one JSON object from stdout, exactly the contract
|
|
429
|
+
* the Python converter already speaks. Costs a process spawn, buys the same
|
|
430
|
+
* timeout and isolation the other formats get.
|
|
431
|
+
*/
|
|
432
|
+
function spawnFigConvert(fig2md, filePath) {
|
|
433
|
+
const runner = path.join(__dirname, 'fig2md-runner.cjs');
|
|
434
|
+
const run = spawnSync(process.execPath, [runner, filePath, userDataDir()], {
|
|
435
|
+
encoding: 'utf8',
|
|
436
|
+
timeout: CONVERT_TIMEOUT_MS,
|
|
437
|
+
maxBuffer: MAX_MARKDOWN_BYTES * 4,
|
|
438
|
+
});
|
|
439
|
+
if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
|
|
440
|
+
if (run.status !== 0) {
|
|
441
|
+
return { ok: false, reason: 'convert-failed', detail: (run.stderr || '').slice(0, 300) };
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(run.stdout);
|
|
445
|
+
} catch {
|
|
446
|
+
return { ok: false, reason: 'convert-failed', detail: 'fig converter produced no JSON' };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function convert(filePath, { converter = CONVERTER, python: pythonOverride = null } = {}) {
|
|
451
|
+
if (!isTargetPath(filePath)) return { ok: false, reason: 'not-target' };
|
|
452
|
+
if (isSensitivePath(filePath)) return { ok: false, reason: 'sensitive' };
|
|
453
|
+
|
|
454
|
+
let stat;
|
|
455
|
+
try {
|
|
456
|
+
stat = fs.statSync(filePath);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return { ok: false, reason: 'missing', detail: String(e.message || e) };
|
|
459
|
+
}
|
|
460
|
+
if (stat.size > MAX_SOURCE_BYTES) {
|
|
461
|
+
return { ok: false, reason: 'too-large', detail: `${stat.size} bytes` };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const cached = readCache(filePath);
|
|
465
|
+
if (cached) return { ok: true, cached: true, cacheFile: cached.cacheFile, meta: cached.meta };
|
|
466
|
+
|
|
467
|
+
// Figma files take the Node converter; everything else goes to markitdown.
|
|
468
|
+
if (path.extname(filePath).toLowerCase() === '.fig') {
|
|
469
|
+
const fig2md = require('./fig2md.cjs');
|
|
470
|
+
let result = spawnFigConvert(fig2md, filePath);
|
|
471
|
+
// The .fig parser is an npm install of a few hundred KB, fast enough to
|
|
472
|
+
// wait for inline the first time a Figma file turns up.
|
|
473
|
+
if (!result.ok && result.reason === 'no-figparser'
|
|
474
|
+
&& process.env.CTS_DOC2MD_NO_AUTOINSTALL !== '1') {
|
|
475
|
+
fig2md.installFigParser(userDataDir());
|
|
476
|
+
result = spawnFigConvert(fig2md, filePath);
|
|
477
|
+
}
|
|
478
|
+
if (!result.ok) return result;
|
|
479
|
+
const written = writeCache(filePath, result.markdown, {
|
|
480
|
+
note: result.note, truncated: false, rows: 0, pages: 0, markupBytes: 0,
|
|
481
|
+
});
|
|
482
|
+
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// The override exists so tests can drive a stub converter with any Python at
|
|
486
|
+
// all: the normal search insists the interpreter can import markitdown,
|
|
487
|
+
// which would make the whole path untestable without the real package.
|
|
488
|
+
let python = pythonOverride || findInterpreter();
|
|
489
|
+
if (!python && !pythonOverride) {
|
|
490
|
+
// Telling someone to run the install command is wrong when the install
|
|
491
|
+
// cannot succeed on this machine. A too-old interpreter is a different
|
|
492
|
+
// problem with a different fix, so it gets its own reason rather than
|
|
493
|
+
// hiding behind "no converter".
|
|
494
|
+
if (!findVenvBase()) {
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
reason: 'python-too-old',
|
|
498
|
+
detail: 'markitdown needs Python 3.10 or newer; none was found on PATH',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (ensureConverterInstalled()) python = findInterpreter();
|
|
502
|
+
}
|
|
503
|
+
if (!python) return { ok: false, reason: 'no-markitdown' };
|
|
504
|
+
|
|
505
|
+
const py = interpreterParts(python);
|
|
506
|
+
const run = spawnSync(py.bin, [...py.args, converter, filePath], {
|
|
507
|
+
encoding: 'utf8',
|
|
508
|
+
timeout: CONVERT_TIMEOUT_MS,
|
|
509
|
+
maxBuffer: MAX_MARKDOWN_BYTES * 4,
|
|
510
|
+
windowsHide: true,
|
|
511
|
+
});
|
|
512
|
+
if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
|
|
513
|
+
if (run.status !== 0) {
|
|
514
|
+
return { ok: false, reason: 'convert-failed', detail: (run.stderr || '').slice(0, 300) };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let payload;
|
|
518
|
+
try {
|
|
519
|
+
payload = JSON.parse(run.stdout);
|
|
520
|
+
} catch {
|
|
521
|
+
return { ok: false, reason: 'convert-failed', detail: 'converter produced no JSON' };
|
|
522
|
+
}
|
|
523
|
+
if (!payload.ok) return { ok: false, reason: payload.reason, detail: payload.detail };
|
|
524
|
+
|
|
525
|
+
let markdown = payload.markdown || '';
|
|
526
|
+
let clipped = false;
|
|
527
|
+
if (Buffer.byteLength(markdown, 'utf8') > MAX_MARKDOWN_BYTES) {
|
|
528
|
+
// Reading a 20MB markdown file is the same waste in a different format.
|
|
529
|
+
markdown = markdown.slice(0, MAX_MARKDOWN_BYTES);
|
|
530
|
+
clipped = true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const written = writeCache(filePath, markdown, {
|
|
534
|
+
note: payload.note || null,
|
|
535
|
+
truncated: !!payload.truncated || clipped,
|
|
536
|
+
rows: payload.rows || 0,
|
|
537
|
+
pages: payload.pages || 0,
|
|
538
|
+
markupBytes: payload.markup_bytes || 0,
|
|
539
|
+
clipped,
|
|
540
|
+
});
|
|
541
|
+
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* First-use install, so nobody has to be told to run a setup command.
|
|
546
|
+
*
|
|
547
|
+
* A team rollout dies on any step a person has to be told about, so the
|
|
548
|
+
* converter installs itself the first time a document actually shows up. It
|
|
549
|
+
* is still lazy rather than part of `install`: the venv is 47MB, and someone
|
|
550
|
+
* who never opens a document should never pay for it.
|
|
551
|
+
*
|
|
552
|
+
* The install runs detached and the caller waits only briefly. A cold install
|
|
553
|
+
* measured 6 seconds on a fast connection, but a corporate network can be far
|
|
554
|
+
* slower, and a hook that blocks a prompt for a minute is worse than a
|
|
555
|
+
* document that converts on the next turn. So: start it, wait up to
|
|
556
|
+
* `waitMs`, and if it is still going, say so and let this turn proceed
|
|
557
|
+
* without the conversion.
|
|
558
|
+
*
|
|
559
|
+
* Returns true when a converter is ready to use right now.
|
|
560
|
+
*/
|
|
561
|
+
function ensureConverterInstalled({ waitMs = 15_000 } = {}) {
|
|
562
|
+
if (process.env.CTS_DOC2MD_NO_AUTOINSTALL === '1') return false;
|
|
563
|
+
if (findInterpreter()) return true;
|
|
564
|
+
|
|
565
|
+
const lock = path.join(userDataDir(), 'doc2md-install.lock');
|
|
566
|
+
let running = false;
|
|
567
|
+
try {
|
|
568
|
+
const started = JSON.parse(fs.readFileSync(lock, 'utf8')).startedAt;
|
|
569
|
+
// A lock older than the pip timeout is a crashed run, not a live one.
|
|
570
|
+
running = Number.isFinite(started) && Date.now() - started < 900_000;
|
|
571
|
+
} catch { /* no lock, or an unreadable one: treat as not running */ }
|
|
572
|
+
|
|
573
|
+
if (!running) {
|
|
574
|
+
try {
|
|
575
|
+
fs.mkdirSync(userDataDir(), { recursive: true });
|
|
576
|
+
fs.writeFileSync(lock, JSON.stringify({ startedAt: Date.now() }));
|
|
577
|
+
const cli = path.join(__dirname, '..', 'bin', 'cli.js');
|
|
578
|
+
// Detached, so a session that ends mid-install does not take the
|
|
579
|
+
// install with it — the next session finds it finished.
|
|
580
|
+
const child = spawn(process.execPath, [cli, 'doc2md', 'install-converter'], {
|
|
581
|
+
detached: true,
|
|
582
|
+
stdio: 'ignore',
|
|
583
|
+
// Without this Windows pops a console window for the install, in the
|
|
584
|
+
// middle of someone's prompt.
|
|
585
|
+
windowsHide: true,
|
|
586
|
+
});
|
|
587
|
+
child.unref();
|
|
588
|
+
} catch {
|
|
589
|
+
try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ }
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Poll cheaply: the interpreter file appearing is the first sign, and the
|
|
595
|
+
// import probe is the acceptance test. interpreterCache has to be cleared
|
|
596
|
+
// or the memoized null from the top of this function would stick.
|
|
597
|
+
const deadline = Date.now() + waitMs;
|
|
598
|
+
while (Date.now() < deadline) {
|
|
599
|
+
if (fs.existsSync(managedPython())) {
|
|
600
|
+
interpreterCache = undefined;
|
|
601
|
+
if (findInterpreter()) {
|
|
602
|
+
try { fs.rmSync(lock, { force: true }); } catch { /* best effort */ }
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// A synchronous sleep, because every caller here is synchronous. 400ms
|
|
607
|
+
// keeps the poll count low over a 15s wait.
|
|
608
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 400);
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Where the "markitdown is not installed" notice records that it was shown. */
|
|
614
|
+
function noticePath() {
|
|
615
|
+
return path.join(userDataDir(), 'doc2md-notice.json');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function noticeAlreadyShown() {
|
|
619
|
+
try {
|
|
620
|
+
return JSON.parse(fs.readFileSync(noticePath(), 'utf8')).shown === true;
|
|
621
|
+
} catch {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Forget that the notice was shown.
|
|
628
|
+
*
|
|
629
|
+
* Called after the converter is installed, so that if it later disappears the
|
|
630
|
+
* user is told once more instead of meeting permanent silence.
|
|
631
|
+
*/
|
|
632
|
+
function clearNotice() {
|
|
633
|
+
try {
|
|
634
|
+
fs.rmSync(noticePath(), { force: true });
|
|
635
|
+
} catch { /* nothing to forget */ }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function markNoticeShown() {
|
|
639
|
+
try {
|
|
640
|
+
fs.mkdirSync(userDataDir(), { recursive: true });
|
|
641
|
+
fs.writeFileSync(noticePath(), JSON.stringify({ shown: true, at: new Date().toISOString() }));
|
|
642
|
+
} catch { /* an unwritable state dir just means the notice repeats */ }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const INSTALL_HINT = 'claude-token-saver doc2md install-converter';
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Decide what to tell Claude Code about one PreToolUse(Read) payload.
|
|
649
|
+
*
|
|
650
|
+
* Returns null when the hook should stay out of the way, or a PreToolUse hook
|
|
651
|
+
* output object. Blocking is the right call for a successful conversion:
|
|
652
|
+
* allowing the Read and merely mentioning the .md would put the binary in the
|
|
653
|
+
* context window anyway, which is the cost this exists to avoid.
|
|
654
|
+
*/
|
|
655
|
+
function decideForRead(context, opts = {}) {
|
|
656
|
+
if (!context || context.tool_name !== 'Read') return null;
|
|
657
|
+
const toolInput = context.tool_input;
|
|
658
|
+
const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
659
|
+
if (!isTargetPath(filePath)) return null;
|
|
660
|
+
|
|
661
|
+
const result = convert(filePath, opts);
|
|
662
|
+
const name = path.basename(filePath);
|
|
663
|
+
|
|
664
|
+
if (result.ok) {
|
|
665
|
+
const bits = [`[doc2md] ${name} 는 Markdown 으로 변환했습니다.`];
|
|
666
|
+
bits.push(` 변환본: ${result.cacheFile}`);
|
|
667
|
+
if (result.meta && result.meta.note) bits.push(` ${result.meta.note}`);
|
|
668
|
+
if (result.meta && result.meta.clipped) {
|
|
669
|
+
bits.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
670
|
+
}
|
|
671
|
+
bits.push(' 원본 대신 이 파일을 Read 하십시오. 원본을 직접 확인해야 한다면 그 이유를 밝히십시오.');
|
|
672
|
+
return { deny: true, reason: bits.join('\n') };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// From here down the Read is allowed through untouched. The only question is
|
|
676
|
+
// whether the model is told why nothing was converted.
|
|
677
|
+
if (result.reason === 'no-markitdown') {
|
|
678
|
+
if (noticeAlreadyShown()) return null;
|
|
679
|
+
markNoticeShown();
|
|
680
|
+
return {
|
|
681
|
+
deny: false,
|
|
682
|
+
reason: `[doc2md] ${name} 를 변환할 변환기를 지금 설치하고 있습니다(첫 실행에만 걸립니다).\n`
|
|
683
|
+
+ ' 설치가 끝나면 다음 요청부터 자동으로 변환합니다. 이번 turn 은 원본을 그대로 읽습니다.\n'
|
|
684
|
+
+ ` 진행 상황: ${INSTALL_HINT} 를 직접 실행하면 설치 로그를 볼 수 있습니다.`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (result.reason === 'drm-protected') {
|
|
688
|
+
return {
|
|
689
|
+
deny: false,
|
|
690
|
+
reason: `[doc2md] ${name} 는 DRM 으로 보호된 파일이라 변환하지 못했습니다(${result.detail || ''}).\n`
|
|
691
|
+
+ ' DRM 은 암호 입력으로 풀리지 않습니다. 벤더 에이전트가 허용한 프로그램에서만 평문이 보이므로, '
|
|
692
|
+
+ '사용자에게 DRM 클라이언트에서 연 뒤 해제본으로 저장하거나 반출 승인을 받은 사본을 요청하십시오.',
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
if (result.reason === 'encrypted') {
|
|
696
|
+
return {
|
|
697
|
+
deny: false,
|
|
698
|
+
reason: `[doc2md] ${name} 는 암호가 걸린 문서라 변환하지 못했습니다. `
|
|
699
|
+
+ '사용자에게 암호를 푼 사본을 요청하십시오. 이 도구는 암호를 묻거나 저장하지 않습니다.',
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (result.reason === 'python-too-old') {
|
|
703
|
+
if (noticeAlreadyShown()) return null;
|
|
704
|
+
markNoticeShown();
|
|
705
|
+
return {
|
|
706
|
+
deny: false,
|
|
707
|
+
reason: `[doc2md] ${name} 를 변환하지 못했습니다. 변환기(markitdown)는 Python 3.10 이상이 필요한데 PATH 에서 찾지 못했습니다.\n`
|
|
708
|
+
+ ' macOS 기본 /usr/bin/python3 는 3.9 입니다. `brew install python` 으로 새 버전을 설치한 뒤 다시 시도하십시오.\n'
|
|
709
|
+
+ ' 그때까지는 원본을 그대로 읽습니다. 이 안내는 한 번만 표시됩니다.',
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
if (result.reason === 'no-text') {
|
|
713
|
+
return {
|
|
714
|
+
deny: false,
|
|
715
|
+
reason: `[doc2md] ${name} 에서 본문 텍스트를 추출하지 못했습니다(스캔 PDF 로 보입니다). 원본을 직접 확인하십시오.`,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
// The one case that blocks without converting. A file that expands to fill
|
|
719
|
+
// the disk is not something to hand on to the next reader with a shrug.
|
|
720
|
+
if (result.reason === 'unsafe-archive') {
|
|
721
|
+
return {
|
|
722
|
+
deny: true,
|
|
723
|
+
reason: `[doc2md] ${name} 는 압축 폭탄으로 보여 변환하지 않았습니다 (${result.detail}). 신뢰할 수 있는 파일인지 먼저 확인하십시오.`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (result.reason === 'bad-archive') {
|
|
727
|
+
return {
|
|
728
|
+
deny: false,
|
|
729
|
+
reason: `[doc2md] ${name} 는 압축 파일로 열리지 않습니다 (${result.detail}). 내려받다 끊겼을 수 있습니다.`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (result.reason === 'too-large') {
|
|
733
|
+
return {
|
|
734
|
+
deny: false,
|
|
735
|
+
reason: `[doc2md] ${name} 는 크기 상한(50MB)을 넘어 변환하지 않았습니다 (${result.detail}).`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (result.reason === 'sensitive') {
|
|
739
|
+
return {
|
|
740
|
+
deny: false,
|
|
741
|
+
reason: `[doc2md] ${name} 는 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다. 평문 사본을 남기지 않기 위한 조치입니다.`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (result.reason === 'timeout' || result.reason === 'convert-failed') {
|
|
745
|
+
return {
|
|
746
|
+
deny: false,
|
|
747
|
+
reason: `[doc2md] ${name} 변환에 실패했습니다(${result.reason}). 원본을 그대로 읽습니다.`,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Document paths mentioned in a prompt.
|
|
755
|
+
*
|
|
756
|
+
* This exists because the PreToolUse hook cannot reach the formats it was
|
|
757
|
+
* written for. Claude Code rejects pptx/xlsx/docx as binary *before* running
|
|
758
|
+
* the hook — verified: a `.pdf` Read fires the hook, a `.pptx` Read never
|
|
759
|
+
* does — so by the time doc2md could speak, the tool call is already refused.
|
|
760
|
+
* UserPromptSubmit runs earlier than any of that and sees the raw text, so a
|
|
761
|
+
* path the user typed can be converted before the model tries to open it.
|
|
762
|
+
*
|
|
763
|
+
* Quoted, `@`-prefixed and bare paths all count. Windows drive letters are
|
|
764
|
+
* matched too, since the rest of the module is path-agnostic.
|
|
765
|
+
*/
|
|
766
|
+
function documentPathsIn(text) {
|
|
767
|
+
if (typeof text !== 'string' || !text) return [];
|
|
768
|
+
const exts = TARGET_EXTENSIONS.map((e) => e.slice(1)).join('|');
|
|
769
|
+
// Backslashes count as path characters, not just separators to tolerate:
|
|
770
|
+
// `C:\\Users\\me\\deck.pptx` and `\\\\server\\share\\deck.pptx` are how
|
|
771
|
+
// Windows users write a path, and without them the scanner silently sees no
|
|
772
|
+
// documents at all on that platform.
|
|
773
|
+
const re = new RegExp(`[@'"\`]?((?:[A-Za-z]:)?[~./\\\\][^\\s'"\`]*\\.(?:${exts}))`, 'gi');
|
|
774
|
+
const found = [];
|
|
775
|
+
for (const m of text.matchAll(re)) {
|
|
776
|
+
const raw = m[1];
|
|
777
|
+
const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(raw);
|
|
778
|
+
if (!found.includes(abs)) found.push(abs);
|
|
779
|
+
}
|
|
780
|
+
return found;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// One prompt naming a dozen decks should not turn a keystroke into a minute of
|
|
784
|
+
// conversion. The rest are named in the note so nothing disappears quietly.
|
|
785
|
+
const MAX_PROMPT_CONVERSIONS = 3;
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* The one line that makes a conversion visible: what came in, what went out,
|
|
789
|
+
* and what not attaching the original was worth.
|
|
790
|
+
*
|
|
791
|
+
* The sizes matter more than they look. A 30MB deck that becomes 90KB of
|
|
792
|
+
* Markdown is the whole argument for this feature, and without the figures
|
|
793
|
+
* the model has nothing concrete to tell the user it happened.
|
|
794
|
+
*/
|
|
795
|
+
function conversionSummary(sourcePath, meta = {}) {
|
|
796
|
+
const parts = [];
|
|
797
|
+
try {
|
|
798
|
+
parts.push(`${humanBytes(fs.statSync(sourcePath).size)} → ${humanBytes(meta.markdownBytes || 0)}`);
|
|
799
|
+
} catch { /* the size is a nicety, not the point */ }
|
|
800
|
+
if (meta.tokens) parts.push(`약 ${Number(meta.tokens).toLocaleString('en-US')} 토큰`);
|
|
801
|
+
if (meta.savedUsd > 0) parts.push(`첨부 대비 약 $${Number(meta.savedUsd).toFixed(2)} 절감(추정)`);
|
|
802
|
+
return parts.join(', ');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Context to inject for a UserPromptSubmit payload, or null.
|
|
807
|
+
*
|
|
808
|
+
* Returns plain text because that is what this hook can give the model:
|
|
809
|
+
* stdout becomes context it can act on. There is no decision to make here —
|
|
810
|
+
* the prompt is not blocked, it is answered better.
|
|
811
|
+
*/
|
|
812
|
+
function contextForPrompt(payload, opts = {}) {
|
|
813
|
+
const lang = opts.lang === 'ko' ? 'ko' : 'en';
|
|
814
|
+
if (!payload || typeof payload.prompt !== 'string') return null;
|
|
815
|
+
const paths = documentPathsIn(payload.prompt).filter((p) => {
|
|
816
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
817
|
+
});
|
|
818
|
+
if (paths.length === 0) return null;
|
|
819
|
+
|
|
820
|
+
const lines = [];
|
|
821
|
+
const targets = paths.slice(0, MAX_PROMPT_CONVERSIONS);
|
|
822
|
+
for (const p of targets) {
|
|
823
|
+
const name = path.basename(p);
|
|
824
|
+
const result = convert(p, opts);
|
|
825
|
+
if (result.ok) {
|
|
826
|
+
lines.push(` ${name} → ${result.cacheFile}`);
|
|
827
|
+
lines.push(` ${conversionSummary(p, result.meta)}`);
|
|
828
|
+
if (result.meta && result.meta.note) lines.push(` ${result.meta.note}`);
|
|
829
|
+
if (result.meta && result.meta.clipped) {
|
|
830
|
+
lines.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
831
|
+
}
|
|
832
|
+
} else if (result.reason === 'no-markitdown') {
|
|
833
|
+
lines.push(lang === 'ko'
|
|
834
|
+
? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
|
|
835
|
+
: ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
|
|
836
|
+
} else if (result.reason === 'drm-protected') {
|
|
837
|
+
lines.push(lang === 'ko'
|
|
838
|
+
? ` ${name}: DRM 으로 보호된 파일입니다(${result.detail || ''}). 암호로는 풀 수 없으므로, DRM 클라이언트에서 저장한 해제본이나 반출 승인 사본을 달라고 사용자에게 요청하십시오.`
|
|
839
|
+
: ` ${name}: the file is DRM-wrapped (${result.detail || ''}). A password will not open it — ask the user for a copy released from DRM.`);
|
|
840
|
+
} else if (result.reason === 'encrypted') {
|
|
841
|
+
lines.push(lang === 'ko'
|
|
842
|
+
? ` ${name}: 암호가 걸린 문서입니다. 암호를 푼 사본을 달라고 사용자에게 요청하십시오.`
|
|
843
|
+
: ` ${name}: the document is password-protected. Ask the user for an unlocked copy.`);
|
|
844
|
+
} else if (result.reason === 'python-too-old') {
|
|
845
|
+
lines.push(lang === 'ko'
|
|
846
|
+
? ` ${name}: 변환기가 Python 3.10 이상을 요구하는데 PATH 에 없습니다(macOS 기본은 3.9). \`brew install python\` 후 다시 시도하도록 사용자에게 안내하십시오.`
|
|
847
|
+
: ` ${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\`.`);
|
|
848
|
+
} else if (result.reason === 'no-figparser') {
|
|
849
|
+
lines.push(lang === 'ko'
|
|
850
|
+
? ` ${name}: .fig 파서가 없어 변환하지 못했습니다. 설치: claude-token-saver doc2md install-converter`
|
|
851
|
+
: ` ${name}: no .fig parser installed. Install it with: claude-token-saver doc2md install-converter`);
|
|
852
|
+
} else if (result.reason === 'sensitive') {
|
|
853
|
+
lines.push(lang === 'ko'
|
|
854
|
+
? ` ${name}: 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다.`
|
|
855
|
+
: ` ${name}: skipped — the filename matches a sensitive-document pattern.`);
|
|
856
|
+
} else {
|
|
857
|
+
lines.push(lang === 'ko'
|
|
858
|
+
? ` ${name}: 변환하지 못했습니다 (${result.reason}).`
|
|
859
|
+
: ` ${name}: conversion failed (${result.reason}).`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (paths.length > targets.length) {
|
|
863
|
+
lines.push(lang === 'ko'
|
|
864
|
+
? ` (문서 ${paths.length}개 가운데 앞 ${targets.length}개만 변환했습니다. 나머지는 \`claude-token-saver doc2md <경로>\` 로 변환하십시오.)`
|
|
865
|
+
: ` (converted the first ${targets.length} of ${paths.length} documents; run \`claude-token-saver doc2md <path>\` for the rest.)`);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return lang === 'ko'
|
|
869
|
+
? [
|
|
870
|
+
'[doc2md] 이 프롬프트에 문서 경로가 있어 Markdown 으로 변환해 두었습니다.',
|
|
871
|
+
...lines,
|
|
872
|
+
'원본 대신 위 변환본을 Read 하십시오. pptx·xlsx·docx 는 Read 도구가 이진 파일이라며 거부하므로 원본을 직접 열 수 없습니다.',
|
|
873
|
+
'변환이 일어났다는 사실은 사용자에게도 보여야 하므로, 답변 첫 줄에 변환한 문서마다 다음 형식으로 한 줄씩 출력하십시오: `📄 [claude-token-saver] doc2md: <파일명> (<원본 크기> → <Markdown 크기>, 약 <토큰> 토큰)`. 절감액이 함께 안내됐다면 그 금액도 같은 줄 끝에 덧붙이십시오.',
|
|
874
|
+
'경로가 주어지지 않은 문서를 열어야 할 때는 `claude-token-saver doc2md <경로>` 를 실행해 변환본 경로를 얻으십시오.',
|
|
875
|
+
].join('\n')
|
|
876
|
+
: [
|
|
877
|
+
'[doc2md] This prompt names document paths, which have been converted to Markdown:',
|
|
878
|
+
...lines,
|
|
879
|
+
'Read the conversions above rather than the originals: Read refuses pptx/xlsx/docx as binary files, so the originals cannot be opened directly.',
|
|
880
|
+
'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.',
|
|
881
|
+
'For a document whose path was not given here, run `claude-token-saver doc2md <path>` to get its conversion.',
|
|
882
|
+
].join('\n');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The standing note injected at session start.
|
|
887
|
+
*
|
|
888
|
+
* Two things the model cannot work out on its own: that attaching a document
|
|
889
|
+
* costs far more than naming its path, and that the binary-file refusal it
|
|
890
|
+
* will meet on a pptx has a one-command answer.
|
|
891
|
+
*/
|
|
892
|
+
function sessionNote(lang = 'en') {
|
|
893
|
+
if (lang === 'ko') {
|
|
894
|
+
return [
|
|
895
|
+
'[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
|
|
896
|
+
' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `claude-token-saver doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
|
|
897
|
+
' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
|
|
898
|
+
' 변환은 단방향입니다. 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 문서 수정 요청을 받으면: ① 원본을 복사하고 ② 복사본을 스크립트로 수정하십시오. pptx·docx·xlsx 는 도구 venv 파이썬에 python-pptx·python-docx·openpyxl 이 준비되어 있고(`claude-token-saver doc2md` 상태 출력에 경로가 나옵니다), .fig 는 openfig-core 로 편집·재인코드합니다. ③ 수정한 복사본을 doc2md 로 재변환해 변경이 들어갔는지 검증하십시오. 차트·이미지 같은 시각 요소는 변환본에 안 잡히므로 텍스트 검증만으로 완료를 단정하지 마십시오.',
|
|
899
|
+
].join('\n');
|
|
900
|
+
}
|
|
901
|
+
return [
|
|
902
|
+
'[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
|
|
903
|
+
' If Read refuses a document as a binary file, run `claude-token-saver doc2md <path>` and Read the .md it prints.',
|
|
904
|
+
' 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.',
|
|
905
|
+
' 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.',
|
|
906
|
+
].join('\n');
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Guard for Edit/Write. Two writes are refused, for different reasons.
|
|
911
|
+
*
|
|
912
|
+
* A write to a conversion in the cache: the conversion is one-way, so an edit
|
|
913
|
+
* there changes nothing the user cares about — and worse, the cache still
|
|
914
|
+
* matches the source's mtime, so the corrupted copy would be served on every
|
|
915
|
+
* later read while looking exactly like the document. The deny explains where
|
|
916
|
+
* the work should go instead.
|
|
917
|
+
*
|
|
918
|
+
* A write to the original document: Edit and Write emit text, and a pptx or
|
|
919
|
+
* .fig overwritten with text is destroyed, not edited. Nothing this tool
|
|
920
|
+
* ships can write those formats back.
|
|
921
|
+
*/
|
|
922
|
+
function decideForWrite(context) {
|
|
923
|
+
if (!context || (context.tool_name !== 'Edit' && context.tool_name !== 'Write')) return null;
|
|
924
|
+
const toolInput = context.tool_input;
|
|
925
|
+
const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
926
|
+
if (!filePath) return null;
|
|
927
|
+
const abs = path.resolve(filePath);
|
|
928
|
+
|
|
929
|
+
if (abs.startsWith(cacheDir() + path.sep)) {
|
|
930
|
+
let source = null;
|
|
931
|
+
try {
|
|
932
|
+
source = JSON.parse(fs.readFileSync(metaPathFor(abs), 'utf8')).source;
|
|
933
|
+
} catch { /* the deny stands on its own */ }
|
|
934
|
+
return {
|
|
935
|
+
deny: true,
|
|
936
|
+
reason: '[doc2md] 이 파일은 변환 캐시입니다. 여기를 고쳐도 원본 문서에는 아무것도 반영되지 않고, '
|
|
937
|
+
+ '캐시만 오염된 채 다음 읽기부터 계속 서빙됩니다.\n'
|
|
938
|
+
+ (source ? ` 원본: ${source}\n` : '')
|
|
939
|
+
+ ' 문서 수정이 목적이라면: 원본을 복사한 뒤(cp) 복사본을 스크립트로 수정하십시오. '
|
|
940
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 에 python-pptx·python-docx·openpyxl 이 설치되어 있고, `
|
|
941
|
+
+ '.fig 는 doc2md-fig 의 openfig-core 로 편집·재인코드할 수 있습니다. '
|
|
942
|
+
+ '수정 후 복사본을 `claude-token-saver doc2md <복사본>` 으로 재변환해 의도한 변경이 들어갔는지 확인하십시오. 원본은 절대 직접 수정하지 마십시오.',
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (isTargetPath(abs)) {
|
|
947
|
+
return {
|
|
948
|
+
deny: true,
|
|
949
|
+
reason: `[doc2md] ${path.basename(abs)} 는 이진 문서입니다. Edit/Write 는 텍스트를 쓰므로 이 파일을 파괴합니다. `
|
|
950
|
+
+ '수정하려면 원본을 복사한 뒤(cp) 복사본을 스크립트로 고치십시오. '
|
|
951
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 의 python-pptx·python-docx·openpyxl, .fig 는 openfig-core 를 쓰고, `
|
|
952
|
+
+ '수정 후 `claude-token-saver doc2md <복사본>` 재변환으로 결과를 검증하십시오.',
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** The decision rendered as the JSON Claude Code expects on stdout. */
|
|
959
|
+
function formatHookOutput(decision) {
|
|
960
|
+
if (!decision) return null;
|
|
961
|
+
return JSON.stringify({
|
|
962
|
+
hookSpecificOutput: {
|
|
963
|
+
hookEventName: 'PreToolUse',
|
|
964
|
+
permissionDecision: decision.deny ? 'deny' : 'allow',
|
|
965
|
+
permissionDecisionReason: decision.reason,
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
module.exports = {
|
|
971
|
+
TARGET_EXTENSIONS,
|
|
972
|
+
MAX_SOURCE_BYTES,
|
|
973
|
+
INSTALL_HINT,
|
|
974
|
+
MARKITDOWN_SPEC,
|
|
975
|
+
managedPython,
|
|
976
|
+
installConverter,
|
|
977
|
+
ensureConverterInstalled,
|
|
978
|
+
findVenvBase,
|
|
979
|
+
interpreterCandidates,
|
|
980
|
+
interpreterParts,
|
|
981
|
+
clearNotice,
|
|
982
|
+
cacheDir,
|
|
983
|
+
cachePathFor,
|
|
984
|
+
metaPathFor,
|
|
985
|
+
isTargetPath,
|
|
986
|
+
isSensitivePath,
|
|
987
|
+
documentPathsIn,
|
|
988
|
+
contextForPrompt,
|
|
989
|
+
sessionNote,
|
|
990
|
+
findInterpreter,
|
|
991
|
+
readCache,
|
|
992
|
+
writeCache,
|
|
993
|
+
convert,
|
|
994
|
+
decideForRead,
|
|
995
|
+
decideForWrite,
|
|
996
|
+
formatHookOutput,
|
|
997
|
+
};
|