package config import "strings" // The two world sources in the query chain, in the order they answer: a live // SearXNG search first, the offline ZIMs behind it (owner's call, 2026-08-02). // Everything of HIS still comes first — the personal boundary runs above both, // so a question about him is never searched. // // Only the query string leaves the box in either case. Notes, facts, the // persona block and the history are never part of a request; neither // internal/websearch nor internal/kiwix can read the store. // SearchConfig — the self-hosted SearXNG instance she searches with. // // External search is allowed and off unless configured (CLAUDE.md). Configuring // it is the whole opt-in: no `search` block, no query ever leaves the LAN. type SearchConfig struct { // URL — base address of the SearXNG instance, e.g. "http://searxng:9563". // Empty ⇒ the whole block is normalised to nil and the source stays off. // // The instance needs `search.formats` to include `json` in its settings.yml. // A stock install answers 403 to format=json, and then every search fails. URL string `json:"url,omitempty"` // MaxResults — how many hits are kept as evidence. 0 ⇒ DefaultSearchResults. // Small on purpose: the snippets share a 4096-token context with the persona // block and the prompt. MaxResults int `json:"max_results,omitempty"` // SnippetRunes — how much of the joined evidence reaches the phraser. // 0 ⇒ DefaultSearchSnippetRunes. SnippetRunes int `json:"snippet_runes,omitempty"` // Language — SearXNG's `language` parameter, e.g. "ru", "en" or "auto". // Empty ⇒ the instance default. He asks in Russian and in English, so // pinning one language here is usually the wrong call. Language string `json:"language,omitempty"` // Engines — comma-separated engine names to restrict the search to, e.g. // "duckduckgo,wikipedia". Empty ⇒ whatever the instance has enabled. Engines string `json:"engines,omitempty"` // Timeout — per-search budget. 0 ⇒ websearch.DefaultTimeout. SearXNG waits // on the slowest upstream engine, so this is the knob that decides how long // a voice turn can stall on a bad network. Timeout Duration `json:"timeout,omitempty"` } // Search defaults, applied in normaliseSearch. const ( DefaultSearchResults = 4 DefaultSearchSnippetRunes = 1500 ) // normaliseSearch applies the block's defaults. No address, nothing to search. func (c *Config) normaliseSearch() { if c.Search != nil && strings.TrimSpace(c.Search.URL) == "" { c.Search = nil } if c.Search == nil { return } if c.Search.MaxResults <= 0 { c.Search.MaxResults = DefaultSearchResults } if c.Search.SnippetRunes <= 0 { c.Search.SnippetRunes = DefaultSearchSnippetRunes } } // KiwixConfig — the offline encyclopedia. A kiwix-serve instance holding ZIM // archives (Wikipedia, ifixit, devdocs) on the LAN, searched when the live // search is empty, unreachable, or the line is down. Dark until configured, // same as every other reach. // // This is the "local sources first" rule in CLAUDE.md made concrete: a 1.7B // does not know enough to answer a world question, but it can read. A local // read costs nothing and leaves the box only as far as the LAN. type KiwixConfig struct { // URL — base address of kiwix-serve, e.g. "http://kiwix:8080". Empty ⇒ the // whole block is normalised to nil and the source stays off. URL string `json:"url,omitempty"` // Book — the ZIM to search, by its catalog name, e.g. // "wikipedia_en_all_maxi_2026-02". Take it from the /content/… href in // /catalog/v2/entries; the display title is not the name. // // Required. kiwix-serve answers 400 to a search with an empty books.name, // so a block without one is normalised to nil rather than left to fail one // query at a time. Book string `json:"book,omitempty"` // BookRU — the ZIM to search when the question is in Russian, by the same // catalog name. Empty ⇒ every question goes to Book. // // It exists because the rewriter is a workaround, not a feature (V-508). An // English ZIM cannot match a Russian sentence, so the resident model turns // the question into English keywords first, and that costs a model call and // loses whatever the keywords drop. A Russian ZIM matches the question as he // asked it. So a Cyrillic question searches this book verbatim and skips the // rewrite, and the English book keeps answering English ones. BookRU string `json:"book_ru,omitempty"` // MaxResults — how many hits are asked for. 0 ⇒ DefaultKiwixResults. // Only the top few reach the phraser regardless; the rest are context the // snippet ranking throws away. MaxResults int `json:"max_results,omitempty"` // SnippetRunes — how much of the joined snippets is handed to the phraser. // 0 ⇒ DefaultKiwixSnippetRunes. Sized against the 4096-token context, which // also holds the persona block and the prompt. SnippetRunes int `json:"snippet_runes,omitempty"` // Rewrite — turn the Russian question into English keywords with the // resident model before searching. The ZIMs are English and kiwix ranks by // keyword, not meaning, so a Russian sentence matches nothing. Costs one // short LLM call per query. Default true; set false only to measure the // difference or when the books are Russian. Rewrite *bool `json:"rewrite,omitempty"` } // Kiwix defaults, applied in normaliseKiwix. const ( DefaultKiwixResults = 5 DefaultKiwixSnippetRunes = 1500 ) // RewriteEnabled — Rewrite with its default applied. Absent ⇒ on. func (k *KiwixConfig) RewriteEnabled() bool { return k.Rewrite == nil || *k.Rewrite } // normaliseKiwix applies the block's defaults. No address or no book, nothing // to search. func (c *Config) normaliseKiwix() { if c.Kiwix != nil && (strings.TrimSpace(c.Kiwix.URL) == "" || strings.TrimSpace(c.Kiwix.Book) == "") { c.Kiwix = nil } if c.Kiwix == nil { return } if c.Kiwix.MaxResults <= 0 { c.Kiwix.MaxResults = DefaultKiwixResults } if c.Kiwix.SnippetRunes <= 0 { c.Kiwix.SnippetRunes = DefaultKiwixSnippetRunes } }