Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/agent/tool-runner.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,7 +16,7 @@ export class ToolRunner {

constructor(
private app: App,
private searcher: DuckDuckGoSearcher,
private searcher: DuckDuckGoSearcher | TavilySearcher,
private crawler: Crawler,
private searchLimit: number
) {}
Expand Down
13 changes: 11 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
70 changes: 70 additions & 0 deletions src/search/tavily.ts
Original file line number Diff line number Diff line change
@@ -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<string, { results: SearchResult[]; timestamp: number }>();
private readonly cacheTtlMs = 5 * 60 * 1000;

constructor(private apiKey: string) {}

updateApiKey(key: string): void {
this.apiKey = key;
}

async search(query: string, limit = 5): Promise<SearchResult[]> {
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();
}
}
35 changes: 33 additions & 2 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +18,8 @@ export interface PenNoteSettings {
openrouterModel: string;
groqApiKey: string;
groqModel: string;
searchProvider: SearchProvider;
tavilyApiKey: string;
searchResultLimit: number;
crawlTimeoutMs: number;
maxAgentIterations: number;
Expand Down