diff --git a/src/agent/tool-runner.ts b/src/agent/tool-runner.ts index 8342bbf..2401896 100644 --- a/src/agent/tool-runner.ts +++ b/src/agent/tool-runner.ts @@ -1,6 +1,7 @@ import { App, TFile } from "obsidian"; import type { CrawlResult, SearchResult } from "../types"; import { DuckDuckGoSearcher } from "../search/duckduckgo"; +import { TavilySearcher } from "../search/tavily"; import { Crawler } from "../search/crawler"; import { replaceSectionContent, upsertBulletInSection } from "../notes/markdown-builder"; @@ -15,7 +16,7 @@ export class ToolRunner { constructor( private app: App, - private searcher: DuckDuckGoSearcher, + private searcher: DuckDuckGoSearcher | TavilySearcher, private crawler: Crawler, private searchLimit: number ) {} diff --git a/src/main.ts b/src/main.ts index 2069b15..e315f58 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import type { AgentStepLog, ChatMessage, ContextStoreData, DiffHunk, PenNoteSett import { LLMClient } from "./llm/llm-client"; import { QueryBuilder } from "./llm/query-builder"; import { DuckDuckGoSearcher } from "./search/duckduckgo"; +import { TavilySearcher } from "./search/tavily"; import { Crawler } from "./search/crawler"; import { ContextStore } from "./memory/context-store"; import { NoteIndexer } from "./memory/note-indexer"; @@ -26,7 +27,7 @@ export default class PenNotePlugin extends Plugin { private llmClient!: LLMClient; private queryBuilder!: QueryBuilder; - private searcher!: DuckDuckGoSearcher; + private searcher!: DuckDuckGoSearcher | TavilySearcher; private crawler!: Crawler; private contextStore!: ContextStore; private noteIndexer!: NoteIndexer; @@ -72,6 +73,7 @@ export default class PenNotePlugin extends Plugin { await this.saveData(this.settings); this.llmClient.updateSettings(this.settings); this.crawler.updateSettings(this.settings); + this.searcher = this.createSearcher(); this.toolRunner = new ToolRunner( this.app, this.searcher, @@ -85,10 +87,17 @@ export default class PenNotePlugin extends Plugin { ); } + private createSearcher(): DuckDuckGoSearcher | TavilySearcher { + if (this.settings.searchProvider === "tavily") { + return new TavilySearcher(this.settings.tavilyApiKey); + } + return new DuckDuckGoSearcher(); + } + private initServices(): void { this.llmClient = new LLMClient(this.settings); this.queryBuilder = new QueryBuilder(this.llmClient); - this.searcher = new DuckDuckGoSearcher(); + this.searcher = this.createSearcher(); this.crawler = new Crawler(this.settings); this.noteIndexer = new NoteIndexer(this.app); this.templateRegistry = new TemplateRegistry(); diff --git a/src/search/tavily.ts b/src/search/tavily.ts new file mode 100644 index 0000000..a483a3a --- /dev/null +++ b/src/search/tavily.ts @@ -0,0 +1,70 @@ +import { requestUrl } from "obsidian"; +import type { SearchResult } from "../types"; + +const TAVILY_SEARCH_URL = "https://api.tavily.com/search"; + +interface TavilySearchResponse { + results: Array<{ + title: string; + url: string; + content: string; + score: number; + }>; +} + +export class TavilySearcher { + private cache = new Map(); + private readonly cacheTtlMs = 5 * 60 * 1000; + + constructor(private apiKey: string) {} + + updateApiKey(key: string): void { + this.apiKey = key; + } + + async search(query: string, limit = 5): Promise { + if (!this.apiKey) { + throw new Error("Tavily API key is not configured. Add it in Settings → PenNote AI → Search & Crawl."); + } + + const cacheKey = `${query}::${limit}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.cacheTtlMs) { + return cached.results; + } + + const response = await requestUrl({ + url: TAVILY_SEARCH_URL, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_key: this.apiKey, + query, + max_results: Math.min(limit, 10), + search_depth: "basic", + topic: "general", + }), + throw: false, + }); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`Tavily search failed: HTTP ${response.status}`); + } + + const data: TavilySearchResponse = response.json; + const results: SearchResult[] = (data.results ?? []).map((r) => ({ + title: r.title, + url: r.url, + snippet: r.content, + })); + + this.cache.set(cacheKey, { results, timestamp: Date.now() }); + return results; + } + + clearCache(): void { + this.cache.clear(); + } +} diff --git a/src/settings.ts b/src/settings.ts index 3fe76d7..bf26e9f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,5 @@ import { App, Notice, Plugin, PluginSettingTab, Setting } from "obsidian"; -import type { LLMProvider, PenNoteSettings } from "./types"; +import type { LLMProvider, PenNoteSettings, SearchProvider } from "./types"; import { LLMClient } from "./llm/llm-client"; export const DEFAULT_SETTINGS: PenNoteSettings = { @@ -18,6 +18,8 @@ export const DEFAULT_SETTINGS: PenNoteSettings = { openrouterModel: "anthropic/claude-opus-4-5", groqApiKey: "", groqModel: "moonshotai/kimi-k2-instruct", + searchProvider: "duckduckgo", + tavilyApiKey: "", searchResultLimit: 5, crawlTimeoutMs: 15000, maxAgentIterations: 10, @@ -245,9 +247,38 @@ export class PenNoteSettingTab extends PluginSettingTab { containerEl.createEl("h3", { text: "Search & Crawl" }); + new Setting(containerEl) + .setName("Search Provider") + .setDesc("Select the web search provider to use for queries.") + .addDropdown((drop) => + drop + .addOption("duckduckgo", "DuckDuckGo") + .addOption("tavily", "Tavily") + .setValue(this.plugin.settings.searchProvider) + .onChange(async (value) => { + this.plugin.settings.searchProvider = value as SearchProvider; + await this.plugin.saveSettings(); + this.display(); + }) + ); + + if (this.plugin.settings.searchProvider === "tavily") { + new Setting(containerEl) + .setName("Tavily API Key") + .setDesc("Your Tavily API key from app.tavily.com") + .addText((text) => { + text.setPlaceholder("tvly-...").setValue(this.plugin.settings.tavilyApiKey).onChange(async (v) => { + this.plugin.settings.tavilyApiKey = v.trim(); + await this.plugin.saveSettings(); + }); + text.inputEl.type = "password"; + text.inputEl.style.width = "100%"; + }); + } + new Setting(containerEl) .setName("Search Result Limit") - .setDesc("Maximum number of DuckDuckGo results to fetch per query.") + .setDesc("Maximum number of search results to fetch per query.") .addSlider((slider) => slider .setLimits(3, 10, 1) diff --git a/src/types.ts b/src/types.ts index 1959ce1..fabfb03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ export type LLMProvider = "mistral" | "openai" | "xai" | "gemini" | "anthropic" | "openrouter" | "groq"; +export type SearchProvider = "duckduckgo" | "tavily"; + export interface PenNoteSettings { provider: LLMProvider; mistralApiKey: string; @@ -16,6 +18,8 @@ export interface PenNoteSettings { openrouterModel: string; groqApiKey: string; groqModel: string; + searchProvider: SearchProvider; + tavilyApiKey: string; searchResultLimit: number; crawlTimeoutMs: number; maxAgentIterations: number;