diff --git a/.gitignore b/.gitignore index 9eeeced..0daf2d4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,10 @@ pnpm-debug.log* # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Others +tmp/ + +# Tesseract.js language data (downloaded at runtime) +*.traineddata \ No newline at end of file diff --git a/examples/tacqueria-receipt.pdf b/examples/tacqueria-receipt.pdf new file mode 100644 index 0000000..38d644d Binary files /dev/null and b/examples/tacqueria-receipt.pdf differ diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c8e8d6..da1c438 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,12 +23,20 @@ "@modelcontextprotocol/sdk": "^1.24.3", "chalk": "^5.6.2", "commander": "^14.0.2", + "ink": "^6.5.1", + "ink-spinner": "^5.0.0", "ora": "^9.0.0", + "react": "^19.2.1", "vectordb": "^0.21.2", "zod": "^3.23.8" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "ink-testing-library": "^4.0.0", + "jsdom": "^27.2.0", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f7ca549..7e6feed 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,15 +1,17 @@ #!/usr/bin/env node -import { exec } from 'node:child_process'; import { resolve } from 'node:path'; -import { promisify } from 'node:util'; -import type { Config } from '@doc-agent/core'; -import { extractDocument } from '@doc-agent/extract'; -import { storage } from '@doc-agent/storage'; import chalk from 'chalk'; import { Command } from 'commander'; -import ora from 'ora'; +import { render } from 'ink'; +import React from 'react'; +import { ExtractApp } from './components/ExtractApp'; -const execAsync = promisify(exec); +// Resolve paths relative to where user ran the command +// INIT_CWD is set by pnpm to original working directory +const cwd = process.env.INIT_CWD || process.cwd(); +function resolvePath(filePath: string): string { + return resolve(cwd, filePath); +} const program = new Command(); @@ -19,72 +21,39 @@ program .description('Document extraction and semantic search CLI') .version('0.1.0'); -async function ensureOllamaModel(model: string) { - const spinner = ora(`Checking for Ollama model: ${model}...`).start(); - try { - const response = await fetch('http://localhost:11434/api/tags'); - if (!response.ok) { - throw new Error('Ollama is not running. Please start Ollama first.'); - } - const data = (await response.json()) as { models: { name: string }[] }; - const modelExists = data.models.some((m) => m.name.includes(model)); - - if (!modelExists) { - spinner.text = `Pulling Ollama model: ${model} (this may take a while)...`; - // Use exec to pull so we can potentially see output or just wait - // Using the API to pull would be better for progress, but for now CLI is robust - await execAsync(`ollama pull ${model}`); - spinner.succeed(`Model ${model} ready.`); - } else { - spinner.succeed(`Model ${model} found.`); - } - } catch (error) { - spinner.fail('Failed to check/pull Ollama model.'); - throw error; - } -} - program .command('extract ') .description('Extract structured data from a document') .option('-p, --provider ', 'AI provider (gemini|openai|ollama)', 'ollama') - .option( - '-m, --model ', - 'Model to use (default: llama3.2-vision for ollama)', - 'llama3.2-vision' - ) + .option('-m, --model ', 'Model to use (ollama: llama3.2-vision, gemini: gemini-2.5-flash)') .option('-d, --dry-run', 'Print JSON only, do not save to database', false) .action(async (file: string, options) => { - try { - if (options.provider === 'ollama') { - await ensureOllamaModel(options.model); - } - - const spinner = ora('Extracting document data...').start(); - - const config: Config = { - aiProvider: options.provider, - geminiApiKey: process.env.GEMINI_API_KEY, - openaiApiKey: process.env.OPENAI_API_KEY, - ollamaModel: options.model, - }; + const absolutePath = resolvePath(file); - const result = await extractDocument(file, config); + // Set default model based on provider if not specified + const defaultModels: Record = { + ollama: 'llama3.2-vision', + gemini: 'gemini-2.5-flash', + openai: 'gpt-4o', + }; + const model = options.model || defaultModels[options.provider] || 'llama3.2-vision'; - if (options.dryRun) { - spinner.succeed(chalk.green('Extraction complete (dry run)')); - } else { - const absolutePath = resolve(file); - await storage.saveDocument(result, absolutePath); - spinner.succeed(chalk.green(`Saved: ${result.filename} (ID: ${result.id})`)); - } + const { waitUntilExit } = render( + React.createElement(ExtractApp, { + file: absolutePath, + provider: options.provider, + model, + dryRun: options.dryRun, + onComplete: () => { + // Normal exit + }, + onError: () => { + process.exitCode = 1; + }, + }) + ); - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - console.error(chalk.red('\nExtraction failed:')); - console.error((error as Error).message); - process.exit(1); - } + await waitUntilExit(); }); program diff --git a/packages/cli/src/components/ConfirmInput.tsx b/packages/cli/src/components/ConfirmInput.tsx new file mode 100644 index 0000000..5e9d7fb --- /dev/null +++ b/packages/cli/src/components/ConfirmInput.tsx @@ -0,0 +1,58 @@ +import { Box, Text, useInput } from 'ink'; + +interface ConfirmInputInteractiveProps { + message: string; + onConfirm: (confirmed: boolean) => void; + defaultValue: boolean; +} + +function ConfirmInputInteractive({ + message, + onConfirm, + defaultValue, +}: ConfirmInputInteractiveProps) { + useInput((input, key) => { + if (input.toLowerCase() === 'y' || (key.return && defaultValue)) { + onConfirm(true); + } else if (input.toLowerCase() === 'n' || (key.return && !defaultValue)) { + onConfirm(false); + } + }); + + return ( + + {message} + {defaultValue ? '[Y/n]' : '[y/N]'} + + ); +} + +interface ConfirmInputProps { + message: string; + onConfirm: (confirmed: boolean) => void; + defaultValue?: boolean; + /** Whether stdin supports raw mode (interactive input) */ + isInteractive: boolean; +} + +export function ConfirmInput({ + message, + onConfirm, + defaultValue = true, + isInteractive, +}: ConfirmInputProps) { + // Non-interactive: just show message, caller handles auto-confirm + if (!isInteractive) { + return ( + + {message} + (auto: {defaultValue ? 'yes' : 'no'}) + + ); + } + + // Interactive mode with useInput + return ( + + ); +} diff --git a/packages/cli/src/components/ErrorDisplay.tsx b/packages/cli/src/components/ErrorDisplay.tsx new file mode 100644 index 0000000..b32dc1d --- /dev/null +++ b/packages/cli/src/components/ErrorDisplay.tsx @@ -0,0 +1,32 @@ +import { Box, Text } from 'ink'; + +interface ErrorDisplayProps { + title: string; + message: string; + suggestions?: string[]; +} + +export function ErrorDisplay({ title, message, suggestions }: ErrorDisplayProps) { + return ( + + + + ✗ {title} + + + + {message} + + {suggestions && suggestions.length > 0 && ( + + Suggestions: + {suggestions.map((suggestion) => ( + + • {suggestion} + + ))} + + )} + + ); +} diff --git a/packages/cli/src/components/ExtractApp.tsx b/packages/cli/src/components/ExtractApp.tsx new file mode 100644 index 0000000..c816a7f --- /dev/null +++ b/packages/cli/src/components/ExtractApp.tsx @@ -0,0 +1,91 @@ +import { Box, useStdin } from 'ink'; +import { + ExtractionProvider, + type ExtractionService, + OllamaProvider, + type OllamaService, +} from '../contexts'; +import { useExtraction } from '../hooks/useExtraction'; +import { useOllama } from '../hooks/useOllama'; +import { ExtractionProgress } from './ExtractionProgress'; +import { OllamaStatus } from './OllamaStatus'; +import { Result } from './Result'; +import { StreamingOutput } from './StreamingOutput'; + +export interface ExtractAppProps { + file: string; + provider: 'gemini' | 'openai' | 'ollama'; + model: string; + dryRun: boolean; + onComplete: () => void; + onError: (error: Error) => void; + // Optional services for testing + ollamaService?: OllamaService; + extractionService?: ExtractionService; +} + +function ExtractAppInner({ + file, + provider, + model, + dryRun, + onComplete, + onError, +}: Omit) { + const { isRawModeSupported } = useStdin(); + + const ollama = useOllama({ + provider, + model, + isInteractive: isRawModeSupported, + }); + + const extraction = useExtraction({ + file, + provider, + model, + dryRun, + shouldStart: ollama.isReady, + onComplete, + onError, + }); + + return ( + + + + {ollama.state.status === 'ready' && ( + + + + )} + + {extraction.state.status === 'extracting' && extraction.responseContent && ( + + )} + + {extraction.result && extraction.state.status === 'complete' && ( + + )} + + ); +} + +export function ExtractApp({ ollamaService, extractionService, ...props }: ExtractAppProps) { + return ( + + + + + + ); +} diff --git a/packages/cli/src/components/ExtractionProgress.tsx b/packages/cli/src/components/ExtractionProgress.tsx new file mode 100644 index 0000000..f06767c --- /dev/null +++ b/packages/cli/src/components/ExtractionProgress.tsx @@ -0,0 +1,81 @@ +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { useEffect, useState } from 'react'; + +export type ExtractionState = + | { status: 'idle' } + | { status: 'extracting'; startTime: number } + | { status: 'saving' } + | { status: 'complete'; id: string; filename: string } + | { status: 'error'; message: string }; + +interface ExtractionProgressProps { + state: ExtractionState; + dryRun?: boolean; +} + +export function ExtractionProgress({ state, dryRun }: ExtractionProgressProps) { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (state.status !== 'extracting') { + setElapsed(0); + return; + } + + const interval = setInterval(() => { + setElapsed(Math.floor((Date.now() - state.startTime) / 1000)); + }, 1000); + + return () => clearInterval(interval); + }, [state]); + + switch (state.status) { + case 'idle': + return null; + + case 'extracting': + return ( + + + + + Extracting document data... + {elapsed > 10 && ({elapsed}s - Local AI can take a moment)} + + ); + + case 'saving': + return ( + + + + + Saving to database... + + ); + + case 'complete': + return ( + + + {dryRun ? ( + Extraction complete (dry run) + ) : ( + + {' '} + Saved: {state.filename} (ID: {state.id}) + + )} + + ); + + case 'error': + return ( + + + Extraction failed: {state.message} + + ); + } +} diff --git a/packages/cli/src/components/OllamaStatus.tsx b/packages/cli/src/components/OllamaStatus.tsx new file mode 100644 index 0000000..7db95ac --- /dev/null +++ b/packages/cli/src/components/OllamaStatus.tsx @@ -0,0 +1,219 @@ +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { ConfirmInput } from './ConfirmInput'; + +export interface PullProgress { + status: string; + completed?: number; + total?: number; +} + +export type OllamaState = + | { status: 'checking' } + | { status: 'not-installed' } + | { status: 'prompt-install' } + | { status: 'installing'; progress?: string } + | { status: 'not-running' } + | { status: 'prompt-start' } + | { status: 'starting' } + | { status: 'checking-model'; model: string } + | { status: 'pulling-model'; model: string; pullProgress?: PullProgress } + | { status: 'ready'; model: string } + | { status: 'error'; message: string } + | { status: 'cancelled' }; + +interface OllamaStatusProps { + state: OllamaState; + isInteractive: boolean; + onInstallConfirm?: (confirmed: boolean) => void; + onStartConfirm?: (confirmed: boolean) => void; +} + +export function OllamaStatus({ + state, + isInteractive, + onInstallConfirm, + onStartConfirm, +}: OllamaStatusProps) { + switch (state.status) { + case 'checking': + return ( + + + + + Checking Ollama... + + ); + + case 'not-installed': + return ( + + + Ollama is not installed + + ); + + case 'prompt-install': + return ( + + + + Ollama is not installed + + + {onInstallConfirm && ( + + )} + + + + (Uses official installer: curl -fsSL https://ollama.com/install.sh | sh) + + + + ); + + case 'installing': + return ( + + + + + {state.progress || 'Installing Ollama...'} + + ); + + case 'not-running': + return ( + + + Ollama is installed but not running + + ); + + case 'prompt-start': + return ( + + + + Ollama is installed but not running + + + {onStartConfirm && ( + + )} + + + ); + + case 'starting': + return ( + + + + + Starting Ollama... + + ); + + case 'checking-model': + return ( + + + + + Checking model: {state.model}... + + ); + + case 'pulling-model': { + const { pullProgress } = state; + const completed = pullProgress?.completed ?? 0; + const total = pullProgress?.total ?? 0; + const hasProgress = total > 0 && completed > 0; + const percent = hasProgress ? Math.round((completed / total) * 100) : 0; + const barWidth = 20; + const filled = hasProgress ? Math.round((percent / 100) * barWidth) : 0; + const bar = hasProgress ? '█'.repeat(filled) + '░'.repeat(barWidth - filled) : ''; + + // Format bytes + const formatBytes = (bytes: number) => { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`; + return `${bytes} B`; + }; + + return ( + + + + Pulling {state.model} + + {hasProgress ? ( + + {bar} + + {' '} + {percent}% ({formatBytes(completed)} / {formatBytes(total)}) + + + ) : ( + + + + + {pullProgress?.status || 'Connecting...'} + + )} + + ); + } + + case 'ready': + return ( + + + Ollama ready: {state.model} + + ); + + case 'error': + return ( + + + + Ollama error: {state.message} + + + ); + + case 'cancelled': + return ( + + + + Cancelled + + + To install manually: + https://ollama.com/download + + Or use cloud AI: + + doc extract file.pdf --provider gemini + + + ); + } +} diff --git a/packages/cli/src/components/Result.tsx b/packages/cli/src/components/Result.tsx new file mode 100644 index 0000000..20064c8 --- /dev/null +++ b/packages/cli/src/components/Result.tsx @@ -0,0 +1,59 @@ +import type { DocumentData } from '@doc-agent/core'; +import { Box, Text } from 'ink'; + +interface ResultProps { + data: DocumentData; + showJson?: boolean; +} + +export function Result({ data, showJson = true }: ResultProps) { + if (showJson) { + return ( + + ───────────────────────────────────── + {JSON.stringify(data, null, 2)} + + ); + } + + return ( + + ───────────────────────────────────── + + Type: + {data.type} + + {data.vendor && ( + + Vendor: + {data.vendor} + + )} + {data.amount !== undefined && ( + + Amount: + ${data.amount.toFixed(2)} + + )} + {data.date && ( + + Date: + {data.date} + + )} + {data.items && data.items.length > 0 && ( + + Items: + {data.items.map((item) => ( + + + • {item.description} + {item.total !== undefined && ${item.total.toFixed(2)}} + + + ))} + + )} + + ); +} diff --git a/packages/cli/src/components/StreamingOutput.tsx b/packages/cli/src/components/StreamingOutput.tsx new file mode 100644 index 0000000..fca1aab --- /dev/null +++ b/packages/cli/src/components/StreamingOutput.tsx @@ -0,0 +1,23 @@ +import { Box, Text } from 'ink'; + +interface StreamingOutputProps { + content: string; + maxLines?: number; +} + +export function StreamingOutput({ content, maxLines = 10 }: StreamingOutputProps) { + if (!content) return null; + + // Show last N lines of content to keep it readable + const lines = content.split('\n'); + const displayLines = lines.slice(-maxLines); + const truncated = lines.length > maxLines; + + return ( + + ─── Response ─── + {truncated && ...} + {displayLines.join('\n')} + + ); +} diff --git a/packages/cli/src/components/__tests__/OllamaStatus.test.tsx b/packages/cli/src/components/__tests__/OllamaStatus.test.tsx new file mode 100644 index 0000000..85dc0a8 --- /dev/null +++ b/packages/cli/src/components/__tests__/OllamaStatus.test.tsx @@ -0,0 +1,92 @@ +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { type OllamaState, OllamaStatus } from '../OllamaStatus'; + +describe('OllamaStatus', () => { + const defaultProps = { + isInteractive: true, + onInstallConfirm: undefined, + onStartConfirm: undefined, + }; + + it('should render checking state', () => { + const state: OllamaState = { status: 'checking' }; + const { lastFrame } = render(React.createElement(OllamaStatus, { ...defaultProps, state })); + + expect(lastFrame()).toContain('Checking Ollama'); + }); + + it('should render ready state with model name', () => { + const state: OllamaState = { status: 'ready', model: 'llama3.2-vision' }; + const { lastFrame } = render(React.createElement(OllamaStatus, { ...defaultProps, state })); + + expect(lastFrame()).toContain('Ollama ready'); + expect(lastFrame()).toContain('llama3.2-vision'); + }); + + it('should render pulling-model state', () => { + const state: OllamaState = { status: 'pulling-model', model: 'test-model' }; + const { lastFrame } = render(React.createElement(OllamaStatus, { ...defaultProps, state })); + + expect(lastFrame()).toContain('Pulling test-model'); + }); + + it('should render pulling-model state with progress', () => { + const state: OllamaState = { + status: 'pulling-model', + model: 'test-model', + pullProgress: { status: 'pulling', completed: 500000000, total: 1000000000 }, + }; + const { lastFrame } = render(React.createElement(OllamaStatus, { ...defaultProps, state })); + + expect(lastFrame()).toContain('Pulling test-model'); + expect(lastFrame()).toContain('50%'); + expect(lastFrame()).toContain('500.0 MB'); + }); + + it('should render error state', () => { + const state: OllamaState = { status: 'error', message: 'Connection failed' }; + const { lastFrame } = render(React.createElement(OllamaStatus, { ...defaultProps, state })); + + expect(lastFrame()).toContain('error'); + expect(lastFrame()).toContain('Connection failed'); + }); + + it('should render cancelled state', () => { + const state: OllamaState = { status: 'cancelled' }; + const { lastFrame } = render(React.createElement(OllamaStatus, { ...defaultProps, state })); + + expect(lastFrame()).toContain('Cancelled'); + }); + + it('should render prompt-install state', () => { + const state: OllamaState = { status: 'prompt-install' }; + const onInstallConfirm = vi.fn(); + const { lastFrame } = render( + React.createElement(OllamaStatus, { + ...defaultProps, + state, + onInstallConfirm, + }) + ); + + expect(lastFrame()).toContain('Ollama is not installed'); + expect(lastFrame()).toContain('Install Ollama'); + }); + + it('should render prompt-start state', () => { + const state: OllamaState = { status: 'prompt-start' }; + const onStartConfirm = vi.fn(); + const { lastFrame } = render( + React.createElement(OllamaStatus, { + ...defaultProps, + state, + onStartConfirm, + }) + ); + + expect(lastFrame()).toContain('not running'); + expect(lastFrame()).toContain('Start Ollama'); + }); +}); diff --git a/packages/cli/src/components/index.ts b/packages/cli/src/components/index.ts new file mode 100644 index 0000000..03ab998 --- /dev/null +++ b/packages/cli/src/components/index.ts @@ -0,0 +1,7 @@ +export { ConfirmInput } from './ConfirmInput'; +export { ErrorDisplay } from './ErrorDisplay'; +export { ExtractApp } from './ExtractApp'; +export { ExtractionProgress, type ExtractionState } from './ExtractionProgress'; +export { type OllamaState, OllamaStatus } from './OllamaStatus'; +export { Result } from './Result'; +export { StreamingOutput } from './StreamingOutput'; diff --git a/packages/cli/src/contexts/ExtractionContext.tsx b/packages/cli/src/contexts/ExtractionContext.tsx new file mode 100644 index 0000000..d893d96 --- /dev/null +++ b/packages/cli/src/contexts/ExtractionContext.tsx @@ -0,0 +1,39 @@ +import type { Config, DocumentData } from '@doc-agent/core'; +import { type ExtractOptions, extractDocument } from '@doc-agent/extract'; +import { storage } from '@doc-agent/storage'; +import { createContext, type ReactNode, useContext } from 'react'; + +// Service interface for dependency injection +export interface ExtractionService { + extractDocument: ( + filePath: string, + config: Config, + options?: ExtractOptions + ) => Promise; + saveDocument: (doc: DocumentData, filePath: string) => Promise; +} + +// Default implementation uses real services +const defaultExtractionService: ExtractionService = { + extractDocument, + saveDocument: storage.saveDocument.bind(storage), +}; + +const ExtractionContext = createContext(defaultExtractionService); + +export interface ExtractionProviderProps { + children: ReactNode; + service?: ExtractionService; +} + +export function ExtractionProvider({ children, service }: ExtractionProviderProps) { + return ( + + {children} + + ); +} + +export function useExtractionService(): ExtractionService { + return useContext(ExtractionContext); +} diff --git a/packages/cli/src/contexts/OllamaContext.tsx b/packages/cli/src/contexts/OllamaContext.tsx new file mode 100644 index 0000000..7826e7a --- /dev/null +++ b/packages/cli/src/contexts/OllamaContext.tsx @@ -0,0 +1,43 @@ +import { createContext, type ReactNode, useContext } from 'react'; +import * as ollamaService from '../services/ollama'; + +// Service interface for dependency injection +export interface OllamaService { + checkOllamaInstalled: typeof ollamaService.checkOllamaInstalled; + checkOllamaRunning: typeof ollamaService.checkOllamaRunning; + installOllama: typeof ollamaService.installOllama; + startOllama: typeof ollamaService.startOllama; + waitForOllama: typeof ollamaService.waitForOllama; + checkModelExists: typeof ollamaService.checkModelExists; + pullModel: typeof ollamaService.pullModel; +} + +// Default implementation uses real service +const defaultOllamaService: OllamaService = { + checkOllamaInstalled: ollamaService.checkOllamaInstalled, + checkOllamaRunning: ollamaService.checkOllamaRunning, + installOllama: ollamaService.installOllama, + startOllama: ollamaService.startOllama, + waitForOllama: ollamaService.waitForOllama, + checkModelExists: ollamaService.checkModelExists, + pullModel: ollamaService.pullModel, +}; + +const OllamaContext = createContext(defaultOllamaService); + +export interface OllamaProviderProps { + children: ReactNode; + service?: OllamaService; +} + +export function OllamaProvider({ children, service }: OllamaProviderProps) { + return ( + + {children} + + ); +} + +export function useOllamaService(): OllamaService { + return useContext(OllamaContext); +} diff --git a/packages/cli/src/contexts/index.ts b/packages/cli/src/contexts/index.ts new file mode 100644 index 0000000..1379339 --- /dev/null +++ b/packages/cli/src/contexts/index.ts @@ -0,0 +1,12 @@ +export { + ExtractionProvider, + type ExtractionProviderProps, + type ExtractionService, + useExtractionService, +} from './ExtractionContext'; +export { + OllamaProvider, + type OllamaProviderProps, + type OllamaService, + useOllamaService, +} from './OllamaContext'; diff --git a/packages/cli/src/hooks/__tests__/useExtraction.test.ts b/packages/cli/src/hooks/__tests__/useExtraction.test.ts new file mode 100644 index 0000000..d1b5991 --- /dev/null +++ b/packages/cli/src/hooks/__tests__/useExtraction.test.ts @@ -0,0 +1,247 @@ +/** + * @vitest-environment jsdom + */ +import type { DocumentData } from '@doc-agent/core'; +import { renderHook, waitFor } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExtractionProvider, type ExtractionService } from '../../contexts'; +import { useExtraction } from '../useExtraction'; + +// Create mock extraction service +const createMockExtractionService = ( + overrides: Partial = {} +): ExtractionService => ({ + extractDocument: vi.fn().mockResolvedValue({ + id: 'test-id', + filename: 'test.pdf', + extractedAt: new Date(), + type: 'receipt', + vendor: 'Test Vendor', + amount: 100, + } as DocumentData), + saveDocument: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +// Wrapper component for providing context +const createWrapper = (service: ExtractionService) => { + return function Wrapper({ children }: { children: ReactNode }) { + return React.createElement(ExtractionProvider, { service }, children); + }; +}; + +describe('useExtraction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should start in idle state', () => { + const mockService = createMockExtractionService(); + + const { result } = renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'ollama', + model: 'llama3.2-vision', + dryRun: false, + shouldStart: false, // Don't start extraction + onComplete: vi.fn(), + onError: vi.fn(), + }), + { wrapper: createWrapper(mockService) } + ); + + expect(result.current.state.status).toBe('idle'); + expect(result.current.result).toBeNull(); + }); + + it('should extract and save when shouldStart is true', async () => { + const mockService = createMockExtractionService(); + const onComplete = vi.fn(); + const onError = vi.fn(); + + const { result } = renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'ollama', + model: 'llama3.2-vision', + dryRun: false, + shouldStart: true, + onComplete, + onError, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('complete'); + }); + + expect(mockService.extractDocument).toHaveBeenCalled(); + expect(mockService.saveDocument).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(result.current.result).not.toBeNull(); + expect(result.current.result?.id).toBe('test-id'); + }); + + it('should skip saving in dry run mode', async () => { + const mockService = createMockExtractionService(); + const onComplete = vi.fn(); + + const { result } = renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'ollama', + model: 'llama3.2-vision', + dryRun: true, // Dry run - skip save + shouldStart: true, + onComplete, + onError: vi.fn(), + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('complete'); + }); + + expect(mockService.extractDocument).toHaveBeenCalled(); + expect(mockService.saveDocument).not.toHaveBeenCalled(); // Should not save + expect(onComplete).toHaveBeenCalled(); + }); + + it('should handle extraction errors', async () => { + const mockService = createMockExtractionService({ + extractDocument: vi.fn().mockRejectedValue(new Error('Extraction failed')), + }); + const onError = vi.fn(); + + const { result } = renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'ollama', + model: 'llama3.2-vision', + dryRun: false, + shouldStart: true, + onComplete: vi.fn(), + onError, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('error'); + }); + + expect(result.current.state).toEqual({ + status: 'error', + message: 'Extraction failed', + }); + expect(onError).toHaveBeenCalled(); + }); + + it('should handle save errors', async () => { + const mockService = createMockExtractionService({ + saveDocument: vi.fn().mockRejectedValue(new Error('Save failed')), + }); + const onError = vi.fn(); + + const { result } = renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'ollama', + model: 'llama3.2-vision', + dryRun: false, + shouldStart: true, + onComplete: vi.fn(), + onError, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('error'); + }); + + expect(result.current.state).toEqual({ + status: 'error', + message: 'Save failed', + }); + expect(onError).toHaveBeenCalled(); + }); + + it('should pass correct config for gemini provider', async () => { + const mockService = createMockExtractionService(); + + renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'gemini', + model: 'gemini-2.5-flash', + dryRun: true, + shouldStart: true, + onComplete: vi.fn(), + onError: vi.fn(), + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(mockService.extractDocument).toHaveBeenCalled(); + }); + + const callArgs = (mockService.extractDocument as ReturnType).mock.calls[0]; + expect(callArgs[1].aiProvider).toBe('gemini'); + expect(callArgs[1].geminiModel).toBe('gemini-2.5-flash'); + }); + + it('should handle streaming callbacks', async () => { + const mockExtract = vi.fn().mockImplementation(async (_file, _config, options) => { + // Simulate streaming + options?.onStream?.({ type: 'prompt', content: 'System prompt...' }); + options?.onStream?.({ type: 'response', content: '{"type": "receipt"}' }); + + return { + id: 'test-id', + filename: 'test.pdf', + extractedAt: new Date(), + type: 'receipt', + } as DocumentData; + }); + + const mockService = createMockExtractionService({ + extractDocument: mockExtract, + }); + + const { result } = renderHook( + () => + useExtraction({ + file: '/path/to/test.pdf', + provider: 'ollama', + model: 'llama3.2-vision', + dryRun: true, + shouldStart: true, + onComplete: vi.fn(), + onError: vi.fn(), + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('complete'); + }); + + // Streaming content should be captured + expect(result.current.promptContent).toBe('System prompt...'); + // Response content may be throttled, just check it was called + expect(mockExtract).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/hooks/__tests__/useOllama.test.ts b/packages/cli/src/hooks/__tests__/useOllama.test.ts new file mode 100644 index 0000000..67e4a19 --- /dev/null +++ b/packages/cli/src/hooks/__tests__/useOllama.test.ts @@ -0,0 +1,311 @@ +/** + * @vitest-environment jsdom + */ +import { renderHook, waitFor } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OllamaProvider, type OllamaService } from '../../contexts'; +import { useOllama } from '../useOllama'; + +// Create mock service +const createMockOllamaService = (overrides: Partial = {}): OllamaService => ({ + checkOllamaInstalled: vi.fn().mockResolvedValue(true), + checkOllamaRunning: vi.fn().mockResolvedValue(true), + installOllama: vi.fn().mockResolvedValue(undefined), + startOllama: vi.fn(), + waitForOllama: vi.fn().mockResolvedValue(true), + checkModelExists: vi.fn().mockResolvedValue(true), + pullModel: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +// Wrapper component for providing context +const createWrapper = (service: OllamaService) => { + return function Wrapper({ children }: { children: ReactNode }) { + return React.createElement(OllamaProvider, { service }, children); + }; +}; + +describe('useOllama', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should skip Ollama check for non-Ollama providers', async () => { + const mockService = createMockOllamaService(); + + const { result } = renderHook( + () => + useOllama({ + provider: 'gemini', + model: 'gemini-2.5-flash', + isInteractive: false, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('ready'); + }); + + expect(result.current.isReady).toBe(true); + expect(mockService.checkOllamaRunning).not.toHaveBeenCalled(); + }); + + it('should check if Ollama is running for Ollama provider', async () => { + const mockService = createMockOllamaService(); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: false, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('ready'); + }); + + expect(mockService.checkOllamaRunning).toHaveBeenCalled(); + expect(mockService.checkModelExists).toHaveBeenCalledWith('llama3.2-vision'); + }); + + it('should prompt for install when Ollama is not installed', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(false), + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: true, // Interactive mode - will prompt + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('prompt-install'); + }); + + expect(result.current.isReady).toBe(false); + }); + + it('should prompt to start when Ollama is installed but not running', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(true), + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: true, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('prompt-start'); + }); + }); + + it('should pull model when it does not exist', async () => { + const mockService = createMockOllamaService({ + checkModelExists: vi.fn().mockResolvedValue(false), + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'new-model', + isInteractive: false, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('ready'); + }); + + expect(mockService.pullModel).toHaveBeenCalledWith('new-model', expect.any(Function)); + }); + + it('should handle install confirmation decline', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(false), + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: true, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('prompt-install'); + }); + + // Decline installation + await result.current.handleInstallConfirm(false); + + await waitFor(() => { + expect(result.current.state.status).toBe('cancelled'); + }); + }); + + it('should handle start confirmation and proceed to model check', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(true), + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: true, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('prompt-start'); + }); + + // Accept start + await result.current.handleStartConfirm(true); + + await waitFor(() => { + expect(result.current.state.status).toBe('ready'); + }); + + expect(mockService.startOllama).toHaveBeenCalled(); + expect(mockService.waitForOllama).toHaveBeenCalled(); + }); + + it('should handle start confirmation decline', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(true), + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: true, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('prompt-start'); + }); + + // Decline start + await result.current.handleStartConfirm(false); + + await waitFor(() => { + expect(result.current.state.status).toBe('cancelled'); + }); + }); + + it('should show error when Ollama fails to start', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(true), + waitForOllama: vi.fn().mockResolvedValue(false), // Fails to start + }); + + const { result } = renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: true, + }), + { wrapper: createWrapper(mockService) } + ); + + await waitFor(() => { + expect(result.current.state.status).toBe('prompt-start'); + }); + + await result.current.handleStartConfirm(true); + + await waitFor(() => { + expect(result.current.state.status).toBe('error'); + }); + + expect(result.current.state).toEqual({ + status: 'error', + message: 'Ollama failed to start within 10 seconds', + }); + }); + + it('should auto-confirm install in non-interactive mode', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(false), + }); + + renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: false, // Non-interactive + }), + { wrapper: createWrapper(mockService) } + ); + + // Should auto-confirm and proceed to install + await waitFor( + () => { + expect(mockService.installOllama).toHaveBeenCalled(); + }, + { timeout: 2000 } + ); + }); + + it('should auto-confirm start in non-interactive mode', async () => { + const mockService = createMockOllamaService({ + checkOllamaRunning: vi.fn().mockResolvedValue(false), + checkOllamaInstalled: vi.fn().mockResolvedValue(true), + }); + + renderHook( + () => + useOllama({ + provider: 'ollama', + model: 'llama3.2-vision', + isInteractive: false, // Non-interactive + }), + { wrapper: createWrapper(mockService) } + ); + + // Should auto-confirm and proceed to start + await waitFor( + () => { + expect(mockService.startOllama).toHaveBeenCalled(); + }, + { timeout: 2000 } + ); + }); +}); diff --git a/packages/cli/src/hooks/index.ts b/packages/cli/src/hooks/index.ts new file mode 100644 index 0000000..f88ba64 --- /dev/null +++ b/packages/cli/src/hooks/index.ts @@ -0,0 +1,6 @@ +export { + type UseExtractionOptions, + type UseExtractionResult, + useExtraction, +} from './useExtraction'; +export { type UseOllamaOptions, type UseOllamaResult, useOllama } from './useOllama'; diff --git a/packages/cli/src/hooks/useExtraction.ts b/packages/cli/src/hooks/useExtraction.ts new file mode 100644 index 0000000..12e2956 --- /dev/null +++ b/packages/cli/src/hooks/useExtraction.ts @@ -0,0 +1,121 @@ +import { resolve } from 'node:path'; +import type { Config, DocumentData } from '@doc-agent/core'; +import type { StreamChunk } from '@doc-agent/extract'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ExtractionState } from '../components/ExtractionProgress'; +import { useExtractionService } from '../contexts/ExtractionContext'; + +export interface UseExtractionOptions { + file: string; + provider: 'gemini' | 'openai' | 'ollama'; + model: string; + dryRun: boolean; + shouldStart: boolean; + onComplete: () => void; + onError: (error: Error) => void; +} + +export interface UseExtractionResult { + state: ExtractionState; + result: DocumentData | null; + promptContent: string; + responseContent: string; +} + +export function useExtraction({ + file, + provider, + model, + dryRun, + shouldStart, + onComplete, + onError, +}: UseExtractionOptions): UseExtractionResult { + const [state, setState] = useState({ status: 'idle' }); + const [result, setResult] = useState(null); + const [promptContent, setPromptContent] = useState(''); + const [responseContent, setResponseContent] = useState(''); + const responseRef = useRef(''); + const lastUpdateRef = useRef(0); + const updateTimerRef = useRef | null>(null); + const extractionService = useExtractionService(); + + const runExtraction = useCallback(async () => { + try { + setState({ status: 'extracting', startTime: Date.now() }); + setPromptContent(''); + responseRef.current = ''; + setResponseContent(''); + + const config: Config = { + aiProvider: provider, + geminiApiKey: process.env.GEMINI_API_KEY, + geminiModel: provider === 'gemini' ? model : undefined, + openaiApiKey: process.env.OPENAI_API_KEY, + ollamaModel: provider === 'ollama' ? model : undefined, + }; + + const THROTTLE_MS = 250; + + const extractedData = await extractionService.extractDocument(file, config, { + onStream: (chunk: StreamChunk) => { + if (chunk.type === 'prompt') { + setPromptContent(chunk.content); + } else if (chunk.type === 'response') { + responseRef.current += chunk.content; + + const now = Date.now(); + if (now - lastUpdateRef.current >= THROTTLE_MS) { + lastUpdateRef.current = now; + setResponseContent(responseRef.current); + } else if (!updateTimerRef.current) { + updateTimerRef.current = setTimeout(() => { + updateTimerRef.current = null; + lastUpdateRef.current = Date.now(); + setResponseContent(responseRef.current); + }, THROTTLE_MS); + } + } + }, + }); + + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current); + updateTimerRef.current = null; + } + setResponseContent(responseRef.current); + setResult(extractedData); + + if (!dryRun) { + setState({ status: 'saving' }); + const absolutePath = resolve(file); + await extractionService.saveDocument(extractedData, absolutePath); + } + + setState({ + status: 'complete', + id: extractedData.id, + filename: extractedData.filename, + }); + + onComplete(); + } catch (error) { + const err = error as Error; + setState({ status: 'error', message: err.message }); + onError(err); + } + }, [file, provider, model, dryRun, onComplete, onError, extractionService]); + + useEffect(() => { + if (shouldStart && state.status === 'idle') { + runExtraction(); + } + }, [shouldStart, state.status, runExtraction]); + + return { + state, + result, + promptContent, + responseContent, + }; +} diff --git a/packages/cli/src/hooks/useOllama.ts b/packages/cli/src/hooks/useOllama.ts new file mode 100644 index 0000000..8574faf --- /dev/null +++ b/packages/cli/src/hooks/useOllama.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { OllamaState, PullProgress } from '../components/OllamaStatus'; +import { useOllamaService } from '../contexts/OllamaContext'; + +export interface UseOllamaOptions { + provider: 'gemini' | 'openai' | 'ollama'; + model: string; + isInteractive: boolean; +} + +export interface UseOllamaResult { + state: OllamaState; + isReady: boolean; + handleInstallConfirm: (confirmed: boolean) => Promise; + handleStartConfirm: (confirmed: boolean) => Promise; +} + +export function useOllama({ provider, model, isInteractive }: UseOllamaOptions): UseOllamaResult { + const [state, setState] = useState({ status: 'checking' }); + const [isReady, setIsReady] = useState(false); + const ollamaService = useOllamaService(); + + // Check model and pull if needed (declared first since other callbacks depend on it) + const checkAndPullModel = useCallback(async () => { + setState({ status: 'checking-model', model }); + const modelExists = await ollamaService.checkModelExists(model); + + if (!modelExists) { + setState({ status: 'pulling-model', model }); + await ollamaService.pullModel(model, (progress: PullProgress) => { + setState({ status: 'pulling-model', model, pullProgress: progress }); + }); + } + + setState({ status: 'ready', model }); + setIsReady(true); + }, [model, ollamaService]); + + // Handle install confirmation + const handleInstallConfirm = useCallback( + async (confirmed: boolean) => { + if (!confirmed) { + setState({ status: 'cancelled' }); + return; + } + + setState({ status: 'installing' }); + try { + await ollamaService.installOllama((progress) => { + setState({ status: 'installing', progress }); + }); + setState({ status: 'prompt-start' }); + } catch (err) { + setState({ status: 'error', message: (err as Error).message }); + } + }, + [ollamaService] + ); + + // Handle start confirmation + const handleStartConfirm = useCallback( + async (confirmed: boolean) => { + if (!confirmed) { + setState({ status: 'cancelled' }); + return; + } + + setState({ status: 'starting' }); + try { + ollamaService.startOllama(); + const started = await ollamaService.waitForOllama(); + if (!started) { + throw new Error('Ollama failed to start within 10 seconds'); + } + // Proceed to model check + await checkAndPullModel(); + } catch (err) { + setState({ status: 'error', message: (err as Error).message }); + } + }, + [checkAndPullModel, ollamaService] + ); + + // Auto-confirm in non-interactive mode + useEffect(() => { + if (isInteractive) return; + + if (state.status === 'prompt-install') { + const timer = setTimeout(() => handleInstallConfirm(true), 500); + return () => clearTimeout(timer); + } + + if (state.status === 'prompt-start') { + const timer = setTimeout(() => handleStartConfirm(true), 500); + return () => clearTimeout(timer); + } + }, [isInteractive, state.status, handleInstallConfirm, handleStartConfirm]); + + // Initial check + useEffect(() => { + const checkOllama = async () => { + // Skip Ollama check for non-Ollama providers + if (provider !== 'ollama') { + setState({ status: 'ready', model: provider }); + setIsReady(true); + return; + } + + setState({ status: 'checking' }); + + // Check if Ollama is running + const isRunning = await ollamaService.checkOllamaRunning(); + if (isRunning) { + await checkAndPullModel(); + return; + } + + // Check if Ollama is installed + const isInstalled = await ollamaService.checkOllamaInstalled(); + if (!isInstalled) { + setState({ status: 'prompt-install' }); + return; + } + + // Installed but not running + setState({ status: 'prompt-start' }); + }; + + checkOllama(); + }, [provider, checkAndPullModel, ollamaService]); + + return { + state, + isReady, + handleInstallConfirm, + handleStartConfirm, + }; +} diff --git a/packages/cli/src/services/__tests__/ollama.test.ts b/packages/cli/src/services/__tests__/ollama.test.ts new file mode 100644 index 0000000..b19f397 --- /dev/null +++ b/packages/cli/src/services/__tests__/ollama.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkModelExists, + checkOllamaInstalled, + checkOllamaRunning, + pullModel, + waitForOllama, +} from '../ollama'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Mock child_process +vi.mock('node:child_process', () => ({ + exec: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('node:util', () => ({ + promisify: vi.fn((fn) => fn), +})); + +describe('Ollama Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('checkOllamaRunning', () => { + it('should return true when Ollama API is accessible', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const result = await checkOllamaRunning(); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith('http://localhost:11434/api/tags'); + }); + + it('should return false when Ollama API is not accessible', async () => { + mockFetch.mockRejectedValueOnce(new Error('Connection refused')); + + const result = await checkOllamaRunning(); + + expect(result).toBe(false); + }); + + it('should return false when API returns non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }); + + const result = await checkOllamaRunning(); + + expect(result).toBe(false); + }); + }); + + describe('checkModelExists', () => { + it('should return true when model exists', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [{ name: 'llama3.2-vision:latest' }, { name: 'qwen2.5vl:7b' }], + }), + }); + + const result = await checkModelExists('llama3.2-vision'); + + expect(result).toBe(true); + }); + + it('should return false when model does not exist', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [{ name: 'other-model' }], + }), + }); + + const result = await checkModelExists('llama3.2-vision'); + + expect(result).toBe(false); + }); + + it('should return false when API call fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('Connection refused')); + + const result = await checkModelExists('llama3.2-vision'); + + expect(result).toBe(false); + }); + }); + + describe('pullModel', () => { + it('should stream progress updates during pull', async () => { + // Create a mock readable stream + const mockReader = { + read: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + `${JSON.stringify({ status: 'pulling', completed: 50, total: 100 })}\n` + ), + }) + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`${JSON.stringify({ status: 'success' })}\n`), + }) + .mockResolvedValueOnce({ done: true }), + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: { + getReader: () => mockReader, + }, + }); + + const progressUpdates: Array<{ status: string; completed?: number; total?: number }> = []; + await pullModel('llama3.2-vision', (progress) => { + progressUpdates.push(progress); + }); + + expect(progressUpdates).toHaveLength(2); + expect(progressUpdates[0]).toEqual({ status: 'pulling', completed: 50, total: 100 }); + expect(progressUpdates[1]).toEqual({ status: 'success' }); + }); + + it('should throw error when pull fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }); + + await expect(pullModel('nonexistent-model')).rejects.toThrow('Failed to pull model'); + }); + }); + + describe('waitForOllama', () => { + it('should return true when Ollama becomes available', async () => { + // First call fails, second succeeds + mockFetch.mockRejectedValueOnce(new Error('Not ready')).mockResolvedValueOnce({ ok: true }); + + const result = await waitForOllama(2000); + + expect(result).toBe(true); + }); + + it('should return false after timeout', async () => { + // Always fail + mockFetch.mockRejectedValue(new Error('Not ready')); + + const result = await waitForOllama(500); + + expect(result).toBe(false); + }); + }); + + describe('checkOllamaInstalled', () => { + it('should be a defined function', () => { + // checkOllamaInstalled uses shell commands which are hard to mock + // Just verify the function exists + expect(checkOllamaInstalled).toBeDefined(); + expect(typeof checkOllamaInstalled).toBe('function'); + }); + }); + + describe('startOllama', () => { + it('should be a defined function', async () => { + const { startOllama } = await import('../ollama'); + expect(startOllama).toBeDefined(); + expect(typeof startOllama).toBe('function'); + }); + }); + + describe('installOllama', () => { + it('should be a defined function', async () => { + const { installOllama } = await import('../ollama'); + expect(installOllama).toBeDefined(); + expect(typeof installOllama).toBe('function'); + }); + }); +}); diff --git a/packages/cli/src/services/index.ts b/packages/cli/src/services/index.ts new file mode 100644 index 0000000..80a636a --- /dev/null +++ b/packages/cli/src/services/index.ts @@ -0,0 +1 @@ +export * from './ollama'; diff --git a/packages/cli/src/services/ollama.ts b/packages/cli/src/services/ollama.ts new file mode 100644 index 0000000..bc82134 --- /dev/null +++ b/packages/cli/src/services/ollama.ts @@ -0,0 +1,187 @@ +import { exec, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +export interface PullProgress { + status: string; + digest?: string; + total?: number; + completed?: number; +} + +export type ProgressCallback = (message: string) => void; +export type PullProgressCallback = (progress: PullProgress) => void; + +/** + * Check if Ollama CLI is installed + */ +export async function checkOllamaInstalled(): Promise { + try { + await execAsync('which ollama'); + return true; + } catch { + return false; + } +} + +/** + * Check if Ollama server is running + */ +export async function checkOllamaRunning(): Promise { + try { + const response = await fetch('http://localhost:11434/api/tags'); + return response.ok; + } catch { + return false; + } +} + +/** + * Install Ollama (macOS via Homebrew, Linux via official script) + */ +export async function installOllama(onProgress?: ProgressCallback): Promise { + const platform = process.platform; + + if (platform === 'darwin') { + // macOS: Use Homebrew if available + try { + await execAsync('which brew'); + } catch { + throw new Error( + 'Please install Ollama manually: https://ollama.com/download\n' + + 'Or install Homebrew first: https://brew.sh' + ); + } + + // Stream brew install output for progress + return new Promise((resolve, reject) => { + const child = spawn('brew', ['install', 'ollama'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let lastLine = ''; + + const handleOutput = (data: Buffer) => { + const lines = data.toString().split('\n').filter(Boolean); + for (const line of lines) { + lastLine = line.trim(); + if (line.includes('Downloading')) { + onProgress?.('Downloading Ollama...'); + } else if (line.includes('Pouring')) { + onProgress?.('Installing Ollama...'); + } else if (line.includes('Caveats')) { + onProgress?.('Finalizing...'); + } + } + }; + + child.stdout?.on('data', handleOutput); + child.stderr?.on('data', handleOutput); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Installation failed: ${lastLine}`)); + } + }); + + child.on('error', reject); + }); + } else if (platform === 'linux') { + onProgress?.('Downloading installer...'); + try { + await execAsync('curl -fsSL https://ollama.com/install.sh | sh'); + } catch { + throw new Error( + 'Installation failed (may need sudo).\n' + + 'Try: curl -fsSL https://ollama.com/install.sh | sudo sh' + ); + } + } else { + throw new Error( + `Automatic install not supported on ${platform}. Visit https://ollama.com/download` + ); + } +} + +/** + * Start Ollama server in background + */ +export function startOllama(): void { + const child = spawn('ollama', ['serve'], { + detached: true, + stdio: 'ignore', + }); + child.unref(); +} + +/** + * Wait for Ollama server to be ready + */ +export async function waitForOllama(maxWaitMs = 10000): Promise { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + if (await checkOllamaRunning()) { + return true; + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +/** + * Check if a model exists locally + */ +export async function checkModelExists(model: string): Promise { + try { + const response = await fetch('http://localhost:11434/api/tags'); + if (!response.ok) return false; + const data = (await response.json()) as { models: { name: string }[] }; + return data.models.some((m) => m.name.includes(model)); + } catch { + return false; + } +} + +/** + * Pull a model from Ollama registry with progress updates + */ +export async function pullModel(model: string, onProgress?: PullProgressCallback): Promise { + const response = await fetch('http://localhost:11434/api/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: model }), + }); + + if (!response.ok) { + throw new Error(`Failed to pull model: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('No response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + for (const line of chunk.split('\n').filter(Boolean)) { + try { + const progress = JSON.parse(line) as PullProgress; + onProgress?.(progress); + + if (progress.status === 'success') { + return; + } + } catch { + // Ignore parse errors + } + } + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 9f30449..21a256e 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 180989c..5eaece1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ export interface SearchResult { export interface Config { aiProvider: 'gemini' | 'openai' | 'ollama'; geminiApiKey?: string; + geminiModel?: string; openaiApiKey?: string; ollamaModel?: string; dbPath?: string; diff --git a/packages/extract/package.json b/packages/extract/package.json index 7874465..bb028a9 100644 --- a/packages/extract/package.json +++ b/packages/extract/package.json @@ -25,6 +25,8 @@ "dependencies": { "@doc-agent/core": "workspace:*", "@google/generative-ai": "^0.24.1", + "pdf-to-img": "^5.0.0", + "tesseract.js": "^6.0.1", "zod": "^3.23.8" }, "devDependencies": { diff --git a/packages/extract/src/__tests__/ocr.test.ts b/packages/extract/src/__tests__/ocr.test.ts new file mode 100644 index 0000000..b970bd8 --- /dev/null +++ b/packages/extract/src/__tests__/ocr.test.ts @@ -0,0 +1,134 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock tesseract.js to avoid worker issues in tests +vi.mock('tesseract.js', () => ({ + default: { + recognize: vi.fn().mockResolvedValue({ + data: { + text: 'Mocked OCR text\nTaqueria 10/10\n$5.99\n$4.49', + }, + }), + }, +})); + +describe('OCR Processing', () => { + // Skip if running in CI without the example file + const examplePath = resolve(__dirname, '../../../../examples/tacqueria-receipt.pdf'); + + it('should extract text from PDF using OCR', async () => { + // Import dynamically to avoid issues with tesseract worker + const { extractDocument } = await import('../index'); + + // Check if example file exists + let fileExists = false; + try { + readFileSync(examplePath); + fileExists = true; + } catch { + fileExists = false; + } + + if (!fileExists) { + console.log('Skipping OCR test - example file not found'); + return; + } + + // Mock the Ollama API to return a simple response + const mockFetch = globalThis.fetch; + globalThis.fetch = async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + + if (urlStr.includes('localhost:11434')) { + return { + ok: true, + json: async () => ({ + response: JSON.stringify({ + type: 'receipt', + vendor: 'Taqueria 10/10', + amount: 22.4, + items: [{ description: 'Test Item', total: 5.99 }], + }), + }), + body: null, + } as Response; + } + return mockFetch(url as RequestInfo, undefined); + }; + + try { + const result = await extractDocument(examplePath, { + aiProvider: 'ollama', + ollamaModel: 'llama3.2-vision', + }); + + // Verify extraction completed + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.filename).toBe('tacqueria-receipt.pdf'); + } finally { + globalThis.fetch = mockFetch; + } + }); + + it('should handle OCR errors gracefully', async () => { + const { extractDocument } = await import('../index'); + + // Create a mock that simulates OCR failure by using invalid image data + const mockFetch = globalThis.fetch; + globalThis.fetch = async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + + if (urlStr.includes('localhost:11434')) { + return { + ok: true, + json: async () => ({ + response: JSON.stringify({ + type: 'receipt', + vendor: 'Test', + amount: 10, + }), + }), + body: null, + } as Response; + } + return mockFetch(url as RequestInfo, undefined); + }; + + try { + // This should not throw even if OCR fails internally + // The extraction should proceed with whatever data is available + const result = await extractDocument(examplePath, { + aiProvider: 'ollama', + ollamaModel: 'llama3.2-vision', + }); + + expect(result).toBeDefined(); + } finally { + globalThis.fetch = mockFetch; + } + }); +}); + +describe('getMimeType', () => { + it('should detect PDF mime type', async () => { + const { getMimeType } = await import('../index'); + expect(getMimeType('test.pdf')).toBe('application/pdf'); + expect(getMimeType('TEST.PDF')).toBe('application/pdf'); + }); + + it('should detect image mime types', async () => { + const { getMimeType } = await import('../index'); + expect(getMimeType('test.png')).toBe('image/png'); + expect(getMimeType('test.jpg')).toBe('image/jpeg'); + expect(getMimeType('test.jpeg')).toBe('image/jpeg'); + expect(getMimeType('test.gif')).toBe('image/gif'); + expect(getMimeType('test.webp')).toBe('image/webp'); + }); + + it('should default to PDF for unknown extensions', async () => { + const { getMimeType } = await import('../index'); + expect(getMimeType('test.unknown')).toBe('application/pdf'); + }); +}); diff --git a/packages/extract/src/__tests__/ollama.test.ts b/packages/extract/src/__tests__/ollama.test.ts index 7abded8..5c15756 100644 --- a/packages/extract/src/__tests__/ollama.test.ts +++ b/packages/extract/src/__tests__/ollama.test.ts @@ -2,6 +2,15 @@ import type { Config } from '@doc-agent/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { extractDocument } from '../index'; +// Mock tesseract.js to avoid worker issues in tests +vi.mock('tesseract.js', () => ({ + default: { + recognize: vi.fn().mockResolvedValue({ + data: { text: 'Mocked OCR text' }, + }), + }, +})); + // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -66,38 +75,63 @@ describe('Ollama Extraction', () => { fs.unlinkSync(testFile); }); - it('should retry once on Zod validation failure', async () => { + it('should coerce invalid type to "other"', async () => { + // Schema is lenient - invalid types become 'other' const invalidResponse = { response: JSON.stringify({ - type: 'invalid_type', // Invalid type + type: 'invalid_type', // Invalid type - will be coerced to 'other' vendor: 'Test Company', }), }; - const validResponse = { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => invalidResponse, + }); + + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + const tmpDir = os.tmpdir(); + const testFile = path.join(tmpDir, 'test-receipt.pdf'); + fs.writeFileSync(testFile, Buffer.from('test pdf content')); + + const config: Config = { + aiProvider: 'ollama', + ollamaModel: 'llama3.2-vision', + }; + + const result = await extractDocument(testFile, config); + + // Invalid type should be coerced to 'other' + expect(result.type).toBe('other'); + expect(result.vendor).toBe('Test Company'); + expect(mockFetch).toHaveBeenCalledTimes(1); // No retry needed + + fs.unlinkSync(testFile); + }); + + it('should coerce string numbers to actual numbers', async () => { + // Schema coerces strings like "100.50" to numbers + const responseWithStrings = { response: JSON.stringify({ type: 'receipt', - vendor: 'Test Company', - amount: 50.0, + vendor: 'Test Store', + amount: '50.99', // String instead of number + items: [{ description: 'Item', total: '25.50' }], }), }; - // First call returns invalid data, second call returns valid data - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => invalidResponse, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => responseWithStrings, + }); const fs = await import('node:fs'); const path = await import('node:path'); const os = await import('node:os'); const tmpDir = os.tmpdir(); - const testFile = path.join(tmpDir, 'test-receipt.pdf'); + const testFile = path.join(tmpDir, 'test-coerce.pdf'); fs.writeFileSync(testFile, Buffer.from('test pdf content')); const config: Config = { @@ -107,8 +141,10 @@ describe('Ollama Extraction', () => { const result = await extractDocument(testFile, config); - expect(result.type).toBe('receipt'); - expect(mockFetch).toHaveBeenCalledTimes(2); // Should retry once + expect(result.amount).toBe(50.99); + expect(typeof result.amount).toBe('number'); + expect(result.items?.[0].total).toBe(25.5); + expect(typeof result.items?.[0].total).toBe('number'); fs.unlinkSync(testFile); }); @@ -196,7 +232,7 @@ describe('Ollama Extraction', () => { fs.unlinkSync(testFile); }); - it('should not retry on non-Zod errors', async () => { + it('should not retry on API errors', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, @@ -251,34 +287,31 @@ describe('Ollama Extraction', () => { await extractDocument(testFile, config); const callBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(callBody.prompt).toContain('image'); // Should detect image type + // With OCR enabled, prompt now includes OCR text + expect(callBody.prompt).toContain('OCR Text'); // OCR is applied to images too fs.unlinkSync(testFile); }); - it('should handle retry exhaustion (Zod error persists)', async () => { - const invalidResponse = { + it('should handle missing type field gracefully', async () => { + // Schema defaults missing type to 'other' + const noTypeResponse = { response: JSON.stringify({ - type: 'invalid_type', // Invalid type + vendor: 'Test Company', + amount: 100, }), }; - // Both calls return invalid data - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => invalidResponse, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => invalidResponse, - }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => noTypeResponse, + }); const fs = await import('node:fs'); const path = await import('node:path'); const os = await import('node:os'); const tmpDir = os.tmpdir(); - const testFile = path.join(tmpDir, 'test-retry-exhausted.pdf'); + const testFile = path.join(tmpDir, 'test-no-type.pdf'); fs.writeFileSync(testFile, Buffer.from('test pdf content')); const config: Config = { @@ -286,10 +319,11 @@ describe('Ollama Extraction', () => { ollamaModel: 'llama3.2-vision', }; - await expect(extractDocument(testFile, config)).rejects.toThrow(); + const result = await extractDocument(testFile, config); - // Should retry once, then fail - expect(mockFetch).toHaveBeenCalledTimes(2); + // Missing type should default to 'other' + expect(result.type).toBe('other'); + expect(result.vendor).toBe('Test Company'); fs.unlinkSync(testFile); }); diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts index 963c432..f5be816 100644 --- a/packages/extract/src/index.ts +++ b/packages/extract/src/index.ts @@ -2,23 +2,50 @@ import { readFileSync } from 'node:fs'; import { extname } from 'node:path'; import type { Config, DocumentData } from '@doc-agent/core'; import { GoogleGenerativeAI } from '@google/generative-ai'; +import { pdf } from 'pdf-to-img'; +import Tesseract from 'tesseract.js'; import { z } from 'zod'; -// Zod schema for DocumentData validation -const LineItemSchema = z.object({ - description: z.string(), - quantity: z.number().optional(), - unitPrice: z.number().optional(), - total: z.number().optional(), -}); +// Zod schema for DocumentData validation (lenient to handle model variations) +// Use coerce to handle strings like "22.40" -> 22.40 +const LineItemSchema = z + .object({ + description: z.string(), + quantity: z.coerce.number().optional(), + unitPrice: z.coerce.number().optional(), + total: z.coerce.number().optional(), + price: z.coerce.number().optional(), // Some models output "price" instead of "total" + }) + .transform((item) => ({ + description: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + total: item.total ?? item.price, // Normalize price -> total + })); const DocumentDataSchema = z.object({ - type: z.enum(['invoice', 'receipt', 'bank_statement', 'other']), - vendor: z.string().optional(), - amount: z.number().optional(), - date: z.string().optional(), - items: z.array(LineItemSchema).optional(), - rawText: z.string().optional(), + // Default to 'other' if type is missing or invalid + type: z.enum(['invoice', 'receipt', 'bank_statement', 'other']).default('other').catch('other'), + vendor: z + .string() + .nullish() + .transform((v) => v ?? undefined), + amount: z.coerce + .number() + .nullish() + .transform((v) => v ?? undefined), + date: z + .string() + .nullish() + .transform((v) => v ?? undefined), + items: z + .array(LineItemSchema) + .nullish() + .transform((v) => v ?? undefined), + rawText: z + .string() + .nullish() + .transform((v) => v ?? undefined), }); // Helper to detect MIME type from file extension @@ -35,7 +62,69 @@ export function getMimeType(filePath: string): string { return mimeTypes[ext] || 'application/pdf'; } -export async function extractDocument(filePath: string, config: Config): Promise { +// Convert PDF to PNG images (all pages) for vision models that don't support PDF +// Returns array of base64 images, or null if conversion fails +async function pdfToImages(filePath: string): Promise { + try { + const document = await pdf(filePath, { scale: 2 }); + const pages: Buffer[] = []; + + for await (const page of document) { + pages.push(Buffer.from(page)); + } + + return pages.length > 0 ? pages : null; + } catch { + // Invalid PDF or other error + return null; + } +} + +// OCR all images in parallel using tesseract.js +// Returns concatenated text with page markers +async function ocrImages(images: Buffer[]): Promise { + if (images.length === 0) return ''; + + try { + // Process all pages in parallel + const results = await Promise.all( + images.map(async (image, index) => { + try { + const result = await Tesseract.recognize(image, 'eng', { + logger: () => {}, // Silent + }); + return { page: index + 1, text: result.data.text }; + } catch { + return { page: index + 1, text: '' }; + } + }) + ); + + // Concatenate with page markers + return results + .filter((r) => r.text.trim()) + .map((r) => `--- Page ${r.page} ---\n${r.text.trim()}`) + .join('\n\n'); + } catch { + return ''; + } +} + +export type StreamChunk = + | { type: 'prompt'; content: string } + | { type: 'response'; content: string }; + +export type StreamCallback = (chunk: StreamChunk) => void; + +export interface ExtractOptions { + onStream?: StreamCallback; +} + +export async function extractDocument( + filePath: string, + config: Config, + options?: ExtractOptions +): Promise { const fileBuffer = readFileSync(filePath); const base64 = fileBuffer.toString('base64'); @@ -44,7 +133,7 @@ export async function extractDocument(filePath: string, config: Config): Promise } if (config.aiProvider === 'ollama') { - return extractWithOllama(filePath, base64, config); + return extractWithOllama(filePath, base64, config, 0, options?.onStream); } throw new Error(`Provider ${config.aiProvider} not yet implemented`); @@ -60,7 +149,8 @@ async function extractWithGemini( } const genai = new GoogleGenerativeAI(config.geminiApiKey); - const model = genai.getGenerativeModel({ model: 'gemini-1.5-flash' }); + const modelName = config.geminiModel || 'gemini-2.5-flash'; + const model = genai.getGenerativeModel({ model: modelName }); const prompt = `Extract structured data from this document as JSON: { @@ -98,29 +188,62 @@ async function extractWithOllama( filePath: string, base64: string, config: Config, - retryCount = 0 + retryCount = 0, + onStream?: StreamCallback ): Promise { const model = config.ollamaModel || 'llama3.2-vision'; const mimeType = getMimeType(filePath); - const systemPrompt = `You are a document extraction assistant. Extract structured data from invoices, receipts, and bank statements. + // Ollama vision models don't support PDF - convert to images first + let imageBase64 = base64; + let ocrText = ''; -CRITICAL: You must respond with ONLY valid JSON, no markdown, no code blocks, no explanations. Just the raw JSON object. + if (mimeType === 'application/pdf') { + const pages = await pdfToImages(filePath); + if (pages && pages.length > 0) { + // Use first page for vision model + imageBase64 = pages[0].toString('base64'); -Expected JSON format: -{ - "type": "invoice" | "receipt" | "bank_statement" | "other", - "vendor": "company or vendor name if available", - "amount": total_amount_as_number_if_available, - "date": "YYYY-MM-DD format if available", - "items": [{"description": "item description", "quantity": number, "unitPrice": number, "total": number}] -} + // OCR all pages in parallel for text reference + if (onStream) { + onStream({ type: 'prompt', content: `Running OCR on ${pages.length} page(s)...` }); + } + ocrText = await ocrImages(pages); + } + } else { + // For images, OCR the single image + const imageBuffer = Buffer.from(base64, 'base64'); + ocrText = await ocrImages([imageBuffer]); + } + + const systemPrompt = `Extract receipt/invoice data as JSON. + +Schema: +{"type":"receipt"|"invoice"|"bank_statement"|"other","vendor":"string","amount":number,"date":"YYYY-MM-DD","items":[{"description":"string","total":number}]} -All fields except "type" are optional. Only include fields you can confidently extract.`; +Rules: +- amount = final total paid +- items = only purchased items (not tax/fees/service charges) +- date in YYYY-MM-DD format +- Use the OCR text below as the primary source for text and numbers +- The image is for layout context only`; - const userPrompt = `Extract structured data from this ${mimeType.includes('image') ? 'image' : 'PDF'} document. Return only valid JSON.`; + // Include OCR text in the user prompt if available + const userPrompt = ocrText + ? `OCR Text (use this for accurate text/numbers):\n${ocrText}\n\nExtract structured data from this document.` + : `Extract structured data from this ${mimeType.includes('image') ? 'image' : 'document'}.`; try { + const shouldStream = !!onStream; + + // Emit full prompts so user can see what we're asking + if (onStream) { + onStream({ + type: 'prompt', + content: `System:\n${systemPrompt}\n\nUser:\n${userPrompt}`, + }); + } + const response = await fetch('http://localhost:11434/api/generate', { method: 'POST', headers: { @@ -130,9 +253,9 @@ All fields except "type" are optional. Only include fields you can confidently e model, prompt: userPrompt, system: systemPrompt, - images: [base64], - stream: false, - format: 'json', + images: [imageBase64], + stream: shouldStream, + format: 'json', // Force valid JSON output }), }); @@ -141,18 +264,55 @@ All fields except "type" are optional. Only include fields you can confidently e throw new Error(`Ollama API error: ${response.status} ${errorText}`); } - const data = (await response.json()) as { response: string }; + let fullResponse = ''; + + if (shouldStream && response.body) { + // Stream the response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + // Ollama streams newline-delimited JSON + for (const line of chunk.split('\n').filter(Boolean)) { + try { + const json = JSON.parse(line) as { response?: string; done?: boolean }; + if (json.response) { + fullResponse += json.response; + onStream({ type: 'response', content: json.response }); + } + } catch { + // Ignore parse errors for partial lines + } + } + } + } else { + // Non-streaming response + const data = (await response.json()) as { response: string }; + fullResponse = data.response; + } + let parsed: unknown; try { - // Clean up response (remove markdown code blocks if present) - const cleaned = data.response - .replace(/```json\n?/g, '') - .replace(/```\n?/g, '') - .trim(); - parsed = JSON.parse(cleaned); + // With format: 'json', Ollama should return valid JSON directly + parsed = JSON.parse(fullResponse.trim()); } catch (_parseError) { - throw new Error(`Failed to parse JSON response: ${data.response}`); + // Fallback: try to extract JSON from response + const jsonStart = fullResponse.indexOf('{'); + const jsonEnd = fullResponse.lastIndexOf('}'); + if (jsonStart !== -1 && jsonEnd !== -1) { + try { + parsed = JSON.parse(fullResponse.slice(jsonStart, jsonEnd + 1)); + } catch { + throw new Error(`Failed to parse JSON response: ${fullResponse}`); + } + } else { + throw new Error(`Failed to parse JSON response: ${fullResponse}`); + } } // Validate with Zod @@ -166,9 +326,9 @@ All fields except "type" are optional. Only include fields you can confidently e ...validated, }; } catch (error) { - // Retry once on validation failure + // Retry once on validation failure (without streaming for retry) if (retryCount === 0 && error instanceof z.ZodError) { - return extractWithOllama(filePath, base64, config, 1); + return extractWithOllama(filePath, base64, config, 1, undefined); } throw error; } diff --git a/packages/storage/src/db.ts b/packages/storage/src/db.ts index 177bb99..66e598b 100644 --- a/packages/storage/src/db.ts +++ b/packages/storage/src/db.ts @@ -27,26 +27,51 @@ export function getDbPath(dataDir?: string): string { export function createDb(connectionString?: string) { const dbPath = connectionString || getDbPath(); - const sqlite = new Database(dbPath); - const db = drizzle(sqlite, { schema }); + let sqlite = new Database(dbPath); + let db = drizzle(sqlite, { schema }); - // Lazy migration logic - runMigrations(db); + // Try migration, auto-reset on schema mismatch + const needsReset = runMigrations(db); + + if (needsReset && !connectionString?.includes(':memory:')) { + // Close and delete the old database + sqlite.close(); + fs.unlinkSync(dbPath); + + // Recreate fresh + sqlite = new Database(dbPath); + db = drizzle(sqlite, { schema }); + runMigrations(db); + + logger.info('Database reset due to schema change.'); + } return db; } -export function runMigrations(db: ReturnType, migrationsFolder?: string) { +/** Run migrations. Returns true if database needs reset (schema mismatch). */ +export function runMigrations(db: ReturnType, migrationsFolder?: string): boolean { const folder = migrationsFolder || path.join(__dirname, '../drizzle'); // Check if migrations folder exists (dev mode vs prod) - if (fs.existsSync(folder)) { - try { - migrate(db, { migrationsFolder: folder }); - } catch (error) { - logger.error(error instanceof Error ? error : new Error(String(error)), 'Migration failed'); - // Don't crash, just warn. might be a locked DB or permission issue. + if (!fs.existsSync(folder)) { + return false; + } + + try { + migrate(db, { migrationsFolder: folder }); + return false; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // Schema mismatch - needs reset + if (errorMsg.includes('has no column named') || errorMsg.includes('already exists')) { + return true; } + + // Other error - log but don't reset + logger.error(error instanceof Error ? error : new Error(errorMsg), 'Migration failed'); + return false; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcca75..7af768c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 24.10.1 '@vitest/coverage-v8': specifier: ^4.0.15 - version: 4.0.15(vitest@4.0.15(@types/node@24.10.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.15(vitest@4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(tsx@4.21.0)(yaml@2.8.2)) lint-staged: specifier: 16.2.7 version: 16.2.7 @@ -31,7 +31,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.15 - version: 4.0.15(@types/node@24.10.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(tsx@4.21.0)(yaml@2.8.2) packages/cli: dependencies: @@ -59,9 +59,18 @@ importers: commander: specifier: ^14.0.2 version: 14.0.2 + ink: + specifier: ^6.5.1 + version: 6.5.1(@types/react@19.2.7)(react@19.2.1) + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@6.5.1(@types/react@19.2.7)(react@19.2.1))(react@19.2.1) ora: specifier: ^9.0.0 version: 9.0.0 + react: + specifier: ^19.2.1 + version: 19.2.1 vectordb: specifier: ^0.21.2 version: 0.21.2(@apache-arrow/ts@14.0.2)(apache-arrow@14.0.2) @@ -69,9 +78,24 @@ importers: specifier: ^3.23.8 version: 3.25.76 devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@types/node': specifier: ^24.10.1 version: 24.10.1 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@19.2.7) + jsdom: + specifier: ^27.2.0 + version: 27.2.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -93,6 +117,12 @@ importers: '@google/generative-ai': specifier: ^0.24.1 version: 0.24.1 + pdf-to-img: + specifier: ^5.0.0 + version: 5.0.0 + tesseract.js: + specifier: ^6.0.1 + version: 6.0.1 zod: specifier: ^3.23.8 version: 3.25.76 @@ -117,7 +147,7 @@ importers: version: 11.10.0 drizzle-orm: specifier: ^0.36.4 - version: 0.36.4(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) + version: 0.36.4(@types/better-sqlite3@7.6.13)(@types/react@19.2.7)(better-sqlite3@11.10.0)(react@19.2.1) env-paths: specifier: ^3.0.0 version: 3.0.0 @@ -157,9 +187,29 @@ packages: resolution: {integrity: sha512-08K9ou5VNbheZFxM5tDWoqjA3ImC50DiuuJ2tj1yEPRfkp8lLLg6XAaJ4On+a0yAXor/8ay5gHnAIshRM44Kpw==} engines: {node: '>=12.17'} + '@acemir/cssom@0.9.28': + resolution: {integrity: sha512-LuS6IVEivI75vKN8S04qRD+YySP0RmU/cV8UNukhQZvprxF+76Z43TNo/a08eCodaGhT1Us8etqS1ZRY9/Or0A==} + + '@alcalzone/ansi-tokenize@0.2.2': + resolution: {integrity: sha512-mkOh+Wwawzuf5wa30bvc4nA+Qb6DIrGWgBhRR/Pw4T9nsgYait8izvXkNyU78D6Wcu3Z+KUdwCmLCxlWjEotYA==} + engines: {node: '>=18'} + '@apache-arrow/ts@14.0.2': resolution: {integrity: sha512-CtwAvLkK0CZv7xsYeCo91ml6PvlfzAmAJZkRYuz2GNBwfYufj5SVi0iuSMwIMkcU/szVwvLdzORSLa5PlF/2ug==} + '@asamuzakjp/css-color@4.1.0': + resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -173,6 +223,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} @@ -234,6 +288,38 @@ packages: cpu: [x64] os: [win32] + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -883,6 +969,70 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/canvas-android-arm64@0.1.84': + resolution: {integrity: sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.84': + resolution: {integrity: sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.84': + resolution: {integrity: sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.84': + resolution: {integrity: sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.84': + resolution: {integrity: sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.84': + resolution: {integrity: sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.84': + resolution: {integrity: sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.84': + resolution: {integrity: sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.84': + resolution: {integrity: sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-x64-msvc@0.1.84': + resolution: {integrity: sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.84': + resolution: {integrity: sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA==} + engines: {node: '>= 10'} + '@neon-rs/load@0.0.74': resolution: {integrity: sha512-/cPZD907UNz55yrc/ud4wDgQKtU1TvkD9jeqZWG6J4IMmZkp6zgjkQcKA8UvpkZlcpPHvc8J17sGzLFbP/LUYg==} @@ -999,9 +1149,31 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tsconfig/node-lts@24.0.0': resolution: {integrity: sha512-8mSTqWwCd6aQpvxSrpQlMoA9RiUZSs7bYhL5qsLXIIaN9HQaINeoydrRu/Y7/fws4bvfuyhs0BRnW9/NI8tySg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -1032,6 +1204,9 @@ packages: '@types/pad-left@2.1.1': resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@vitest/coverage-v8@4.0.15': resolution: {integrity: sha512-FUJ+1RkpTFW7rQITdgTi93qOCWJobWhBirEPCeXh2SW2wsTlFxy51apDz5gzG+ZEYt/THvWeNmhdAoS9DTwpCw==} peerDependencies: @@ -1079,6 +1254,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1094,6 +1273,10 @@ packages: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1102,6 +1285,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1113,6 +1300,9 @@ packages: resolution: {integrity: sha512-EBO2xJN36/XoY81nhLcwCJgFwkboDZeyNQ+OPsG7bCoQjc2BT0aTyH/MR6SrL+LirSNz+cYqjGRlupMMlP1aEg==} hasBin: true + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-back@3.1.0: resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} engines: {node: '>=6'} @@ -1131,6 +1321,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -1140,12 +1334,18 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bmp-js@0.1.0: + resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + body-parser@2.2.1: resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} engines: {node: '>=18'} @@ -1205,10 +1405,22 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-spinners@3.3.0: resolution: {integrity: sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==} engines: {node: '>=18.20'} @@ -1217,6 +1429,10 @@ packages: resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1262,6 +1478,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1278,6 +1498,21 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@5.3.3: + resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1287,6 +1522,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1303,10 +1541,17 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + drizzle-kit@0.28.1: resolution: {integrity: sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ==} hasBin: true @@ -1420,6 +1665,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1447,6 +1696,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.42.0: + resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -1475,6 +1727,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1617,6 +1873,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1624,19 +1884,67 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink-spinner@5.0.0: + resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4.0.0' + react: '>=18.0.0' + + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@6.5.1: + resolution: {integrity: sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: ^6.1.2 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1645,6 +1953,11 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -1653,6 +1966,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1660,6 +1976,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1686,9 +2005,21 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsdom@27.2.0: + resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-bignum@0.0.3: resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} engines: {node: '>=0.8'} @@ -1730,6 +2061,14 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1744,6 +2083,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -1772,6 +2114,10 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1815,6 +2161,15 @@ packages: resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} engines: {node: '>=10'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1833,10 +2188,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + opencollective-postinstall@2.0.3: + resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} + hasBin: true + ora@9.0.0: resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} engines: {node: '>=20'} @@ -1845,10 +2208,17 @@ packages: resolution: {integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==} engines: {node: '>=0.10.0'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1859,6 +2229,15 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-to-img@5.0.0: + resolution: {integrity: sha512-QJy7P2Qbk86lntPPIC3HVZAyw/CvM+CP592UbO7grborOJ74Icspb8A2sdHO33MDb/WHwHZpSJxd771rQTlZKA==} + engines: {node: '>=20'} + hasBin: true + + pdfjs-dist@5.4.449: + resolution: {integrity: sha512-CegnUaT0QwAyQMS+7o2POr4wWUNNe8VaKKlcuoRHeYo98cVnqPpwOXNSx6Trl6szH02JrRcsPgletV6GmF3LtQ==} + engines: {node: '>=20.16.0 || >=22.3.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1913,6 +2292,10 @@ packages: engines: {node: '>=10'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1923,6 +2306,10 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -1939,6 +2326,24 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1947,6 +2352,9 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} @@ -1962,6 +2370,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1984,6 +2396,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -2027,6 +2446,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2056,6 +2478,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2106,6 +2532,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + table-layout@3.0.2: resolution: {integrity: sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==} engines: {node: '>=12.17'} @@ -2118,6 +2547,12 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tesseract.js-core@6.0.0: + resolution: {integrity: sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==} + + tesseract.js@6.0.1: + resolution: {integrity: sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2143,6 +2578,13 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2151,6 +2593,17 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2188,6 +2641,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -2227,6 +2684,7 @@ packages: vectordb@0.21.2: resolution: {integrity: sha512-5tiwUq0jDtfIpcr7NY+kNCTecHCzSq0AqQtMzJphH7z6H6gfrw9t5/Aoy5/QnS0uAWIgqvCbE5qneQOFGxE+Og==} + cpu: [x64, arm64] os: [darwin, linux, win32] deprecated: Use @lancedb/lancedb instead. peerDependencies: @@ -2307,6 +2765,35 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2317,6 +2804,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + wordwrapjs@5.1.1: resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} engines: {node: '>=12.17'} @@ -2328,6 +2819,25 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -2337,6 +2847,12 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod-to-json-schema@3.25.0: resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} peerDependencies: @@ -2352,6 +2868,13 @@ snapshots: lodash: 4.17.21 typical: 7.3.0 + '@acemir/cssom@0.9.28': {} + + '@alcalzone/ansi-tokenize@0.2.2': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@apache-arrow/ts@14.0.2': dependencies: '@types/command-line-args': 5.2.0 @@ -2365,6 +2888,30 @@ snapshots: pad-left: 2.1.0 tslib: 2.8.1 + '@asamuzakjp/css-color@4.1.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -2373,6 +2920,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/runtime@7.28.4': {} + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -2415,6 +2964,28 @@ snapshots: '@biomejs/cli-win32-x64@2.3.8': optional: true + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -2770,6 +3341,50 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/canvas-android-arm64@0.1.84': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.84': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.84': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.84': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.84': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.84': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.84': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.84': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.84': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.84': + optional: true + + '@napi-rs/canvas@0.1.84': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.84 + '@napi-rs/canvas-darwin-arm64': 0.1.84 + '@napi-rs/canvas-darwin-x64': 0.1.84 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.84 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.84 + '@napi-rs/canvas-linux-arm64-musl': 0.1.84 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.84 + '@napi-rs/canvas-linux-x64-gnu': 0.1.84 + '@napi-rs/canvas-linux-x64-musl': 0.1.84 + '@napi-rs/canvas-win32-x64-msvc': 0.1.84 + optional: true + '@neon-rs/load@0.0.74': {} '@rollup/rollup-android-arm-eabi@4.53.3': @@ -2840,8 +3455,30 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.7 + '@tsconfig/node-lts@24.0.0': {} + '@types/aria-query@5.0.4': {} + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 22.19.1 @@ -2871,7 +3508,11 @@ snapshots: '@types/pad-left@2.1.1': {} - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.10.1)(tsx@4.21.0)(yaml@2.8.2))': + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.15 @@ -2884,7 +3525,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.15(@types/node@24.10.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -2934,6 +3575,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -2949,12 +3592,16 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -2972,6 +3619,10 @@ snapshots: pad-left: 2.1.0 tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-back@3.1.0: {} array-back@6.2.2: {} @@ -2986,6 +3637,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + axios@1.13.2: dependencies: follow-redirects: 1.15.11 @@ -3001,6 +3654,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -3011,6 +3668,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bmp-js@0.1.0: {} + body-parser@2.2.1: dependencies: bytes: 3.1.2 @@ -3074,10 +3733,18 @@ snapshots: chownr@1.1.4: {} + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + cli-spinners@3.3.0: {} cli-truncate@5.1.1: @@ -3085,6 +3752,10 @@ snapshots: slice-ansi: 7.1.2 string-width: 8.1.0 + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3123,6 +3794,8 @@ snapshots: content-type@1.0.5: {} + convert-to-spaces@2.0.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -3138,10 +3811,30 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssstyle@5.3.3: + dependencies: + '@asamuzakjp/css-color': 4.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + css-tree: 3.1.0 + + csstype@3.2.3: {} + + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -3152,8 +3845,12 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + drizzle-kit@0.28.1: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -3163,10 +3860,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.36.4(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): + drizzle-orm@0.36.4(@types/better-sqlite3@7.6.13)(@types/react@19.2.7)(better-sqlite3@11.10.0)(react@19.2.1): optionalDependencies: '@types/better-sqlite3': 7.6.13 + '@types/react': 19.2.7 better-sqlite3: 11.10.0 + react: 19.2.1 dunder-proto@1.0.1: dependencies: @@ -3184,6 +3883,8 @@ snapshots: dependencies: once: 1.4.0 + entities@6.0.1: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -3205,6 +3906,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.42.0: {} + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: debug: 4.4.3 @@ -3323,6 +4026,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -3476,6 +4181,10 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -3486,30 +4195,100 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} + ieee754@1.2.1: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@1.3.8: {} + ink-spinner@5.0.0(ink@6.5.1(@types/react@19.2.7)(react@19.2.1))(react@19.2.1): + dependencies: + cli-spinners: 2.9.2 + ink: 6.5.1(@types/react@19.2.7)(react@19.2.1) + react: 19.2.1 + + ink-testing-library@4.0.0(@types/react@19.2.7): + optionalDependencies: + '@types/react': 19.2.7 + + ink@6.5.1(@types/react@19.2.7)(react@19.2.1): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.2 + ansi-escapes: 7.2.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.42.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.1 + react-reconciler: 0.33.0(react@19.2.1) + signal-exit: 3.0.7 + slice-ansi: 7.1.2 + stack-utils: 2.0.6 + string-width: 8.1.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + ws: 8.18.3 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.7 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ipaddr.js@1.9.1: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 + is-in-ci@2.0.0: {} + is-interactive@2.0.0: {} is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-unicode-supported@2.1.0: {} + is-url@1.2.4: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3537,8 +4316,37 @@ snapshots: joycon@3.1.1: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + jsdom@27.2.0: + dependencies: + '@acemir/cssom': 0.9.28 + '@asamuzakjp/dom-selector': 6.7.6 + cssstyle: 5.3.3 + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + json-bignum@0.0.3: {} json-schema-traverse@1.0.0: {} @@ -3585,6 +4393,10 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + lru-cache@11.2.4: {} + + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3601,6 +4413,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.12.2: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -3622,6 +4436,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} mimic-response@3.1.0: {} @@ -3657,6 +4473,10 @@ snapshots: dependencies: semver: 7.7.3 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -3671,10 +4491,16 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 + opencollective-postinstall@2.0.3: {} + ora@9.0.0: dependencies: chalk: 5.6.2 @@ -3691,14 +4517,28 @@ snapshots: dependencies: repeat-string: 1.6.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-key@3.1.1: {} path-to-regexp@8.3.0: {} pathe@2.0.3: {} + pdf-to-img@5.0.0: + dependencies: + pdfjs-dist: 5.4.449 + + pdfjs-dist@5.4.449: + optionalDependencies: + '@napi-rs/canvas': 0.1.84 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3746,6 +4586,12 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -3758,6 +4604,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -3778,6 +4626,20 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-reconciler@0.33.0(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react@19.2.1: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3786,6 +4648,8 @@ snapshots: readdirp@4.1.2: {} + regenerator-runtime@0.13.11: {} + repeat-string@1.6.1: {} require-from-string@2.0.2: {} @@ -3794,6 +4658,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -3843,6 +4712,12 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + semver@7.7.3: {} send@1.2.0: @@ -3908,6 +4783,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: {} @@ -3934,6 +4811,10 @@ snapshots: source-map@0.7.6: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -3981,6 +4862,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + table-layout@3.0.2: dependencies: '@75lb/deep-merge': 1.1.2 @@ -4006,6 +4889,22 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tesseract.js-core@6.0.0: {} + + tesseract.js@6.0.1: + dependencies: + bmp-js: 0.1.0 + idb-keyval: 6.2.2 + is-url: 1.2.4 + node-fetch: 2.7.0 + opencollective-postinstall: 2.0.3 + regenerator-runtime: 0.13.11 + tesseract.js-core: 6.0.0 + wasm-feature-detect: 1.8.0 + zlibjs: 0.3.1 + transitivePeerDependencies: + - encoding + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -4027,12 +4926,28 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@0.0.3: {} + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -4078,6 +4993,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-fest@4.41.0: {} + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -4131,7 +5048,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.15(@types/node@24.10.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.15 '@vitest/mocker': 4.0.15(vite@7.2.6(@types/node@24.10.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -4155,6 +5072,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.1 + jsdom: 27.2.0 transitivePeerDependencies: - jiti - less @@ -4168,6 +5086,32 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wasm-feature-detect@1.8.0: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@8.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4177,6 +5121,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + wordwrapjs@5.1.1: {} wrap-ansi@9.0.2: @@ -4187,10 +5135,20 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.8.2: {} yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + + zlibjs@0.3.1: {} + zod-to-json-schema@3.25.0(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/vitest.config.ts b/vitest.config.ts index 57241ab..7991075 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,16 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - exclude: ['**/node_modules/**', '**/dist/**', '**/*.d.ts', '**/test/**'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/*.d.ts', + '**/test/**', + '**/contexts/index.ts', // Barrel file + '**/hooks/index.ts', // Barrel file + '**/services/index.ts', // Barrel file + '**/components/index.ts', // Barrel file + ], }, }, resolve: {