Skip to content

Latest commit

 

History

History
419 lines (343 loc) · 96.6 KB

File metadata and controls

419 lines (343 loc) · 96.6 KB

Configurations

Thuki reads its runtime configuration from a single TOML file located at:

~/Library/Application Support/com.quietnode.thuki/config.toml

The file is created automatically the first time the app launches. You can edit it with any text editor; changes take effect on the next launch. The in-app Settings panel (open it from the Thuki menu-bar icon) writes to this same file, so editing by hand and clicking through the panel are interchangeable.

First launch

You do not need to do anything. Thuki writes a default config.toml on first run with every field set to a sensible value.

If the directory cannot be written (disk full, permission denied, read-only filesystem), Thuki shows a native alert with the specific error and exits. This is a macOS-level setup problem; Thuki cannot repair it on your behalf.

Editing

Open the file, change a value, save, relaunch Thuki.

# Opens the file in your default TextEdit-like editor
open ~/Library/Application\ Support/com.quietnode.thuki/config.toml

Example

[inference]
# The provider Thuki sends inference to. Defaults to the Built-in engine,
# the bundled llama.cpp server. Switch to Ollama anytime.
active_provider = "builtin"
# Context window size in tokens sent to the active provider with every request.
# For the built-in engine the value becomes `--ctx-size` when the llama-server
# process starts, so changing it restarts the engine. For Ollama, warmup and
# chat share this value so the same runner and its cached KV prefix for the
# system prompt are reused. Raise to fit longer conversations; lower to reduce
# GPU memory use. Valid range: 2048-1048576.
num_ctx = 16384
# Minutes of inactivity before Thuki releases the active model from memory.
# Applies to both local providers (the built-in engine and Ollama).
# 0 = use the provider's natural short default (~5 min): Ollama defers to its
#     own timer, the built-in engine applies its own ~5-minute timer.
# -1 = keep resident forever. Valid range: -1 or 0-1440.
keep_warm_inactivity_minutes = 0

# One block per provider. The built-in entry is always present. A provider's
# selected model lives on its own `model` field (empty until you pick one in
# the model picker).
[[inference.providers]]
id = "builtin"
kind = "builtin"
label = "Built-in"
model = ""

[[inference.providers]]
id = "ollama"
kind = "ollama"
label = "Ollama"
# Where Thuki reaches your Ollama server. Defaults to this Mac; point it at
# another machine to use Ollama running elsewhere (one server at a time).
base_url = "http://127.0.0.1:11434"
model = ""

[prompt]
# The full secretary persona prompt. Seeded on first run. Save changes via
# Settings, which marks the prompt customized so your edit is kept; a hand edit
# made directly here only survives if you also set system_customized = true.
# While system_customized is false the stored value is treated as a cached
# default and refreshed to the built-in prompt on the next load. Clearing the
# prompt via Settings sends only the slash-command appendix, which Thuki always
# appends at runtime so slash commands keep working.
system = "..."
system_customized = false

[window]
overlay_width = 600
max_chat_height = 648
max_images = 3
text_base_px = 15.0
text_line_height = 1.5
text_letter_spacing_px = 0.0
text_font_weight = 500

[quote]
max_display_lines = 4
max_display_chars = 300
max_context_length = 4096

[behavior]
# Write /rewrite and /refine results straight back into the source app,
# replacing your selection, without clicking the in-chat Replace button.
auto_replace = false
# Dismiss the Thuki overlay after a /rewrite or /refine result is replaced
# back into the source app (manual Replace click or auto-replace).
auto_close = false
# When true (default), the built-in engine may search the web on plain turns.
# When false, only /search forces a live look-up.
auto_search = true
# Set true after the first-use web-search notice is dismissed ("Acknowledge").
# Default false so new installs see the notice once.
search_notice_acknowledged = false
# When true (default), completed turns are saved to history without a bookmark click.
auto_save_conversations = true
# Days to keep saved conversations by last activity; -1 keeps forever.
history_retention_days = -1
# Set true after the one-shot auto-save chat notice is dismissed ("Acknowledge").
auto_save_notice_acknowledged = false
# Weights SHA-256 of models you chose to load past the mild "may not fit in
# memory" warning. Managed from the warning card and removable in Settings;
# freeze-risk loads still warn. Sanitized on load (64-hex only, deduped, capped).
dismissed_memory_fit_models = []

[debug]
# Records every chat conversation, including its built-in web-search turns, to
# disk for later inspection.
trace_enabled = false
# Days to keep recorded trace files before they are pruned; -1 keeps forever.
trace_retention_days = 7

[updater]
# Poll for new Thuki releases at startup and on a recurring interval.
auto_check = true
# Hours between background checks. Bound to 1..168.
check_interval_hours = 24
# URL of the signed update manifest. Override only when mirroring releases.
manifest_url = "https://github.com/quiet-node/thuki/releases/latest/download/latest.json"

Reading the reference tables

Every domain below is shown as a single table that lists all constants Thuki uses in that area: both the ones you can tune in config.toml and the ones baked in at compile time. The columns are:

  • Constant: the TOML key (tunable) or Rust/TypeScript identifier (baked-in).
  • Default: the value Thuki ships with.
  • Tunable?: Yes if editable via config.toml, No if compiled in.
  • Why not tunable: only filled for baked-in constants; explains why it is locked.
  • Bounds: the allowed range for tunable numbers. Values outside this range are reset to the default and a warning is logged.
  • Description: what the constant controls, in plain language. For tunable numbers, this also explains what raising or lowering the value actually does for you.

Reference

[inference]

Thuki reaches a model through a provider. active_provider names which one is used; each provider is described by a [[inference.providers]] block. Two kinds exist: Built-in, the bundled llama.cpp llama-server that Thuki spawns and manages itself (no setup, the default on a fresh install); and Ollama, reached over HTTP at a configurable URL, local or remote.

Each provider keeps its own selected model. For the built-in engine, models are GGUF files Thuki downloads itself: pick a curated starter (or paste a Hugging Face repo id) in onboarding or Settings → Models → Discover, and manage installed models from the same place. For Ollama, Thuki discovers installed models live from the /api/tags endpoint; pull a model with ollama pull <slug> and select it. In every case the choice is written to that provider's model field, and when no model is installed and none has been chosen, Thuki refuses to dispatch a chat request and surfaces a "Pick a model" prompt.

Upgrading from an older version is automatic: an older config is migrated in place on first load, so your provider and selected model carry over with no manual steps.

Constant Default Tunable? Bounds Description
active_provider "builtin" Yes id of a provider Which provider receives inference. Must match the id of one of the [[inference.providers]] entries; an empty or dangling value resets to builtin.
num_ctx 16384 Yes [2048, 1048576] Context window size in tokens sent to the active provider with every request. For the built-in engine, the value becomes --ctx-size when the llama-server process starts, so changing it restarts the engine. For Ollama, warmup and chat share this value so the same runner instance and its cached KV prefix for the system prompt are reused: they must match or Ollama creates a second runner and the warmup saves nothing. Ollama silently clamps this to the model's physical maximum. Raise to fit longer conversations: the KV cache grows roughly linearly with the context size (the model weights stay the same), so each doubling roughly doubles its memory footprint; benchmark on your hardware before pushing it high, and lower to reclaim memory. See Tuning the Context Window.
keep_warm_inactivity_minutes 0 Yes -1 or [0, 1440] Minutes of inactivity before Thuki releases the active model from memory. Governs both local providers: the built-in engine stops its sidecar to free RAM, and Ollama is told to release the model from memory. 0 uses the provider's natural short default (about 5 minutes): Ollama defers to its own timer, the built-in engine applies its own ~5-minute timer (DEFAULT_BUILTIN_IDLE_MINUTES). -1 keeps the model resident forever. Raise for longer sessions between uses; lower to reclaim memory sooner.

Each [[inference.providers]] block has these fields:

Field Description
id Stable identifier referenced by active_provider. The builtin and ollama ids are seeded automatically.
kind "builtin" or "ollama". Determines how Thuki talks to the provider.
label Human-readable name shown in Settings.
base_url For the ollama kind: the server's base URL, defaults to http://127.0.0.1:11434 if empty (then re-seeded). Empty for the builtin kind.
model The model selected for this provider, written when you pick one. Empty means "none chosen yet".

If the active model has been removed from Ollama between launches, Thuki silently falls back to the first installed model the next time you open the picker. If no models are installed at all, the next request surfaces a "Model not found" error with the exact ollama pull <name> command to run.

The table below also lists the baked-in safety limits that govern Thuki's communication with provider HTTP APIs (Ollama and the Hugging Face Hub used for model downloads) and the lifecycle of the built-in engine process. None are tunable.

Constant Default Tunable? Why not tunable Bounds Description
DEFAULT_OLLAMA_TAGS_REQUEST_TIMEOUT_SECS 5 s No Protocol cap on a hung daemon to keep the UI responsive. A longer timeout would wedge the model picker; a shorter one would false-trigger on a momentarily slow daemon. How long Thuki waits for Ollama's /api/tags endpoint to respond before giving up. If Ollama accepts the connection but never replies, this prevents the picker from stalling.
DEFAULT_OLLAMA_SHOW_REQUEST_TIMEOUT_SECS 5 s No Protocol cap on a hung daemon to keep the UI responsive. Same rationale as the tags timeout above. How long Thuki waits for Ollama's /api/show endpoint to respond before giving up. Used when fetching capability flags (vision, thinking) for each installed model.
MAX_OLLAMA_TAGS_BODY_BYTES 4 MiB No Defense-in-depth bound on attacker-controlled response body. A misbehaving or compromised Ollama could otherwise stream an unbounded payload and exhaust memory. The largest /api/tags response body Thuki will accept. 4 MiB fits thousands of model entries; anything larger is rejected immediately and the request returns an error.
MAX_OLLAMA_SHOW_BODY_BYTES 4 MiB No Defense-in-depth bound on attacker-controlled response body. Same rationale as MAX_OLLAMA_TAGS_BODY_BYTES. The largest /api/show response body Thuki will accept. Full Modelfiles and parameters can be sizable, but 4 MiB is well above any real model; larger responses are rejected.
MAX_MODEL_SLUG_LEN 256 B No Defense-in-depth bound on adversarial input. Real Ollama slugs are a handful of characters; capping the length stops malformed values long before any network or DB work. The longest model slug Thuki will accept from set_active_model. Anything longer is rejected immediately by validate_model_slug.
VRAM_POLL_INTERVAL_SECS 5 s No Tuning this trades responsiveness against localhost polling load; 5 s is the sweet spot for loopback calls and matches Ollama's internal TTL resolution granularity. How often Thuki polls Ollama's /api/ps to detect VRAM changes made outside Thuki (for example, running ollama stop or a TTL expiry). The Settings panel VRAM indicator reflects these changes within one interval.
ENGINE_HEALTH_DEADLINE_SECS 300 s No Engine lifecycle contract: this bounds the worst-case "warming up" wait the UI can show before a start is declared failed, so changing it alters the UX contract rather than tuning a preference. How long Thuki waits for a freshly spawned built-in engine to pass its /health check before giving up and killing the process. Large GGUF models loading from a cold disk can legitimately take minutes, so the deadline is generous.
ENGINE_HEALTH_POLL_INTERVAL_MS 250 ms No Pure loopback-load tuning: 250 ms detects readiness promptly without hammering the local server while it is busy loading the model. How often Thuki probes the built-in engine's /health endpoint while it starts up. A 503 answer means the model is still loading and the poll continues; 200 means ready.
ENGINE_IDLE_CHECK_INTERVAL_SECS 30 s No Internal timer granularity behind the user-facing keep_warm_inactivity_minutes knob; 30 s keeps the unload within a minute-scale setting's precision at negligible cost. How often the engine runner checks whether the configured idle window has elapsed and the built-in engine should be stopped to free RAM.
DEFAULT_BUILTIN_IDLE_MINUTES 5 min No The fixed translation of the keep_warm_inactivity_minutes = 0 sentinel for the built-in engine, not a separate preference. The built-in engine has no external daemon to defer to, so 0 ("use the provider's natural short default") resolves to this value. Users who want a different timeout set keep_warm_inactivity_minutes directly (N minutes, or -1 for forever). The idle window the built-in engine applies when keep_warm_inactivity_minutes is 0. After this many minutes of inactivity the sidecar is stopped to free RAM.
ENGINE_HEALTH_PROBE_TIMEOUT_SECS 5 s No Internal lifecycle contract between the runner and the engine process. A wedged-but-connected server must not park the poll loop forever; loopback probes are normally instant so 5 s is generous. The poll interval and deadline are the user-facing knobs. How long a single /health GET is allowed to take inside the startup poll loop. If the engine has accepted the TCP connection but stopped responding, this timeout causes the probe to return an error (treated as Wait and retried after ENGINE_HEALTH_POLL_INTERVAL_MS).
ENGINE_COMMAND_QUEUE_CAPACITY 64 No Bounds memory under command bursts; 64 slots is ample for all UI-driven traffic (Ensure, Touch, SetIdleMinutes, Shutdown) under any realistic usage pattern. Capacity of the bounded mpsc channel that carries commands from EngineHandle to the runner actor task. Back-pressure from a full queue is not observable in normal use.
ENGINE_STDERR_TAIL_LINES 20 No Defense-in-depth bound on captured subprocess output: 20 lines cover the load-error block llama-server prints on exit without retaining its whole log. Number of trailing llama-server stderr lines the runner keeps so a crash can report the engine's own reason (e.g. unknown model architecture) instead of a generic message.
ENGINE_STDERR_TAIL_LINE_MAX_BYTES 500 No Defense-in-depth bound on attacker-influenced data: a single pathological newline-less stderr line (e.g. an enormous architecture string echoed from crafted GGUF metadata) is capped during the read, so neither peak read buffering nor the retained tail can grow without limit. Maximum bytes buffered and retained per captured engine stderr line.
ENGINE_CRASH_FALLBACK_MESSAGE "engine process exited unexpectedly" No Internal diagnostic fallback surfaced only when the real reason is unavailable; not meaningful to expose. n/a Reason reported when the built-in engine process exits without leaving any stderr to capture (e.g. an external SIGKILL).
DOWNLOAD_PROGRESS_MIN_INTERVAL_MS 500 ms No Pure IPC hygiene: a fast local connection can deliver thousands of chunks per second and the UI only needs a few updates per second, so throttling below the UI refresh rate is invisible to the user. Minimum interval between Progress events emitted while a model file downloads. An update is also emitted whenever at least 1% of the file has arrived since the last one, whichever comes first, and a final 100% update always precedes verification.
BLOB_HASH_BUFFER_BYTES 4 MiB No Internal I/O buffer with no user-visible effect beyond verify speed. A few-MB buffer turns hashing a multi-GB blob into a few hundred reads instead of hundreds of thousands. Read-buffer size for streaming a downloaded blob through SHA-256 during verification. The common path hashes bytes as they download, so this applies only to a full-length partial left from a prior run or a resumed download's on-disk prefix.
DEFAULT_MAX_CONCURRENT_DOWNLOADS 3 No Defense-in-depth bound against the resource exhaustion of issue #296 (unbounded parallel downloads plus an auto-load froze a memory-constrained Mac). Exposing it would let a user re-introduce the very failure the cap exists to prevent. The most model downloads allowed to transfer bytes at once. A start beyond the cap waits (surfacing a Queued state) for a slot before opening its HTTP transfer, rather than running concurrently.
DEFAULT_DOWNLOAD_DISK_HEADROOM_BYTES 2 GiB No Defense-in-depth floor against the disk-fill failure of issue #296, not a preference. Lowering it would re-open that failure by letting a download fill the volume to the brim and wedge the machine. Free-space headroom kept above a download's own byte needs, enforced both in the pre-download preflight and the periodic mid-transfer re-check. A download is refused (or aborted, keeping its partial for resume) when free space would drop below this floor.
DEFAULT_DOWNLOAD_DISK_RECHECK_INTERVAL_BYTES 256 MiB No Internal safety-probe cadence with no user-visible effect. It only sets how often free space is re-probed mid-transfer; the headroom floor above is what actually governs the abort. How many bytes a transfer writes between successive free-disk re-checks, so a long download that fills the volume after its preflight passed is caught within a few hundred MB and aborted cleanly.
MAX_HF_API_BODY_BYTES 4 MiB No Defense-in-depth bound on attacker-controlled data from a remote service, mirroring MAX_OLLAMA_TAGS_BODY_BYTES. The largest Hugging Face API response body (repo file listings) Thuki will accept while resolving a model to download. Larger responses are rejected mid-stream and the request returns an error.
MAX_GGUF_KV_COUNT 4096 No Defense-in-depth bound on a downloaded GGUF's metadata-key count. A corrupt or hostile metadata_kv_count could otherwise drive an unbounded scan; real models carry a few dozen entries, so 4096 never truncates legitimate metadata. The most GGUF metadata key-value pairs the reasoning classifier scans when reading a downloaded model's chat template. Scanning stops at the cap.
MAX_GGUF_KEY_BYTES 1 KiB No Defense-in-depth bound on a downloaded GGUF's metadata-key length. Keys are short dotted identifiers (tokenizer.chat_template); capping the length stops a corrupt length field from forcing a large allocation. The longest GGUF metadata key the reasoning classifier will read. A longer key stops the scan.
MAX_GGUF_STRING_BYTES 4 MiB No Defense-in-depth bound on a downloaded GGUF's string values. Real chat templates run a few KB to ~100 KB; 4 MiB never truncates one while bounding the memory a corrupt length field can demand. The largest GGUF string value (the chat template or architecture) the reasoning classifier will materialize. A larger value stops the scan and the model relies on the runtime backstop instead.
DENIED_PRIMARY_ARCHES bert, t5, clip, … No Defense-in-depth bound on attacker-influenced GGUF headers, and pin-scoped: a GGUF's general.architecture is editable, so a known non-chat family is refused as a primary chat model rather than handed to the engine, and an engine bump may add families that should stay out of the primary load path. Missing architecture stays soft (filename path) so an incomplete header never bricks an install. Architectures that may never load as a primary chat model. A downloaded or pasted GGUF whose header reports one of these non-chat families (embedding, encoder-only, audio tokenizer) is rejected at finalize/load with a clear "not a chat model" message; a chat arch not on the list still passes.
HF_API_TIMEOUT_SECS 15 s No Protocol cap on a hung remote service so the download UI cannot stall on metadata resolution; 15 s is generous for a small metadata call over the internet. How long Thuki waits for a Hugging Face API metadata call (repo file listing) to respond before giving up. Applies to resolving pasted repo ids and listing a repo's GGUF files, not to the model download itself.
HF_BASE_URL https://huggingface.co No Single origin for model metadata and downloads. Provenance comes from the pinned repo revisions in the curated starter registry, and those pins are only meaningful against the canonical Hub; an arbitrary mirror could serve different content under the same revision ids. The Hugging Face origin Thuki uses for all model metadata calls and blob downloads. Every starter in the registry pins a repo at an exact revision and carries a compiled-in sha256 digest checked after download; the digest catches truncation, bit rot, and resume corruption, while the pinned revision on the canonical Hub is what fixes which content is fetched.
HF_SEARCH_LIMIT 30 No The per-page step for the in-app model browser. The "Load more" control raises the requested page size in multiples of this value, so it is a layout step rather than a user preference. How many GGUF model repos the first page of an in-app Hugging Face search returns, most-downloaded first.
HF_SEARCH_LIMIT_MAX 120 No Defense-in-depth bound on request size: "Load more" grows the requested page size in HF_SEARCH_LIMIT steps, and this caps the largest single request so a runaway page count cannot ask the Hub for an unbounded result set. The largest page size a single in-app Hugging Face search request may ask for, regardless of how many times "Load more" was pressed.
MAX_MODEL_CONTEXT_LENGTH 1 M No Defense-in-depth bound on attacker-controlled GGUF metadata: a repo's context_length is editable (gguf_set_metadata.py) and occasionally inflated, so a value above this sane ceiling is treated as untrustworthy and dropped rather than shown. Mirrors the num_ctx upper bound; 1 M tokens covers every current model. The largest model context window Thuki will trust and display from a Browse-all repo's parsed GGUF metadata. A larger declared value is dropped (no context window shown) rather than rendered. Curated Staff Picks models carry a hand-vetted value in the registry instead.
RUNTIME_OVERHEAD_GB 2.0 No Feeds the approximate RAM-fit hint shown in Library and Discover only; the authoritative per-starter memory estimates live in the model registry. A user-tunable overhead would imply a precision the hint does not claim. Resident-memory overhead added on top of a model's weights size (KV cache plus runtime buffers) when estimating whether it fits in this Mac's RAM.
MAX_HF_SEARCH_QUERY_LEN 200 bytes No Defense-in-depth bound on attacker-influenced input: the query reaches the fixed Hub host (no SSRF) and is percent-encoded by the client, but an unbounded string is still rejected to cap request size. The longest search string Thuki sends to the Hugging Face model search. A longer query is rejected before any network call.
MAX_SSE_LINE_BYTES 1 MiB No Defense-in-depth bound on attacker-controlled stream data. A malicious or broken chat server could otherwise grow a single stream line without limit and exhaust memory. The longest single Server-Sent-Events line Thuki accepts while streaming a chat response over the built-in engine's /v1 endpoint. A stream line exceeding this aborts the response with an error.
DEFAULT_STARTUP_SAFE_MODE_THRESHOLD 2 No Crash-loop safety mechanism, not a preference. After this many consecutive abnormal sessions (the previous process died without a clean exit: freeze, kill -9, OS OOM-kill, panic, or power loss), Thuki enters safe mode and skips auto-loading the active model. Threshold 2 engages only after two consecutive abnormal sessions on purpose, so a single hard reboot or kill -9 does not nag the user, mirroring Firefox's toolkit.startup.max_resumed_crashes. Letting a user raise it would re-arm the whole-machine freeze this guard exists to break. How many consecutive abnormal sessions trip the startup circuit breaker into safe mode. In safe mode the no-user-action model auto-prime is skipped; the model still loads on the user's first message. Safe mode is decided purely by "the previous session never marked a clean exit" plus this streak; a clean exit is recorded only on a genuine quit (Thuki holds a process-lifetime advisory lock the kernel releases on death by any cause).
DEFAULT_SESSION_RECORD_FILENAME "session.json" No Internal filename used by the launch circuit breaker next to config.toml; not meaningful to expose and easy to break by typo. n/a Filename of the JSON session record the circuit breaker durably writes at startup (clean_exit: false) and flips to clean_exit: true only on a real quit, so an abnormal death is detectable on the next launch. Lives in the same directory as config.toml.
DEFAULT_SESSION_LOCK_FILENAME "session.lock" No Internal filename used by the launch circuit breaker next to config.toml; not meaningful to expose and easy to break by typo. n/a Filename of the empty advisory-lock file the circuit breaker holds for the whole process lifetime. The kernel releases the lock on process death by any cause, which is how a crash is detected without a clean-exit signal. Lives in the same directory as config.toml.
SHUTDOWN_SIGNAL_ENGINE_KILL_TIMEOUT_SECS 3 s No Internal shutdown-path safety valve, not a preference. On a polite stop (Ctrl+C or the SIGTERM macOS sends every app at restart) a dedicated thread shuts the sidecar down before re-raising the signal; this only bounds that kill so a wedged engine can never keep the thread from re-raising and leave the app unresponsive past the macOS restart deadline. Exposing it would let a user set a bound long enough to reintroduce that hang. How long the shutdown-signal thread waits for the built-in engine to stop before it re-raises the caught signal and lets the process exit anyway. A sidecar that outlives the bound is reaped at the next launch. The kill is normally near-instant (the child dies on an unblockable SIGKILL).
ORPHAN_REAP_SIGTERM_GRACE_MS 2000 ms No Internal shutdown-hygiene timing constant, not a preference. It only sets how long an orphaned sidecar left by a prior Thuki gets to exit on SIGTERM before the reaper escalates to SIGKILL; the full three-clause orphan predicate is re-checked after the wait, so the grace length never affects which process is killed, only how long a stubborn one is given. The wait between the polite SIGTERM and the escalating SIGKILL the startup reaper sends an orphaned llama-server (ppid == 1, our exact sidecar path, our uid) left behind after a SIGKILL, panic, or machine freeze took Thuki down without a shutdown path.

[prompt]

Controls the personality and instructions Thuki gives to the AI at the start of every conversation.

Constant Default Tunable? Why not tunable Bounds Description
system full built-in body (~6 KB) Yes any string The full secretary personality prompt, seeded into your config.toml on first run. It becomes authoritative only once you save it through Settings (which sets system_customized = true); until then it is treated as a cached copy of the built-in default and is refreshed to the current DEFAULT_SYSTEM_PROMPT_BASE on every load, so app upgrades that ship a new prompt reach you automatically. To make a hand edit in the TOML stick, also set system_customized = true. Saving an empty value via Settings sends no persona at all. The slash-command appendix is always added on top, so /search etc. work either way.
DEFAULT_SYSTEM_CUSTOMIZED false No Internal authority flag. Set to true the first time the user saves the system prompt via Settings. While false, the persisted system is treated as a cached default and is overwritten by DEFAULT_SYSTEM_PROMPT_BASE on every load; once true, the stored value (including an explicit empty) is respected verbatim. Not user-tunable because exposing it would let users suppress the safety net that keeps non-customizing installs on the current built-in persona across upgrades. Tracks whether the user has ever explicitly saved a system prompt through the Settings UI.
DEFAULT_SYSTEM_PROMPT_BASE prompts/system_prompt.txt No The shipped built-in prompt. Seeds system on first run, and is reapplied on every load for any config not customized via Settings (system_customized = false), so edits to this file reach all non-customizing installs. Source-of-truth file used to seed and refresh system.
SLASH_COMMAND_PROMPT_APPENDIX prompts/generated/slash_commands.txt No Auto-generated from the slash-command registry at build time. Editing by hand would desync the AI's understanding of the commands from the real ones. The list of slash commands (/search, /screen, etc.) Thuki tells the AI about so it knows what each one does. Always added on top of your system prompt.

[window]

UI configuration for the floating Thuki window: geometry knobs and input attachment limits. The collapsed-bar height and the close-animation deadline are baked into the frontend (see App.tsx) because their effective range is invisible to users (collapsed height is overwritten by the ResizeObserver within a frame; the hide delay sits below normal perception across its usable range and creates a visible pop if dropped below the exit-animation duration).

Constant Default Tunable? Why not tunable Bounds Description
overlay_width 600.0 Yes [200.0, 2000.0] How wide the floating Thuki window is, in pixels. Raise for wider input/chat at the cost of more screen space; lower to keep Thuki compact.
max_chat_height 648.0 Yes [200.0, 2000.0] The largest the chat window can grow to as conversation gets longer. Raise to see more chat history without scrolling; lower to keep Thuki from taking over your screen on long chats.
max_images 3 Yes [1, 20] Maximum number of images you can manually attach to a single message by pasting or dragging. A /screen capture always counts as one extra on top of this limit. Raise for richer visual context per message; lower to keep prompts compact.
text_base_px 15.0 Yes [11.0, 22.0] Base font size for chat text and the AskBar input, in CSS pixels. Drives the --thuki-text-base CSS variable consumed by the AI markdown body, the user chat bubble text, and the AskBar textarea (plus its caret-tracking mirror). Other surfaces (Settings panel, onboarding) keep fixed sizes. Raise for easier-to-read conversation text; lower to fit more text on screen.
text_line_height 1.5 Yes [1.0, 2.5] Line-height multiplier applied to chat text and the AskBar input. Drives the --thuki-text-line-height CSS variable. Raise for airier, easier-to-skim replies; lower to fit more lines on screen.
text_letter_spacing_px 0.0 Yes [-0.5, 2.0] Extra space between characters, in CSS pixels. Drives the --thuki-text-letter-spacing CSS variable. Raise for airier letters; drop below zero to tighten the typography.
text_font_weight 500 Yes {400, 500, 600, 700} CSS font-weight applied to chat and AskBar text. Drives the --thuki-text-font-weight CSS variable. Only the four loaded Nunito weights are accepted; off-grid values reset to the default. Raise for a heavier presence; lower for a lighter look.
COLLAPSED_WINDOW_HEIGHT 80 px No Frontend constant; overwritten by ResizeObserver before the frame renders, so any value in the user-visible range produces identical results. The initial height of the collapsed input bar, in pixels. Overwritten by ResizeObserver on every render, so the value the user sees is always determined dynamically.
HIDE_COMMIT_DELAY_MS 350 ms No Frontend constant; the value sits below normal perception across its usable range and creates a visible pop if dropped below the exit-animation duration. How long Thuki waits after you close the window before it hides the underlying NSPanel. Keeps the exit animation from being cut off.

[quote]

Controls how text you select in another app (and bring to Thuki) appears as a quote in the input bar, and how much of it actually gets sent to the AI.

Constant Default Tunable? Why not tunable Bounds Description
max_display_lines 4 Yes [1, 100] How many lines of the quoted text are shown as a preview in the input bar. The full text is still sent to the AI; this only affects what you see. Raise to preview more of the quote at a glance; lower to keep the input bar compact.
max_display_chars 300 Yes [1, 10000] How many characters of the quoted text are shown as a preview in the input bar. Same idea as max_display_lines: the full text is still sent to the AI. Raise for a longer preview; lower to keep the bar compact.
max_context_length 4096 Yes [1, 65536] How many characters of the quoted text are actually sent to the AI. Anything past this is cut off. Raise if you quote long passages and want the AI to see all of it; lower if your model has a small context window or you want to save tokens on big selections.

[behavior]

Controls rewrite/replace dismiss behavior and whether the built-in engine may open the web on plain chat turns.

Constant Default Tunable? Why not tunable Bounds Description
auto_replace false Yes When on, a /rewrite or /refine result is written straight back into the source app, replacing your highlighted text, the moment the rewrite is ready, with no extra click. When off, the rewrite appears in Thuki and you press the Replace button to send it back. The Replace button is available either way.
auto_close false Yes When on, the Thuki overlay closes itself right after a /rewrite or /refine result is replaced back into the source app, whether the replace happened automatically (auto_replace) or from a manual Replace click. Only closes on a successful replace. Independent of auto_replace. Turn on for a one-shot rewrite-and-dismiss flow; leave off to keep Thuki open and replace repeatedly.
auto_search true Yes On (default): search the web when a plain message needs live facts. Off: stay local unless you type /search. Toggle from Settings › Behavior.
search_notice_acknowledged false Yes When false, Thuki shows the one-time v0.16 ask-bar version announcement (Auto search spotlight). Set true when the user taps Acknowledge; the announcement never returns. Key name is historical; behavior is announcement dismiss, independent of auto_search (CTA still adapts Turn on/off).
auto_save_conversations true Yes On (default): each completed turn is written to local history without a bookmark click. Off: only an explicit Save persists the chat. Toggle from Settings › Behavior.
history_retention_days -1 Yes n/a -1 or 1..3650 How many days saved chats are kept by last activity (updated_at) before a startup or confirmed retention-change prune deletes them. Raise to keep history longer; lower to reclaim disk sooner; -1 to keep forever. 0 and other out-of-range values reset to the default. Changing to a finite window asks for confirmation before write + prune.
auto_save_notice_acknowledged false Yes When false, after the first auto-saved turn Thuki can show a one-shot chat-header notice that chats are being saved. Set true on Acknowledge; the notice never returns. Independent of auto_save_conversations.
dismissed_memory_fit_models [] Yes up to 64 entries Weights SHA-256 of models you chose to load past the mild "may not fit in memory" warning ("Don't warn me about this model again"). A listed model skips the warning only in the mild over-limit band; a freeze-risk load (estimate at or above free memory) still warns. Managed from the warning card and removable per row in Settings › Behavior. Sanitized on load: only 64-char lowercase-hex entries are kept, duplicates are dropped, and the list is capped FIFO at 64.

Memory-fit gate

Loading a model estimates its resident footprint and compares it to the memory free right now, refusing an un-forced load that would not fit. The two fractions below bound that decision; both are baked-in safety constants, not preferences.

Constant Value Tunable? Why not tunable Description
MODEL_FIT_HARD_BLOCK_FRACTION 3.00 No Defense-in-depth freeze bound Freeze-band floor: at or above 3x free memory, the "may not fit" warning always fires and can never be suppressed by a per-model remember (dismissed_memory_fit_models), so the "Always allow this model" action is hidden and only the single force plus "Switch model" remain. A load needing at least triple the free memory is a gross over-commit that risks a non-pageable Metal wiring and a hard freeze. Below this floor a merely-over-the-ceiling load (up to 3x free RAM: the common "squeeze it out" case where the estimate is conservative and it usually still fits) can be remembered. A per-turn "Load once" still bypasses the floor; a persisted remember never does, because free RAM is dynamic.
MAX_DISMISSED_MEMORY_FIT_MODELS 64 No Bound on a user-editable list Cap on how many remembered memory-fit overrides are kept in config and memory. Adding beyond the cap evicts the oldest entry FIFO, so repeated opt-ins can never grow the list without bound.

Web search

Thuki searches the web on its own whenever a question needs current information, and the /search command forces a fresh search for a single message. The search runs on the bundled, keyless engine with nothing to install, so there are no user-tunable search keys in config.toml: the pipeline's engine set, timeouts, and tuning are fixed for a no-setup experience. One baked-in constant is worth documenting for its security role.

Related: user behavior in commands.md and privacy.md; design handbook in built-in-web-search.md; privacy egress in search-privacy.md; developer evaluation harnesses in search-eval.md.

Constant Default Tunable? Why not tunable Bounds Description
SOURCE_DELIMITER_TOKEN_HEX_LEN 32 No Defense-in-depth over attacker-controlled web content: it sets the width of the per-request random token that fences fetched sources off in the writer prompt. Exposing it could only shorten the token and weaken the fence, never help a user. Number of hex characters in the fresh random delimiter that wraps retrieved web pages before they reach the AI. A new token is minted for every search, so a malicious page cannot know it and cannot forge the closing marker to break out of the quoted region and inject instructions (prompt-injection spotlighting).
VERTICAL_RETRY_ATTEMPTS 1 No Locked resilience/anti-bot-detection trade-off, not a preference. Applies only to the weather, encyclopedia, news, and sports verticals' GETs (Open-Meteo geocoding and forecast, Wikipedia search and summary, Google News RSS, ESPN). The clock feature's identical Open-Meteo geocoding call is a separate, unretried code path since it sits outside the search decision. DuckDuckGo and Mojeek (SERP engines) never retry, because a same-identity retry pattern-matches a bot burst and has triggered multi-hour SERP IP blocks in the past. Exposing this would let a user re-introduce that risk. How many times a vertical GET is retried after it fails at the transport level or returns HTTP 429/5xx, before the turn falls through to the next tier.
VERTICAL_RETRY_BACKOFF_MIN_MS 300 ms No Internal resilience policy, not a preference; paired with the max bound below to keep the retry delay in a range that neither looks like a lockstep bot pattern nor stalls a turn. Lower bound of the jittered delay before a vertical's single automatic retry.
VERTICAL_RETRY_BACKOFF_MAX_MS 800 ms No Internal resilience policy, not a preference. Same rationale as the min bound above. Upper bound of the jittered delay before a vertical's single automatic retry.
REACHABILITY_PROBE_TIMEOUT_MS 800 ms No Internal robustness bound on the offline check, not a knob: its only user-visible effect is how fast a turn that was never going to reach the web gives up. A probe that misses this deadline proves nothing and is treated as "unknown", never as "offline". Deadline for the one reachability probe (DNS resolution of every SERP engine host the turn is about to contact; any host resolving counts as reachable; no new third-party host is involved).
OFFLINE_SHORTCIRCUIT_WINDOW_MS 1500 ms No Judgment value trading offline give-up latency against the risk of cutting a live-but-slow request short. Raising it makes an offline user wait longer for the truth; lowering it leaves a slow-starting request less room to win the race, so it stays fixed. How long a turn whose probe proved there is no network path still waits on the real search requests before it discloses that the web could not be reached. Replaces the tens of seconds of stacked per-engine timeouts an offline device used to sit through. A request that returns inside this window always wins.

[debug]

Records every chat conversation, including its built-in web-search turns, as JSON-Lines under app_data_dir/traces/chat/<conversation_id>.jsonl. Off by default; toggleable from Settings. Trace files stay on your disk and are never uploaded.

Field Default Tunable? Why not tunable Bounds Description
trace_enabled false Yes Records every chat conversation, including its web-search turns, to disk for debugging.
trace_retention_days 7 Yes n/a -1 or 1..3650 How many days recorded trace files are kept before a startup / on-change prune deletes them. Raise to keep history longer; lower to reclaim disk sooner; -1 to keep forever. 0 and other out-of-range values reset to the default.

[updater]

Controls how Thuki polls for new releases. The actual download, signature verification, and binary swap are handled by the bundled Tauri updater plugin against a signed manifest hosted on GitHub Releases. The manifest is verified against an ed25519 public key compiled into the app, so a hijacked release cannot push a malicious binary to existing installs.

Field Default Tunable? Why not tunable Bounds Description
auto_check true Yes n/a n/a Whether Thuki polls for updates automatically. When false, only the "Check now" button in Settings triggers a check. The tray badge and Settings banner still appear if a check finds an update.
check_interval_hours 24 Yes n/a 1..168 Hours between automatic background checks. Raise to spend less bandwidth on update polling; lower to surface new releases sooner. The interval also gates the startup check after a freshly resumed session.
manifest_url GitHub releases default Yes n/a n/a URL of the signed update manifest. Override only when mirroring releases (for example, an internal release feed). Empty values fall back to the default URL.
MAX_UPDATER_SNOOZE_HOURS 8760 No Defense-in-depth bound on hours arriving from the frontend IPC; prevents u64 arithmetic in the snooze handlers from wrapping if a hostile or buggy caller supplies an extreme value. n/a Maximum number of hours a "snooze update" request can defer the next nag. Caps at one year so the deadline math cannot overflow even in the worst case.
DEFAULT_UPDATER_STATE_FILENAME "updater_state.json" No Internal sidecar filename used for snooze persistence next to config.toml; not meaningful to expose and easy to break by typo. n/a Filename of the JSON sidecar that records snooze deadlines so they survive app restarts. Lives in the same directory as config.toml.

[activation] (not in TOML)

Settings for the double-tap-Control hotkey that opens Thuki, plus the macOS Accessibility permission check. None of these are user-tunable: the hotkey listener runs in a low-level system thread that cannot read live config, so changing them would require restructuring the keyboard plumbing.

Constant Default Tunable? Why not tunable Bounds Description
ACTIVATION_WINDOW 400 ms No Event-tap callback cannot read Tauri managed state; would require a redesign to expose. No user has reported needing a different cadence. How fast you have to double-tap Control to open Thuki: the second tap must happen within this many milliseconds of the first, otherwise it does not count.
ACTIVATION_COOLDOWN 600 ms No Same as above. After Thuki opens or closes, this is how long it ignores another double-tap. Prevents accidental rapid-fire toggling when you tap too many times in a row.
KC_PRIMARY_L 0x3b No macOS hardware key code for left Control. Not user-meaningful; wrong value would brick activation. The internal macOS hardware code for the LEFT Control key. This is not something you set; it is just the number macOS uses to identify that key.
KC_PRIMARY_R 0x3e No macOS hardware key code for right Control. Not user-meaningful; wrong value would brick activation. The internal macOS hardware code for the RIGHT Control key. Same idea as KC_PRIMARY_L.
MAX_PERMISSION_ATTEMPTS 6 No Internal retry budget for the Accessibility prompt; no user-facing reason to tune. When you first run Thuki, it asks for Accessibility permission so it can listen for the Control key. This is how many times Thuki re-checks while it waits.
PERMISSION_POLL_INTERVAL 5 s No Same as above. How often (in seconds) Thuki re-checks for Accessibility permission while it waits for you to grant it.

[vision] (not in TOML)

Limits and quality settings for images you attach to a message (whether you drag them in or capture them with /screen). The number of images per message is the tunable [window] max_images (default 3) plus one slot reserved for a /screen capture; the size and quality settings below are fixed, tuned for the best balance of file size and AI accuracy.

Constant Default Tunable? Why not tunable Bounds Description
MAX_IMAGE_SIZE_BYTES 30 MiB No Frontend rejection threshold aligned with local vision models' practical decode ceiling. The biggest image file you can attach (30 MB). Files larger than this are rejected before they're even processed, so an oversized image cannot crash the AI.
MAX_DIMENSION 1920 px No Downscale target that balances vision-model accuracy against payload size. If an image is wider or taller than this many pixels, Thuki shrinks it to fit (keeping its aspect ratio). Keeps file sizes manageable without hurting how well vision models see it.
JPEG_QUALITY 85 No Balances file size against visual fidelity for vision models; changes would invalidate historical saved images. The compression level Thuki uses when saving attached images as JPEG (on a 1–100 scale; higher = better quality and bigger file). 85 is the sweet spot for vision models.

[history] (not in TOML)

Settings for the conversation history panel (where you scroll back through past chats and search them). Not user-tunable.

Constant Default Tunable? Why not tunable Bounds Description
SEARCH_DEBOUNCE_MS 200 No UX tuning; no meaningful user signal for changing this. Raising it makes search feel sluggish; lowering it wastes cycles on every keystroke. When you type in the history search box, Thuki waits this many milliseconds after your last keystroke before actually running the search. Stops Thuki from running a fresh search on every single character you type, while still feeling instant.
STRIP_PATTERNS 17 token strings No Defense-in-depth bound on external/attacker-controlled data: special turn-boundary tokens leaked by fine-tuned models would corrupt cross-model history if persisted. Exposing this list as a config knob would let a malformed or adversarial model response disable the sanitization layer. The set of special delimiters (e.g. <|im_start|>, [INST], <think>) that major model families use internally. Some fine-tuned models leak these into message.content; Thuki strips them before storing an assistant reply and again at render time so switching between model families does not produce visible garbage in the chat window. The TypeScript mirror of this list (src/utils/sanitizeAssistantContent.ts) must be kept in sync when new model families are added.

Email capture (not in TOML)

Backs the optional "Help shape Thuki" email ask (the onboarding roadmap screen and Settings ▸ About). Thuki sends your email only when you click the button, and POSTs just { email, source } to a public proxy that holds the email-service key, so no secret ever lives in the app. Not user-tunable.

Constant Default Tunable? Why not tunable Bounds Description
DEFAULT_SUBSCRIBE_ENDPOINT https://thuki.app/api/subscribe No Fixed external-service endpoint, not a knob: the proxy at this origin holds the email-service key and enforces the contract, so pointing it elsewhere would silently break the subscribe flow. The public proxy URL the "Help shape Thuki" ask POSTs { email, source } to. A 200 (including already-subscribed) is success; any other response surfaces a generic, retryable error.
DEFAULT_SUBSCRIBE_TIMEOUT_SECS 15 No Internal robustness bound on a one-shot network call, not a preference: it caps how long the sending state can last if the proxy stalls. Per-request timeout (seconds) for the subscribe POST. If the proxy does not respond in time the request fails into the same generic, retryable error rather than hanging.

Built-in web search (not in TOML)

Governs the invisible auto-search that runs on the built-in engine: a deterministic pre-filter and a persona-free classifier decide whether a turn needs the web and which source tier (weather, sports, news, Wikipedia, or the general engines) best answers it. The constants below are the deterministic guards that keep the Wikipedia vertical from answering a volatile question from a static encyclopedia summary even when the classifier mis-routes one there, the operators that bias DuckDuckGo and Google News toward recent results on a freshness-seeking question (Mojeek does not get one: see mojeek_request's rustdoc for why), the thresholds that decide what language a question is written in (so a Vietnamese or Japanese question searches the Vietnamese or Japanese web, and its Wikipedia edition, rather than the English one), the keyword map that routes a question to ESPN's live scoreboard, and the recency-prior fusion that, on that same freshness-seeking question, re-ranks the general engine tier's already-fetched sources toward the newest one instead of relevance alone. None are user-tunable. Defaults and bounds live in src-tauri/src/config/defaults.rs. Pipeline design: built-in-web-search.md.

Constant Default Tunable? Why not tunable Bounds Description
WIKI_VOLATILITY_MARKERS latest, current, status, today, recent, upcoming, anniversary plus non-English single-token today/now forms (heute, hoy, 今日, …) No Routing contract: a fixed set of freshness words that guard a model-routed decision against a known non-answer. Exposing it as a knob would let a bad edit send volatile questions to a static encyclopedia summary. Freshness words that disqualify a question from the Wikipedia vertical and arm DDG date bias + recency fusion. Non-English tokens close the gap where e.g. Vietnamese "hôm nay" used to leave freshness off.
WIKI_VOLATILITY_PHRASES right now, this year, how old is, … plus multilingual today phrases (hôm nay, mới nhất, aujourd hui, …) No Same routing-contract rationale as the single-word markers; split out only because these span a word boundary. Multi-word freshness phrases matched as whole token sequences. Includes VI/FR "today/latest" forms required for language-parity live queries.
PRICE_INTENT_MARKERS price, prices, cost, rate, giá, precio, … No Evidence-contract list: price asks must take the freshness path and the numeric-utility filter even without an explicit "today" word. Any one token forces freshness + price-intent evidence filters (see STALE_PATH_YEAR_LAG, PRICE_LIKE_MIN_DIGIT_RUN).
STALE_PATH_YEAR_LAG 2 No Evidence-pipeline constant: multi-year archive paths must not ground "today" answers. Drop /YYYY/ path years at or older than now_year - lag on freshness turns.
PRICE_LIKE_MIN_DIGIT_RUN 2 No Ranking-algorithm heuristic bound for price-intent numeric utility. Minimum consecutive ASCII digits that count as a price-like figure in a chunk.
PRICE_MAGNITUDE_MIN_PRIMARY 10000 No Evidence-pipeline bound for cross-source price magnitude consensus. Minimum primary quote that enters the magnitude-outlier filter (ignores tiny indices / percents).
PRICE_MAGNITUDE_RATIO 5.0 No Evidence-pipeline bound for cross-source price magnitude consensus. Max/min primary ratio that triggers dropping the minority order-of-magnitude cluster (kills 14.35M-vs-145.5M SJC class).
WIKI_VOLATILITY_MIN_YEAR 2025 No Routing-contract bound tied to the volatility guard: a present/future year signals a live-world question a static extract cannot answer. Not a preference. The earliest 4-digit year that reads as a present/future freshness signal. A question naming a year at or above this is refused by the Wikipedia vertical; an earlier year is history, which Wikipedia serves well.
DDG_FRESHNESS_DF_VALUE "w" No Fixed protocol convention of an external service: DuckDuckGo's own df date-filter parameter, not a Thuki preference. When a question carries a freshness signal (the same markers as above), Thuki adds this value as a df form field AND a df cookie on the DuckDuckGo request, narrowing results to the past week.
NEWS_FRESHNESS_OPERATOR "when:7d" No Fixed protocol convention of an external service: Google News RSS's own query-operator syntax. When a question carries a freshness signal, Thuki appends this operator to the query sent to the Google News feed, correcting its default ordering (which otherwise skews stale) to the past 7 days.
SEARCH_LANG_DEFAULT "en" No Anchor of the language allowlist: a value outside that allowlist has no verified request shape on any search channel, so it is not a preference to expose. The language every search channel falls back to when a question's language can be read neither from its script nor from your system locale ($LANG).
SEARCH_LANG_SCRIPT_RATIO_MIN 0.30 No Defense-in-depth bound on external input: it caps how loudly one stray character may speak for a whole question. A presence check would let a single quoted character redirect an entire search. The share of a question's letters that must belong to one script (Han, Kana, Hangul, Thai, Arabic, Hebrew, Greek) before that script decides the question's language. "what does 中 mean" scores 0.08 and stays English.
SEARCH_LANG_VI_TOKEN_RATIO_MIN 0.30 No Same defense-in-depth rationale: Vietnamese is Latin script, so its only signal is its diacritics, and those ride into English on loanwords. The share of a question's words that must carry a Vietnamese-specific character before the question is treated as Vietnamese. "what does phở mean" scores 0.25 and stays English; a Vietnamese question below the bar still resolves through your locale.
DDG_DEFAULT_REGION "wt-wt" No Fixed protocol convention of an external service: DuckDuckGo's own region code, not a Thuki preference. The DuckDuckGo region used when a question's language does not resolve: wt-wt is DuckDuckGo's "worldwide, no bias" code, replacing the United States bias that was previously forced onto every query.
SEARCH_DEFAULT_ACCEPT_LANGUAGE "en-US,en;q=0.9" No Fixed protocol convention of an external service: an HTTP header value. The Accept-Language header sent on an English search. DuckDuckGo's region code selects a region, not a language, so this header is the only language lever the engine tier has and it follows the resolved language.
SEARCH_ACCEPT_LANGUAGE_FALLBACK ",en;q=0.5" No Fixed protocol convention of an external service: an HTTP header value. Appended to a non-English Accept-Language header, so a page with no edition in your language still ranks its English edition ahead of an arbitrary third language rather than being excluded.
MOJEEK_LANGUAGE_BIAS_BOOST "100" No Protocol cap imposed by an external service: 100 is Mojeek's documented maximum for its lbb parameter. The strength of the language bias sent to Mojeek alongside the language code on a non-English search: full weight on the requested language.
SPORTS_LEAGUE_MAP 9 keyword → league mappings No Routing contract mapping known competitions/leagues to their ESPN API path segments, the same rationale as the Wikipedia volatility markers. Exposing it as a knob would let a bad edit silently break the vertical for a whole league. Keyword-to-league map (e.g. "nba" → the NBA, "world cup" → the FIFA World Cup) that decides whether a question can be answered from ESPN's live scoreboard. A question naming none of these leagues never reaches the sports vertical.
SPORTS_SCHEDULE_WINDOW_DAYS 7 No Pipeline-shape constant, not a preference: ESPN's scoreboard defaults to only today's slate, which cannot answer "when is the next match" once today's fixtures are all live or finished. Wide enough to always contain the next fixture of an active competition, narrow enough to keep the block within its source budget. How many days forward from today the sports vertical asks ESPN's scoreboard to cover (?dates=<today>-<today+N>), so the response spans the next fixtures rather than just today's games. The next-match answer is then computed in code from the returned event states.
SEARCH_CACHE_TTL_S 600 (10 min) No Internal robustness bound, same rationale as the classifier/router timeouts: erring toward a fresh re-search costs nothing, a stale cached answer served past its window does. How long the fetched pages of a successful search stay reusable for a follow-up that repeats, rephrases, or drills into the same topic. The cache holds up to SEARCH_CACHE_MAX_ENTRIES recent searches, scoped to the current conversation; a new conversation, a reset, or a follow-up after the window elapses always triggers a fresh search instead.
SEARCH_CACHE_MAX_ENTRIES 4 No Internal memory-safety bound: caps the multi-turn page cache so a long conversation cannot grow the retained-page list without limit. Every entry keeps fetched web-page text (attacker-influenceable content), so an unbounded list would let one conversation grow process memory freely. At the cap the oldest entry is evicted. Maximum number of recent searches the conversation page cache keeps for reuse. A cached follow-up re-runs the fresh post-fetch pipeline over the stored pages of any of them, so an adjacent-detail question is answered from what was already fetched, not re-searched; a turn the pages cannot ground escalates to a fresh search.
SEARCH_CACHE_PAGE_TEXT_MAX_BYTES 24576 (24 KiB) No Internal memory-safety bound on attacker-influenceable fetched page text: a page's readability-extracted body is not otherwise hard-bounded in bytes, so each stored page's text is truncated to this many bytes (at a UTF-8 char boundary) before caching. Holds a typical article's readable body while capping the worst case. Maximum bytes of one fetched page's text a conversation cache entry retains.
SEARCH_CACHE_ENTRY_TEXT_MAX_BYTES 131072 (128 KiB) No Internal memory-safety bound on attacker-influenceable fetched page text: once one entry's cumulative (already per-page-capped) page text would exceed this budget the remaining pages are dropped from the tail (the first page is always kept). With the two caps above this fixes the cache's retained-text memory at SEARCH_CACHE_MAX_ENTRIES * SEARCH_CACHE_ENTRY_TEXT_MAX_BYTES worst case. Maximum total bytes of page text a single conversation cache entry retains.
SERP_CACHE_TTL_S 300 (5 min) No Internal robustness bound, same rationale as SEARCH_CACHE_TTL_S: a repeat scrape within the window is served from memory, both saving latency and starving the engines' volume-triggered rate limits, while the short window keeps ranked results fresh. How long a per-engine result list stays reusable in the in-memory web cache before the engine is scraped again for the same query. Held in memory only, never written to disk; process exit wipes it.
PAGE_CACHE_TTL_S 900 (15 min) No Internal robustness bound, same rationale as SERP_CACHE_TTL_S, with a longer window because article text drifts more slowly than the ranked result set that points at it. How long an extracted page body stays reusable in the in-memory web cache before the page is fetched again. Held in memory only, never written to disk; process exit wipes it.
SERP_CACHE_MAX_ENTRIES 64 No Internal memory-safety bound: caps the in-memory web cache so a long session cannot grow the SERP map without limit. At the cap the oldest-inserted entry is evicted. Maximum number of per-engine result lists the in-memory web cache holds at once.
SERP_MAX_RAW_HITS_PER_QUERY 64 No Defense-in-depth bound on external/attacker-controlled data: a keyless engine's result page parses into one row per DOM node with no row cap, so an oversized or format-changed response could otherwise cache an unbounded list. The cap sits well above a real ~30-row page, so it never truncates a normal result set (every row still reaches fusion, preserving recall) and only bounds the pathological case. Maximum raw rows kept from a single engine's parsed result page before it is cached and fused. Distinct from the post-fusion SERP_MAX_RESULTS_PER_QUERY output ceiling: fusion still sees the whole page, and only the final fused list is trimmed to the output cap.
PAGE_CACHE_MAX_ENTRIES 128 No Internal memory-safety bound, same rationale as SERP_CACHE_MAX_ENTRIES, larger because a single search fans out to several fetched pages, so the page working set is bigger than the query working set. Maximum number of extracted page bodies the in-memory web cache holds at once.
CLASSIFIER_ASSISTANT_PREFIX_CHARS 300 No Classifier-prompt shape constant, not a preference: the router embeds recent assistant answers so it can resolve an elliptical follow-up, and this bounds how much of each answer it reads. Tuning it up would bloat the warm-slot classifier prompt; down would drop the named entity that resolves the reference. Maximum characters of each embedded assistant answer the persona-free classifier reads when rewriting an elliptical follow-up ("how about him?", "what about X?") into a standalone question. The opening of an answer carries the entities that resolve the reference; user questions are short and embedded whole.
CITE_MAGNITUDE_ABBREVIATIONS bn/mn/tn/b/m/t/k → ×10⁹/10⁶/10¹²/10⁹/10⁶/10¹²/10³ No Fixed English financial-shorthand vocabulary for the citation audit's numeric-consistency guard, not a preference: editing it would silently change which figures the guard recognizes as matching. Letter suffixes the numeric-consistency guard recognizes directly after a digit run ($615B, 1.2mn) when checking whether a claim's figure is backed by its cited source, so formatting differences alone never hide (or fabricate) a match.
CITE_MAGNITUDE_WORDS thousand/million/billion/trillion/triệu/tỷ/nghìn/ngàn/trăm (+ ASCII folds) → ×10³/10⁶/10⁹/10¹²/10⁶/10⁹/10³/10³/10² No Fixed multi-locale magnitude vocabulary for the same guard, same rationale as CITE_MAGNITUDE_ABBREVIATIONS. Spelled-out magnitude words the numeric-consistency guard recognizes after a digit run and whitespace (615 billion, 144500 triệu), normalized to the same value as abbreviated and fully-expanded forms. Vietnamese scale words stop a double-count against dotted full figures (144.500.000).
CITE_UNIT_SUFFIXES /lượng/kg/°C/đồng/… → unit ids No Fixed unit vocabulary for the same guard: editing it changes which unit clashes the audit can catch. Kept minimal to smoke-proven money/weather units. Unit tokens bound to a number after optional whitespace so a claim's đ/kg cannot pass against a source's đ/lượng when the digits alone match.
CITE_MONTH_NAMES 12 English month names → 1-12 No Fixed English calendar vocabulary for the same guard, same rationale as CITE_MAGNITUDE_ABBREVIATIONS. Month names the numeric-consistency guard recognizes in a July 9, 2026-style date mention, alongside the numeric M/D/YYYY and ISO YYYY-MM-DD forms it also checks.
CITE_UNVERIFIABLE_MIN_SOURCE_BYTES 20 No Defense-in-depth bound over externally fetched web content, not a user preference. Minimum byte length a cited source's fetched text must reach before the citation audit will score a claim against it. Below this (including an empty source), the citation is classified "unverifiable" instead of "unsupported": the live-observed shape of a JS-widget single-page-app result (a Binance or MEXC price page, for example), whose extraction succeeds but yields only a loading placeholder or an empty SERP-snippet fallback, never real content. Unlike an unsupported citation, an unverifiable one never triggers the answer-facing hedge note.
ENGINE_REQUERY_MAX 1 No Fixed network-requery budget per turn is a product invariant. After the engine tier assembles sources, a judge checks whether they answer the question. On insufficient-missing, Thuki fires one requery round (judge requery_queries when present, else standalone+missing concat), merges new sources, then re-judges once. If still missing, the writer gets a partial directive so related metrics are not treated as the asked fact.
REQUERY_MISSING_MAX_CHARS 80 No Engine query hygiene, not a preference: the judge's missing phrase is free-form model prose and can run to a full sentence, and a long tail of prose degrades keyless-engine SERP quality far more than a whole trailing word left out does. Maximum characters of the judge's missing phrase appended to the standalone question when building the fallback bounded requery (only when the judge omitted requery_queries), truncated at the last word boundary within the cap so the text actually searched never ends mid-word. The trace's missing field still records the judge's full, uncapped phrase.
REQUERY_QUERY_MAX 2 No Fixed per-turn network budget: more than two requery SERPs rarely helps and doubles third-party burst. Maximum number of keyword SERP queries the one engine-tier requery round may issue from a judge requery_queries list (or the single fallback concat query).
REQUERY_QUERY_MAX_CHARS 120 No Engine query hygiene, same rationale as REQUERY_MISSING_MAX_CHARS. Maximum characters of each judge-authored requery keyword string after trim and word-boundary clip.
TABLE_EXTRACT_MAX_CHARS 4000 No Extract size bound over attacker-controlled HTML, not a preference. Max characters of HTML table text appended to (or used instead of) readability article body so level/amount figures in tables survive when news-style readability drops them.
TABLE_EXTRACT_MAX_TABLES 8 No Extract size bound, same rationale as TABLE_EXTRACT_MAX_CHARS. Max <table> elements harvested per page during table extract.
TABLE_EXTRACT_MAX_CELLS_PER_TABLE 200 No Extract size bound, same rationale as TABLE_EXTRACT_MAX_CHARS. Max cells read from one table (row-major) during table extract.
RECENCY_ALPHA 0.3 No Corpus-sensitive ranking parameter. This is a conservative first guess (informed by arXiv:2509.19376, which shows relevance-only ranking misses the newest item on a time-sensitive query), not a value tuned against a real evaluation corpus yet, so it is not exposed as something a user could sensibly set. Weight given to a source's recency, versus its relevance, when a freshness-seeking question's general engine tier re-ranks its already-fetched sources: final_score = RECENCY_ALPHA * recency + (1 - RECENCY_ALPHA) * relevance_norm. Only applied when the turn already carries a freshness signal (the same markers as WIKI_VOLATILITY_MARKERS); a non-fresh turn's ranking is untouched.
RECENCY_HALF_LIFE_DAYS 14.0 No Corpus-sensitive ranking parameter, the same conservative-first-guess rationale as RECENCY_ALPHA, pending tuning against a real evaluation corpus. Half-life, in days, of the exponential recency decay exp(-ln(2) * age_days / RECENCY_HALF_LIFE_DAYS) fed into RECENCY_ALPHA's fusion formula. A source published exactly one half-life ago scores 0.5, the deliberate same value an undated source gets (RECENCY_NEUTRAL_SCORE).
RECENCY_NEUTRAL_SCORE 0.5 No Algorithm invariant of the fusion formula, not a tuning knob: a source with no extractable date must read as "moderately fresh," never as evidence of staleness (0.0) or of freshness (1.0) it does not actually have. Recency score assigned to a source whose published/modified date could not be extracted from its page (no JSON-LD, meta tag, or <time> element carried a valid one). Never dropped for being undated: it competes on relevance alone.
RECENCY_FUTURE_TOLERANCE_HOURS 24 No Defense-in-depth bound on attacker-controlled page metadata: a page's declared published/modified date is untrusted input, and the parsing that reads it is bounded the same way the readability extractor's own DOM element cap is. Clock-skew tolerance, in hours, applied when validating an extracted date against the current time. A date claiming to be more than this far in the future is treated as undated rather than trusted as a freshness signal.
FETCH_FIRST_K_COMPLETIONS 3 No Internal latency-shape bound on the page-fetch fan-out, not a preference: bounds tail latency without dropping any information (an abandoned fetch still contributes its snippet). n/a How many of the budgeted page fetches must finish before the fetch stage moves on to ranking, out of the full fetch budget raced concurrently. Whichever comes first between this count and FETCH_SOFT_DEADLINE_MS applies; any fetch still in flight at that point is abandoned and falls back to its SERP snippet, the same fallback a genuine per-URL fetch failure already uses.
FETCH_SOFT_DEADLINE_MS 2000 (2 s) No Same rationale as FETCH_FIRST_K_COMPLETIONS: an internal latency bound, not a preference. Only ever shortens the wait: the fetch stage's per-URL timeout remains the hard cap on any single fetch, so this soft deadline never extends it. n/a Soft aggregate deadline (milliseconds) for the whole page-fetch fan-out. Once it elapses, the fetch stage proceeds with whatever pages have completed so far, regardless of FETCH_FIRST_K_COMPLETIONS, and any still-in-flight fetch degrades to its SERP snippet.
QUOTE_STAT_SCORE_NUDGE 0.5 No Ranking-algorithm constant: a moderate, deterministic BM25 score bump informed by GEO (arXiv:2311.09735), which found LLM answer synthesis preferentially cites quote- and statistic-bearing passages, not a value a user could sensibly tune. Added to a chunk's BM25 score when it comes from a credibility-boosted reference-grade domain and also carries a quote or inline statistic, so a reference source's plainer prose does not lose a close ranking tie to a distractor chunk phrased more citably. Only applied to a chunk that already scored above zero, so it can never resurrect an irrelevant chunk.
STATISTIC_MIN_DIGIT_RUN 3 No Ranking-algorithm heuristic bound paired with QUOTE_STAT_SCORE_NUDGE, not a preference. Minimum run of consecutive digits that counts as an inline statistic for the score nudge above (a %-suffixed figure counts regardless of digit-run length). Three digits excludes incidental one- and two-digit numbers while still catching years, unmarked percentages, and larger reported figures.
CHUNK_CJK_TARGET_CHARS 500 No Retrieval-pipeline shape constant, the character-script counterpart of the word-based chunk target, not a preference. Target size, in characters, of one page chunk when the page is written in an unspaced script (Chinese, Japanese, Thai, Lao, Khmer, Burmese). Those scripts put no whitespace between words, so the word-based target cannot size a chunk there; ~500 characters carry roughly the information of the ~350 English words the word path targets, so both paths land in the same retrieval band.
CHUNK_CJK_MAX_CHARS 700 No Retrieval-pipeline shape constant paired with CHUNK_CJK_TARGET_CHARS: a hard ceiling that guarantees forward progress, not a value a user could sensibly tune. Hard character ceiling for one chunk on the unspaced-script path. A sentence longer than this (a paragraph with no sentence terminator, the normal shape of Thai prose) is split at this width, so a degenerate whole-page chunk is impossible by construction.
CHUNK_CJK_SENTENCE_TERMINATORS 。!?;.!? No Retrieval-pipeline shape constant: a fixed punctuation contract for splitting unspaced-script text, not a preference. Sentence terminators the unspaced-script chunker splits on, kept with the sentence they end. The full-width comma and the ideographic comma are deliberately absent: they separate clauses, not sentences.
CHUNK_UNSPACED_RATIO_MIN 0.3 No Retrieval-pipeline shape constant: the routing threshold between the two chunking paths, not a preference. Minimum fraction of a page's non-whitespace characters that must belong to an unspaced script before the page chunks on characters instead of words. Set low so a CJK page carrying Latin markup, URLs, and numbers still takes the character path; Korean, which is whitespace-delimited, never does.
UNLISTED_DOMAIN_CHUNK_CAP 2 No Retrieval-pipeline diversity bound conditioned on an upstream credibility signal, informed by realistic-RAG research (arXiv:2505.15561) showing distracting top-K passages, not rank position, drive citation of junk over a reference source, so it is a pipeline shape decision rather than a user preference. Maximum chunks a domain absent from the credibility list may contribute to the assembled context once a credibility-boosted reference-grade domain has already contributed at least one chunk. Never fires on a result set with no boosted domain, and a capped domain still contributes up to this many chunks; retrieved information is never discarded outright.

What happens on bad input

Thuki tries to keep itself running with a working configuration rather than crash on a typo. Here is what it does in each case:

  • The file is missing: Thuki writes a fresh defaults file and launches normally.
  • A field is missing: Thuki uses the default for that field; your other settings stay as-is.
  • A field is empty or just whitespace: Thuki uses the default for that field.
  • A number is outside its allowed range: Thuki resets that field to the default and logs a warning. (You can see warnings in Console.app.)
  • The file is not valid TOML at all: Thuki renames the broken file to config.toml.corrupt-<unix_timestamp> and writes a fresh defaults file. Your old file is kept so you can open it and copy out anything you want to recover.