2025-12-16 20:50:28 +00:00
import { arrayBufferToBase64 , FileManager , TAbstractFile , TFile , TFolder , type Vault } from "obsidian" ;
2025-10-12 20:26:01 +00:00
import { Resolve } from "./DependencyService" ;
import { Services } from "./Services" ;
2025-11-10 08:03:27 +00:00
import type VaultkeeperAIPlugin from "main" ;
2025-10-12 20:26:01 +00:00
import { Path } from "Enums/Path" ;
2025-12-22 20:02:02 +00:00
import { pathExtname , randomSample , shuffleArray } from "Helpers/Helpers" ;
2025-11-12 21:12:36 +00:00
import { StringTools } from "Helpers/StringTools" ;
2026-01-28 21:23:47 +00:00
import type { IPageText , ISearchMatch , ISearchSnippet } from "../Types/SearchTypes" ;
2025-10-24 16:25:19 +00:00
import type { SanitiserService } from "./SanitiserService" ;
2025-10-25 11:46:07 +00:00
import { FileEvent } from "Enums/FileEvent" ;
2025-11-03 17:14:32 +00:00
import type { SettingsService } from "./SettingsService" ;
2025-11-17 19:02:15 +00:00
import { Exception } from "Helpers/Exception" ;
2025-11-24 21:29:54 +00:00
import type { EventService } from "./EventService" ;
import { DiffService } from "./DiffService" ;
import * as path from "path-browserify" ;
import { Event } from "Enums/Event" ;
2025-12-04 23:04:20 +00:00
import { AbortService } from "./AbortService" ;
2026-02-15 23:07:39 +00:00
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse" ;
2026-03-15 19:48:28 +00:00
import { FileType , isBinaryFile , isDocumentFile , isFileType } from "Enums/FileType" ;
import { readDocument , readPDF } from "Helpers/DocumentHelper" ;
2025-10-25 11:46:07 +00:00
2025-10-29 19:35:19 +00:00
interface IFileEventArgs {
2025-10-25 11:46:07 +00:00
oldPath : string ;
}
2025-10-12 20:26:01 +00:00
/* This service protects the users vault through their exclusions. The plugin root is excluded by default */
export class VaultService {
2025-11-10 08:03:27 +00:00
private readonly AGENT_ROOT_DIR = Path . VaultkeeperAIDir ;
private readonly AGENT_ROOT_CONTENTS = ` ${ Path . VaultkeeperAIDir } /** ` ;
2025-10-12 20:26:01 +00:00
2025-10-25 11:46:07 +00:00
private readonly vault : Vault ;
2025-11-10 08:03:27 +00:00
private readonly plugin : VaultkeeperAIPlugin ;
2025-11-03 17:14:32 +00:00
private readonly settingsService : SettingsService ;
2025-10-16 21:58:30 +00:00
private readonly fileManager : FileManager ;
2025-10-24 16:25:19 +00:00
private readonly sanitiserService : SanitiserService ;
2025-11-24 21:29:54 +00:00
private readonly diffService : DiffService ;
private readonly eventService : EventService ;
2025-10-12 20:26:01 +00:00
public constructor ( ) {
2025-11-10 08:03:27 +00:00
this . plugin = Resolve < VaultkeeperAIPlugin > ( Services . VaultkeeperAIPlugin ) ;
2025-12-16 20:50:28 +00:00
2025-10-12 20:26:01 +00:00
this . vault = this . plugin . app . vault ;
2025-11-24 21:29:54 +00:00
this . fileManager = this . plugin . app . fileManager
2025-11-03 17:14:32 +00:00
this . settingsService = Resolve < SettingsService > ( Services . SettingsService ) ;
2025-10-24 16:25:19 +00:00
this . sanitiserService = Resolve < SanitiserService > ( Services . SanitiserService ) ;
2025-11-24 21:29:54 +00:00
this . diffService = Resolve < DiffService > ( Services . DiffService ) ;
this . eventService = Resolve < EventService > ( Services . EventService ) ;
2025-10-12 20:26:01 +00:00
}
2025-10-29 19:35:19 +00:00
public registerFileEvents ( handleFileEvent : ( event : FileEvent , file : TAbstractFile , args : IFileEventArgs ) = > void ) {
2025-10-25 11:46:07 +00:00
this . plugin . registerEvent ( this . vault . on ( FileEvent . Create , file = > handleFileEvent ( FileEvent . Create , file , { oldPath : "" } ) ) ) ;
this . plugin . registerEvent ( this . vault . on ( FileEvent . Modify , file = > handleFileEvent ( FileEvent . Modify , file , { oldPath : "" } ) ) ) ;
this . plugin . registerEvent ( this . vault . on ( FileEvent . Rename , ( file , oldPath ) = > handleFileEvent ( FileEvent . Rename , file , { oldPath : oldPath } ) ) ) ;
this . plugin . registerEvent ( this . vault . on ( FileEvent . Delete , file = > handleFileEvent ( FileEvent . Delete , file , { oldPath : "" } ) ) ) ;
}
2025-10-12 22:11:36 +00:00
public getMarkdownFiles ( allowAccessToPluginRoot : boolean = false ) : TFile [ ] {
return this . vault . getMarkdownFiles ( ) . filter ( file = > ! this . isExclusion ( file . path , allowAccessToPluginRoot ) ) ;
2025-10-12 20:26:01 +00:00
}
2025-10-12 22:11:36 +00:00
public getAbstractFileByPath ( filePath : string , allowAccessToPluginRoot : boolean = false ) : TAbstractFile | null {
2025-10-24 16:25:19 +00:00
filePath = this . sanitiserService . sanitize ( filePath ) ;
2025-10-12 22:11:36 +00:00
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
2025-11-17 19:02:15 +00:00
Exception . log ( ` Plugin attempted to retrieve a file that is in the exclusions list: ${ filePath } ` ) ;
2025-10-12 20:26:01 +00:00
return null ;
2025-10-12 22:11:36 +00:00
}
2025-10-12 20:26:01 +00:00
return this . vault . getAbstractFileByPath ( filePath ) ;
}
2025-11-02 20:16:06 +00:00
public async exists ( filePath : string , allowAccessToPluginRoot : boolean = false ) : Promise < boolean > {
filePath = this . sanitiserService . sanitize ( filePath ) ;
2025-10-24 16:25:19 +00:00
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
2025-11-17 19:02:15 +00:00
Exception . log ( ` Plugin attempted to access a file that is in the exclusions list: ${ filePath } ` ) ;
2025-10-24 16:25:19 +00:00
return false ;
}
2025-11-02 20:16:06 +00:00
2026-04-08 22:08:21 +00:00
return await this . vault . adapter . exists ( filePath , true ) ;
2025-10-24 16:25:19 +00:00
}
2025-12-16 20:50:28 +00:00
public async read ( file : TFile , allowAccessToPluginRoot : boolean = false ) : Promise < string | Error > {
2025-11-17 19:02:15 +00:00
const filePath = this . sanitiserService . sanitize ( file . path ) ;
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to read a file that is in the exclusions list: ${ filePath } ` ) ;
2025-12-16 20:50:28 +00:00
return Exception . new ( ` File does not exist: ${ filePath } ` ) ;
}
2026-03-15 19:48:28 +00:00
const fileExtension = file . extension . toLowerCase ( ) ;
if ( isBinaryFile ( fileExtension ) ) {
2025-12-22 20:02:02 +00:00
const arrayBuffer = await this . readBinaryData ( file , allowAccessToPluginRoot ) ;
if ( arrayBuffer ) {
return arrayBufferToBase64 ( arrayBuffer ) ;
}
2025-10-12 22:11:36 +00:00
}
2025-12-16 20:50:28 +00:00
2026-03-15 19:48:28 +00:00
if ( isDocumentFile ( fileExtension ) ) {
const arrayBuffer = await this . readBinaryData ( file , allowAccessToPluginRoot ) ;
if ( arrayBuffer ) {
2026-06-28 14:08:50 +00:00
return ( readDocument ( arrayBuffer , fileExtension ) ) [ 0 ] . text ;
2026-03-15 19:48:28 +00:00
}
}
2025-10-12 20:26:01 +00:00
return await this . vault . read ( file ) ;
}
2025-12-22 20:02:02 +00:00
public async readBinaryData ( file : TFile , allowAccessToPluginRoot : boolean = false ) : Promise < ArrayBuffer | null > {
const filePath = this . sanitiserService . sanitize ( file . path ) ;
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to read a file that is in the exclusions list: ${ filePath } ` ) ;
return null ;
}
return await this . vault . readBinary ( file ) ;
}
2025-11-24 21:29:54 +00:00
public async create ( filePath : string , content : string , allowAccessToPluginRoot : boolean = false , requiresConfirmation : boolean = true ) : Promise < TFile | Error > {
2025-10-24 16:25:19 +00:00
filePath = this . sanitiserService . sanitize ( filePath ) ;
2026-07-10 20:23:44 +00:00
const fileExtension = pathExtname ( filePath ) ;
2025-10-12 22:11:36 +00:00
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
2025-11-17 19:02:15 +00:00
Exception . log ( ` Plugin attempted to create a file that is in the exclusion list: ${ filePath } ` ) ;
return Exception . new ( ` Failed to create file, permission denied: ${ filePath } ` ) ;
}
2025-11-24 21:29:54 +00:00
2026-07-10 20:23:44 +00:00
if ( isBinaryFile ( pathExtname ( fileExtension ) ) || isDocumentFile ( pathExtname ( fileExtension ) ) ) {
return Exception . new ( ` Creating ${ pathExtname ( filePath ) } files is not supported ` ) ;
2025-12-16 20:50:28 +00:00
}
2025-11-24 21:29:54 +00:00
const fileName = path . basename ( filePath ) ;
return this . proposeChange ( fileName , fileName , "" , content , requiresConfirmation , async ( ) = > {
2025-11-17 19:02:15 +00:00
await this . createDirectories ( filePath , allowAccessToPluginRoot ) ;
return await this . vault . create ( filePath , content ) ;
2025-11-24 21:29:54 +00:00
} ) ;
2025-10-12 20:26:01 +00:00
}
2025-11-24 21:29:54 +00:00
public async modify ( file : TFile , content : string , allowAccessToPluginRoot : boolean = false , requiresConfirmation : boolean = true ) : Promise < TFile | Error > {
2025-11-17 19:02:15 +00:00
const filePath = this . sanitiserService . sanitize ( file . path ) ;
2026-07-10 20:23:44 +00:00
const fileExtension = pathExtname ( filePath ) ;
2025-10-12 22:11:36 +00:00
if ( this . isExclusion ( file . path , allowAccessToPluginRoot ) ) {
2025-11-17 19:02:15 +00:00
Exception . log ( ` Plugin attempted to modify a file that is in the exclusion list: ${ filePath } ` ) ;
return Exception . new ( ` File does not exist: ${ filePath } ` ) ;
}
2025-11-24 21:29:54 +00:00
2026-07-10 20:23:44 +00:00
if ( isBinaryFile ( pathExtname ( filePath ) ) || isDocumentFile ( pathExtname ( fileExtension ) ) ) {
return Exception . new ( ` Modifying ${ pathExtname ( filePath ) } files is not supported ` ) ;
2025-12-16 20:50:28 +00:00
}
const currentContent = await this . read ( file , allowAccessToPluginRoot ) ;
if ( currentContent instanceof Error ) {
return currentContent ;
}
return this . proposeChange ( file . name , file . name , currentContent , content , requiresConfirmation , async ( ) = > {
2025-11-17 19:02:15 +00:00
await this . vault . process ( file , ( ) = > content ) ;
return file ;
2025-11-24 21:29:54 +00:00
} ) ;
2025-10-12 20:26:01 +00:00
}
2026-05-30 11:33:38 +00:00
public async updateFrontmatter ( file : TFile , mutate : ( frontmatter : Record < string , unknown > ) = > void , allowAccessToPluginRoot : boolean = false ) : Promise < TFile | Error > {
const filePath = this . sanitiserService . sanitize ( file . path ) ;
2026-07-10 20:23:44 +00:00
const fileExtension = pathExtname ( filePath ) ;
2026-05-30 11:33:38 +00:00
if ( this . isExclusion ( file . path , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to update frontmatter of a file that is in the exclusion list: ${ filePath } ` ) ;
return Exception . new ( ` File does not exist: ${ filePath } ` ) ;
}
2026-07-10 20:23:44 +00:00
if ( isBinaryFile ( pathExtname ( filePath ) ) || isDocumentFile ( pathExtname ( fileExtension ) ) ) {
return Exception . new ( ` Modifying ${ pathExtname ( filePath ) } files is not supported ` ) ;
2026-05-30 11:33:38 +00:00
}
try {
// frontmatter updates are not fed through 'proposeChange'
await this . fileManager . processFrontMatter ( file , ( frontmatter : Record < string , unknown > ) = > mutate ( frontmatter ) ) ;
return file ;
} catch ( error ) {
Exception . log ( error ) ;
return Exception . new ( error ) ;
}
}
2026-02-20 00:32:44 +00:00
public async patch ( file : TFile , oldContent : string [ ] , newContent : string [ ] , allowAccessToPluginRoot : boolean = false , requiresConfirmation : boolean = true ) : Promise < TFile | Error > {
2025-12-05 18:39:01 +00:00
const filePath = this . sanitiserService . sanitize ( file . path ) ;
if ( this . isExclusion ( file . path , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to patch a file that is in the exclusion list: ${ filePath } ` ) ;
return Exception . new ( ` File does not exist: ${ filePath } ` ) ;
}
2026-07-10 20:23:44 +00:00
if ( isBinaryFile ( pathExtname ( filePath ) ) ) {
return Exception . new ( ` Patching ${ pathExtname ( filePath ) } files is not supported ` ) ;
2026-02-20 00:32:44 +00:00
}
if ( oldContent . length !== newContent . length ) {
return Exception . new ( ` Mismatched patch arrays: ${ oldContent . length } old content entries but ${ newContent . length } new content entries. Each old content entry must have a corresponding new content entry. ` ) ;
2025-12-16 20:50:28 +00:00
}
2025-12-05 18:39:01 +00:00
const currentContent = await this . read ( file , allowAccessToPluginRoot ) ;
2025-12-16 20:50:28 +00:00
if ( currentContent instanceof Error ) {
return currentContent ;
}
2026-02-20 00:56:18 +00:00
for ( const content of oldContent ) {
2026-02-20 00:32:44 +00:00
if ( ! currentContent . includes ( content ) && ! StringTools . toWhitespaceFlexibleRegex ( content ) . test ( currentContent ) ) {
return Exception . new ( ` Content to replace was not found in the file, the old content must match exactly. No changes have been made. Unmatched content: " ${ content } " ` ) ;
}
2025-12-05 18:39:01 +00:00
}
2026-02-20 00:32:44 +00:00
let updatedContent = currentContent ;
for ( let i = 0 ; i < oldContent . length ; i ++ ) {
if ( updatedContent . includes ( oldContent [ i ] ) ) {
updatedContent = updatedContent . replace ( oldContent [ i ] , newContent [ i ] ) ;
} else {
updatedContent = updatedContent . replace ( StringTools . toWhitespaceFlexibleRegex ( oldContent [ i ] ) , newContent [ i ] ) ;
}
}
2025-12-05 18:39:01 +00:00
2025-12-05 23:02:55 +00:00
return this . proposeChange ( file . name , file . name , currentContent , updatedContent , requiresConfirmation , async ( ) = > {
await this . vault . process ( file , ( ) = > updatedContent ) ;
2025-12-05 18:39:01 +00:00
return file ;
} ) ;
}
2025-12-04 23:04:20 +00:00
public async delete ( file : TAbstractFile , allowAccessToPluginRoot : boolean = false , requiresConfirmation : boolean = true ) : Promise < void | Error > {
2025-11-17 19:02:15 +00:00
const filePath = this . sanitiserService . sanitize ( file . path ) ;
2026-04-08 22:08:21 +00:00
const isFile = file instanceof TFile ;
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to delete a ${ isFile ? "file" : "folder" } that is in the exclusions list: ${ filePath } ` )
return isFile ? Exception . new ( ` File does not exist: ${ filePath } GOT ` )
: Exception . new ( ` Deletion failed. The folder or its contents may be protected: ${ filePath } ` ) ;
2025-10-16 21:58:30 +00:00
}
2025-12-04 23:04:20 +00:00
// handle file deletion
2026-04-08 22:08:21 +00:00
if ( isFile ) {
2025-12-16 20:50:28 +00:00
const currentContent = await this . read ( file , allowAccessToPluginRoot )
if ( currentContent instanceof Error ) {
return currentContent ;
}
return this . proposeChange ( file . name , file . name , currentContent , "" , requiresConfirmation , async ( ) = > {
await this . fileManager . trashFile ( file ) ;
2025-12-04 23:04:20 +00:00
} ) ;
}
// handle folder deletion
2025-10-16 21:58:30 +00:00
try {
2025-11-10 13:44:16 +00:00
await this . fileManager . trashFile ( file ) ;
2025-10-16 21:58:30 +00:00
} catch ( error ) {
2025-11-17 19:02:15 +00:00
Exception . log ( error ) ;
return Exception . new ( error ) ;
2025-10-16 21:58:30 +00:00
}
}
2025-11-17 19:02:15 +00:00
public async move ( sourcePath : string , destinationPath : string , allowAccessToPluginRoot : boolean = false ) : Promise < void | Error > {
2025-10-24 16:25:19 +00:00
sourcePath = this . sanitiserService . sanitize ( sourcePath ) ;
destinationPath = this . sanitiserService . sanitize ( destinationPath ) ;
2025-11-17 19:02:15 +00:00
const file = this . getAbstractFileByPath ( sourcePath , allowAccessToPluginRoot ) ;
2025-12-16 20:50:28 +00:00
2025-10-16 21:58:30 +00:00
if ( file === null ) {
2026-04-09 21:20:37 +00:00
return Exception . new ( ` Move failed as source does not exist: ${ sourcePath } ` ) ;
2025-10-16 21:58:30 +00:00
}
2026-04-09 21:20:37 +00:00
const isFile = file instanceof TFile ;
2025-12-10 21:27:58 +00:00
if ( this . isExclusion ( destinationPath , allowAccessToPluginRoot ) ) {
return Exception . new ( ` Failed to rename " ${ sourcePath } " to " ${ destinationPath } ", permission denied. ` )
}
2025-10-16 21:58:30 +00:00
try {
2026-04-09 21:20:37 +00:00
if ( isFile ) {
await this . createDirectories ( destinationPath , allowAccessToPluginRoot ) ;
} else {
const parentPath = destinationPath . substring ( 0 , destinationPath . lastIndexOf ( "/" ) ) ;
if ( parentPath ) await this . createDirectories ( parentPath , allowAccessToPluginRoot ) ;
}
2025-10-16 21:58:30 +00:00
await this . fileManager . renameFile ( file , destinationPath ) ;
} catch ( error ) {
2025-11-17 19:02:15 +00:00
Exception . log ( error ) ;
return Exception . new ( error ) ;
2025-10-12 20:26:01 +00:00
}
}
2026-02-15 22:46:25 +00:00
public async createBinary ( filePath : string , data : ArrayBuffer , allowAccessToPluginRoot : boolean = false ) : Promise < TFile | Error > {
filePath = this . sanitiserService . sanitize ( filePath ) ;
if ( this . isExclusion ( filePath , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to create a binary file that is in the exclusion list: ${ filePath } ` ) ;
return Exception . new ( ` Failed to create file, permission denied: ${ filePath } ` ) ;
}
try {
await this . createDirectories ( filePath , allowAccessToPluginRoot ) ;
return await this . vault . createBinary ( filePath , data ) ;
} catch ( error ) {
Exception . log ( error ) ;
return Exception . new ( error ) ;
}
}
public async modifyBinary ( file : TFile , data : ArrayBuffer , allowAccessToPluginRoot : boolean = false ) : Promise < TFile | Error > {
const filePath = this . sanitiserService . sanitize ( file . path ) ;
if ( this . isExclusion ( file . path , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to modify a binary file that is in the exclusion list: ${ filePath } ` ) ;
return Exception . new ( ` File does not exist: ${ filePath } ` ) ;
}
try {
await this . vault . modifyBinary ( file , data ) ;
return file ;
} catch ( error ) {
Exception . log ( error ) ;
return Exception . new ( error ) ;
}
}
2025-11-01 11:43:32 +00:00
public async listDirectoryContents ( path : string , recursive : boolean = true , allowAccessToPluginRoot : boolean = false ) : Promise < TAbstractFile [ ] > {
2025-11-17 19:02:15 +00:00
path = this . sanitiserService . sanitize ( path ) ;
2025-10-25 11:46:07 +00:00
2025-11-17 19:02:15 +00:00
const files = await this . listFilesInDirectory ( path , recursive , allowAccessToPluginRoot ) ;
const folders = await this . listFoldersInDirectory ( path , recursive , allowAccessToPluginRoot ) ;
2025-10-25 11:46:07 +00:00
return [ . . . files , . . . folders ] as TAbstractFile [ ] ;
}
2025-11-01 11:43:32 +00:00
public async listFilesInDirectory ( path : string , recursive : boolean = true , allowAccessToPluginRoot : boolean = false ) : Promise < TFile [ ] > {
2025-11-17 19:02:15 +00:00
path = this . sanitiserService . sanitize ( path ) ;
const dir : TAbstractFile | null = this . getAbstractFileByPath ( path , allowAccessToPluginRoot ) ;
2025-10-12 22:11:36 +00:00
if ( dir == null || ! ( dir instanceof TFolder ) ) {
return [ ] ;
}
let files : TFile [ ] = [ ] ;
2025-11-10 11:14:06 +00:00
for ( const child of dir . children ) {
2025-10-12 22:11:36 +00:00
if ( child instanceof TFile ) {
2025-11-01 11:43:32 +00:00
if ( ! this . isExclusion ( child . path , allowAccessToPluginRoot ) ) {
files . push ( child ) ;
}
2025-10-12 22:11:36 +00:00
} else if ( child instanceof TFolder && recursive ) {
2025-11-01 11:43:32 +00:00
if ( ! this . isExclusion ( child . path , allowAccessToPluginRoot ) ) {
const childFiles = await this . listFilesInDirectory ( child . path , recursive , allowAccessToPluginRoot ) ;
files = files . concat ( childFiles ) ;
}
2025-10-12 22:11:36 +00:00
}
}
2025-11-01 11:43:32 +00:00
return files ;
2025-10-12 22:11:36 +00:00
}
2025-11-01 11:43:32 +00:00
public async listFoldersInDirectory ( path : string , recursive : boolean = true , allowAccessToPluginRoot : boolean = false ) : Promise < TFolder [ ] > {
2025-11-17 19:02:15 +00:00
path = this . sanitiserService . sanitize ( path ) ;
const dir : TAbstractFile | null = this . getAbstractFileByPath ( path , allowAccessToPluginRoot ) ;
2025-11-01 11:43:32 +00:00
if ( dir == null || ! ( dir instanceof TFolder ) ) {
return [ ] ;
}
let folders : TFolder [ ] = [ ] ;
2025-11-10 11:14:06 +00:00
for ( const child of dir . children ) {
2025-11-01 11:43:32 +00:00
if ( ! ( child instanceof TFolder ) ) {
continue ;
}
if ( ! this . isExclusion ( child . path , allowAccessToPluginRoot ) ) {
folders . push ( child ) ;
if ( recursive ) {
const childFolders = await this . listFoldersInDirectory ( child . path , recursive , allowAccessToPluginRoot ) ;
folders = folders . concat ( childFolders ) ;
}
}
}
return folders ;
}
public async searchVaultFiles ( searchTerm : string , allowAccessToPluginRoot : boolean = false ) : Promise < ISearchMatch [ ] > {
2025-11-12 21:12:36 +00:00
if ( searchTerm . trim ( ) === "" ) {
2025-11-12 21:49:16 +00:00
return [ ] ;
2025-11-12 21:12:36 +00:00
}
2025-11-12 21:49:16 +00:00
2025-11-12 21:12:36 +00:00
// Always ensure 'g' flag is present for extractSnippets to work correctly
// (regex.exec in a loop requires 'g' flag to advance, otherwise infinite loop)
const regex = StringTools . asRegex ( searchTerm , [ "i" , "g" ] ) ;
if ( regex === null ) {
2025-11-12 21:49:16 +00:00
return [ ] ;
2025-10-14 17:54:16 +00:00
}
2025-11-01 11:43:32 +00:00
const files : TFile [ ] = await this . listFilesInDirectory ( Path . Root , true , allowAccessToPluginRoot ) ;
2025-10-14 17:54:16 +00:00
2025-11-12 21:49:16 +00:00
// Randomize file order to ensure varied results across multiple searches
const shuffledFiles = shuffleArray ( files ) ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
// Process files in parallel batches with early termination
const BATCH_SIZE = 100 ;
const MAX_MATCHES = this . settingsService . settings . searchResultsLimit * 3 ;
const allMatches : ISearchMatch [ ] = [ ] ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
for ( let i = 0 ; i < shuffledFiles . length ; i += BATCH_SIZE ) {
// Early termination: stop if we have enough matches
if ( allMatches . length >= MAX_MATCHES ) {
break ;
2025-10-14 18:50:45 +00:00
}
2025-11-12 21:49:16 +00:00
const batch = shuffledFiles . slice ( i , i + BATCH_SIZE ) ;
const batchPromises = batch . map ( async ( file ) = > {
try {
2025-12-16 20:50:28 +00:00
let content ;
2026-03-15 19:48:28 +00:00
const fileExtension = file . extension . toLocaleLowerCase ( ) ;
if ( isFileType ( fileExtension , FileType . PDF ) ) {
2025-12-16 20:50:28 +00:00
const arrayBuffer = await this . vault . readBinary ( file ) ;
2025-12-20 14:48:42 +00:00
content = await readPDF ( arrayBuffer ) ;
2026-03-15 19:48:28 +00:00
} else if ( isDocumentFile ( fileExtension ) ) {
const arrayBuffer = await this . vault . readBinary ( file ) ;
2026-06-28 14:08:50 +00:00
content = readDocument ( arrayBuffer , fileExtension ) ;
2025-12-16 20:50:28 +00:00
} else {
2025-12-20 14:48:42 +00:00
content = [ { text : await this . vault . cachedRead ( file ) , pageNumber : 1 } ] as IPageText [ ] ;
2025-12-16 20:50:28 +00:00
}
2025-11-12 21:49:16 +00:00
const snippets = this . extractSnippets ( content , regex ) ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
// Check filename match
2026-01-27 20:29:20 +00:00
const hasFilenameMatch = file . basename . match ( regex ) !== null || file . name . match ( regex ) !== null ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
// Return match if content has snippets OR filename matches
if ( snippets . length > 0 || hasFilenameMatch ) {
return { file , snippets } ;
}
return null ;
} catch ( error ) {
2025-12-06 14:09:46 +00:00
Exception . log ( error ) ;
2025-11-12 21:49:16 +00:00
return null ;
}
} ) ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
const batchResults = await Promise . all ( batchPromises ) ;
for ( const result of batchResults ) {
if ( result !== null ) {
allMatches . push ( result ) ;
}
2025-10-15 19:13:14 +00:00
}
}
2025-11-12 21:49:16 +00:00
// Sample files if we have more than the limit
let selectedMatches : ISearchMatch [ ] ;
if ( allMatches . length > this . settingsService . settings . searchResultsLimit ) {
selectedMatches = randomSample ( allMatches , this . settingsService . settings . searchResultsLimit ) ;
} else {
selectedMatches = allMatches ;
}
return selectedMatches ;
2025-10-14 18:50:45 +00:00
}
2025-10-31 15:03:40 +00:00
public isExclusion ( filePath : string , allowAccessToPluginRoot : boolean = false ) : boolean {
const exclusions = allowAccessToPluginRoot
2025-11-03 17:14:32 +00:00
? this . settingsService . settings . exclusions
: [ this . AGENT_ROOT_DIR , this . AGENT_ROOT_CONTENTS , . . . this . settingsService . settings . exclusions ] ;
2025-10-31 15:03:40 +00:00
return exclusions . some ( pattern = > {
if ( filePath === pattern ) {
return true ;
}
// First, temporarily replace wildcards to protect them from escaping
let regexPattern = pattern
. replace ( /\*\*/g , "::DOUBLESTAR::" ) // Temporarily replace **
. replace ( /\*/g , "::SINGLESTAR::" ) // Temporarily replace *
. replace ( /[.+?^${}()|[\]\\]/g , "\\$&" ) // Escape special regex chars
. replace ( /::SINGLESTAR::/g , "[^/]*" ) // * matches anything except /
. replace ( /::DOUBLESTAR::/g , ".*" ) ; // ** matches anything including /
// If pattern ends with /, match the directory and all its contents
if ( pattern . endsWith ( "/" ) ) {
regexPattern = regexPattern + ".*" ;
}
// Add anchors for full path matching
const regex = new RegExp ( "^" + regexPattern + "(/.*)?$" ) ;
return regex . test ( filePath ) ;
} ) ;
}
2026-04-06 17:07:59 +00:00
public async createDirectories ( filePath : string , allowAccessToPluginRoot : boolean = false ) : Promise < void | Error > {
const dirPath : string = path . extname ( filePath )
? filePath . substring ( 0 , filePath . lastIndexOf ( "/" ) )
: filePath ;
2025-10-16 21:58:30 +00:00
2025-10-30 08:34:11 +00:00
const dirs : string [ ] = dirPath . split ( "/" ) ;
2025-10-16 21:58:30 +00:00
let currentPath = "" ;
2025-11-17 19:02:15 +00:00
const failures : string [ ] = [ ] ;
2025-10-16 21:58:30 +00:00
for ( const dir of dirs ) {
if ( dir ) {
currentPath = currentPath ? ` ${ currentPath } / ${ dir } ` : dir ;
2026-04-06 17:07:59 +00:00
if ( ! ( await this . exists ( currentPath , allowAccessToPluginRoot ) ) ) {
const result = await this . createDirectory ( currentPath , allowAccessToPluginRoot ) ;
if ( result instanceof Error ) {
failures . push ( currentPath ) ;
2025-11-17 19:02:15 +00:00
}
2025-10-16 21:58:30 +00:00
}
}
}
2025-11-17 19:02:15 +00:00
if ( failures . length > 0 ) {
return Exception . new ( ` Failed to create the following directories: ${ String ( failures ) } ` ) ;
}
2025-10-16 21:58:30 +00:00
}
2026-04-06 17:07:59 +00:00
private async createDirectory ( path : string , allowAccessToPluginRoot : boolean = false ) : Promise < TFolder | Error > {
path = this . sanitiserService . sanitize ( path ) ;
if ( this . isExclusion ( path , allowAccessToPluginRoot ) ) {
Exception . log ( ` Plugin attempted to create a folder that is in the exclusion list: ${ path } ` ) ;
return Exception . new ( ` Failed to create folder, permission denied: ${ path } ` ) ;
}
return await this . vault . createFolder ( path ) ;
}
2025-12-20 14:48:42 +00:00
private extractSnippets ( pages : IPageText [ ] , regex : RegExp ) : ISearchSnippet [ ] {
const allSnippets : ISearchSnippet [ ] = [ ] ;
2025-10-14 18:50:45 +00:00
2025-12-20 14:48:42 +00:00
for ( const page of pages ) {
const matchPositions : { matchIndex : number ; matchLength : number } [ ] = [ ] ;
let match : RegExpExecArray | null ;
2025-10-14 18:50:45 +00:00
2025-12-20 14:48:42 +00:00
// First pass: collect all match positions without extracting text
while ( ( match = regex . exec ( page . text ) ) !== null ) {
matchPositions . push ( {
matchIndex : match.index ,
matchLength : match [ 0 ] . length
} ) ;
}
2025-10-14 18:50:45 +00:00
2025-12-20 14:48:42 +00:00
regex . lastIndex = 0 ;
2025-10-14 18:50:45 +00:00
2025-12-20 14:48:42 +00:00
if ( matchPositions . length > 0 ) {
// Second pass: merge overlapping positions and extract text only once per snippet
const pageSnippets = this . mergeOverlappingSnippets ( matchPositions , page . text , page . pageNumber ) ;
allSnippets . push ( . . . pageSnippets ) ;
}
2025-11-12 21:49:16 +00:00
}
2025-12-20 14:48:42 +00:00
return allSnippets ;
2025-10-14 18:50:45 +00:00
}
2025-12-20 14:48:42 +00:00
private mergeOverlappingSnippets ( matchPositions : { matchIndex : number ; matchLength : number } [ ] , content : string , pageNumber : number
2025-11-12 21:49:16 +00:00
) : ISearchSnippet [ ] {
if ( matchPositions . length === 0 ) return [ ] ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
// Sort by match position
matchPositions . sort ( ( a , b ) = > a . matchIndex - b . matchIndex ) ;
2025-10-14 18:50:45 +00:00
2025-11-03 20:46:23 +00:00
const snippetSize = this . settingsService . settings . snippetSizeLimit / 2 ;
2025-11-12 21:49:16 +00:00
const merged : ISearchSnippet [ ] = [ ] ;
let current = matchPositions [ 0 ] ;
2025-10-14 18:50:45 +00:00
2025-11-12 21:49:16 +00:00
for ( let i = 1 ; i < matchPositions . length ; i ++ ) {
const next = matchPositions [ i ] ;
2025-10-14 18:50:45 +00:00
2025-11-03 20:46:23 +00:00
const currentEnd = Math . min ( content . length , current . matchIndex + current . matchLength + snippetSize ) ;
const nextStart = Math . max ( 0 , next . matchIndex - snippetSize ) ;
2025-10-14 18:50:45 +00:00
if ( nextStart <= currentEnd ) {
2025-11-12 21:49:16 +00:00
// Merge overlapping matches
2025-10-14 18:50:45 +00:00
current = {
2025-10-14 18:54:17 +00:00
matchIndex : current.matchIndex ,
matchLength : next.matchIndex + next . matchLength - current . matchIndex
2025-10-14 18:50:45 +00:00
} ;
} else {
2025-11-12 21:49:16 +00:00
// Extract text only for finalized (non-overlapping) snippet
const snippetStart = Math . max ( 0 , current . matchIndex - snippetSize ) ;
const snippetEnd = Math . min ( content . length , current . matchIndex + current . matchLength + snippetSize ) ;
merged . push ( {
text : content.substring ( snippetStart , snippetEnd ) ,
matchIndex : current.matchIndex ,
2025-12-20 14:48:42 +00:00
matchLength : current.matchLength ,
pageNumber : pageNumber
2025-11-12 21:49:16 +00:00
} ) ;
2025-10-14 18:50:45 +00:00
current = next ;
}
}
2025-11-12 21:49:16 +00:00
// Extract text for the last snippet
const snippetStart = Math . max ( 0 , current . matchIndex - snippetSize ) ;
const snippetEnd = Math . min ( content . length , current . matchIndex + current . matchLength + snippetSize ) ;
merged . push ( {
text : content.substring ( snippetStart , snippetEnd ) ,
matchIndex : current.matchIndex ,
2025-12-20 14:48:42 +00:00
matchLength : current.matchLength ,
pageNumber : pageNumber
2025-11-12 21:49:16 +00:00
} ) ;
2025-10-14 18:50:45 +00:00
return merged ;
}
2025-11-24 21:29:54 +00:00
private async proposeChange < T > ( oldFileName : string , newFileName : string , oldContent : string , newContent : string ,
requiresConfirmation : boolean = true , performChange : ( ) = > Promise < T > ) : Promise < T | Error > {
try {
2026-07-06 18:05:19 +00:00
const result = this . settingsService . settings . freeEdit || ! requiresConfirmation ? { accepted : true } :
await this . diffService . requestDiff ( oldFileName , newFileName , oldContent , newContent ) ;
2025-11-24 21:29:54 +00:00
if ( result . accepted ) {
return await performChange ( ) ;
}
2026-01-30 22:35:54 +00:00
let response = AIToolResponse . UserRejectionMessage ;
2025-11-24 21:29:54 +00:00
if ( result . suggestion ) {
2026-01-30 22:35:54 +00:00
response = ` ${ AIToolResponse . UserSuggestionMessage } \ n ${ result . suggestion } ` ;
2025-11-24 21:29:54 +00:00
}
return Exception . new ( response ) ;
} catch ( error ) {
2025-12-04 23:04:20 +00:00
if ( AbortService . isAbortError ( error ) ) {
throw error ;
}
2025-11-24 21:29:54 +00:00
this . eventService . trigger ( Event . DiffClosed ) ;
Exception . log ( error ) ;
2025-12-04 23:04:20 +00:00
2025-11-24 21:29:54 +00:00
return Exception . new ( error ) ;
}
}
2025-10-12 20:26:01 +00:00
}