diff --git a/AIAgentSettingTab.ts b/AIAgentSettingTab.ts index 470d221..d98fd78 100644 --- a/AIAgentSettingTab.ts +++ b/AIAgentSettingTab.ts @@ -153,7 +153,7 @@ export class AIAgentSettingTab extends PluginSettingTab { .setName("AI File Exclusions") .setDesc("Set which directories and files the AI should ignore. Enter one path per line - supports glob patterns like folder/**, *.md") .addTextArea(text => { - text.setPlaceholder(`Examples:\n\n${Path.UserInstruction}\n${Path.Conversations}/*.json\nPrivateNotes/**`) + text.setPlaceholder(`Examples:\n\n${Path.Conversations}/*.json\nPrivateNotes/**`) .setValue(this.plugin.settings.exclusions.join("\n")) .onChange(async (value) => { this.plugin.settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0); diff --git a/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts b/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts index 1c48ba2..9922bc1 100644 --- a/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts +++ b/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts @@ -4,24 +4,28 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; export const SearchVaultFiles: IAIFunctionDefinition = { name: AIFunction.SearchVaultFiles, description: `Searches the content of all vault files using regex pattern matching. - Returns files containing the search term with contextual snippets showing where matches appear. + Returns files containing any of the search terms with contextual snippets showing where matches appear. Use this function when you need to: - Find specific concepts, keywords, or text within note contents - - Locate content matching a pattern or phrase + - Locate content matching multiple patterns or phrases - Answer questions about what the user has written about a topic - - Search across both file names and file contents simultaneously`, + - Search across both file names and file contents simultaneously + - Search for multiple related terms or variations in a single query`, parameters: { type: "object", properties: { - search_term: { - type: "string", - description: `The regex pattern to search for in vault files. Supports both simple text searches (e.g., 'meeting notes', 'project alpha') and advanced regex patterns (e.g., '(urgent|important)', '\\d{4}-\\d{2}-\\d{2}' for dates). The search is case-insensitive and performed on both file names and content. Use empty string "" to return all vault files.` + search_terms: { + type: "array", + items: { + type: "string" + }, + description: `Array of regex patterns to search for in vault files. Each pattern supports both simple text searches (e.g., 'meeting notes', 'project alpha') and advanced regex patterns (e.g., '(urgent|important)', '\\d{4}-\\d{2}-\\d{2}' for dates). Files matching ANY of the search terms will be returned (OR logic). The search is performed on both file names and content. Examples: ['meeting', 'project'] or ['TODO', 'FIXME', 'urgent'] or ['\\d{4}-\\d{2}-\\d{2}', 'deadline']. Use empty array [] to return all vault files.`, }, user_message: { type: "string", description: "A short message to be displayed to the user explaining what is being searched for. Example: 'Searching for notes about project meetings' or 'Finding files containing todo items'" } }, - required: ["search_term", "user_message"] + required: ["search_terms", "user_message"] } } \ No newline at end of file diff --git a/AIClasses/IPrompt.ts b/AIClasses/IPrompt.ts index f8b2ffe..8cee851 100644 --- a/AIClasses/IPrompt.ts +++ b/AIClasses/IPrompt.ts @@ -1,9 +1,8 @@ import type AIAgentPlugin from "main"; -import type { TFile, Vault } from "obsidian"; import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import { SystemInstruction } from "./SystemPrompt"; -import { Path } from "Enums/Path"; +import type { FileSystemService } from "Services/FileSystemService"; export interface IPrompt { systemInstruction(): string; @@ -12,10 +11,12 @@ export interface IPrompt { export class AIPrompt implements IPrompt { - private vault: Vault; + private readonly plugin: AIAgentPlugin; + private readonly fileSystemService: FileSystemService; public constructor() { - this.vault = Resolve(Services.AIAgentPlugin).app.vault; + this.plugin = Resolve(Services.AIAgentPlugin); + this.fileSystemService = Resolve(Services.FileSystemService); } public systemInstruction(): string { @@ -23,7 +24,7 @@ export class AIPrompt implements IPrompt { } public async userInstruction(): Promise { - const userInstruction: TFile | null = this.vault.getFileByPath(Path.UserInstruction); - return userInstruction ? await this.vault.read(userInstruction) : ""; + const userInstruction: string | null = await this.fileSystemService.readFile(this.plugin.settings.userInstruction, true); + return userInstruction ?? ""; } } \ No newline at end of file diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 725cc8b..f572b0c 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -9,6 +9,7 @@ import ChatSearchResults from "./ChatSearchResults.svelte"; import type { Writable } from "svelte/store"; import type { InputService } from "Services/InputService"; + import UserInstruction from "./UserInstruction.svelte"; export let hasNoApiKey: boolean; export let isSubmitting: boolean; @@ -24,8 +25,11 @@ const searchState: Writable = searchStateStore.searchState; let textareaElement: HTMLDivElement; + let userInstructionButton: HTMLButtonElement; let submitButton: HTMLButtonElement; let editModeButton: HTMLButtonElement; + + let userInstructionActive = false; let userRequest = ""; export function focusInput() { @@ -34,6 +38,10 @@ }); } + $: if (userInstructionButton) { + setIcon(userInstructionButton, "user-round-pen"); + } + $: if (submitButton) { setIcon(submitButton, isSubmitting ? "square" : "send-horizontal"); } @@ -65,6 +73,7 @@ } async function handleKeydown(e: KeyboardEvent) { + userInstructionActive = false; if ($searchState.active) { await continueSearch(e); return; @@ -124,13 +133,7 @@ if (e.key === "Enter") { e.preventDefault(); - if ($searchState.selectedResult !== "" && $searchState.position != null) { - const node = SearchTrigger.toNode($searchState.trigger, $searchState.selectedResult); - - inputService.deleteTextRange($searchState.position, inputService.getCursorPosition(textareaElement), textareaElement); - inputService.insertElementAtCursor(node, textareaElement); - } - searchStateStore.resetSearch(); + handleSearchResultAcceptance(); return; } @@ -157,6 +160,16 @@ } } + function handleSearchResultAcceptance() { + if ($searchState.selectedResult !== "" && $searchState.position != null && $searchState.trigger != null) { + const node = SearchTrigger.toNode($searchState.trigger, $searchState.selectedResult); + + inputService.deleteTextRange($searchState.position, inputService.getCursorPosition(textareaElement), textareaElement); + inputService.insertElementAtCursor(node, textareaElement); + } + searchStateStore.resetSearch(); + } + function handleInput() { if (textareaElement) { userRequest = textareaElement.textContent || ""; @@ -242,9 +255,20 @@
0 ? "var(--size-4-2)" : 0}> - +
+
+ +
+ + +
import { basename } from 'path'; - import type { ISearchState } from 'Stores/SearchStateStore'; + import type { ISearchState, SearchStateStore } from 'Stores/SearchStateStore'; import { tick } from 'svelte'; import { setIcon } from 'obsidian'; import { SearchTrigger } from 'Enums/SearchTrigger'; + import { Resolve } from 'Services/DependencyService'; + import { Services } from 'Services/Services'; export let searchState: ISearchState; + export let onResultAccept: () => void; + + const searchStateStore: SearchStateStore = Resolve(Services.SearchStateStore); - let contentDiv: HTMLDivElement; let height = 0; + let contentDiv: HTMLDivElement; let resultElements: (HTMLDivElement | null)[] = []; let iconElements: (HTMLDivElement | null)[] = []; @@ -64,11 +69,17 @@
-
+
{#each searchState.results as searchResult, index}
+ style:background-color={searchResult === searchState.selectedResult ? "var(--interactive-accent)" : "transparent"} + on:mouseenter={() => searchStateStore.setSelectedResult(index)} + on:click={onResultAccept} + on:keydown={() => {}}>
{basename(searchResult)}
{searchResult}
@@ -104,6 +115,9 @@ border-color: var(--background-primary-alt); border-width: 1px; padding: var(--size-2-2) var(--size-4-2); + cursor: pointer; + position: relative; + z-index: 10; } .input-search-result-icon { @@ -113,18 +127,21 @@ align-items: center; justify-content: center; padding-right: var(--size-4-2); + pointer-events: none; } .input-search-result-title { grid-row: 1; grid-column: 2; font-family: var(--font-interface-theme); + pointer-events: none; } .input-search-result-subtitle { grid-row: 2; grid-column: 2; font-family: var(--font-interface-theme); + pointer-events: none; font-size: var(--font-smallest); color: var(--text-muted); } diff --git a/Components/UserInstruction.svelte b/Components/UserInstruction.svelte new file mode 100644 index 0000000..d972124 --- /dev/null +++ b/Components/UserInstruction.svelte @@ -0,0 +1,190 @@ + + +
+ {#if userInstructionActive} + {#if userInstructions.length === 0} +
+
+
+ + {Copy.UserInstructions1} + + {Copy.UserInstructions2} + + {Copy.UserInstructions3} + +
+
+
+ {/if} + {#if userInstructions.length > 0} +
+ {#each userInstructions as userInstruction, index} +
selectedInstruction = index} + on:click={handleInstructionSelect} + on:keydown={() => {}}> +
{basename(userInstruction)}
+
{userInstruction}
+
+ {/each} +
+ {/if} + {/if} +
+ + \ No newline at end of file diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 0470425..e7d57bb 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -1,5 +1,8 @@ export enum Copy { ApiRequestAborted = "Request has been cancelled", + UserInstructions1 = "You can create custom ", + UserInstructions2 = "instructions", + UserInstructions3 = " that the AI will follow.", // Model display names ClaudeSonnet_4_5 = "Claude Sonnet 4.5", @@ -22,4 +25,264 @@ export enum Copy { GPT_4_1 = "GPT-4.1", GPT_4_1_Mini = "GPT-4.1 Mini", GPT_4_1_Nano = "GPT-4.1 Nano", + + EXAMPLE_USER_INSTRUCTION = `### TL;DR + +**My recommendation would be to write down in your own words what you would like the AI to specialise in and how you would like it to manage your vault. Then ask an AI to write a system prompt using the latest best practices from your description.** + +--- + +# System Prompt Template for LLMs (2025) + +A clear, structured system prompt template following the latest best practices for effective LLM interactions. + +--- + +## Template Structure + +### 1. Role & Identity +**Define who the AI should be.** + +\`\`\` +You are [role/persona with specific expertise]. +\`\`\` + +**Example:** +\`\`\` +You are an experienced technical writer who specializes in creating clear documentation for software developers. +\`\`\` + +**Why this matters:** Role-playing guides the model's tone and depth, helping it understand the appropriate level of expertise and communication style. + +--- + +### 2. Core Objective +**State the primary purpose clearly and directly.** + +\`\`\` +Your main goal is to [specific objective]. +\`\`\` + +**Example:** +\`\`\` +Your main goal is to help users write bug-free Python code by providing clear explanations and suggesting best practices. +\`\`\` + +**Why this matters:** Being specific and concise helps the model understand exactly what you want without overloading it with unnecessary information. + +--- + +### 3. Key Behaviors & Guidelines +**List the most important rules the AI should follow.** + +\`\`\` +Always: +- [Behavior 1] +- [Behavior 2] +- [Behavior 3] + +Never: +- [Restriction 1] +- [Restriction 2] +\`\`\` + +**Example:** +\`\`\` +Always: +- Explain concepts in simple terms before diving into technical details +- Provide working code examples when suggesting solutions +- Ask clarifying questions when requirements are ambiguous + +Never: +- Make assumptions about the user's skill level without asking +- Suggest deprecated or insecure coding practices +- Provide code without explaining what it does +\`\`\` + +**Why this matters:** Clear instructions with both positive and negative examples help establish consistent response patterns. + +--- + +### 4. Output Format (Optional) +**Specify how responses should be structured.** + +\`\`\` +Format your responses as follows: +[structure description] +\`\`\` + +**Example:** +\`\`\` +Format your responses as follows: +1. Brief summary (1-2 sentences) +2. Detailed explanation +3. Code example (if applicable) +4. Common pitfalls to avoid +\`\`\` + +**Why this matters:** Defining the expected format helps the model stay focused and produces outputs that are easier to read and use. + +--- + +### 5. Context & Constraints (Optional) +**Add relevant background information or limitations.** + +\`\`\` +Context: [relevant background] +Constraints: [specific limitations] +\`\`\` + +**Example:** +\`\`\` +Context: You're helping developers who are migrating from Python 2 to Python 3. +Constraints: +- Keep responses under 500 words +- Focus only on Python 3.8+ features +- Assume users have basic Python knowledge +\`\`\` + +**Why this matters:** Providing context ensures relevance while constraints prevent the model from being too verbose or off-topic. + +--- + +### 6. Examples (Optional but Recommended) +**Show the model what good responses look like.** + +\`\`\` +Example interaction: +User: [example input] +Assistant: [example output] +\`\`\` + +**Example:** +\`\`\` +Example interaction: +User: How do I read a CSV file in Python? +Assistant: Here's the most common approach using the pandas library: + +import pandas as pd +df = pd.read_csv('file.csv') + +This reads the CSV into a DataFrame, which makes it easy to analyze and manipulate the data. If you don't have pandas installed, use: pip install pandas +\`\`\` + +**Why this matters:** Examples anchor model behavior more effectively than descriptions alone, establishing clear patterns for responses. + +--- + +### 7. Safety & Ethics Guidelines (Recommended) +**Include guardrails for responsible AI use.** + +\`\`\` +Safety guidelines: +- [Ethical principle 1] +- [Ethical principle 2] +\`\`\` + +**Example:** +\`\`\` +Safety guidelines: +- Never provide code that could be used for malicious purposes +- Decline requests that violate privacy or security best practices +- If you're uncertain about something, say so clearly rather than guessing +\`\`\` + +**Why this matters:** Prompt scaffolding with safety logic helps limit the model's ability to produce harmful outputs, even when facing adversarial input. + +--- + +## Complete Example + +Here's a full system prompt using the template: + +\`\`\` +You are a friendly Python tutor who helps beginners learn programming through clear explanations and hands-on examples. + +Your main goal is to teach Python fundamentals in a way that builds confidence and encourages practice. + +Always: +- Break down complex concepts into simple, digestible steps +- Use real-world analogies to explain abstract ideas +- Provide runnable code examples that users can test immediately +- Encourage questions and celebrate progress +- Check for understanding before moving to advanced topics + +Never: +- Assume the user knows jargon without explanation +- Skip error handling in code examples +- Make the user feel bad for not understanding something + +Format your responses as follows: +1. Concept explanation in plain English +2. Code example with comments +3. What happens when you run it +4. Try it yourself suggestion + +Context: You're helping complete beginners who may have never programmed before. +Constraints: Keep explanations under 300 words per concept. + +Example interaction: +User: What's a variable? +Assistant: A variable is like a labeled box where you store information. You give it a name, and Python remembers what's inside. + +# Create a variable +age = 25 + +Here we created a variable called "age" and put the number 25 in it. Now whenever we use "age" in our code, Python knows we mean 25. + +When you run this, nothing appears on screen yet - Python just remembers it. To see what's inside, use print(age). + +Try it yourself: Create a variable called "name" and store your name in it using quotes, like name = "Alex" + +Safety guidelines: +- Never suggest downloading packages from untrusted sources +- If a user asks about something potentially harmful, explain why it's risky instead +\`\`\` + +--- + +## Quick Tips for Writing System Prompts + +1. **Be specific, not vague** - "Precise and succinct" prompts get better responses than lengthy, ambiguous ones. + +2. **Test and iterate** - Prompt engineering is an iterative process. Test your prompt, observe the outputs, and refine based on results. + +3. **Use natural language** - Write like you're briefing a smart colleague, not programming a computer. + +4. **Don't overload** - Avoid cramming too many instructions into one prompt. Break complex tasks into simpler parts. + +5. **Consider your model** - Different models respond better to different structures (e.g., GPT-4 likes clear formatting, Claude prefers declarative phrasing). + +6. **Version control matters** - Track prompt versions so you can compare performance and roll back if needed. + +--- + +## Variables for Dynamic Prompts + +For reusable templates, use variables for content that changes: + +\`\`\` +User's question: {{user_question}} +User's experience level: {{experience_level}} +Preferred programming language: {{language}} +\`\`\` + +**Why this matters:** Variables make prompts flexible and reusable across different contexts without rewriting the entire prompt. + +--- + +## Common Mistakes to Avoid + +❌ **Too vague:** "Help the user with code" +✅ **Specific:** "Help the user debug Python errors by identifying the issue, explaining why it occurred, and suggesting a fix" + +❌ **Conflicting instructions:** "Be brief but explain everything in detail" +✅ **Clear priorities:** "Provide concise summaries, with the option to elaborate if the user asks" + +❌ **No examples:** Just describing what you want +✅ **With examples:** Showing exactly what good output looks like + +--- + +**Remember:** A good system prompt is clear, dense, and easy to understand, leaving no room for misinterpretation. Start simple, test thoroughly, and refine based on real results.` } \ No newline at end of file diff --git a/Enums/Path.ts b/Enums/Path.ts index abf6496..e512008 100644 --- a/Enums/Path.ts +++ b/Enums/Path.ts @@ -1,6 +1,7 @@ export enum Path { Root = "/", AIAgentDir = "AI Agent", - UserInstruction = `${Path.AIAgentDir}/AGENT_INSTRUCTIONS.md`, - Conversations = `${Path.AIAgentDir}/Conversations` + Conversations = `${Path.AIAgentDir}/Conversations`, + UserInstructions = `${Path.AIAgentDir}/User Instructions`, + ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md` }; \ No newline at end of file diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index 0f444ea..ba2c7b8 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -14,7 +14,7 @@ export class AIFunctionService { public async performAIFunction(functionCall: AIFunctionCall): Promise { switch (functionCall.name) { case AIFunction.SearchVaultFiles: - return new AIFunctionResponse(functionCall.name, await this.searchVaultFiles(functionCall.arguments.search_term), functionCall.toolId); + return new AIFunctionResponse(functionCall.name, await this.searchVaultFiles(functionCall.arguments.search_terms), functionCall.toolId); case AIFunction.ReadVaultFiles: return new AIFunctionResponse(functionCall.name, await this.readVaultFiles(functionCall.arguments.file_paths), functionCall.toolId); @@ -46,16 +46,24 @@ export class AIFunctionService { } } - private async searchVaultFiles(searchTerm: string): Promise { - const matches: ISearchMatch[] = searchTerm.trim() === "" ? [] : await this.fileSystemService.searchVaultFiles(searchTerm); + private async searchVaultFiles(searchTerms: string[]): Promise { + let results: { searchTerm: string, results: object[] }[] = []; - return matches.map(match => ({ - path: match.file.path, - snippets: match.snippets.map((snippet) => ({ - text: snippet.text, - matchPosition: snippet.matchIndex - })) - })); + for (const searchTerm of searchTerms) { + const matches: ISearchMatch[] = searchTerm.trim() === "" ? [] : await this.fileSystemService.searchVaultFiles(searchTerm); + results.push({ + searchTerm: searchTerm, + results: matches.map(match => ({ + path: match.file.path, + snippets: match.snippets.map((snippet) => ({ + text: snippet.text, + matchPosition: snippet.matchIndex + })) + })) + }); + } + + return results; } private async readVaultFiles(filePaths: string[]): Promise { diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts index 1db5aa2..84b398b 100644 --- a/Services/ConversationNamingService.ts +++ b/Services/ConversationNamingService.ts @@ -35,7 +35,7 @@ export class ConversationNamingService { try { const generatedName: string = await this.namingProvider.generateName(userPrompt, abortController.signal); - const validatedName: string = this.validateName(generatedName); + const validatedName: string = await this.validateName(generatedName); const stillExists = this.conversationService.getCurrentConversationPath() === conversationPath; if (!stillExists) { @@ -54,12 +54,12 @@ export class ConversationNamingService { } } - private validateName(generatedName: string): string { + private async validateName(generatedName: string): Promise { let cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, "").split(/\s+/).slice(0, 6).join(" "); let index = 1; let availableTitle = cleanedTitle; - while (this.vaultService.exists(`${Path.Conversations}/${availableTitle}.json`, true)) { + while (await this.vaultService.exists(`${Path.Conversations}/${availableTitle}.json`, true)) { availableTitle = `${cleanedTitle}(${index})`; index++; diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 3ee8f02..abe90e2 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -17,7 +17,6 @@ export class VaultService { private readonly AGENT_ROOT_DIR = Path.AIAgentDir; private readonly AGENT_ROOT_CONTENTS = `${Path.AIAgentDir}/**`; - private readonly USER_INSTRUCTION = Path.UserInstruction; private readonly vault: Vault; private readonly plugin: AIAgentPlugin; @@ -52,12 +51,15 @@ export class VaultService { return this.vault.getAbstractFileByPath(filePath); } - public exists(filePath: string, allowAccessToPluginRoot: boolean = false): boolean { + public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + filePath = this.sanitiserService.sanitize(filePath); + if (this.isExclusion(filePath, allowAccessToPluginRoot)) { console.error(`Plugin attempted to access a file that is in the exclusions list: ${filePath}`); return false; } - return this.getAbstractFileByPath(filePath, allowAccessToPluginRoot) instanceof TFile; + + return await this.vault.adapter.exists(filePath); } public async read(file: TFile, allowAccessToPluginRoot: boolean = false): Promise { @@ -220,7 +222,7 @@ export class VaultService { } } - // If more than 20 matches, randomly sample 20 + // randomly sample matches if more than N matches are found let selectedMatches: { file: TFile; snippet: ISearchSnippet }[]; if (flatMatches.length > 20) { selectedMatches = randomSample(flatMatches, 20); @@ -255,9 +257,8 @@ export class VaultService { } public isExclusion(filePath: string, allowAccessToPluginRoot: boolean = false): boolean { - // the ai should never be able to edit the user instruction const exclusions = allowAccessToPluginRoot - ? [this.USER_INSTRUCTION, ...this.plugin.settings.exclusions] + ? this.plugin.settings.exclusions : [this.AGENT_ROOT_DIR, this.AGENT_ROOT_CONTENTS, ...this.plugin.settings.exclusions]; return exclusions.some(pattern => { @@ -294,7 +295,7 @@ export class VaultService { for (const dir of dirs) { if (dir) { currentPath = currentPath ? `${currentPath}/${dir}` : dir; - if (this.getAbstractFileByPath(currentPath, allowAccessToPluginRoot) == null) { + if (!(await this.exists(currentPath, allowAccessToPluginRoot))) { await this.createFolder(currentPath, allowAccessToPluginRoot); } } diff --git a/Stores/SearchStateStore.ts b/Stores/SearchStateStore.ts index 6996e46..06bafaa 100644 --- a/Stores/SearchStateStore.ts +++ b/Stores/SearchStateStore.ts @@ -53,25 +53,18 @@ export class SearchStateStore { public setResults(results: string[]) { this.searchState.update(state => ({ ...state, results })); - this.setSelectedResultToFirst(); + this.setSelectedResult(0); } - public setSelectedResultToFirst() { - this.searchState.update(state => ({ ...state, selectedResult: state.results.length > 0 ? state.results[0] : "" })); - } - - public setSelectedResultToNext() { + public setSelectedResult(index: number) { this.searchState.update(state => { - if (state.results.length === 0) { + if (index >= state.results.length) { return state; } - const currentIndex = state.results.indexOf(state.selectedResult); - const nextIndex = (currentIndex + 1) % state.results.length; - return { ...state, - selectedResult: state.results[nextIndex] + selectedResult: state.results[index] }; }); } @@ -93,6 +86,22 @@ export class SearchStateStore { }; }); } + + public setSelectedResultToNext() { + this.searchState.update(state => { + if (state.results.length === 0) { + return state; + } + + const currentIndex = state.results.indexOf(state.selectedResult); + const nextIndex = (currentIndex + 1) % state.results.length; + + return { + ...state, + selectedResult: state.results[nextIndex] + }; + }); + } public initializeSearch(trigger: SearchTrigger, position: number) { this.searchState.update(state => ({ diff --git a/__tests__/Services/AIFunctionService.test.ts b/__tests__/Services/AIFunctionService.test.ts index 99b4a02..b5307ed 100644 --- a/__tests__/Services/AIFunctionService.test.ts +++ b/__tests__/Services/AIFunctionService.test.ts @@ -80,13 +80,13 @@ describe('AIFunctionService - Integration Tests', () => { const result = await service.performAIFunction({ name: AIFunction.SearchVaultFiles, - arguments: { search_term: 'test' }, + arguments: { search_terms: ['test'] }, toolId: 'tool_1' } as any); expect(result.name).toBe(AIFunction.SearchVaultFiles); expect(result.toolId).toBe('tool_1'); - expect(result.response).toEqual([ + expect(result.response).toEqual([{searchTerm: 'test', results: [ { path: 'notes/test.md', snippets: [ @@ -100,29 +100,29 @@ describe('AIFunctionService - Integration Tests', () => { { text: 'Guide for testing', matchPosition: 0 } ] } - ]); + ]}]); }); it('should return empty array when search term is empty', async () => { const result = await service.performAIFunction({ name: AIFunction.SearchVaultFiles, - arguments: { search_term: '' }, + arguments: { search_terms: [''] }, toolId: 'tool_2' } as any); // Empty search terms return empty results - expect(result.response).toEqual([]); + expect(result.response).toEqual([{searchTerm: '', results: []}]); }); it('should return empty array when search term is whitespace', async () => { const result = await service.performAIFunction({ name: AIFunction.SearchVaultFiles, - arguments: { search_term: ' ' }, + arguments: { search_terms: [' '] }, toolId: 'tool_3' } as any); // Whitespace search terms return empty results (after trim) - expect(result.response).toEqual([]); + expect(result.response).toEqual([{searchTerm: ' ', results: []}]); }); it('should return empty array when no matches found', async () => { @@ -130,12 +130,12 @@ describe('AIFunctionService - Integration Tests', () => { const result = await service.performAIFunction({ name: AIFunction.SearchVaultFiles, - arguments: { search_term: 'nonexistent' }, + arguments: { search_terms: ['nonexistent'] }, toolId: 'tool_4' } as any); // No matches returns empty results - expect(result.response).toEqual([]); + expect(result.response).toEqual([{searchTerm: 'nonexistent', results: []}]); }); it('should handle single match', async () => { @@ -150,12 +150,14 @@ describe('AIFunctionService - Integration Tests', () => { const result = await service.performAIFunction({ name: AIFunction.SearchVaultFiles, - arguments: { search_term: 'single' }, + arguments: { search_terms: ['single'] }, toolId: 'tool_5' } as any); expect(result.response).toHaveLength(1); - expect(result.response[0].path).toBe('single.md'); + expect(result.response[0].searchTerm).toBe('single'); + expect(result.response[0].results).toHaveLength(1); + expect(result.response[0].results[0].path).toBe('single.md'); }); }); @@ -573,11 +575,11 @@ describe('AIFunctionService - Integration Tests', () => { const searchResult = await service.performAIFunction({ name: AIFunction.SearchVaultFiles, - arguments: { search_term: 'test' }, + arguments: { search_terms: ['test'] }, toolId: 'search_1' } as any); - const foundPath = searchResult.response[0].path; + const foundPath = searchResult.response[0].results[0].path; // Then read mockFileSystemService.readFile.mockResolvedValue('File content here'); diff --git a/__tests__/Services/ConversationNamingService.test.ts b/__tests__/Services/ConversationNamingService.test.ts index a262064..2fcfed9 100644 --- a/__tests__/Services/ConversationNamingService.test.ts +++ b/__tests__/Services/ConversationNamingService.test.ts @@ -60,31 +60,31 @@ describe('ConversationNamingService', () => { }); describe('validateName', () => { - it('should trim whitespace from name', () => { - const result = (service as any).validateName(' Test Title '); + it('should trim whitespace from name', async () => { + const result = await (service as any).validateName(' Test Title '); expect(result).toBe('Test Title'); }); - it('should remove leading and trailing quotes', () => { - const result1 = (service as any).validateName('"Test Title"'); + it('should remove leading and trailing quotes', async () => { + const result1 = await (service as any).validateName('"Test Title"'); expect(result1).toBe('Test Title'); - const result2 = (service as any).validateName("'Test Title'"); + const result2 = await (service as any).validateName("'Test Title'"); expect(result2).toBe('Test Title'); }); - it('should limit to 6 words', () => { - const result = (service as any).validateName('One Two Three Four Five Six Seven Eight'); + it('should limit to 6 words', async () => { + const result = await (service as any).validateName('One Two Three Four Five Six Seven Eight'); expect(result).toBe('One Two Three Four Five Six'); }); - it('should handle duplicate names with incrementing index', () => { - mockVaultService.exists.mockImplementation((path: string) => { + it('should handle duplicate names with incrementing index', async () => { + mockVaultService.exists.mockImplementation(async (path: string) => { return path === `${Path.Conversations}/Test Title.json` || path === `${Path.Conversations}/Test Title(1).json`; }); - const result = (service as any).validateName('Test Title'); + const result = await (service as any).validateName('Test Title'); expect(result).toBe('Test Title(2)'); expect(mockVaultService.exists).toHaveBeenCalledWith(`${Path.Conversations}/Test Title.json`, true); @@ -92,24 +92,24 @@ describe('ConversationNamingService', () => { expect(mockVaultService.exists).toHaveBeenCalledWith(`${Path.Conversations}/Test Title(2).json`, true); }); - it('should throw error when stack limit is reached', () => { + it('should throw error when stack limit is reached', async () => { // Make exists always return true to simulate infinite duplicates - mockVaultService.exists.mockReturnValue(true); + mockVaultService.exists.mockResolvedValue(true); - expect(() => { - (service as any).validateName('Test Title'); - }).toThrow('Stack limit reached'); + await expect(async () => { + await (service as any).validateName('Test Title'); + }).rejects.toThrow('Stack limit reached'); }); - it('should handle names with multiple spaces correctly', () => { - const result = (service as any).validateName('Test Title With Spaces'); + it('should handle names with multiple spaces correctly', async () => { + const result = await (service as any).validateName('Test Title With Spaces'); expect(result).toBe('Test Title With Spaces'); }); - it('should return unique name when no duplicates exist', () => { - mockVaultService.exists.mockReturnValue(false); + it('should return unique name when no duplicates exist', async () => { + mockVaultService.exists.mockResolvedValue(false); - const result = (service as any).validateName('Unique Title'); + const result = await (service as any).validateName('Unique Title'); expect(result).toBe('Unique Title'); expect(mockVaultService.exists).toHaveBeenCalledWith(`${Path.Conversations}/Unique Title.json`, true); diff --git a/__tests__/Services/VaultService.test.ts b/__tests__/Services/VaultService.test.ts index f6230af..53cf673 100644 --- a/__tests__/Services/VaultService.test.ts +++ b/__tests__/Services/VaultService.test.ts @@ -27,7 +27,10 @@ const mockVault = { createFolder: vi.fn(), getFiles: vi.fn(), getAllFolders: vi.fn(), - on: vi.fn() + on: vi.fn(), + adapter: { + exists: vi.fn() + } }; const mockFileManager = { @@ -88,6 +91,9 @@ describe('VaultService - Integration Tests', () => { // Reset plugin settings mockPluginSettings.exclusions = []; + // Set default mock for adapter.exists (can be overridden in individual tests) + mockVault.adapter.exists.mockResolvedValue(false); + // Mock console.error to prevent noise in tests consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -226,37 +232,35 @@ describe('VaultService - Integration Tests', () => { }); describe('exists', () => { - it('should return true when file exists and is not excluded', () => { - const mockFile = createMockFile('note.md'); - mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + it('should return true when file exists and is not excluded', async () => { + mockVault.adapter.exists.mockResolvedValue(true); - const result = vaultService.exists('note.md'); + const result = await vaultService.exists('note.md'); expect(result).toBe(true); }); - it('should return false when file is excluded', () => { - const result = vaultService.exists('AI Agent/test.md', false); + it('should return false when file is excluded', async () => { + const result = await vaultService.exists('AI Agent/test.md', false); expect(result).toBe(false); expect(consoleErrorSpy).toHaveBeenCalled(); }); - it('should return false when file does not exist', () => { - mockVault.getAbstractFileByPath.mockReturnValue(null); + it('should return false when file does not exist', async () => { + mockVault.adapter.exists.mockResolvedValue(false); - const result = vaultService.exists('nonexistent.md'); + const result = await vaultService.exists('nonexistent.md'); expect(result).toBe(false); }); - it('should return false when abstract file is a folder, not a file', () => { - const mockFolder = createMockFolder('folder'); - mockVault.getAbstractFileByPath.mockReturnValue(mockFolder); + it('should return true when folder exists', async () => { + mockVault.adapter.exists.mockResolvedValue(true); - const result = vaultService.exists('folder'); + const result = await vaultService.exists('folder'); - expect(result).toBe(false); + expect(result).toBe(true); }); }); @@ -326,11 +330,9 @@ describe('VaultService - Integration Tests', () => { it('should not create directories that already exist', async () => { const mockFile = createMockFile('existing/note.md'); - const existingFolder = createMockFolder('existing'); - mockVault.getAbstractFileByPath.mockImplementation((path: string) => { - if (path === 'existing') return existingFolder; - return null; + mockVault.adapter.exists.mockImplementation(async (path: string) => { + return path === 'existing'; }); mockVault.create.mockResolvedValue(mockFile); @@ -958,95 +960,69 @@ describe('VaultService - Integration Tests', () => { }); describe('isExclusion (private method behavior)', () => { - it('should exclude exact path matches', () => { + it('should exclude exact path matches', async () => { mockPluginSettings.exclusions = ['secret.md']; - const result = vaultService.exists('secret.md'); + const result = await vaultService.exists('secret.md'); expect(result).toBe(false); }); - it('should handle wildcard * (matches any non-slash)', () => { + it('should handle wildcard * (matches any non-slash)', async () => { mockPluginSettings.exclusions = ['folder/*.md']; // Mock files to exist in vault - mockVault.getAbstractFileByPath.mockImplementation((path: string) => { - if (path === 'folder/file.md' || path === 'folder/sub/file.md') { - return createMockFile(path); - } - return null; - }); + mockVault.adapter.exists.mockResolvedValue(true); - expect(vaultService.exists('folder/file.md')).toBe(false); - expect(vaultService.exists('folder/sub/file.md')).toBe(true); // * doesn't match / + expect(await vaultService.exists('folder/file.md')).toBe(false); + expect(await vaultService.exists('folder/sub/file.md')).toBe(true); // * doesn't match / }); - it('should handle double wildcard ** (matches anything including slashes)', () => { + it('should handle double wildcard ** (matches anything including slashes)', async () => { mockPluginSettings.exclusions = ['private/**']; // Mock files to exist in vault - mockVault.getAbstractFileByPath.mockImplementation((path: string) => { - if (path === 'private/file.md' || path === 'private/sub/deep/file.md' || path === 'public/file.md') { - return createMockFile(path); - } - return null; - }); + mockVault.adapter.exists.mockResolvedValue(true); - expect(vaultService.exists('private/file.md')).toBe(false); - expect(vaultService.exists('private/sub/deep/file.md')).toBe(false); - expect(vaultService.exists('public/file.md')).toBe(true); + expect(await vaultService.exists('private/file.md')).toBe(false); + expect(await vaultService.exists('private/sub/deep/file.md')).toBe(false); + expect(await vaultService.exists('public/file.md')).toBe(true); }); - it('should handle patterns ending with / to match directory and contents', () => { + it('should handle patterns ending with / to match directory and contents', async () => { mockPluginSettings.exclusions = ['temp/']; - expect(vaultService.exists('temp/file.md')).toBe(false); - expect(vaultService.exists('temp/sub/file.md')).toBe(false); + expect(await vaultService.exists('temp/file.md')).toBe(false); + expect(await vaultService.exists('temp/sub/file.md')).toBe(false); }); - it('should always exclude AI Agent root by default', () => { - const result = vaultService.exists('AI Agent/file.md', false); + it('should always exclude AI Agent root by default', async () => { + const result = await vaultService.exists('AI Agent/file.md', false); expect(result).toBe(false); }); - it('should always exclude user instruction file', () => { - const result = vaultService.exists('AI Agent/AGENT_INSTRUCTIONS.md', true); - - expect(result).toBe(false); - }); - - it('should handle special regex characters in patterns', () => { + it('should handle special regex characters in patterns', async () => { mockPluginSettings.exclusions = ['folder[test].md']; // Mock files to exist in vault - mockVault.getAbstractFileByPath.mockImplementation((path: string) => { - if (path === 'folder[test].md' || path === 'foldert.md') { - return createMockFile(path); - } - return null; - }); + mockVault.adapter.exists.mockResolvedValue(true); // Should match literally, not as regex character class - expect(vaultService.exists('folder[test].md')).toBe(false); - expect(vaultService.exists('foldert.md')).toBe(true); + expect(await vaultService.exists('folder[test].md')).toBe(false); + expect(await vaultService.exists('foldert.md')).toBe(true); }); - it('should handle multiple exclusion patterns', () => { + it('should handle multiple exclusion patterns', async () => { mockPluginSettings.exclusions = ['private/**', 'temp/', '*.secret']; // Mock files to exist in vault - mockVault.getAbstractFileByPath.mockImplementation((path: string) => { - if (path === 'private/file.md' || path === 'temp/file.md' || path === 'data.secret' || path === 'public/file.md') { - return createMockFile(path); - } - return null; - }); + mockVault.adapter.exists.mockResolvedValue(true); - expect(vaultService.exists('private/file.md')).toBe(false); - expect(vaultService.exists('temp/file.md')).toBe(false); - expect(vaultService.exists('data.secret')).toBe(false); - expect(vaultService.exists('public/file.md')).toBe(true); + expect(await vaultService.exists('private/file.md')).toBe(false); + expect(await vaultService.exists('temp/file.md')).toBe(false); + expect(await vaultService.exists('data.secret')).toBe(false); + expect(await vaultService.exists('public/file.md')).toBe(true); }); }); diff --git a/main.ts b/main.ts index 715b11d..a83fd63 100644 --- a/main.ts +++ b/main.ts @@ -6,17 +6,28 @@ import { AIAgentSettingTab } from 'AIAgentSettingTab'; import { Services } from 'Services/Services'; import type { StatusBarService } from 'Services/StatusBarService'; import { DeregisterAllServices, Resolve } from 'Services/DependencyService'; +import type { VaultService } from 'Services/VaultService'; +import { Path } from 'Enums/Path'; +import { Copy } from 'Enums/Copy'; interface IAIAgentSettings { + firstTimeStart: boolean; + model: string; apiKey: string; exclusions: string[]; + + userInstruction: string; } const DEFAULT_SETTINGS: IAIAgentSettings = { + firstTimeStart: true, + model: AIProviderModel.ClaudeSonnet_4_5, apiKey: "", - exclusions: [] + exclusions: [], + + userInstruction: "" } export default class AIAgentPlugin extends Plugin { @@ -50,6 +61,10 @@ export default class AIAgentPlugin extends Plugin { }); this.addSettingTab(new AIAgentSettingTab(this.app, this)); + + this.app.workspace.onLayoutReady(async () => { + await this.setup(this); + }); } public async onunload() { @@ -83,4 +98,17 @@ export default class AIAgentPlugin extends Plugin { await this.saveData(this.settings); RegisterAiProvider(this); } + + // create example user instruction (on first launch only) + private async setup(plugin: AIAgentPlugin) { + if (!plugin.settings.firstTimeStart) { + return; + } + plugin.settings.firstTimeStart = false; + await plugin.saveSettings(); + + const vaultService: VaultService = Resolve(Services.VaultService); + + await vaultService.create(Path.ExampleUserInstructions, Copy.EXAMPLE_USER_INSTRUCTION, true); + } } \ No newline at end of file