dsh-file-convert 0.4.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.
Files changed (67) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +225 -0
  3. package/README.zh-CN.md +203 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/config.d.ts +36 -0
  6. package/lib/config.js +17 -0
  7. package/lib/core/binaries/cache.d.ts +13 -0
  8. package/lib/core/binaries/cache.js +137 -0
  9. package/lib/core/binaries/download.d.ts +21 -0
  10. package/lib/core/binaries/download.js +140 -0
  11. package/lib/core/binary.d.ts +7 -0
  12. package/lib/core/binary.js +29 -0
  13. package/lib/core/converters/data.d.ts +14 -0
  14. package/lib/core/converters/data.js +145 -0
  15. package/lib/core/converters/image.d.ts +12 -0
  16. package/lib/core/converters/image.js +64 -0
  17. package/lib/core/converters/media.d.ts +21 -0
  18. package/lib/core/converters/media.js +135 -0
  19. package/lib/core/converters/office.d.ts +33 -0
  20. package/lib/core/converters/office.js +206 -0
  21. package/lib/core/converters/pdf-env.d.ts +1 -0
  22. package/lib/core/converters/pdf-env.js +8 -0
  23. package/lib/core/converters/pdf.d.ts +22 -0
  24. package/lib/core/converters/pdf.js +316 -0
  25. package/lib/core/detect.d.ts +16 -0
  26. package/lib/core/detect.js +121 -0
  27. package/lib/core/errors.d.ts +5 -0
  28. package/lib/core/errors.js +16 -0
  29. package/lib/core/formats.d.ts +11 -0
  30. package/lib/core/formats.js +60 -0
  31. package/lib/core/index.d.ts +25 -0
  32. package/lib/core/index.js +52 -0
  33. package/lib/core/inspect.d.ts +13 -0
  34. package/lib/core/inspect.js +134 -0
  35. package/lib/core/ocr.d.ts +38 -0
  36. package/lib/core/ocr.js +152 -0
  37. package/lib/core/optimizers.d.ts +29 -0
  38. package/lib/core/optimizers.js +275 -0
  39. package/lib/core/paths.d.ts +11 -0
  40. package/lib/core/paths.js +19 -0
  41. package/lib/core/router.d.ts +64 -0
  42. package/lib/core/router.js +288 -0
  43. package/lib/core/types.d.ts +199 -0
  44. package/lib/core/types.js +8 -0
  45. package/lib/core/utils/exec.d.ts +38 -0
  46. package/lib/core/utils/exec.js +89 -0
  47. package/lib/core/utils/pages.d.ts +12 -0
  48. package/lib/core/utils/pages.js +46 -0
  49. package/lib/format.d.ts +23 -0
  50. package/lib/format.js +84 -0
  51. package/lib/index.d.ts +6 -0
  52. package/lib/index.js +51 -0
  53. package/lib/tools/batch-convert.d.ts +5 -0
  54. package/lib/tools/batch-convert.js +155 -0
  55. package/lib/tools/convert-file.d.ts +3 -0
  56. package/lib/tools/convert-file.js +57 -0
  57. package/lib/tools/inspect-file.d.ts +3 -0
  58. package/lib/tools/inspect-file.js +29 -0
  59. package/lib/tools/install-media.d.ts +9 -0
  60. package/lib/tools/install-media.js +55 -0
  61. package/lib/tools/install-ocr.d.ts +7 -0
  62. package/lib/tools/install-ocr.js +43 -0
  63. package/lib/tools/list-conversions.d.ts +2 -0
  64. package/lib/tools/list-conversions.js +18 -0
  65. package/lib/tools/optimize-file.d.ts +3 -0
  66. package/lib/tools/optimize-file.js +91 -0
  67. package/package.json +66 -0
@@ -0,0 +1,12 @@
1
+ import type { ConvertError } from '../types.js';
2
+ export declare class PageRangeError extends Error {
3
+ readonly error: ConvertError;
4
+ constructor(error: ConvertError);
5
+ }
6
+ /**
7
+ * Parse a one-based, inclusive page selection like "1-3,5,8-10" into a
8
+ * deduplicated, ascending list of page numbers bounded by pageCount.
9
+ * Throws PageRangeError (code: invalid_input) on malformed or out-of-range
10
+ * selections so agents get a correction-friendly message.
11
+ */
12
+ export declare function parsePageRange(spec: string, pageCount: number): number[];
@@ -0,0 +1,46 @@
1
+ import { convertError } from '../errors.js';
2
+ export class PageRangeError extends Error {
3
+ error;
4
+ constructor(error) {
5
+ super(error.message);
6
+ this.error = error;
7
+ }
8
+ }
9
+ /**
10
+ * Parse a one-based, inclusive page selection like "1-3,5,8-10" into a
11
+ * deduplicated, ascending list of page numbers bounded by pageCount.
12
+ * Throws PageRangeError (code: invalid_input) on malformed or out-of-range
13
+ * selections so agents get a correction-friendly message.
14
+ */
15
+ export function parsePageRange(spec, pageCount) {
16
+ const cleaned = spec.trim();
17
+ if (!cleaned) {
18
+ throw new PageRangeError(convertError('invalid_input', 'Page selection is empty.', {
19
+ hint: 'Use one-based ranges like 1-3,5,8-10.',
20
+ }));
21
+ }
22
+ if (!/^\d+(\s*-\s*\d+)?(\s*,\s*\d+(\s*-\s*\d+)?)*$/.test(cleaned)) {
23
+ throw new PageRangeError(convertError('invalid_input', `Invalid page selection: '${spec}'.`, {
24
+ hint: 'Use one-based ranges like 1-3,5,8-10 (commas separate pages and ranges).',
25
+ }));
26
+ }
27
+ const seen = new Set();
28
+ for (const part of cleaned.split(',')) {
29
+ const [rawStart, rawEnd] = part.split('-').map((s) => s.trim());
30
+ const start = Number.parseInt(rawStart, 10);
31
+ const end = rawEnd === undefined ? start : Number.parseInt(rawEnd, 10);
32
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
33
+ throw new PageRangeError(convertError('invalid_input', `Invalid page range '${part.trim()}' in '${spec}'.`, {
34
+ hint: 'Ranges must be ascending, one-based and inclusive, e.g. 2-4.',
35
+ }));
36
+ }
37
+ if (end > pageCount) {
38
+ throw new PageRangeError(convertError('invalid_input', `Page ${end} is out of range: the document has ${pageCount} page(s).`, {
39
+ hint: `Use page numbers between 1 and ${pageCount}.`,
40
+ }));
41
+ }
42
+ for (let p = start; p <= end; p++)
43
+ seen.add(p);
44
+ }
45
+ return [...seen].sort((a, b) => a - b);
46
+ }
@@ -0,0 +1,23 @@
1
+ import type { ConvertResult, ConversionStatus, InspectResult } from './core/index.js';
2
+ export declare function formatBytes(n: number): string;
3
+ export declare function formatDuration(ms: number): string;
4
+ export declare function formatConvertResult(result: ConvertResult): string;
5
+ export declare function formatFailure(error: {
6
+ code: string;
7
+ message: string;
8
+ detail?: string;
9
+ hint?: string;
10
+ }): string;
11
+ export declare function formatInspect(inspect: InspectResult): string;
12
+ export declare function formatConversionList(statuses: ConversionStatus[]): string;
13
+ export declare function formatBatchSummary(summary: BatchSummary): string;
14
+ export interface BatchSummary {
15
+ inputDir: string;
16
+ outputDir?: string;
17
+ outputFormat: string;
18
+ converted: string[];
19
+ skipped: string[];
20
+ failed: string[];
21
+ /** Anything the user must know beyond the counts (e.g. truncation at the limit). */
22
+ notes: string[];
23
+ }
package/lib/format.js ADDED
@@ -0,0 +1,84 @@
1
+ /** Human-readable, agent-relayable text for conversion results. */
2
+ const MAX_LISTED = 20;
3
+ export function formatBytes(n) {
4
+ if (n < 1024)
5
+ return `${n} B`;
6
+ if (n < 1024 * 1024)
7
+ return `${(n / 1024).toFixed(1)} KB`;
8
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
9
+ }
10
+ export function formatDuration(ms) {
11
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
12
+ }
13
+ export function formatConvertResult(result) {
14
+ if (!result.ok)
15
+ return formatFailure(result.error);
16
+ const lines = [
17
+ `Converted: ${result.input} (${result.from}) -> ${result.output} (${result.to})`,
18
+ `${formatBytes(result.bytesIn)} -> ${formatBytes(result.bytesOut)} in ${formatDuration(result.durationMs)}`,
19
+ ];
20
+ if (result.outputs && result.outputs.length > 1) {
21
+ lines.push(`Outputs (${result.outputs.length}): ${result.outputs.slice(0, MAX_LISTED).join(', ')}` +
22
+ (result.outputs.length > MAX_LISTED ? ` … +${result.outputs.length - MAX_LISTED} more` : ''));
23
+ }
24
+ for (const warning of result.warnings)
25
+ lines.push(`Warning: ${warning}`);
26
+ return lines.join('\n');
27
+ }
28
+ export function formatFailure(error) {
29
+ const lines = [`Conversion failed (${error.code}): ${error.message}`];
30
+ if (error.hint)
31
+ lines.push(`Hint: ${error.hint}`);
32
+ if (error.detail)
33
+ lines.push(`Detail: ${error.detail}`);
34
+ return lines.join('\n');
35
+ }
36
+ export function formatInspect(inspect) {
37
+ return JSON.stringify(inspect, null, 2);
38
+ }
39
+ export function formatConversionList(statuses) {
40
+ const width = Math.max(...statuses.map((s) => `${s.from} -> ${s.to}`.length)) + 2;
41
+ const rows = statuses.map((s) => {
42
+ const pair = `${s.from} -> ${s.to}`;
43
+ const label = pair.padEnd(width, ' ');
44
+ const state = s.available ? 'available' : `unavailable (missing: ${s.missing.join(', ')})`;
45
+ return `${label}${state}${s.experimental ? ' [experimental]' : ''}`;
46
+ });
47
+ const unavailable = statuses.filter((s) => !s.available).length;
48
+ const footer = unavailable === 0
49
+ ? `${statuses.length} conversions, all local, no external dependencies required.`
50
+ : `${statuses.length} conversions, ${unavailable} unavailable. Install the missing tool to enable them.`;
51
+ return rows.join('\n') + '\n' + footer;
52
+ }
53
+ export function formatBatchSummary(summary) {
54
+ const lines = [
55
+ `Batch convert in ${summary.inputDir} -> ${summary.outputFormat.toUpperCase()}`,
56
+ `Converted: ${summary.converted.length}, skipped: ${summary.skipped.length}, failed: ${summary.failed.length}`,
57
+ ];
58
+ if (summary.outputDir)
59
+ lines.push(`Output dir: ${summary.outputDir}`);
60
+ for (const note of summary.notes)
61
+ lines.push(`Note: ${note}`);
62
+ if (summary.converted.length > 0) {
63
+ lines.push(`Recently converted:`);
64
+ for (const item of summary.converted.slice(0, MAX_LISTED))
65
+ lines.push(` + ${item}`);
66
+ if (summary.converted.length > MAX_LISTED)
67
+ lines.push(` … +${summary.converted.length - MAX_LISTED} more`);
68
+ }
69
+ if (summary.skipped.length > 0) {
70
+ lines.push(`Skipped (already exists, pass overwrite:true to replace):`);
71
+ for (const item of summary.skipped.slice(0, MAX_LISTED))
72
+ lines.push(` = ${item}`);
73
+ if (summary.skipped.length > MAX_LISTED)
74
+ lines.push(` … +${summary.skipped.length - MAX_LISTED} more`);
75
+ }
76
+ if (summary.failed.length > 0) {
77
+ lines.push(`Failed:`);
78
+ for (const item of summary.failed.slice(0, MAX_LISTED))
79
+ lines.push(` x ${item}`);
80
+ if (summary.failed.length > MAX_LISTED)
81
+ lines.push(` … +${summary.failed.length - MAX_LISTED} more`);
82
+ }
83
+ return lines.join('\n');
84
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type Config as ConvertConfig } from './config.js';
3
+ export { Config } from './config.js';
4
+ export declare const name = "dsh-file-convert";
5
+ export declare const inject: string[];
6
+ export declare function apply(ctx: Context, config: ConvertConfig): void;
package/lib/index.js ADDED
@@ -0,0 +1,51 @@
1
+ import { createRouter } from './core/index.js';
2
+ import { createConvertFileTool } from './tools/convert-file.js';
3
+ import { createBatchConvertTool } from './tools/batch-convert.js';
4
+ import { createInspectFileTool } from './tools/inspect-file.js';
5
+ import { createListConversionsTool } from './tools/list-conversions.js';
6
+ import { createOptimizeFileTool } from './tools/optimize-file.js';
7
+ import { createInstallMediaTool } from './tools/install-media.js';
8
+ import { createInstallOcrTool } from './tools/install-ocr.js';
9
+ export { Config } from './config.js';
10
+ export const name = 'dsh-file-convert';
11
+ export const inject = ['tools'];
12
+ const CONSOLE_LOGGER = {
13
+ debug: () => { },
14
+ info: (msg) => console.log(`[dsh-file-convert] ${msg}`),
15
+ warn: (msg) => console.warn(`[dsh-file-convert] ${msg}`),
16
+ error: (msg) => console.error(`[dsh-file-convert] ${msg}`),
17
+ };
18
+ export function apply(ctx, config) {
19
+ const logger = isLogger(ctx.logger) ? ctx.logger : CONSOLE_LOGGER;
20
+ const router = createRouter({
21
+ quality: config.quality,
22
+ dpi: config.dpi,
23
+ timeoutMs: config.timeoutMs,
24
+ outputRoots: config.outputRoots,
25
+ binaryOverrides: {
26
+ ...(config.ffmpegPath ? { ffmpegPath: config.ffmpegPath } : {}),
27
+ ...(config.ffprobePath ? { ffprobePath: config.ffprobePath } : {}),
28
+ ...(config.sofficePath ? { sofficePath: config.sofficePath } : {}),
29
+ ...(config.ghostscriptPath ? { ghostscriptPath: config.ghostscriptPath } : {}),
30
+ ...(config.pythonPath ? { pythonPath: config.pythonPath } : {}),
31
+ ...(config.tesseractPath ? { tesseractPath: config.tesseractPath } : {}),
32
+ },
33
+ maxInputBytes: config.maxInputMb * 1024 * 1024,
34
+ maxPdfPages: config.maxPdfPages,
35
+ maxOutputPixels: config.maxOutputPixels,
36
+ });
37
+ ctx.tools.register(createConvertFileTool(router, config, logger));
38
+ ctx.tools.register(createBatchConvertTool(router, config, logger));
39
+ ctx.tools.register(createInspectFileTool(router, config, logger));
40
+ ctx.tools.register(createListConversionsTool(router, logger));
41
+ ctx.tools.register(createOptimizeFileTool(router, config, logger));
42
+ ctx.tools.register(createInstallMediaTool(config, logger));
43
+ ctx.tools.register(createInstallOcrTool(config, logger));
44
+ logger.info('dsh-file-convert loaded: 7 tools registered (26 conversions; media/ocr dependencies install on request)');
45
+ }
46
+ function isLogger(value) {
47
+ return (typeof value === 'object' &&
48
+ value !== null &&
49
+ typeof value.info === 'function' &&
50
+ typeof value.warn === 'function');
51
+ }
@@ -0,0 +1,5 @@
1
+ import type { ConversionRouter, Logger } from '../core/index.js';
2
+ import { DetectError } from '../core/index.js';
3
+ import type { Config } from '../config.js';
4
+ export declare function createBatchConvertTool(router: ConversionRouter, config: Config, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;
5
+ export { DetectError };
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { defineTool } from '@deepseek-ai/dsh-tools';
5
+ import { formatFromExtension, parseFormatArg } from '../core/index.js';
6
+ import { batchOutputPath } from '../core/index.js';
7
+ import { detectFile, DetectError } from '../core/index.js';
8
+ import { formatBatchSummary } from '../format.js';
9
+ const MAX_CONCURRENCY = 4;
10
+ export function createBatchConvertTool(router, config, logger) {
11
+ return defineTool({
12
+ name: 'batch_convert',
13
+ description: 'Convert every matching file in a directory in one call (top level only), e.g. "convert all JPGs in this folder to WebP". Local execution, no uploads. Outputs land in <input_dir>/output by default; existing outputs are skipped unless overwrite is true.',
14
+ parameters: {
15
+ input_dir: { type: 'string', required: true, description: 'Directory containing the input files (non-recursive).' },
16
+ output_format: {
17
+ type: 'string',
18
+ required: true,
19
+ description: 'Target format: png, jpg, webp, svg, pdf, json, yaml, csv or txt.',
20
+ },
21
+ input_format: {
22
+ type: 'string',
23
+ description: 'Only convert files of this source format (e.g. jpg). Omit to auto-detect convertible files.',
24
+ },
25
+ output_dir: { type: 'string', description: 'Output directory. Default: <input_dir>/output.' },
26
+ overwrite: { type: 'boolean', description: 'Replace existing outputs. Default false (skip them).' },
27
+ quality: { type: 'integer', description: 'JPEG/WebP quality 1-100 (default from plugin config, 85).' },
28
+ dpi: { type: 'integer', description: 'Rasterization DPI for PDF/SVG inputs (default from plugin config, 150).' },
29
+ pages: { type: 'string', description: "Page selection for PDF inputs, e.g. '1-3,5' (one-based, inclusive)." },
30
+ ocr: { type: 'boolean', description: 'OCR pdf -> txt conversions for scanned documents. Slower.' },
31
+ ocr_lang: { type: 'string', description: "OCR languages, '+'-separated. Default 'chi_sim+eng'." },
32
+ },
33
+ output: {
34
+ schema: { type: 'string' },
35
+ render: (_args, value) => [{ type: 'text', text: value }],
36
+ },
37
+ timeoutMs: config.timeoutMs * 4,
38
+ isConcurrencySafe: () => true,
39
+ async execute(args, exec) {
40
+ const to = parseFormatArg(args.output_format);
41
+ if (!to)
42
+ throw new Error(`Unknown output format '${args.output_format}'. Supported: png, jpg, webp, svg, pdf, json, yaml, csv, txt.`);
43
+ const fromFilter = args.input_format
44
+ ? (() => {
45
+ const parsed = parseFormatArg(args.input_format);
46
+ if (!parsed)
47
+ throw new Error(`Unknown source format '${args.input_format}'.`);
48
+ return parsed;
49
+ })()
50
+ : undefined;
51
+ const inputDir = args.input_dir;
52
+ let entries;
53
+ try {
54
+ entries = await fs.readdir(inputDir, { withFileTypes: true });
55
+ }
56
+ catch (err) {
57
+ throw new Error(`Cannot read directory ${inputDir}: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ const files = entries.filter((e) => e.isFile()).map((e) => path.join(inputDir, e.name)).sort();
60
+ const maxFiles = config.batchMaxFiles;
61
+ const candidates = [];
62
+ let examined = 0;
63
+ let truncated = false;
64
+ for (const file of files) {
65
+ if (candidates.length >= maxFiles) {
66
+ truncated = true;
67
+ break;
68
+ }
69
+ examined++;
70
+ if (fromFilter) {
71
+ const ext = path.extname(file).replace(/^\./, '');
72
+ if (formatFromExtension(ext) === fromFilter)
73
+ candidates.push({ file, from: fromFilter });
74
+ continue;
75
+ }
76
+ try {
77
+ const { detection } = await detectFile(file);
78
+ if (detection.format !== to && router.route(detection.format, to)) {
79
+ candidates.push({ file, from: detection.format });
80
+ }
81
+ }
82
+ catch {
83
+ /* unknown formats are simply not candidates */
84
+ }
85
+ }
86
+ const notExamined = files.length - examined;
87
+ if (candidates.length === 0) {
88
+ return `No convertible files found in ${inputDir}${fromFilter ? ` with format ${fromFilter}` : ''}.`;
89
+ }
90
+ const notes = [];
91
+ if (truncated) {
92
+ notes.push(`Reached the ${maxFiles}-file batch limit; ${notExamined} file(s) in the directory were not processed. ` +
93
+ `Raise 'batchMaxFiles' in the plugin config or narrow the run with 'input_format', then convert again.`);
94
+ }
95
+ const outputDir = args.output_dir ?? path.join(inputDir, 'output');
96
+ await fs.mkdir(outputDir, { recursive: true });
97
+ const summary = {
98
+ inputDir,
99
+ outputDir,
100
+ outputFormat: to,
101
+ converted: [],
102
+ skipped: [],
103
+ failed: [],
104
+ notes,
105
+ };
106
+ // The pool adapts to the strictest converter involved (ffmpeg = 2, sharp = 4).
107
+ const involvedConcurrency = new Set();
108
+ for (const candidate of candidates) {
109
+ const converter = router.route(candidate.from, to);
110
+ if (converter)
111
+ involvedConcurrency.add(converter.concurrency);
112
+ }
113
+ const poolSize = Math.max(1, Math.min(MAX_CONCURRENCY, os.cpus().length, ...(involvedConcurrency.size ? involvedConcurrency : [MAX_CONCURRENCY])));
114
+ let next = 0;
115
+ let aborted = false;
116
+ const worker = async () => {
117
+ while (next < candidates.length) {
118
+ if (exec.signal.aborted) {
119
+ aborted = true;
120
+ return;
121
+ }
122
+ const candidate = candidates[next++];
123
+ const result = await router.convertFile({
124
+ input: candidate.file,
125
+ outputFormat: to,
126
+ output: batchOutputPath(outputDir, candidate.file, to),
127
+ overwrite: args.overwrite,
128
+ quality: args.quality,
129
+ dpi: args.dpi,
130
+ pages: args.pages,
131
+ ocr: args.ocr,
132
+ ocrLang: args.ocr_lang,
133
+ }, { logger, signal: exec.signal });
134
+ const name = path.basename(candidate.file);
135
+ if (result.ok) {
136
+ summary.converted.push(`${name} -> ${path.relative(outputDir, result.output) || path.basename(result.output)}`);
137
+ }
138
+ else if (result.error.code === 'output_exists') {
139
+ summary.skipped.push(name);
140
+ }
141
+ else {
142
+ summary.failed.push(`${name}: ${result.error.message}`);
143
+ }
144
+ }
145
+ };
146
+ await Promise.all(Array.from({ length: poolSize }, worker));
147
+ if (aborted) {
148
+ summary.failed.push(`Cancelled with ${candidates.length - next} files not processed.`);
149
+ }
150
+ return formatBatchSummary(summary);
151
+ },
152
+ });
153
+ }
154
+ // DetectError is re-exported for tests that assert detection failures.
155
+ export { DetectError };
@@ -0,0 +1,3 @@
1
+ import type { ConversionRouter, Logger } from '../core/index.js';
2
+ import type { Config } from '../config.js';
3
+ export declare function createConvertFileTool(router: ConversionRouter, config: Config, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,57 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { formatConvertResult, formatFailure } from '../format.js';
3
+ export function createConvertFileTool(router, config, logger) {
4
+ return defineTool({
5
+ name: 'convert_file',
6
+ description: 'Convert one file between supported formats, fully local: PNG/JPG/WEBP/SVG images, PDF (to PNG/JPG/TXT), JSON/YAML/CSV data. No API keys, no uploads, no token cost. The output file defaults to the input directory with the new extension. Use inspect_file first when the input is unclear, and list_conversions to see what is supported.',
7
+ parameters: {
8
+ input: { type: 'string', required: true, description: 'Absolute path of the file to convert.' },
9
+ output_format: {
10
+ type: 'string',
11
+ required: true,
12
+ description: 'Target format: png, jpg, webp, svg, pdf, json, yaml, csv or txt. Aliases like jpeg/yml are accepted.',
13
+ },
14
+ output: {
15
+ type: 'string',
16
+ description: 'Optional absolute output path. Defaults to next to the input file. If the plugin config sets outputRoots, the path must be inside one of them.',
17
+ },
18
+ overwrite: { type: 'boolean', description: 'Replace the output file if it exists. Default false.' },
19
+ quality: { type: 'integer', description: 'JPEG/WebP quality 1-100 (default from plugin config, 85).' },
20
+ dpi: { type: 'integer', description: 'Rasterization DPI for PDF/SVG inputs (default from plugin config, 150).' },
21
+ pages: {
22
+ type: 'string',
23
+ description: "Page selection for PDF inputs, one-based and inclusive: '1-3,5,8-10'. Outputs keep their real page numbers. Applies to pdf -> png/jpg/txt.",
24
+ },
25
+ ocr: {
26
+ type: 'boolean',
27
+ description: 'OCR the pages instead of reading the text layer (pdf -> txt only). For scanned PDFs. Uses a local Tesseract when installed, otherwise the bundled tesseract.js (slower; downloads language data on first use).',
28
+ },
29
+ ocr_lang: {
30
+ type: 'string',
31
+ description: "OCR languages, '+'-separated. Default 'chi_sim+eng'.",
32
+ },
33
+ },
34
+ output: {
35
+ schema: { type: 'string' },
36
+ render: (_args, value) => [{ type: 'text', text: value }],
37
+ },
38
+ timeoutMs: config.timeoutMs,
39
+ isConcurrencySafe: () => true,
40
+ async execute(args, exec) {
41
+ const result = await router.convertFile({
42
+ input: args.input,
43
+ outputFormat: args.output_format,
44
+ output: args.output,
45
+ overwrite: args.overwrite,
46
+ quality: args.quality,
47
+ dpi: args.dpi,
48
+ pages: args.pages,
49
+ ocr: args.ocr,
50
+ ocrLang: args.ocr_lang,
51
+ }, { logger, signal: exec.signal });
52
+ if (!result.ok)
53
+ throw new Error(formatFailure(result.error));
54
+ return formatConvertResult(result);
55
+ },
56
+ });
57
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConversionRouter, Logger } from '../core/index.js';
2
+ import type { Config } from '../config.js';
3
+ export declare function createInspectFileTool(router: ConversionRouter, config: Config, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,29 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { DetectError } from '../core/index.js';
3
+ import { formatFailure, formatInspect } from '../format.js';
4
+ export function createInspectFileTool(router, config, logger) {
5
+ void logger;
6
+ return defineTool({
7
+ name: 'inspect_file',
8
+ description: 'Inspect a file before converting it: format (by content, not just extension), dimensions for images, page count / encryption / scanned-PDF detection for PDFs, record counts for JSON/YAML/CSV, plus file size. Returns JSON.',
9
+ parameters: {
10
+ input: { type: 'string', required: true, description: 'Absolute path of the file to inspect.' },
11
+ },
12
+ output: {
13
+ schema: { type: 'string' },
14
+ render: (_args, value) => [{ type: 'text', text: value }],
15
+ },
16
+ timeoutMs: Math.min(config.timeoutMs, 30_000),
17
+ isConcurrencySafe: () => true,
18
+ async execute(args) {
19
+ try {
20
+ return formatInspect(await router.inspect(args.input));
21
+ }
22
+ catch (err) {
23
+ if (err instanceof DetectError)
24
+ throw new Error(formatFailure(err.error));
25
+ throw err;
26
+ }
27
+ },
28
+ });
29
+ }
@@ -0,0 +1,9 @@
1
+ import type { Logger } from '../core/index.js';
2
+ import type { Config } from '../config.js';
3
+ /**
4
+ * One explicit, user-approved path to media support: downloads pinned static
5
+ * ffmpeg/ffprobe builds (FFmpeg 6.1.1) into the plugin cache via the
6
+ * npmmirror binary CDN, with the GitHub release as a sha256-identical
7
+ * fallback. System installs always keep priority; this only fills the gap.
8
+ */
9
+ export declare function createInstallMediaTool(config: Config, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,55 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { FFMPEG, FFPROBE, resolveBinary } from '../core/index.js';
3
+ import { downloadBinary } from '../core/binaries/download.js';
4
+ import { formatBytes } from '../format.js';
5
+ /**
6
+ * One explicit, user-approved path to media support: downloads pinned static
7
+ * ffmpeg/ffprobe builds (FFmpeg 6.1.1) into the plugin cache via the
8
+ * npmmirror binary CDN, with the GitHub release as a sha256-identical
9
+ * fallback. System installs always keep priority; this only fills the gap.
10
+ */
11
+ export function createInstallMediaTool(config, logger) {
12
+ return defineTool({
13
+ name: 'install_media_dependencies',
14
+ description: 'Download pinned static ffmpeg and ffprobe builds (FFmpeg 6.1.1, about 56 MB total on Windows as two ~28 MB downloads) into the plugin cache (~/.dsh-file-convert/bin), so mp4/mov/wav conversions and video optimize_file work without a system install. Served from the npmmirror binary CDN with the GitHub release as fallback, both sha256-verified. Ask the user for consent before calling. Skips what is already available; a system ffmpeg keeps priority over the cache.',
15
+ parameters: {
16
+ force: {
17
+ type: 'boolean',
18
+ description: 'Re-download even if a cached copy exists (e.g. after a corrupted download). Default false.',
19
+ },
20
+ },
21
+ output: {
22
+ schema: { type: 'string' },
23
+ render: (_args, value) => [{ type: 'text', text: value }],
24
+ },
25
+ timeoutMs: Math.max(config.timeoutMs, 900_000), // big files, slow networks
26
+ isConcurrencySafe: () => false,
27
+ async execute(args, exec) {
28
+ const overrides = {};
29
+ if (config.ffmpegPath)
30
+ overrides.ffmpegPath = config.ffmpegPath;
31
+ if (config.ffprobePath)
32
+ overrides.ffprobePath = config.ffprobePath;
33
+ const lines = [];
34
+ for (const dep of [FFMPEG, FFPROBE]) {
35
+ const existing = await resolveBinary(dep, overrides, logger);
36
+ if (existing && args.force !== true) {
37
+ lines.push(`= ${dep.name}: already available at ${existing}`);
38
+ continue;
39
+ }
40
+ logger.info(`downloading ${dep.name} into the plugin cache...`);
41
+ const outcome = await downloadBinary(dep, {
42
+ timeoutMs: Math.max(config.timeoutMs, 900_000),
43
+ signal: exec.signal,
44
+ force: args.force === true,
45
+ });
46
+ lines.push(`+ ${dep.name}: installed at ${outcome.path} (${formatBytes(outcome.bytes)}) - ${outcome.versionLine}`);
47
+ }
48
+ return [
49
+ 'Media dependencies ready:',
50
+ ...lines,
51
+ 'No restart needed - the cache is checked on every conversion.',
52
+ ].join('\n');
53
+ },
54
+ });
55
+ }
@@ -0,0 +1,7 @@
1
+ import type { Logger } from '../core/index.js';
2
+ import type { Config } from '../config.js';
3
+ /**
4
+ * Explicit consent path for OCR language data: downloads tesseract.js packs
5
+ * into the plugin cache. Conversions never download these implicitly.
6
+ */
7
+ export declare function createInstallOcrTool(config: Config, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,43 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { TESSERACT, installOcrLanguages, ocrLanguagesCached, resolveBinary, tessdataDir } from '../core/index.js';
3
+ import { formatBytes } from '../format.js';
4
+ /**
5
+ * Explicit consent path for OCR language data: downloads tesseract.js packs
6
+ * into the plugin cache. Conversions never download these implicitly.
7
+ */
8
+ export function createInstallOcrTool(config, logger) {
9
+ return defineTool({
10
+ name: 'install_ocr_dependencies',
11
+ description: 'Download OCR language data for the bundled tesseract.js engine (about 10-30 MB per language, cached in ~/.dsh-file-convert/tessdata) so pdf -> txt with ocr: true works without a local Tesseract. Ask the user for consent before calling. Skips when a local Tesseract CLI is installed or the data is already cached.',
12
+ parameters: {
13
+ lang: { type: 'string', description: "OCR languages, '+'-separated. Default 'chi_sim+eng'." },
14
+ force: { type: 'boolean', description: 'Re-download even if the language data is already cached. Default false.' },
15
+ },
16
+ output: {
17
+ schema: { type: 'string' },
18
+ render: (_args, value) => [{ type: 'text', text: value }],
19
+ },
20
+ timeoutMs: Math.max(config.timeoutMs, 900_000),
21
+ isConcurrencySafe: () => false,
22
+ async execute(args, exec) {
23
+ const lang = args.lang ?? 'chi_sim+eng';
24
+ const overrides = {};
25
+ if (config.tesseractPath)
26
+ overrides.tesseractPath = config.tesseractPath;
27
+ const cli = await resolveBinary(TESSERACT, overrides, logger);
28
+ if (cli && args.force !== true) {
29
+ return `Local Tesseract found at ${cli} - OCR works without any download (languages come from its own traineddata).`;
30
+ }
31
+ if (args.force !== true && (await ocrLanguagesCached(lang))) {
32
+ return `Language data for '${lang}' is already cached in ${tessdataDir()}. Nothing to do.`;
33
+ }
34
+ const t0 = Date.now();
35
+ const { files, bytes } = await installOcrLanguages(lang, { logger, signal: exec.signal });
36
+ return [
37
+ 'OCR language data ready:',
38
+ `+ ${lang}: ${files.join(', ')} (${formatBytes(bytes)}) in the plugin cache`,
39
+ `Downloaded in ${Math.round((Date.now() - t0) / 100) / 10}s. No restart needed.`,
40
+ ].join('\n');
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,2 @@
1
+ import type { ConversionRouter, Logger } from '../core/index.js';
2
+ export declare function createListConversionsTool(router: ConversionRouter, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,18 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { formatConversionList } from '../format.js';
3
+ export function createListConversionsTool(router, logger) {
4
+ void logger;
5
+ return defineTool({
6
+ name: 'list_conversions',
7
+ description: 'List every conversion dsh-file-convert supports on this machine, including which ones are unavailable because an external tool is missing. Call this before converting when unsure whether a pair is supported.',
8
+ parameters: {},
9
+ output: {
10
+ schema: { type: 'string' },
11
+ render: (_args, value) => [{ type: 'text', text: value }],
12
+ },
13
+ isConcurrencySafe: () => true,
14
+ async execute() {
15
+ return formatConversionList(await router.listConversions());
16
+ },
17
+ });
18
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConversionRouter, Logger } from '../core/index.js';
2
+ import type { Config } from '../config.js';
3
+ export declare function createOptimizeFileTool(router: ConversionRouter, config: Config, logger: Logger): import("@deepseek-ai/dsh-tools").ToolDefinition;