diff --git a/package.json b/package.json index d4d3c7a..c50b91f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-note-categorization", - "version": "0.1.6", + "version": "0.1.7", "scripts": { "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", diff --git a/src/manifest.json b/src/manifest.json index 2c28157..466aace 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.harsh16gupta.notecategorization", "app_min_version": "3.5", - "version": "0.1.6", + "version": "0.1.7", "name": "Note Categorization Plugin", "description": "AI-based note categorisation: clusters notes semantically, suggests tags and notebook structures, and detects stale notes.", "author": "Harsh Gupta", diff --git a/src/panel/setupPanel.ts b/src/panel/setupPanel.ts index 9f592df..a590fab 100644 --- a/src/panel/setupPanel.ts +++ b/src/panel/setupPanel.ts @@ -20,6 +20,8 @@ export async function setupPanel(operationState: OperationState): Promise { @@ -29,15 +31,21 @@ export async function setupPanel(operationState: OperationState): Promise { - panelState = { type: 'status', text }; + onStatus: (text, isNativeAiUsed) => { + panelState = { type: 'status', text, isNativeAiUsed }; }, - onProgress: (current, total, cached, skipped) => { - panelState = { type: 'progress', current, total, cached, skipped }; + onProgress: (current, total, cached, skipped, isNativeAiUsed) => { + panelState = { type: 'progress', current, total, cached, skipped, isNativeAiUsed }; }, - onComplete: (strategies, notes) => { - lastResultsState = { strategies, notes, selectedStrategyIndex: 0 }; - panelState = { type: 'results', strategies, notes }; + onComplete: (strategies, notes, isNativeAiUsed, isAiNamingUsed) => { + lastResultsState = { + strategies, + notes, + selectedStrategyIndex: 0, + isNativeAiUsed, + isAiNamingUsed, + }; + panelState = { type: 'results', strategies, notes, isNativeAiUsed, isAiNamingUsed }; }, onError: (message) => { panelState = { type: 'error', message }; @@ -59,6 +67,8 @@ export async function setupPanel(operationState: OperationState): Promise(promise: Promise, ms: number): Promise { * in a single unchained expression as a defensive practice to prevent proxy path * state accumulation across Joplin sandbox runtime versions. */ -export async function upgradeClusterNamesWithAi(results: BenchmarkResult[], documents: DocumentText[]): Promise { +export async function upgradeClusterNamesWithAi( + results: BenchmarkResult[], + documents: DocumentText[], +): Promise { + let anyUpgraded = false; await Promise.all( results.map(async (result) => { if (!result.clusterNames || Object.keys(result.clusterNames).length === 0) { @@ -266,6 +270,10 @@ export async function upgradeClusterNamesWithAi(results: BenchmarkResult[], docu // If AI didn't provide a name for this cluster, keep the TF-IDF name } + if (upgradedCount > 0) { + anyUpgraded = true; + } + log(`AI naming: upgraded ${upgradedCount}/${clusterIds.length} cluster names`); // Resolve name collisions (same logic pattern as postProcess.ts) @@ -277,6 +285,8 @@ export async function upgradeClusterNamesWithAi(results: BenchmarkResult[], docu } }), ); + + return anyUpgraded; } /** diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index 08a8bd1..e34faac 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -12,9 +12,14 @@ import { upgradeClusterNamesWithAi } from './clustering/aiNamingService'; import { EmbeddingWorkerOrchestrator } from './EmbeddingWorkerOrchestrator'; export interface PipelineCallbacks { - onStatus: (text: string) => void; - onProgress: (current: number, total: number, cached: number, skipped: number) => void; - onComplete: (strategies: import('../types/cluster').BenchmarkResult[], notes: PanelNote[]) => void; + onStatus: (text: string, isNativeAiUsed?: boolean) => void; + onProgress: (current: number, total: number, cached: number, skipped: number, isNativeAiUsed?: boolean) => void; + onComplete: ( + strategies: import('../types/cluster').BenchmarkResult[], + notes: PanelNote[], + isNativeAiUsed?: boolean, + isAiNamingUsed?: boolean, + ) => void; onError: (message: string) => void; } @@ -52,7 +57,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac if (await isNativeAiReady()) { log('Native AI Search active: using native embeddings pipeline'); - callbacks.onStatus('Fetching native embeddings...'); + callbacks.onStatus('Fetching native embeddings...', true); try { const noteIds = notes.map((n) => n.id); @@ -102,7 +107,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac if (validNotes.length < 3) { log('Too few indexed notes found in native DB. Falling back to local ONNX Web Worker.'); } else { - callbacks.onStatus('Clustering...'); + callbacks.onStatus('Clustering...', true); const clusterStart = performance.now(); const adaptiveConfig = createAdaptiveConfig( nativeResult.dimension, @@ -121,14 +126,15 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac callbacks.onStatus( `Extracting topics for ${results.reduce((sum, r) => sum + r.clusterCount, 0)} clusters...`, + true, ); const enrichStart = performance.now(); - await enrichResultsWithTags(results, allPipelineDocuments, 5, callbacks.onStatus); + await enrichResultsWithTags(results, allPipelineDocuments, 5, (t) => callbacks.onStatus(t, true)); log(`Topic extraction: ${Math.round(performance.now() - enrichStart)}ms`); - callbacks.onStatus('Generating AI cluster names...'); + callbacks.onStatus('Generating AI cluster names...', true); const aiStart = performance.now(); - await upgradeClusterNamesWithAi(results, allPipelineDocuments); + const isAiNamingUsed = await upgradeClusterNamesWithAi(results, allPipelineDocuments); log(`AI naming: ${Math.round(performance.now() - aiStart)}ms`); const panelNotes: PanelNote[] = validNotes.map((n) => ({ @@ -136,7 +142,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac title: n.title, })); - callbacks.onComplete(results, panelNotes); + callbacks.onComplete(results, panelNotes, true, isAiNamingUsed); return; } } catch (err) { @@ -161,7 +167,13 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac await cache.beginUpdate(); const batchStartTime = performance.now(); - const orchestrator = new EmbeddingWorkerOrchestrator(installDir, notes, cache, callbacks); + const fallbackCallbacks: PipelineCallbacks = { + ...callbacks, + onStatus: (text) => callbacks.onStatus(text, false), + onProgress: (current, total, cached, skipped) => + callbacks.onProgress(current, total, cached, skipped, false), + }; + const orchestrator = new EmbeddingWorkerOrchestrator(installDir, notes, cache, fallbackCallbacks); let result; try { @@ -184,7 +196,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac await cache.endUpdate(); - callbacks.onStatus('Clustering...'); + callbacks.onStatus('Clustering...', false); if (noteVectors.length < 3) { callbacks.onError('Too few notes for clustering (need at least 3).'); @@ -207,14 +219,17 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac }; }); - callbacks.onStatus(`Extracting topics for ${results.reduce((sum, r) => sum + r.clusterCount, 0)} clusters...`); + callbacks.onStatus( + `Extracting topics for ${results.reduce((sum, r) => sum + r.clusterCount, 0)} clusters...`, + false, + ); const enrichStart = performance.now(); - await enrichResultsWithTags(results, allPipelineDocuments, 5, callbacks.onStatus); + await enrichResultsWithTags(results, allPipelineDocuments, 5, (t) => callbacks.onStatus(t, false)); log(`Topic extraction: ${Math.round(performance.now() - enrichStart)}ms`); - callbacks.onStatus('Generating AI cluster names...'); + callbacks.onStatus('Generating AI cluster names...', false); const aiStart = performance.now(); - await upgradeClusterNamesWithAi(results, allPipelineDocuments); + const isAiNamingUsed = await upgradeClusterNamesWithAi(results, allPipelineDocuments); log(`AI naming: ${Math.round(performance.now() - aiStart)}ms`); const panelNotes: PanelNote[] = noteVectors.map((nv) => ({ @@ -222,7 +237,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac title: nv.title, })); - callbacks.onComplete(results, panelNotes); + callbacks.onComplete(results, panelNotes, false, isAiNamingUsed); } catch (err) { const message = err instanceof Error ? err.message : String(err); logErr('Pipeline failed:', message); diff --git a/src/types/panel.ts b/src/types/panel.ts index b572248..6bbdb41 100644 --- a/src/types/panel.ts +++ b/src/types/panel.ts @@ -30,9 +30,17 @@ export interface ApplyMessage { // Plugin → Webview export type PanelMessage = - | { type: 'status'; text: string } - | { type: 'progress'; current: number; total: number; cached: number; skipped: number } - | { type: 'results'; strategies: BenchmarkResult[]; notes: PanelNote[]; selectedStrategyIndex?: number } + | { type: 'status'; text: string; isNativeAiUsed?: boolean } + | { type: 'progress'; current: number; total: number; cached: number; skipped: number; isNativeAiUsed?: boolean } + | { + type: 'results'; + strategies: BenchmarkResult[]; + notes: PanelNote[]; + selectedStrategyIndex?: number; + isNativeAiUsed?: boolean; + isAiNamingUsed?: boolean; + /* eslint-disable-next-line no-mixed-spaces-and-tabs */ + } | { type: 'error'; message: string } | { type: 'apply_status'; text: string } | { type: 'apply_progress'; current: number; total: number } diff --git a/src/webview/components/NoticeBanner.tsx b/src/webview/components/NoticeBanner.tsx new file mode 100644 index 0000000..f41f705 --- /dev/null +++ b/src/webview/components/NoticeBanner.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +interface NoticeBannerProps { + variant?: 'info' | 'warning'; + title: string; + message: string; + onClose?: () => void; +} + +export const NoticeBanner: React.FC = ({ variant = 'info', title, message, onClose }) => { + return ( +
+
+ {variant === 'info' ? ( + + + + + + ) : ( + + + + + + )} +
+
+ {title}: {message} +
+ {onClose && ( + + )} +
+ ); +}; diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx index d002279..2f9636b 100644 --- a/src/webview/context/AppStateContext.tsx +++ b/src/webview/context/AppStateContext.tsx @@ -17,6 +17,8 @@ interface AppStateContextType { notes: PanelNote[]; selectedStrategyIndex: number; activeView: ViewType; + isNativeAiUsed: boolean; + isAiNamingUsed: boolean; runPipeline: () => void; changeStrategy: (index: number) => void; setView: (view: ViewType) => void; @@ -96,6 +98,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil notes, selectedStrategyIndex, activeView, + isNativeAiUsed, + isAiNamingUsed, runPipeline, changeStrategy, setView, @@ -110,6 +114,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setSelectedStrategyIndex, setError, setActiveView, + setIsNativeAiUsed, + setIsAiNamingUsed, } = usePipelineState(() => startPolling(), resetApplyState); const handlePollResponse = React.useCallback( @@ -119,6 +125,9 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil switch (msg.type) { case 'status': setStatusText(msg.text || ''); + if (typeof msg.isNativeAiUsed === 'boolean') { + setIsNativeAiUsed(msg.isNativeAiUsed); + } break; case 'progress': @@ -128,6 +137,9 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil cached: msg.cached || 0, skipped: msg.skipped || 0, }); + if (typeof msg.isNativeAiUsed === 'boolean') { + setIsNativeAiUsed(msg.isNativeAiUsed); + } break; case 'results': { @@ -140,6 +152,12 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil ); const defaultIdx = kmeansIdx !== -1 ? kmeansIdx : 0; setSelectedStrategyIndex(msg.selectedStrategyIndex ?? defaultIdx); + if (typeof msg.isNativeAiUsed === 'boolean') { + setIsNativeAiUsed(msg.isNativeAiUsed); + } + if (typeof msg.isAiNamingUsed === 'boolean') { + setIsAiNamingUsed(msg.isAiNamingUsed); + } setError(null); setActiveView('dashboard'); break; @@ -225,6 +243,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil setUndoProgress, setUndoError, setUndoSuccess, + setIsNativeAiUsed, + setIsAiNamingUsed, ], ); @@ -302,6 +322,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil notes, selectedStrategyIndex, activeView, + isNativeAiUsed, + isAiNamingUsed, runPipeline, changeStrategy, setView, diff --git a/src/webview/context/usePipelineState.ts b/src/webview/context/usePipelineState.ts index 7d7020e..a91f2bb 100644 --- a/src/webview/context/usePipelineState.ts +++ b/src/webview/context/usePipelineState.ts @@ -16,6 +16,8 @@ export function usePipelineState(startPolling: () => void, resetApplyState: () = const [notes, setNotes] = React.useState([]); const [selectedStrategyIndex, setSelectedStrategyIndex] = React.useState(0); const [activeView, setActiveView] = React.useState('idle'); + const [isNativeAiUsed, setIsNativeAiUsed] = React.useState(true); + const [isAiNamingUsed, setIsAiNamingUsed] = React.useState(true); const runPipeline = async () => { setIsRunning(true); @@ -25,6 +27,8 @@ export function usePipelineState(startPolling: () => void, resetApplyState: () = setNotes([]); setError(null); setActiveView('idle'); + setIsNativeAiUsed(true); + setIsAiNamingUsed(true); // Reset apply/undo/cleanup states resetApplyState(); @@ -145,6 +149,8 @@ export function usePipelineState(startPolling: () => void, resetApplyState: () = notes, selectedStrategyIndex, activeView, + isNativeAiUsed, + isAiNamingUsed, runPipeline, changeStrategy, setView, @@ -159,5 +165,7 @@ export function usePipelineState(startPolling: () => void, resetApplyState: () = setSelectedStrategyIndex, setError, setActiveView, + setIsNativeAiUsed, + setIsAiNamingUsed, }; } diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index 5175fd5..f924f2e 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -4,6 +4,8 @@ import { Header } from '../components/Header'; import { StrategySection } from '../components/StrategySection'; import { ClusterCard } from '../components/ClusterCard'; +import { NoticeBanner } from '../components/NoticeBanner'; + export const DashboardPage: React.FC = () => { const { isRunning, @@ -22,6 +24,8 @@ export const DashboardPage: React.FC = () => { applyChanges, isUndoing, settings, + isNativeAiUsed, + isAiNamingUsed, } = useAppState(); const selectedStrategy = strategies[selectedStrategyIndex]; @@ -30,31 +34,31 @@ export const DashboardPage: React.FC = () => { const [newClusterName, setNewClusterName] = React.useState(''); const [duplicateError, setDuplicateError] = React.useState(false); + const [isNativeAiDismissed, setIsNativeAiDismissed] = React.useState(false); + const [isAiNamingDismissed, setIsAiNamingDismissed] = React.useState(false); + const { clusters, noise, sortedClusterIds } = React.useMemo(() => { const clusters: { [key: number]: number[] } = {}; const noise: number[] = []; if (selectedStrategy) { - const clusterNames = selectedStrategy.clusterNames || {}; - Object.keys(clusterNames).forEach((clusterId) => { - clusters[Number(clusterId)] = []; - }); + const assignments = selectedStrategy.assignments || []; - selectedStrategy.assignments.forEach((clusterId, noteIndex) => { + assignments.forEach((clusterId, noteIdx) => { if (clusterId === -1) { - noise.push(noteIndex); + noise.push(noteIdx); } else { if (!clusters[clusterId]) { clusters[clusterId] = []; } - clusters[clusterId].push(noteIndex); + clusters[clusterId].push(noteIdx); } }); } const sortedClusterIds = Object.keys(clusters) .map(Number) - .sort((a, b) => clusters[b].length - clusters[a].length); + .sort((a, b) => a - b); return { clusters, noise, sortedClusterIds }; }, [selectedStrategy]); @@ -88,12 +92,30 @@ export const DashboardPage: React.FC = () => {
+ {!isNativeAiUsed && !isNativeAiDismissed && ( + setIsNativeAiDismissed(true)} + /> + )} + + {!isAiNamingUsed && !isAiNamingDismissed && ( + setIsAiNamingDismissed(true)} + /> + )} +
{selectedStrategy && sortedClusterIds.map((id) => ( diff --git a/src/webview/pages/EmptyStatePage.tsx b/src/webview/pages/EmptyStatePage.tsx index 3e003f7..7d9b53f 100644 --- a/src/webview/pages/EmptyStatePage.tsx +++ b/src/webview/pages/EmptyStatePage.tsx @@ -4,12 +4,23 @@ import { Header } from '../components/Header'; import { ProgressBar } from '../components/ProgressBar'; import { EmptyState } from '../components/EmptyState'; +import { NoticeBanner } from '../components/NoticeBanner'; + export const EmptyStatePage: React.FC = () => { - const { isRunning, runPipeline, statusText, progress } = useAppState(); + const { isRunning, runPipeline, statusText, progress, isNativeAiUsed } = useAppState(); + const [isDismissed, setIsDismissed] = React.useState(false); return (
+ {isRunning && !isNativeAiUsed && !isDismissed && ( + setIsDismissed(true)} + /> + )} {isRunning ? : }
); diff --git a/src/webview/panel.css b/src/webview/panel.css index 1f067a4..e346287 100644 --- a/src/webview/panel.css +++ b/src/webview/panel.css @@ -591,6 +591,74 @@ body { border-color: color-mix(in srgb, #dc2626 15%, var(--joplin-divider-color)); } +/* NOTICE BANNERS */ + +.notice-banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + font-size: 0.8em; + line-height: 1.45; + margin-bottom: 12px; + border: 1px solid color-mix(in srgb, var(--joplin-color-blue, #2563eb) 20%, var(--joplin-divider-color)); + background: color-mix(in srgb, var(--joplin-color-blue, #2563eb) 5%, var(--joplin-background-color)); + color: var(--joplin-color); + position: relative; +} + +.notice-banner.info { + border-color: color-mix(in srgb, var(--joplin-color-blue, #2563eb) 22%, var(--joplin-divider-color)); + background: color-mix(in srgb, var(--joplin-color-blue, #2563eb) 6%, var(--joplin-background-color)); +} + +.notice-banner.warning { + border-color: color-mix(in srgb, var(--joplin-color-blue, #2563eb) 18%, var(--joplin-divider-color)); + background: color-mix(in srgb, var(--joplin-color-blue, #2563eb) 4%, var(--joplin-background-color)); +} + +.notice-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + color: var(--joplin-color-blue, #2563eb); + opacity: 0.9; +} + +.notice-content { + flex: 1; + opacity: 0.85; +} + +.notice-content strong { + font-weight: 600; + opacity: 1; +} + +.notice-close-btn { + background: transparent; + border: none; + padding: 2px; + cursor: pointer; + color: var(--joplin-color); + opacity: 0.4; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: opacity 150ms ease, background 150ms ease; + flex-shrink: 0; + margin-top: 1px; +} + +.notice-close-btn:hover { + opacity: 0.9; + background: color-mix(in srgb, var(--joplin-color) 8%, transparent); +} + /* HISTORY PAGE */ .undo-history-card { diff --git a/test/pipeline/clustering/aiNamingService.test.ts b/test/pipeline/clustering/aiNamingService.test.ts index 6efe025..42f9e17 100644 --- a/test/pipeline/clustering/aiNamingService.test.ts +++ b/test/pipeline/clustering/aiNamingService.test.ts @@ -1,10 +1,24 @@ -jest.mock('api', () => ({}), { virtual: true }); +const mockChat = jest.fn(); +jest.mock( + 'api', + () => ({ + __esModule: true, + default: { + ai: { + chat: (...args: unknown[]) => mockChat(...args), + }, + }, + }), + { virtual: true }, +); import { sanitizeAiName, buildNamingPrompt, parseAiNamesResponse, + upgradeClusterNamesWithAi, } from '../../../src/pipeline/clustering/aiNamingService'; +import { BenchmarkResult } from '../../../src/types/cluster'; describe('sanitizeAiName', () => { it('trims whitespace', () => { @@ -241,3 +255,71 @@ describe('parseAiNamesResponse', () => { expect(result).toEqual({ 0: 'Alpha', 3: 'Beta' }); }); }); + +describe('upgradeClusterNamesWithAi', () => { + beforeEach(() => { + mockChat.mockReset(); + }); + + it('returns true and upgrades cluster names when ai.chat succeeds', async () => { + mockChat.mockResolvedValueOnce({ text: '{"0": "AI Web Dev", "1": "AI Travel"}' }); + + const results: BenchmarkResult[] = [ + { + strategyName: 'kmeans-auto', + algorithm: 'kmeans', + clusterCount: 2, + silhouetteScore: 0.8, + outlierCount: 0, + timeMs: 10, + clusterSizes: [2, 2], + clusterNames: { 0: 'Web', 1: 'Travel' }, + assignments: [0, 0, 1, 1], + tags: { 0: ['web'], 1: ['travel'] }, + }, + ]; + const documents = [ + { title: 'React Guide', body: 'React' }, + { title: 'Vue Guide', body: 'Vue' }, + { title: 'Paris Trip', body: 'Paris' }, + { title: 'Tokyo Trip', body: 'Tokyo' }, + ]; + + const upgraded = await upgradeClusterNamesWithAi(results, documents); + + expect(upgraded).toBe(true); + expect(results[0].clusterNames?.[0]).toBe('AI Web Dev'); + expect(results[0].clusterNames?.[1]).toBe('AI Travel'); + }); + + it('returns false and keeps TF-IDF names when ai.chat fails or throws', async () => { + mockChat.mockRejectedValueOnce(new Error('AI chat unavailable')); + + const results: BenchmarkResult[] = [ + { + strategyName: 'kmeans-auto', + algorithm: 'kmeans', + clusterCount: 2, + silhouetteScore: 0.8, + outlierCount: 0, + timeMs: 10, + clusterSizes: [2, 2], + clusterNames: { 0: 'TF-IDF Web', 1: 'TF-IDF Travel' }, + assignments: [0, 0, 1, 1], + tags: { 0: ['web'], 1: ['travel'] }, + }, + ]; + const documents = [ + { title: 'React Guide', body: 'React' }, + { title: 'Vue Guide', body: 'Vue' }, + { title: 'Paris Trip', body: 'Paris' }, + { title: 'Tokyo Trip', body: 'Tokyo' }, + ]; + + const upgraded = await upgradeClusterNamesWithAi(results, documents); + + expect(upgraded).toBe(false); + expect(results[0].clusterNames?.[0]).toBe('TF-IDF Web'); + expect(results[0].clusterNames?.[1]).toBe('TF-IDF Travel'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 158f2e3..948b38b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "baseUrl": ".", "skipLibCheck": true, "strict": true, - "noEmit": true + "noEmit": true, + "types": ["jest", "node"] } }