Skip to content
Merged
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
36 changes: 34 additions & 2 deletions Sources/Domain/WebSearchAndToolControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,13 @@ enum SearchPluginProvider: String, Codable, CaseIterable, Identifiable, Sendable
enum ExaSearchType: String, Codable, CaseIterable, Sendable {
case auto
case fast
case deep
case neural
case deepLite = "deep-lite"
case deep
case deepReasoning = "deep-reasoning"
case instant

static let publicCases: [ExaSearchType] = [.auto, .fast, .neural, .deep, .instant]
static let publicCases: [ExaSearchType] = [.auto, .fast, .neural, .deepLite, .deep, .deepReasoning, .instant]

static func resolved(from rawValue: String?) -> ExaSearchType? {
guard let value = rawValue?.trimmedNonEmpty?.lowercased() else {
Expand All @@ -131,6 +133,30 @@ enum ExaSearchType: String, Codable, CaseIterable, Sendable {
}
}

/// Exa per-request category filter. Validated against Exa's documented allowlist.
enum ExaCategory: String, Codable, CaseIterable, Sendable {
case company
case researchPaper = "research paper"
case news
case personalSite = "personal site"
case financialReport = "financial report"
case people

static func resolved(from rawValue: String?) -> ExaCategory? {
guard let value = rawValue?.trimmedNonEmpty?.lowercased() else {
return nil
}
return ExaCategory(rawValue: value)
}
}

/// Firecrawl source kinds for the v2 search API. JSON-encoded as `[String]` in user defaults.
enum FirecrawlSourceKind: String, Codable, CaseIterable, Sendable {
case web
case news
case images
}

/// Built-in web search controls (app plugin-backed).
struct SearchPluginControls: Codable, Sendable {
var preferJinSearch: Bool?
Expand All @@ -142,6 +168,7 @@ struct SearchPluginControls: Codable, Sendable {

// Exa-specific
var exaSearchType: ExaSearchType?
var exaCategory: String?

// Brave-specific
var braveCountry: String?
Expand All @@ -150,6 +177,7 @@ struct SearchPluginControls: Codable, Sendable {

// Firecrawl-specific
var firecrawlExtractContent: Bool?
var firecrawlCountry: String?

// Tavily-specific
var tavilySearchDepth: String? // "basic" | "fast" | "advanced" | "ultra_fast"
Expand All @@ -163,10 +191,12 @@ struct SearchPluginControls: Codable, Sendable {
includeRawContent: Bool? = nil,
fetchPageContent: Bool? = nil,
exaSearchType: ExaSearchType? = nil,
exaCategory: String? = nil,
braveCountry: String? = nil,
braveLanguage: String? = nil,
braveSafesearch: String? = nil,
firecrawlExtractContent: Bool? = nil,
firecrawlCountry: String? = nil,
tavilySearchDepth: String? = nil,
tavilyTopic: String? = nil
) {
Expand All @@ -177,10 +207,12 @@ struct SearchPluginControls: Codable, Sendable {
self.includeRawContent = includeRawContent
self.fetchPageContent = fetchPageContent
self.exaSearchType = exaSearchType
self.exaCategory = exaCategory
self.braveCountry = braveCountry
self.braveLanguage = braveLanguage
self.braveSafesearch = braveSafesearch
self.firecrawlExtractContent = firecrawlExtractContent
self.firecrawlCountry = firecrawlCountry
self.tavilySearchDepth = tavilySearchDepth
self.tavilyTopic = tavilyTopic
}
Expand Down
105 changes: 18 additions & 87 deletions Sources/Tools/BuiltinSearchHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,99 +144,30 @@ extension BuiltinSearchToolHub {
return String(data: data, encoding: .utf8)
}

func braveFreshnessValue(recencyDays: Int) -> String {
switch recencyDays {
case ...1:
return "pd"
case ...7:
return "pw"
case ...31:
return "pm"
default:
return "py"
}
}

func tavilyTimeRange(recencyDays: Int) -> String {
switch recencyDays {
case ...1:
return "day"
case ...7:
return "week"
case ...31:
return "month"
default:
return "year"
}
}

func perplexityRecencyFilter(recencyDays: Int) -> String {
switch recencyDays {
case ...1:
return "day"
case ...7:
return "week"
case ...31:
return "month"
default:
return "year"
}
}

func perplexitySearchDomainFilter(includeDomains: [String], excludeDomains: [String]) -> [String] {
let include = includeDomains.compactMap(normalizedTrimmedString)
if !include.isEmpty {
// Perplexity Search API allows either include-only or exclude-only filters.
return Array(include.prefix(20))
}
/// Augments a Firecrawl query with Google-style `site:` / `-site:` operators because the v2
/// search endpoint does not accept first-class include/exclude domain arrays.
/// Caps each list at 10 entries to keep the URL/query sane.
static func firecrawlAugmentedQuery(
_ query: String,
includeDomains: [String],
excludeDomains: [String]
) -> String {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let includes = includeDomains.compactMap { $0.trimmedNonEmpty }.prefix(10)
let excludes = excludeDomains.compactMap { $0.trimmedNonEmpty }.prefix(10)

let exclude = excludeDomains.compactMap(normalizedTrimmedString).map { "-\($0)" }
return Array(exclude.prefix(20))
}
var pieces: [String] = trimmed.isEmpty ? [] : [trimmed]

var tavilyDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}

func tavilySearchDepthValue(_ value: String?) -> String {
guard let depth = normalizedTrimmedString(value)?.lowercased() else {
return "basic"
if !includes.isEmpty {
let operators = includes.map { "site:\($0)" }
pieces.append(operators.count == 1 ? operators[0] : "(\(operators.joined(separator: " OR ")))")
}
let normalized = depth.replacingOccurrences(of: "-", with: "_")

switch normalized {
case "basic", "fast", "advanced", "ultra_fast":
return normalized == "ultra_fast" ? "ultra-fast" : normalized
default:
return "basic"
for domain in excludes {
pieces.append("-site:\(domain)")
}
}

func tavilyTopicValue(_ value: String?) -> String {
guard let topic = normalizedTrimmedString(value)?.lowercased() else { return "general" }
switch topic {
case "general", "news", "finance":
return topic
default:
return "general"
}
}

func firecrawlRecencyValue(recencyDays: Int) -> String {
switch recencyDays {
case ...1:
return "qdr:d"
case ...7:
return "qdr:w"
case ...31:
return "qdr:m"
default:
return "qdr:y"
}
return pieces.joined(separator: " ")
}

func urlHost(_ urlString: String) -> String? {
Expand Down
20 changes: 19 additions & 1 deletion Sources/Tools/BuiltinSearchProvider+Brave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ extension BuiltinSearchToolHub {
let language = normalizedTrimmedString(route.overrides?.braveLanguage) ?? route.settings.braveLanguage
let safesearch = normalizedTrimmedString(route.overrides?.braveSafesearch) ?? route.settings.braveSafesearch

let freshness = args.recencyDays.map { braveFreshnessValue(recencyDays: $0) }
let freshness = args.recencyDays.map { Self.braveDateRangeFreshness(recencyDays: $0) }
let pageCount = Int(ceil(Double(desiredMaxResults) / Double(BraveSearchAPI.maxCount)))
let maxPages = min(pageCount, BraveSearchAPI.maxOffset + 1)

Expand Down Expand Up @@ -76,4 +76,22 @@ extension BuiltinSearchToolHub {

return BuiltinSearchToolOutput(provider: .brave, query: args.query, resultCount: rows.count, results: rows)
}

/// Builds a UTC `YYYY-MM-DDtoYYYY-MM-DD` window for Brave's `freshness` parameter — strictly
/// more precise than the coarse `pd|pw|pm|py` buckets the API also accepts.
nonisolated static func braveDateRangeFreshness(recencyDays: Int, now: Date = Date()) -> String {
let calendar = Calendar(identifier: .gregorian)
var utc = calendar
utc.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt

let clamped = max(1, recencyDays)
let start = utc.date(byAdding: .day, value: -clamped, to: now) ?? now

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"

return "\(formatter.string(from: start))to\(formatter.string(from: now))"
}
}
Loading
Loading