Agents quote phrases; exact_match requires a balanced quoted phrase and does not wrap. Domain rule no longer substring-rejects OR. Cap skip is not Warn. Titan may implement this SHA.
19 KiB
Sift P3.1 — search filters and multi-query merge
Drafted 2026-09-29 by talos from Kyle's ask to steal useful Tavily-cookbook patterns for sift (thread in Mattermost). Reviewer: Claude. Implementer: titan.
Amended 2026-09-29 after Claude's review of 7f4f0d5 (R1–R10) and of 73ff7b6
(E1–E3). Spec approved; titan implements this SHA.
P3 shipped /v1/search + /v2/search with SearXNG / Brave LLM Context / Tavily
fan-in, canonical-URL dedupe, RRF, cache, and a daily Tavily cap. The Tavily
client hardcodes search_depth: "basic" and sends no filters
(internal/search/tavily.go). Native search accepts only query, k,
rerank, digest.
This phase adds the cookbook's high-leverage search knobs without taking crawl,
map, research, or include_answer.
1. Scope
In:
- Native filters on
POST /v1/searchandsift search. - Passing those filters through to Tavily when that source is enabled.
- Honest SearXNG approximations where the engine can do it; notes (not source failure) where it cannot.
- Post-merge domain enforcement in sift for restrict-mode include and exclude, for every source.
- Multi-query search: several focused queries, one URL-deduped result list, fused with RRF across per-query lists.
- Optional
min_scorecut on Tavily's own score, per source, before merge. published_dateon a hit when a source supplies it (Tavily news; SearXNGpublishedDatewhen present).- Skill recipe: search → score/domain filter →
sift batch, do not fetch every hit.
Out:
/v2/searchstays the Firecrawl contract (query,limitonly). Unknown fields ignored. No filters on v2. v2 builds its own output map and must not leakpublished_date.- Crawl, map, Tavily
/extract/crawl/map,include_answer,include_raw_content,include_images— P3 §3.3 still holds. - HTML lexical chunks, same-host map, Research/SSE/JSON-schema — later phases.
- Changing
internal/guard/orinternal/auth/. - Fetching result URLs inside search.
Guard and contract: filters only change query text, SearXNG query params, and the Tavily JSON body. Endpoints stay config-built. Nothing from the request reaches a dialer. No guard or auth change.
2. Native request (POST /v1/search)
Existing fields unchanged. Additive:
{
"query": "…",
"queries": ["…", "…"],
"k": 8,
"rerank": true,
"digest": false,
"include_domains": ["openai.com"],
"exclude_domains": ["pinterest.com"],
"include_domains_mode": "restrict",
"time_range": "month",
"start_date": "2026-09-01",
"end_date": "2026-09-29",
"topic": "news",
"exact_match": false,
"min_score": 0
}
time_range and start_date/end_date are mutually exclusive (Tavily 400s if
both are sent). The example above shows the fields, not a legal combination.
2.1 Error strings (tests compile against these)
- Query problems → 400,
"error": "invalid query", empty hint. - Filter problems → 400,
"error": "invalid search","hint"names the field (include_domains,exclude_domains,include_domains_mode,time_range,start_date,end_date,topic,min_score,exact_match).
2.2 Validation
- Exactly one of
query(non-empty) orqueries(1–4 non-empty strings). Both set, orquerieslonger than 4, or any empty element →invalid query. Each string still has P3's 1500-rune cap (invalid query). ASCII"is allowed in queries (agents quote phrases themselves). exact_match: truerequires at least one balanced quoted phrase in each query (a"…"pair with one or more runes between; no unpaired"). Otherwiseinvalid searchhintexact_match. Do not wrap or rewrite the query.exact_match: false/omitted: quotes are ordinary query text.include_domains/exclude_domains: hostnames only. Max 20 include, 20 exclude after normalise+dedupe. Empty list = omitted. Bad value →invalid searchhintinclude_domainsorexclude_domains.- Domain normalisation (apply before validation and before any
site:rewrite): lowercase; strip a trailing dot; ASCII letters/digits/hyphens (punycodexn--labels allowed); no scheme, port, path, wildcard, or space; at most 253 characters. Letters/digits/hyphens already make operator injection impossible (that needs a space). Do not substring-rejectOR—oracle.comis valid. A space, e.g.a.com OR b.com, → 400. include_domains_mode: omitted/restrict/prefer. Set withoutinclude_domains→invalid searchhintinclude_domains_mode. Omitted withinclude_domainspresent meansrestrict.time_range: omitted orday/week/month/year(and Tavily aliasesd/w/m/y, normalised to the long form). Elseinvalid searchhinttime_range.start_date/end_date:YYYY-MM-DDor omitted.endbeforestart→invalid searchhintend_date. Either date together withtime_range→invalid searchhinttime_range.topic: omitted/general/news/finance. Elseinvalid searchhinttopic.min_score: omitted or0= off; otherwise0 < x ≤ 1. Elseinvalid searchhintmin_score.digest: truestill warnsdigest ignored(P3). That warning is API-level, not a source failure.
/v2/search does not grow. Hermes never sends these fields.
3. Response and source notes
/v1/search results gain optional "published_date" (RFC 1123 or ISO date
string as the vendor sent it; omit the key when unknown). Do not invent dates.
Hit grows PublishedDate string (json:"published_date,omitempty"). When
Dedupe or RRF replaces a hit's text with a longer snippet, keep the first
non-empty published_date. Map SearXNG publishedDate when the engine
supplies it.
sizes unchanged. cached unchanged.
3.1 SourceOut.Notes (R1 — do not reuse Warn)
Today a non-empty SourceOut.Warn means failure (engine.go Run loop): the
source's hits are discarded, it lands in sources_failed, it is counted in
search_errors, and caching is disabled. Filter honesty must not take that path.
type SourceOut struct {
Hits []Hit
Warn string // failure only (timeout, HTTP error, pin)
Notes []string // honesty: filters not applied, recall loss
Credits int
}
Warnkeeps today's meaning. Hits discarded; source failed; no cache.Notesare appended to the responsewarnings(deduplicated across queries). Hits are kept; the source is insources_ok; the result may be cached.- Test: hits plus a note → hits kept, searxng in
sources_ok, result cached.
4. Tavily client
Still POST the configured URL only. Still include_answer: false,
include_raw_content: false, include_images: false, search_depth: "basic".
Still drop inbound answer. Still never call /extract, /crawl, /map.
Always send auto_parameters: false. Auto mode can choose advanced, which
costs 2 credits and breaks "credit cost is fixed".
When the request has filters, add only the fields that are set:
include_domains,exclude_domainsinclude_domains_modeonly wheninclude_domainsis settime_rangeorstart_date/end_date, never bothtopicwhen notgeneral(omittedtopicisgeneral)exact_match: truewhen the flag is set (query text unchanged, including the agent's quotes)include_published_date: truewhentopicisnewsor a date filter is set
Leave filter_by_published_date unset. Tavily's time_range and dates
filter on publish date or last updated date; filter_by_published_date
also drops undated results. Unset means undated and recently updated pages
can pass the date filter.
Leave include_usage unset. usage.credits only comes back when requested;
today's count is the code's fallback of 1, which is correct for basic.
Map results[].published_date onto Hit. Count usage.credits as today
(fallback 1).
A fixture test must still prove include_answer is false, auto_parameters is
false, search_depth is basic, and no extract/crawl path exists.
5. SearXNG and Brave
Do not pretend they support Tavily's full filter set. Vendor gaps are notes,
never Warn. Domain restrict/exclude are enforced in sift after merge (§6.3);
site: is a recall optimisation only.
SearXNG (GET {url}/search, same client/dialer as P3):
- One
include_domainsentry: prefix the query withsite:<host>. - Several:
site:a OR site:b OR …up to 3, then notesearxng: extra include_domains dropped. exclude_domains: no query rewrite (post-filter enforces). No note unless recall is otherwise lost.include_domains_mode=prefer: nosite:prefix, no post-filter; notesearxng: include_domains_mode=prefer not applied.time_range: send SearXNG query paramtime_range=(day/week/month/year). Never a bang in the query text.topic=news: sendcategories=news. Never!news.topic=finance: notesearxng: topic ignored(no finance category).exact_match: send the query unchanged. SearXNG engines already treat quotes as phrases. Do not wrap again.start_date/end_date: ignored + notesearxng: start_date/end_date not applied.min_score > 0: notesearxng: min_score not applied; keep hits.
Live instance (straylight, 2026-09-29, not independently verified here):
time_range=month and categories=news both returned 200 with normal result
lists. Send both params. Tests are offline fixtures.
Brave LLM Context: no documented domain/date/topic/exact filters. Do not add a second Brave HTTP API. Post-filter still enforces restrict include and exclude. Notes when the corresponding filter was set:
brave_llm: time_range not appliedbrave_llm: topic not appliedbrave_llm: exact_match not appliedbrave_llm: include_domains_mode=prefer not appliedbrave_llm: min_score not applied
Request URL unchanged aside from q (do not wrap for exact_match; Brave
cannot apply it). Deduplicate identical notes across queries.
6. Pipeline
Source.Search grows a Filter argument (zero value = today's behaviour).
6.1 Extra queries run sequentially per source (R5)
Sources still run in parallel within one query, as today. Queries run
sequentially. Up to 4 queries means up to 4 Brave and 4 Tavily paid calls;
four parallel Brave calls will hit the plan's rate limit, and a 429 marks
brave failed (Warn) and disables caching.
A Tavily call still counts toward the daily cap. If the cap trips mid-batch,
later queries skip Tavily with the same tavily: daily cap warning (skip path,
not a failed attempted source). The queries then ran against different source
sets. Do not cache that result (the key's sources would be wrong). Test
this.
6.2 min_score (R2)
After merge, Hit.Score is the RRF value Σ 1/(60+rank). With 3 sources the
maximum is about 0.05, so a post-merge --min-score 0.5 would drop everything.
With rerank:false scores are vendor-native (Tavily 0–1, SearXNG unbounded,
Brave 0). With Tavily no_rerank, Tavily scores mix with RRF.
Apply min_score per source, before the merge, to Tavily's own relevance
score only. That is the cookbook's usage. Other sources get a note
(searxng: min_score not applied, brave_llm: min_score not applied) and keep
their hits. Do not drop score-less Brave/SearXNG hits.
6.3 Domain post-filter (R3)
After each query's merge, for every source, drop hits that violate:
include_domainswhen mode isrestrict(omitted mode = restrict)exclude_domains(always)
Match is exact on the result host: host == d or host has suffix "." + d
(host lowercased, trailing dot stripped). Cost is negligible.
prefermode: no post-filter. Tavily honoursinclude_domains_mode; other sources get the prefer note.site:is a recall optimisation only. Notes are for recall loss, not for "filter ignored" when the post-filter already enforced it.- As written without this, Brave ignores the filter entirely and SearXNG
engines that do not understand
site:return off-domain hits.
6.4 Multi-query merge (R4)
Do not concatenate-then-truncate: if q1 returns k or more hits, q2–q4
contribute nothing.
For queries: [q1, q2, …] (max 4), or a single query treated as a list of one:
- For each query, sequentially: existing fan-in (parallel sources, same
filters, same per-source timeout) → Tavily
min_score→ today'smergeSearch(RRF / no-rerank path) → domain post-filter. - RRF across the per-query fused lists (each query's list is one ranked list). URLs several queries found rise.
- Dedupe (P3: first source wins attribution; later longer snippet replaces
text; keep first non-empty
published_date). - Truncate to
k.
Test: q1 returns k or more unique hits, and q2's single distinct top hit
still appears.
Do not fetch any result URL.
7. Cache
P3 key is sha256(norm(query) | sources | k | hour_bucket | caller_class).
Replace the payload with a canonical JSON object, keys sorted, then sha256. It must hold:
caller_class, hour bucket (2006-01-02T15UTC),k, attemptedsources(sorted) — exactly as P3 has themqueries: normalised, ordered list (a singlequeryis a one-element list)include_domains/exclude_domains: normalised, sorted, deduplicatedinclude_domains_mode(restrictorprefer; omit the key when no include list)time_rangein long form (d→day); dates asYYYY-MM-DDor omittopic, with omitted meaninggeneralso the two share a keyexact_match(bool)min_scorein one fixed number format (e.g.'%g'is not stable — usestrconv.FormatFloat(x, 'f', -1, 64)or a documented equivalent, same for 0 and omitted)
Same query without filters must not hit a filtered entry.
Still: do not cache if any attempted source failed (Warn). Still do not cache
if the Tavily cap tripped partway through a multi-query run. Still no page
bodies, no Tavily answers.
8. Logs
P3 §8 already bans hosts in the search log for every caller, and filter values are hosts or query content.
- Never log domain values, query text, dates, or topic values.
- Restricted callers keep exactly P3's fields:
caller,q_len,sources,n,cache. - Fleet callers may add
n_queriesand the filter kinds (filters=domains,time,topic), never values.rerank_msstays as today. q_lenfor several queries: sum of their rune lengths.
Test: restricted and fleet requests with include_domains: ["secret.example"]
and 3 queries → no log record contains the host or any query.
9. CLI
sift search "<query>" [--k n] [--json]
[--include-domain host]... [--exclude-domain host]...
[--domains-mode restrict|prefer]
[--time-range day|week|month|year]
[--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
[--topic general|news|finance]
[--exact-match]
[--min-score 0.5]
[--queries-file path]
--min-score 0.5 is Tavily's 0–1 scale (applied only to Tavily, §6.2).
--queries-file is a JSON array of 1–4 strings. It is mutually exclusive with
the positional query. Repeatable --include-domain / --exclude-domain.
Default text output: if published_date is present, print it as a fourth line
under the snippet. --json is the native envelope.
10. Skill
skill/SKILL.md:
- Document the new flags with one example matching the cookbook Product News
Tracker: official domain search +
topic=news+time_range=month. - Recipe: after
sift search --json, do notsift fetchevery URL. Drop hits below a Tavily score you care about (or take top N), thensift batch submit. web_searchremains the fallback only when sift is unreachable. Hermesweb_searchstill hits/v2/searchand does not get filters — say so.
11. Tests (minimum)
Owner-given invariants (these are where a free-form implementation drifts):
- R1 notes: hits plus a note → hits kept, source in
sources_ok, result cached. AWarnstill fails the source and disables cache. - R2 min_score: Tavily hit at 0.4 dropped when
min_score=0.5; SearXNG / Brave hits kept; notes present. Post-merge RRF scores are not compared tomin_score. - R3 domain post-filter: Brave (and SearXNG) hit whose host is not in
restrict-include, or is in exclude, is dropped.
preferkeeps it. Invalid domain with a space, e.g.a.com OR b.com, → 400invalid search.oracle.comis valid. - R4 multi-query RRF: q1 returns k unique hits; q2's distinct top hit still appears.
- R5 cap mid-batch: Tavily cap trips after query 1 → later queries skip Tavily; result is not cached.
- R6 cache key: same query different
include_domains→ miss; same filters → hit; omittedtopicandtopic=generalshare a key;danddayshare a key. - R9 logs: restricted and fleet with
include_domains:["secret.example"]and 3 queries → no log record contains the host or any query. - Tavily outbound JSON: filters present when set;
include_answeralways false;auto_parametersfalse;search_depthbasic; no extract/crawl;include_domains_modeabsent unless include list set; never bothtime_rangeand dates;exact_match: truewith query containing"phrase"→ flag true and query unchanged (still quoted);exact_match: truewith no quoted phrase → 400invalid searchhintexact_match. - Tavily inbound:
answerdropped;published_datemapped; missing date omitted. - Validation table for §2 (
invalid queryvsinvalid search+ hint). - SearXNG:
site:prefix for one domain;exact_matchdoes not wrapq(query already quoted by the agent);time_range/categoriesas query params not bangs. - Brave: request URL unchanged (aside from
q); notes as §5; post-filter still drops off-domain hits. /v2/searchgolden unchanged (no new keys required, nopublished_date).- Restricted caller: same sources; logs still have no query/host/URL.
make gate (and contract if P3's contract harness is still in the tree).
12. Code layout
Touch, do not explode:
internal/search—Filtertype;SourceOut.Notes; extendSource.Search; Tavily/SearXNG/Brave; engine loop; domain post-filter; multi-query RRFinternal/api/search.go— native decode + validation + log fieldscmd/sift/searchcmd.go— flagsinternal/dcachesearch keyskill/SKILL.md,README.md
No new dependency. No internal/api/api.go growth if a new file is cleaner
(internal/api/search_filter.go). Guard and auth stay closed.
13. Rollout
- Claude approved
73ff7b6once E1–E3 are in (this SHA). No further spec round. - SearXNG probe already returned 200 for
time_range=andcategories=news. - titan implements on
git.wntrmute.dev:kyle/siftfrom this SHA. Split commits if it helps; one phase doc (invariants in §11 — titan writes those tests; Claude checks them in the code review). titan does not editinternal/guard/,internal/auth/, or~/src/nixos. - Claude reviews the code.
- straylight bumps the orion pin.
14. Explicit non-goals (do not sneak in)
Map, crawl, HTML BM25 chunks, Research, include_answer, social-platform
crawlers, changing /v2/search. Those were ranked later in the cookbook
write-up on purpose.