diff --git a/.gitignore b/.gitignore index b3fea81..0a11468 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ docs/superpowers/ .superpowers/ *.profraw +# SearXNG container dumps upstream defaults here on each start +sandbox/search-box/searxng/settings.yml.new + diff --git a/CLAUDE.md b/CLAUDE.md index 7830c4d..0440a0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ The loader is forgiving and never crashes the app on user config: - Missing file → defaults seeded and written. (Only fatal failure path is the seed write itself.) - Missing fields/sections → `#[serde(default)]` fills from compiled defaults. -- Empty/whitespace strings → replaced with compiled defaults. +- Empty/whitespace strings → replaced with compiled defaults. Exception: `prompt.system` is a deliberate user override; an empty value is preserved and means "send no persona" (only the slash-command appendix is composed into `resolved_system`). - Out-of-bounds numerics → reset to default with a stderr warning. - Unparseable TOML → file renamed `config.toml.corrupt-` and a fresh defaults file written. diff --git a/docs/configurations.md b/docs/configurations.md index 166e671..1d5f14c 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -42,10 +42,11 @@ keep_warm_inactivity_minutes = 0 num_ctx = 16384 [prompt] -# Leave empty to use the built-in secretary persona. -# Thuki always appends the generated slash-command appendix at runtime, -# whether or not this field is set, so slash commands keep working. -system = "" +# The full secretary persona prompt. Seeded on first run so this file is the +# single source of truth: edit it to tune behavior. Clearing it sends only +# the slash-command appendix, which Thuki always appends at runtime so slash +# commands keep working. +system = "..." [window] overlay_width = 600 @@ -131,8 +132,8 @@ Controls the personality and instructions Thuki gives to the AI at the start of | Constant | Default | Tunable? | Why not tunable | Bounds | Description | | :------------------------------ | :------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :--------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `system` | `""` | Yes | — | any string | Your custom personality or instructions for the AI (for example, "You are a terse Rust expert"). Leave this empty to use Thuki's built-in secretary personality. The list of slash commands is always added on top, so `/search` etc. work either way. | -| `DEFAULT_SYSTEM_PROMPT_BASE` | `prompts/system_prompt.txt` | No | This is the fallback used when `system` is empty. To customize, set `system` instead, edit it in your `config.toml` rather than this file. | — | The built-in secretary personality Thuki uses when you have not set a custom `system` prompt. | +| `system` | full built-in body (~17 KB) | Yes | — | any string | The full secretary personality prompt. Seeded into your `config.toml` on first run so the file is the single source of truth: edit, tweak, or replace it. Clearing the field sends no persona at all. The slash-command appendix is always added on top, so `/search` etc. work either way. | +| `DEFAULT_SYSTEM_PROMPT_BASE` | `prompts/system_prompt.txt` | No | The shipped seed for `system` on first run. Once your `config.toml` exists, only the file matters; this constant is no longer consulted at runtime. | — | Source-of-truth file used to seed `system` on first run. | | `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]` diff --git a/src-tauri/src/config/loader.rs b/src-tauri/src/config/loader.rs index 90c4fee..b53a915 100644 --- a/src-tauri/src/config/loader.rs +++ b/src-tauri/src/config/loader.rs @@ -33,9 +33,8 @@ use super::defaults::{ DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, - DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS, - DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, - SLASH_COMMAND_PROMPT_APPENDIX, + DEFAULT_SEARXNG_URL, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, + DEFAULT_UPDATER_MANIFEST_URL, SLASH_COMMAND_PROMPT_APPENDIX, }; use super::error::ConfigError; use super::schema::AppConfig; @@ -153,13 +152,12 @@ pub(crate) fn resolve(config: &mut AppConfig) { "inference.num_ctx", ); - // Prompt section: empty base -> built-in. Compose resolved_system. - let base = if config.prompt.system.trim().is_empty() { - DEFAULT_SYSTEM_PROMPT_BASE - } else { - &config.prompt.system - }; - config.prompt.resolved_system = compose_system_prompt(base, SLASH_COMMAND_PROMPT_APPENDIX); + // Prompt section: compose the user's persona with the slash-command + // appendix into the runtime-only `resolved_system`. The on-disk `system` + // string is the single source of truth; if the user has cleared it, no + // persona is sent (only the appendix). + config.prompt.resolved_system = + compose_system_prompt(&config.prompt.system, SLASH_COMMAND_PROMPT_APPENDIX); // Window section. clamp_f64( @@ -300,6 +298,8 @@ pub fn compose_system_prompt(base: &str, appendix: &str) -> String { let appendix = appendix.trim(); if appendix.is_empty() { base.to_string() + } else if base.is_empty() { + appendix.to_string() } else { format!("{base}\n\n{appendix}") } diff --git a/src-tauri/src/config/schema.rs b/src-tauri/src/config/schema.rs index 6dad6eb..46e4b22 100644 --- a/src-tauri/src/config/schema.rs +++ b/src-tauri/src/config/schema.rs @@ -20,8 +20,9 @@ use super::defaults::{ DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S, - DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL, DEFAULT_TOP_K_URLS, - DEFAULT_UPDATER_AUTO_CHECK, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, + DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL, + DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK, + DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL, }; /// Static, user-tunable inference daemon configuration. @@ -61,18 +62,16 @@ impl Default for InferenceSection { } } -/// Prompt configuration. `system` holds only the user-editable base text. -/// The slash-command appendix is composed at load time into `resolved_system` -/// and is never written back to the file. `resolved_system` is computed, not -/// serialized. -/// -/// Note: `#[derive(Default)]` is correct here because both fields genuinely -/// start empty: `system` empty means "use the built-in persona", and -/// `resolved_system` is populated by the loader before any consumer reads it. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +/// Prompt configuration. `system` holds the user-editable persona prompt; on +/// first run it is seeded with the full built-in body so the file is the +/// single source of truth. The slash-command appendix is composed at load +/// time into `resolved_system` and is never written back to the file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct PromptSection { - /// User-editable persona prompt. Empty means "use the built-in default". + /// User-editable persona prompt. Seeded with the built-in body and + /// freely editable thereafter. If the user clears it, no persona is + /// sent (only the slash-command appendix). pub system: String, /// Composed runtime value (base prompt plus slash-command appendix). /// Not serialized; computed by the loader. @@ -80,6 +79,15 @@ pub struct PromptSection { pub resolved_system: String, } +impl Default for PromptSection { + fn default() -> Self { + Self { + system: DEFAULT_SYSTEM_PROMPT_BASE.to_string(), + resolved_system: String::new(), + } + } +} + /// Overlay UI configuration. Holds window geometry 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 diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 6f9230b..55f7c1b 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -56,7 +56,7 @@ fn defaults_const_values_match_schema_defaults() { DEFAULT_KEEP_WARM_INACTIVITY_MINUTES ); assert_eq!(c.inference.num_ctx, DEFAULT_NUM_CTX); - assert_eq!(c.prompt.system, ""); + assert_eq!(c.prompt.system, DEFAULT_SYSTEM_PROMPT_BASE); assert_eq!(c.prompt.resolved_system, ""); assert_eq!(c.window.overlay_width, DEFAULT_OVERLAY_WIDTH); assert_eq!(c.window.max_chat_height, DEFAULT_MAX_CHAT_HEIGHT); @@ -96,7 +96,7 @@ fn section_defaults_are_sensible() { assert_eq!(m.ollama_url, DEFAULT_OLLAMA_URL); let p = PromptSection::default(); - assert!(p.system.is_empty()); + assert_eq!(p.system, DEFAULT_SYSTEM_PROMPT_BASE); let w = WindowSection::default(); assert_eq!(w.overlay_width, DEFAULT_OVERLAY_WIDTH); @@ -161,6 +161,18 @@ fn compose_system_prompt_skips_appendix_when_totally_empty() { assert_eq!(got, "hello"); } +#[test] +fn compose_system_prompt_returns_appendix_only_when_base_empty() { + let got = compose_system_prompt("", "world"); + assert_eq!(got, "world"); +} + +#[test] +fn compose_system_prompt_returns_appendix_only_when_base_whitespace() { + let got = compose_system_prompt(" \n\t", "world"); + assert_eq!(got, "world"); +} + // ── loader: first run (file missing) ──────────────────────────────────────── #[test] @@ -497,26 +509,29 @@ fn resolve_empty_ollama_url_falls_back() { } #[test] -fn resolve_empty_system_prompt_uses_built_in_base_plus_appendix() { +fn resolve_empty_system_prompt_keeps_only_appendix() { + // The user has explicitly cleared their persona; resolved_system contains + // the slash-command appendix only. Built-in persona is no longer auto + // re-injected, so the on-disk file remains the single source of truth. let dir = fresh_temp_dir(); let path = config_path_in(&dir); std::fs::write( &path, r#" [prompt] - system = " " + system = "" "#, ) .unwrap(); let config = load_from_path(&path).unwrap(); - assert!(config + assert_eq!( + config.prompt.resolved_system, + SLASH_COMMAND_PROMPT_APPENDIX.trim() + ); + assert!(!config .prompt .resolved_system .contains(DEFAULT_SYSTEM_PROMPT_BASE.trim())); - assert!(config - .prompt - .resolved_system - .contains(SLASH_COMMAND_PROMPT_APPENDIX.trim())); } #[test] diff --git a/src/settings/components/index.tsx b/src/settings/components/index.tsx index 13f674e..1f0898f 100644 --- a/src/settings/components/index.tsx +++ b/src/settings/components/index.tsx @@ -135,12 +135,14 @@ export function Textarea({ placeholder, maxLength, ariaLabel, + rows = 4, }: { value: string; onChange: (next: string) => void; placeholder?: string; maxLength?: number; ariaLabel?: string; + rows?: number; }) { return (