import { resolve } from 'node:path'; import { createCanvas } from '@napi-rs/canvas'; import { configPath, readConfig, writeConfig } from './config.js'; import { costLabel, discoverModels, selectModel } from './models.js'; import { parsePdfToArtifacts, planPdfParse } from './parser.js'; import { pdfPageCount, renderPdfPages } from './pdf.js'; import { PROMPT_PROFILES, promptRef } from './prompts.js'; import type { CompleteModel, ModelRegistryView, OkraPiConfig, ParsePlan, ParseProgress, } from './types.js'; export interface CommandRuntime { cwd: string; registry: ModelRegistryView; completeModel: CompleteModel; onProgress?: (progress: ParseProgress) => void; } interface ParseCommandOptions { source: string; model?: string; pages?: string; dpi?: number; maxCostUsd?: number; outputDir?: string; dryRun: boolean; } export function tokenizeCommand(input: string): string[] { const tokens: string[] = []; const pattern = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\S+)/g; for (const match of input.matchAll(pattern)) { const token = match[1] ?? match[2] ?? match[3]; tokens.push(token.replace(/\\([\\"'])/g, '$1')); } return tokens; } function formatTable(headers: string[], rows: string[][]): string { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)), ); const line = (values: string[]) => values.map((value, index) => value.padEnd(widths[index])).join(' ').trimEnd(); return [line(headers), line(widths.map((width) => '-'.repeat(width))), ...rows.map(line)].join( '\n', ); } export function formatModelTable(registry: ModelRegistryView, config: OkraPiConfig): string { const candidates = discoverModels(registry); if (candidates.length === 0) { return 'No Pi vision models have configured credentials. Use /login, then run /pdf-parse models again.'; } const rows = candidates.map((candidate) => [ `${config.defaultModel === candidate.fullId ? '* ' : ' '}${candidate.fullId}`, candidate.access, candidate.prompt ? promptRef(candidate.prompt) : 'untested', costLabel(candidate), ]); return `${formatTable(['MODEL', 'ACCESS', 'PARSER PROMPT', 'EST/PAGE'], rows)}\n\n* saved default; untested models are visible but never auto-selected.\nConfigured credentials are validated by the provider when a request is sent.`; } function formatPlan(plan: ParsePlan): string { return [ `Source: ${plan.source}`, `Pages: ${plan.selectedPages.description} (${plan.selectedPageCount}/${plan.pageCount})`, `Model: ${plan.model}`, `Auth: ${plan.access}`, `Prompt: ${plan.prompt}@${plan.promptVersion}`, `Cost: ${plan.costNote}`, ].join('\n'); } function parseParseOptions(tokens: string[]): ParseCommandOptions { let source: string | undefined; let model: string | undefined; let pages: string | undefined; let dpi: number | undefined; let outputDir: string | undefined; let maxCostUsd: number | undefined; let dryRun = false; const valueAfter = (index: number, flag: string): string => { const value = tokens[index + 1]; if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value.`); return value; }; for (let index = 0; index < tokens.length; index++) { const token = tokens[index]; if (token === '-m' || token === '--model') { model = valueAfter(index, token); index++; } else if (token === '--pages') { pages = valueAfter(index, token); index++; } else if (token === '--dpi') { const value = valueAfter(index, token); dpi = Number(value); if (!Number.isInteger(dpi) || dpi < 72 || dpi > 300) { throw new Error(`Invalid --dpi value "${value}". Use an integer from 72 to 300.`); } index++; } else if (token === '--out') { outputDir = valueAfter(index, token); index++; } else if (token === '--max-cost') { const value = valueAfter(index, token); maxCostUsd = Number(value); if (!Number.isFinite(maxCostUsd)) throw new Error(`Invalid --max-cost value "${value}".`); index++; } else if (token === '--dry-run') { dryRun = true; } else if (token.startsWith('-')) { throw new Error(`Unknown parse option "${token}".`); } else if (!source) { source = token; } else { throw new Error(`Unexpected parse argument "${token}".`); } } if (!source) { throw new Error( 'Usage: /pdf-parse parse [-m model] [--pages 1-3,8] [--dpi 72-300] [--max-cost USD] [--dry-run]', ); } return { source, model, pages, dpi, outputDir, maxCostUsd, dryRun }; } function helpText(): string { return [ 'PDF parser commands:', ' /pdf-parse models', ' /pdf-parse models default ', ' /pdf-parse models alias ', ' /pdf-parse model ', ' /pdf-parse prompts [provider/model]', ' /pdf-parse doctor [pdf]', ' /pdf-parse parse [-m model] [--pages 1-3,8] [--dpi 72-300] [--max-cost USD] [--dry-run]', ].join('\n'); } async function doctorReport(tokens: string[], runtime: CommandRuntime): Promise { if (tokens.length > 1) throw new Error('Usage: /pdf-parse doctor [pdf]'); const candidates = discoverModels(runtime.registry); const ready = candidates.filter((candidate) => candidate.prompt); const lines = [ 'PDF Parse doctor', `Node: ${process.version}`, `Config: ${configPath()}`, ]; try { const canvas = createCanvas(2, 2); canvas.getContext('2d').fillRect(0, 0, 1, 1); lines.push('Renderer: ready'); } catch (error) { lines.push(`Renderer: failed — ${error instanceof Error ? error.message : String(error)}`); } lines.push(`Pi vision models with credentials: ${candidates.length}`); lines.push(`Validated model/prompt pairs: ${ready.length}`); if (ready.length > 0) { try { const config = await readConfig(); const selected = selectModel(runtime.registry, { config }); lines.push(`Default pair: ${selected.fullId} (${promptRef(selected.prompt!)})`); } catch (error) { lines.push(`Default pair: unavailable — ${error instanceof Error ? error.message : String(error)}`); } } else { lines.push('Next: use /login , then /pdf-parse models.'); } const input = tokens[0]; if (input) { const source = resolve(runtime.cwd, input); try { const pageCount = await pdfPageCount(source); const [page] = await renderPdfPages( source, { pages: [1], description: '1' }, 72, ); lines.push( `PDF smoke: ready — ${pageCount} page${pageCount === 1 ? '' : 's'}; page 1 rendered ${page.width}×${page.height}`, ); } catch (error) { lines.push(`PDF smoke: failed — ${error instanceof Error ? error.message : String(error)}`); } } return lines.join('\n'); } export async function runPdfParseCommand(args: string, runtime: CommandRuntime): Promise { const tokens = tokenizeCommand(args); const command = tokens.shift(); if (!command || command === 'help') return helpText(); const config = await readConfig(); if (command === 'models') { const subcommand = tokens.shift(); if (!subcommand) return formatModelTable(runtime.registry, config); if (subcommand === 'default') { const reference = tokens.shift(); if (!reference) return config.defaultModel ?? 'No default PDF model is set.'; if (tokens.length > 0) throw new Error('Usage: /pdf-parse models default '); const selected = selectModel(runtime.registry, { requested: reference, config }); await writeConfig({ ...config, defaultModel: selected.fullId }); return `Default PDF model: ${selected.fullId} (${promptRef(selected.prompt!)})`; } if (subcommand === 'alias') { const name = tokens.shift(); const reference = tokens.shift(); if (!name || !reference || tokens.length > 0) { throw new Error('Usage: /pdf-parse models alias '); } if (!/^[a-z][a-z0-9_-]*$/i.test(name)) { throw new Error('Alias names must begin with a letter and contain only letters, numbers, _ or -.'); } const selected = selectModel(runtime.registry, { requested: reference, config }); await writeConfig({ ...config, aliases: { ...config.aliases, [name]: selected.fullId }, }); return `Model alias ${name} -> ${selected.fullId}`; } throw new Error(`Unknown models command "${subcommand}".`); } if (command === 'model') { const reference = tokens.shift(); if (!reference || tokens.length > 0) { throw new Error('Usage: /pdf-parse model '); } const selected = selectModel(runtime.registry, { requested: reference, config }); await writeConfig({ ...config, defaultModel: selected.fullId }); return `Default PDF model: ${selected.fullId} (${promptRef(selected.prompt!)})`; } if (command === 'prompts') { const reference = tokens.shift(); if (tokens.length > 0) throw new Error('Usage: /pdf-parse prompts [provider/model]'); if (reference) { const selected = selectModel(runtime.registry, { requested: reference, config }); const prompt = selected.prompt!; return [ `${selected.fullId} -> ${promptRef(prompt)}`, `BBox order: ${prompt.bboxOrder}`, `Estimated tokens/page: ${prompt.estimatedInputTokensPerPage} input + ${prompt.estimatedOutputTokensPerPage} output`, ].join('\n'); } return PROMPT_PROFILES.map( (prompt) => `${promptRef(prompt)} ${prompt.bboxOrder} ${prompt.modelIds.join(', ')}`, ).join('\n'); } if (command === 'doctor') return doctorReport(tokens, runtime); if (command === 'parse') { const options = parseParseOptions(tokens); const source = resolve(runtime.cwd, options.source); const parseOptions = { model: options.model, pages: options.pages, dpi: options.dpi, maxCostUsd: options.maxCostUsd, config, onProgress: runtime.onProgress, }; if (options.dryRun) { const { plan } = await planPdfParse(source, runtime.registry, parseOptions); return `Dry run — no page images sent.\n${formatPlan(plan)}`; } const { plan, result, artifacts } = await parsePdfToArtifacts( source, runtime.registry, runtime.completeModel, parseOptions, options.outputDir ? resolve(runtime.cwd, options.outputDir) : undefined, ); return [ `Parsed ${result.meta.pageCount} page${result.meta.pageCount === 1 ? '' : 's'} into ${result.blocks.length} layout blocks.`, formatPlan(plan), `Output: ${artifacts.outputDir}`, ].join('\n'); } throw new Error(`Unknown PDF parser command "${command}".\n${helpText()}`); } /** @deprecated Use runPdfParseCommand. */ export const runOkraCommand = runPdfParseCommand;