feat: add multi-term search and user instruction UI with file path setting

Refactor SearchVaultFiles to accept array of search terms, add user instruction panel in chat input with toggle button, move user instruction path to settings (from hardcoded), improve search result interaction with mouse/keyboard support, fix async exists() method in VaultService, and create example instructions file on first launch
This commit is contained in:
Andrew Beal 2025-11-02 20:16:06 +00:00
parent 0a7d95e0fe
commit c0178dc364
16 changed files with 706 additions and 169 deletions

View file

@ -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);

View file

@ -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"]
}
}

View file

@ -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<AIAgentPlugin>(Services.AIAgentPlugin).app.vault;
this.plugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
}
public systemInstruction(): string {
@ -23,7 +24,7 @@ export class AIPrompt implements IPrompt {
}
public async userInstruction(): Promise<string> {
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 ?? "";
}
}

View file

@ -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<ISearchState> = 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 @@
<div id="input-container" class:edit-mode={editModeActive}>
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
<ChatSearchResults searchState={$searchState}/>
<ChatSearchResults searchState={$searchState} onResultAccept={handleSearchResultAcceptance}/>
</div>
<div id="user-instruction-container" style:padding-top={userInstructionActive ? "var(--size-4-2)" : 0}>
<UserInstruction bind:userInstructionActive={userInstructionActive}/>
</div>
<button
id="user-instruction-button"
bind:this={userInstructionButton}
on:click={() => { userInstructionActive = !userInstructionActive; searchStateStore.resetSearch() }}
aria-label="User Instruction">
</button>
<div
id="input-field"
class:error={hasNoApiKey}
@ -289,7 +313,7 @@
grid-column: 1;
display: grid;
grid-template-rows: auto var(--size-4-3) 1fr var(--size-4-3);
grid-template-columns: var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
grid-template-columns: var(--size-4-3) auto var(--size-4-2) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
border-radius: var(--modal-radius);
background-color: var(--background-primary);
}
@ -301,12 +325,25 @@
#input-search-results-container {
grid-row: 1;
grid-column: 2 / 7;
grid-column: 2 / 9;
}
#user-instruction-container {
grid-row: 1;
grid-column: 2 / 9;
}
#user-instruction-button {
grid-row: 3;
grid-column: 2;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
}
#input-field {
grid-row: 3;
grid-column: 2;
grid-column: 4;
height: 100%;
max-height: 30vh;
border-radius: var(--input-radius);
@ -363,7 +400,7 @@
#edit-mode-button {
grid-row: 3;
grid-column: 4;
grid-column: 6;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
@ -371,7 +408,7 @@
#submit-button {
grid-row: 3;
grid-column: 6;
grid-column: 8;
border-radius: var(--button-radius);
padding-left: var(--size-4-5);
padding-right: var(--size-4-5);

View file

@ -1,14 +1,19 @@
<script lang="ts">
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<SearchStateStore>(Services.SearchStateStore);
let contentDiv: HTMLDivElement;
let height = 0;
let contentDiv: HTMLDivElement;
let resultElements: (HTMLDivElement | null)[] = [];
let iconElements: (HTMLDivElement | null)[] = [];
@ -64,11 +69,17 @@
</script>
<div id="input-search-results" style:height="{height}px">
<div id="input-search-results-inner-container" bind:this={contentDiv}>
<div id="input-search-results-inner-container" bind:this={contentDiv} role="listbox" tabindex="0">
{#each searchState.results as searchResult, index}
<div class="input-search-result-container"
role="option"
tabindex="-1"
aria-selected={searchResult === searchState.selectedResult}
bind:this={resultElements[index]}
style:background-color={searchResult === searchState.selectedResult ? "var(--interactive-accent)" : "transparent"}>
style:background-color={searchResult === searchState.selectedResult ? "var(--interactive-accent)" : "transparent"}
on:mouseenter={() => searchStateStore.setSelectedResult(index)}
on:click={onResultAccept}
on:keydown={() => {}}>
<div class="input-search-result-icon" bind:this={iconElements[index]}></div>
<div class="input-search-result-title">{basename(searchResult)}</div>
<div class="input-search-result-subtitle">{searchResult}</div>
@ -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);
}

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { Copy } from "Enums/Copy";
import { Path } from "Enums/Path";
import type AIAgentPlugin from "main";
import { basename } from "path";
import { Resolve } from "Services/DependencyService";
import type { FileSystemService } from "Services/FileSystemService";
import { Services } from "Services/Services";
import { tick } from "svelte";
export let userInstructionActive: boolean;
let height = 0;
let emptyResultsContentDiv: HTMLDivElement;
let resultsContentDiv: HTMLDivElement;
let userInstructions: string[] = [];
let selectedInstruction: number = 0;
$: userInstructions, updateHeight();
const plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
const fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
function updateHeight() {
tick().then(() => {
if (resultsContentDiv) {
height = resultsContentDiv.scrollHeight;
}
else if (emptyResultsContentDiv) {
height = emptyResultsContentDiv.scrollHeight;
} else {
height = 0;
}
});
}
$: if (userInstructionActive) {
loadUserInstructions();
} else {
userInstructions = [];
}
async function loadUserInstructions() {
const files = await fileSystemService.listFilesInDirectory(Path.UserInstructions, true, true);
userInstructions = files.map(file => file.path).filter(path => path != Path.ExampleUserInstructions);
tick().then(() => {
if (resultsContentDiv) {
resultsContentDiv.focus();
}
});
}
function handleInstructionSelect() {
if (selectedInstruction < userInstructions.length) {
plugin.settings.userInstruction = userInstructions[selectedInstruction];
}
userInstructionActive = false;
}
async function handleKeydown(e: KeyboardEvent) {
if (!userInstructionActive) {
return;
}
e.preventDefault();
if (e.key.startsWith("Arrow")) {
if (e.key === "ArrowUp") {
selectedInstruction = selectedInstruction <= 0 ? userInstructions.length - 1 : selectedInstruction - 1;
}
if (e.key === "ArrowDown") {
selectedInstruction = selectedInstruction >= userInstructions.length - 1 ? 0 : selectedInstruction + 1;
}
return;
}
if (e.key === "Enter") {
handleInstructionSelect();
}
if (e.key === "Escape") {
userInstructionActive = false;
return;
}
}
</script>
<div id="user-instruction-results" style:height="{height}px">
{#if userInstructionActive}
{#if userInstructions.length === 0}
<div bind:this={emptyResultsContentDiv}>
<div id="user-instruction-empty" class="user-instruction-container">
<div class="user-instruction-title">
<span>
{Copy.UserInstructions1}
<span id="user-instruction-link">
{Copy.UserInstructions2}
</span>
{Copy.UserInstructions3}
</span>
</div>
</div>
</div>
{/if}
{#if userInstructions.length > 0}
<div
id="user-instruction-results-inner-container"
bind:this={resultsContentDiv}
role="listbox"
tabindex="0"
on:keydown={handleKeydown}>
{#each userInstructions as userInstruction, index}
<div class="user-instruction-container"
role="option"
tabindex="-1"
aria-selected={selectedInstruction === index}
style:background-color={selectedInstruction === index ? "var(--interactive-accent)" : "transparent"}
on:mouseenter={() => selectedInstruction = index}
on:click={handleInstructionSelect}
on:keydown={() => {}}>
<div class="user-instruction-title">{basename(userInstruction)}</div>
<div class="user-instruction-subtitle">{userInstruction}</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
#user-instruction-results {
max-height: 15em;
transition: height 0.2s ease-out;
overflow: auto;
scroll-behavior: smooth;
}
#user-instruction-results::-webkit-scrollbar {
display: none;
}
#user-instruction-empty {
padding-top: var(--size-4-2);
padding-bottom: var(--size-4-2);
display: flex;
justify-content: center;
}
#user-instruction-results-inner-container {
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
.user-instruction-container {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: 1fr;
border-style: solid;
border-radius: var(--size-2-2);
border-color: var(--background-primary-alt);
border-width: 1px;
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
.user-instruction-title {
grid-row: 1;
grid-column: 1;
font-family: var(--font-interface-theme);
}
.user-instruction-subtitle {
grid-row: 2;
grid-column: 1;
font-family: var(--font-interface-theme);
font-size: var(--font-smallest);
color: var(--text-muted);
}
#user-instruction-link {
color: var(--interactive-accent);
cursor: pointer;
}
#user-instruction-link:hover {
text-decoration: underline;
}
</style>

View file

@ -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.`
}

View file

@ -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`
};

View file

@ -14,7 +14,7 @@ export class AIFunctionService {
public async performAIFunction(functionCall: AIFunctionCall): Promise<AIFunctionResponse> {
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<object> {
const matches: ISearchMatch[] = searchTerm.trim() === "" ? [] : await this.fileSystemService.searchVaultFiles(searchTerm);
private async searchVaultFiles(searchTerms: string[]): Promise<object> {
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<object> {

View file

@ -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<string> {
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++;

View file

@ -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<boolean> {
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<string> {
@ -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);
}
}

View file

@ -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 => ({

View file

@ -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');

View file

@ -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);

View file

@ -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);
});
});

30
main.ts
View file

@ -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<VaultService>(Services.VaultService);
await vaultService.create(Path.ExampleUserInstructions, Copy.EXAMPLE_USER_INSTRUCTION, true);
}
}