diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-05 08:36:31 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-05 17:36:31 +0100 |
| commit | e6ab243dfde79c50ce5661b630ed26b9a1504dae (patch) | |
| tree | a8bd99d3a22f6592d91fad7766574310e7fc1dbc /crates | |
| parent | feat: initial draft of atuin-shell (#3206) (diff) | |
| download | atuin-e6ab243dfde79c50ce5661b630ed26b9a1504dae.zip | |
feat: Allow setting multipliers for frequency, recency, and frecency scores (#3235)
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/atuin-client/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 20 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings/watcher.rs | 256 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/components/search.rs | 27 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/daemon.rs | 14 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/lib.rs | 23 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/search/index.rs | 102 |
7 files changed, 417 insertions, 26 deletions
diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index 91f37b42..ba65a78d 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -52,6 +52,7 @@ tokio = { workspace = true } semver = { workspace = true } thiserror = { workspace = true } futures = "0.3" +notify = "7" crypto_secretbox = "0.1.1" generic-array = { version = "0.14", features = ["serde"] } serde_with = "3.8.1" diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index bfa94f6e..ddd047cd 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -27,6 +27,7 @@ mod dotfiles; mod kv; pub(crate) mod meta; mod scripts; +pub mod watcher; #[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)] pub enum SearchMode { @@ -472,6 +473,18 @@ pub struct Daemon { pub struct Search { /// The list of enabled filter modes, in order of priority. pub filters: Vec<FilterMode>, + + /// The recency score multiplier for the search index (default: 1.0). + /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables. + pub recency_score_multiplier: f64, + + /// The frequency score multiplier for the search index (default: 1.0). + /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables. + pub frequency_score_multiplier: f64, + + /// The overall frecency score multiplier for the search index (default: 1.0). + /// Applied after combining recency and frequency scores. + pub frecency_score_multiplier: f64, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -720,6 +733,10 @@ impl Default for Search { FilterMode::Workspace, FilterMode::Directory, ], + + recency_score_multiplier: 1.0, + frequency_score_multiplier: 1.0, + frecency_score_multiplier: 1.0, } } } @@ -1299,6 +1316,9 @@ impl Settings { .set_default("logs.ai.file", "ai.log")? .set_default("kv.db_path", kv_path.to_str())? .set_default("scripts.db_path", scripts_path.to_str())? + .set_default("search.recency_score_multiplier", 1.0)? + .set_default("search.frequency_score_multiplier", 1.0)? + .set_default("search.frecency_score_multiplier", 1.0)? .set_default("meta.db_path", meta_path.to_str())? .set_default( "search.filters", diff --git a/crates/atuin-client/src/settings/watcher.rs b/crates/atuin-client/src/settings/watcher.rs new file mode 100644 index 00000000..740b8d12 --- /dev/null +++ b/crates/atuin-client/src/settings/watcher.rs @@ -0,0 +1,256 @@ +//! Config file watching for automatic settings reload. +//! +//! This module provides a `SettingsWatcher` that monitors the config file +//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel. +//! +//! # Example +//! +//! ```no_run +//! use atuin_client::settings::watcher::global_settings_watcher; +//! +//! async fn example() -> eyre::Result<()> { +//! let watcher = global_settings_watcher()?; +//! let mut rx = watcher.subscribe(); +//! +//! // React to settings changes +//! while rx.changed().await.is_ok() { +//! let settings = rx.borrow(); +//! println!("Settings updated!"); +//! } +//! Ok(()) +//! } +//! ``` + +use std::{ + path::PathBuf, + sync::{Arc, OnceLock}, + time::Duration, +}; + +use eyre::{Result, WrapErr}; +use log::{debug, error, info, warn}; +use notify::{ + Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher, + event::{EventKind, ModifyKind}, +}; +use tokio::sync::watch; + +use super::Settings; + +/// Global singleton for the settings watcher. +static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new(); + +/// Get the global settings watcher singleton. +/// +/// Initializes the watcher on first call. Subsequent calls return the same instance. +/// The watcher monitors the config file for changes and broadcasts updates. +pub fn global_settings_watcher() -> Result<&'static SettingsWatcher> { + let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string())); + + match result { + Ok(watcher) => Ok(watcher), + Err(e) => Err(eyre::eyre!("{}", e)), + } +} + +/// Watches the config file for changes and broadcasts updated settings. +/// +/// Uses `notify` for cross-platform file watching and `tokio::sync::watch` +/// for efficient broadcast to multiple subscribers. +pub struct SettingsWatcher { + /// Receiver for settings updates. Clone this to subscribe. + rx: watch::Receiver<Arc<Settings>>, + /// Keeps the file watcher alive for the lifetime of this struct. + _watcher: RecommendedWatcher, +} + +impl SettingsWatcher { + /// Create a new settings watcher. + /// + /// Loads initial settings and starts watching the config file for changes. + /// Changes are debounced (500ms) to avoid multiple reloads during saves. + pub fn new() -> Result<Self> { + let initial_settings = Arc::new(Settings::new()?); + let (tx, rx) = watch::channel(initial_settings); + + let config_path = Self::config_path(); + info!("starting config file watcher: {:?}", config_path); + + let watcher = Self::create_watcher(tx, config_path)?; + + Ok(Self { + rx, + _watcher: watcher, + }) + } + + /// Subscribe to settings updates. + /// + /// Returns a receiver that will be notified when settings change. + /// Use `changed().await` to wait for the next update, then `borrow()` + /// to access the current settings. + pub fn subscribe(&self) -> watch::Receiver<Arc<Settings>> { + self.rx.clone() + } + + /// Get the current settings without subscribing to updates. + pub fn current(&self) -> Arc<Settings> { + self.rx.borrow().clone() + } + + /// Get the config file path. + fn config_path() -> PathBuf { + let config_dir = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") { + PathBuf::from(p) + } else { + atuin_common::utils::config_dir() + }; + config_dir.join("config.toml") + } + + /// Create the file watcher with debouncing. + fn create_watcher( + tx: watch::Sender<Arc<Settings>>, + config_path: PathBuf, + ) -> Result<RecommendedWatcher> { + // Channel for debouncing file events + let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>(); + + // Spawn debounce thread + let config_path_clone = config_path.clone(); + std::thread::spawn(move || { + Self::debounce_loop(debounce_rx, tx, config_path_clone); + }); + + // Clone config_path for use in the watcher callback + let config_path_for_watcher = config_path.clone(); + + // Canonicalize config path for reliable comparison on macOS + // (handles symlinks like /var -> /private/var) + let canonical_config_path = config_path_for_watcher + .canonicalize() + .unwrap_or_else(|_| config_path_for_watcher.clone()); + + // Create file watcher + let mut watcher = RecommendedWatcher::new( + move |res: Result<notify::Event, notify::Error>| { + match res { + Ok(event) => { + // Defensive: if paths is empty, we can't filter, so assume + // it might be our config file and trigger a reload to be safe + if event.paths.is_empty() { + warn!( + "config watcher: event has no paths, triggering reload to be safe" + ); + let _ = debounce_tx.send(()); + return; + } + + // Only react to events for our specific config file + // (filter out editor temp files, backups, etc.) + let is_config_file = event.paths.iter().any(|path| { + // Canonicalize for reliable comparison (handles macOS symlinks) + let canonical_event_path = + path.canonicalize().unwrap_or_else(|_| path.clone()); + + // Check if this event is for our config file + // (either exact match or the file was renamed to our config) + canonical_event_path == canonical_config_path + || path.file_name() == config_path_for_watcher.file_name() + }); + + if !is_config_file { + return; + } + + // Only react to modify events (content changes) or creates + if matches!( + event.kind, + EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any) + | EventKind::Create(_) + ) { + debug!("config file event detected: {:?}", event); + // Send to debounce channel (ignore send errors - receiver might be gone) + let _ = debounce_tx.send(()); + } + } + Err(e) => { + error!("file watcher error: {}", e); + } + } + }, + NotifyConfig::default(), + ) + .wrap_err("failed to create file watcher")?; + + // Watch the config file's parent directory (some editors create new files) + let watch_path = config_path.parent().unwrap_or(&config_path); + + // Defensive: ensure watch path exists before trying to watch + if !watch_path.exists() { + warn!( + "config directory does not exist, creating it: {:?}", + watch_path + ); + std::fs::create_dir_all(watch_path) + .wrap_err_with(|| format!("failed to create config directory: {:?}", watch_path))?; + } + + watcher + .watch(watch_path, RecursiveMode::NonRecursive) + .wrap_err_with(|| format!("failed to watch config directory: {:?}", watch_path))?; + + info!("config file watcher initialized for: {:?}", watch_path); + Ok(watcher) + } + + /// Debounce loop that batches file events and reloads settings. + fn debounce_loop( + rx: std::sync::mpsc::Receiver<()>, + tx: watch::Sender<Arc<Settings>>, + config_path: PathBuf, + ) { + const DEBOUNCE_DURATION: Duration = Duration::from_millis(500); + + loop { + // Wait for first event + if rx.recv().is_err() { + // Channel closed, watcher was dropped + debug!("config watcher debounce loop exiting"); + return; + } + + // Drain any additional events within debounce window + while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() { + // Keep draining + } + + // Defensive: check if config file exists before reloading + // (handles case where file was deleted - we'll get notified when it's recreated) + if !config_path.exists() { + debug!( + "config file does not exist, skipping reload: {:?}", + config_path + ); + continue; + } + + // Now reload settings + info!("config file changed, reloading settings: {:?}", config_path); + match Settings::new() { + Ok(settings) => { + if tx.send(Arc::new(settings)).is_err() { + // All receivers dropped + debug!("all settings subscribers dropped, exiting"); + return; + } + info!("settings reloaded successfully"); + } + Err(e) => { + warn!("failed to reload settings: {}", e); + // Keep the old settings, don't broadcast the error + } + } + } + } +} diff --git a/crates/atuin-daemon/src/components/search.rs b/crates/atuin-daemon/src/components/search.rs index 7fb59dea..9fc87fae 100644 --- a/crates/atuin-daemon/src/components/search.rs +++ b/crates/atuin-daemon/src/components/search.rs @@ -120,6 +120,7 @@ impl Component for SearchComponent { // Spawn background task to load history into index let index = self.index.clone(); let db = handle.history_db().clone(); + let handle_for_loader = handle.clone(); self.loader_handle = Some(tokio::spawn(async move { info!( @@ -141,8 +142,9 @@ impl Component for SearchComponent { "Initial history load complete; {} unique commands indexed", index.read().await.command_count() ); - // Build initial frecency map - index.read().await.rebuild_frecency().await; + // Build initial frecency map with current settings + let settings = handle_for_loader.settings().await; + index.read().await.rebuild_frecency(&settings.search).await; info!("Initial frecency map built"); break; } @@ -156,6 +158,7 @@ impl Component for SearchComponent { // Spawn background task to periodically refresh frecency let index_for_frecency = self.index.clone(); + let handle_for_frecency = handle.clone(); self.frecency_handle = Some(tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs( FRECENCY_REFRESH_INTERVAL_SECS, @@ -163,7 +166,12 @@ impl Component for SearchComponent { loop { interval.tick().await; trace!("Refreshing frecency map"); - index_for_frecency.read().await.rebuild_frecency().await; + let settings = handle_for_frecency.settings().await; + index_for_frecency + .read() + .await + .rebuild_frecency(&settings.search) + .await; } })); @@ -231,11 +239,22 @@ impl Component for SearchComponent { tracing::error!("Failed to rebuild search index: {}", e); } } + DaemonEvent::SettingsReloaded => { + info!("Settings reloaded, rebuilding frecency map with new multipliers"); + let handle_guard = self.handle.read().await; + if let Some(handle) = handle_guard.as_ref() { + let settings = handle.settings().await; + self.index + .read() + .await + .rebuild_frecency(&settings.search) + .await; + } + } // Events we don't care about DaemonEvent::SyncCompleted { .. } | DaemonEvent::SyncFailed { .. } | DaemonEvent::ForceSync - | DaemonEvent::SettingsReloaded | DaemonEvent::ShutdownRequested => {} } Ok(()) diff --git a/crates/atuin-daemon/src/daemon.rs b/crates/atuin-daemon/src/daemon.rs index ec0b7b68..625ca205 100644 --- a/crates/atuin-daemon/src/daemon.rs +++ b/crates/atuin-daemon/src/daemon.rs @@ -119,12 +119,20 @@ impl DaemonHandle { /// via `handle.settings()` to pick up the changes. pub async fn reload_settings(&self) -> Result<()> { let new_settings = Settings::new()?; - *self.state.settings.write().await = new_settings; - self.emit(DaemonEvent::SettingsReloaded); - tracing::info!("settings reloaded"); + self.apply_settings(new_settings).await; Ok(()) } + /// Apply already-loaded settings and emit a SettingsReloaded event. + /// + /// Use this when settings have already been loaded (e.g., from a file watcher) + /// to avoid parsing the config file twice. + pub async fn apply_settings(&self, settings: Settings) { + *self.state.settings.write().await = settings; + self.emit(DaemonEvent::SettingsReloaded); + tracing::info!("settings applied"); + } + /// Get the encryption key. pub fn encryption_key(&self) -> &[u8; 32] { &self.state.encryption_key diff --git a/crates/atuin-daemon/src/lib.rs b/crates/atuin-daemon/src/lib.rs index 6dc04db3..84f808e4 100644 --- a/crates/atuin-daemon/src/lib.rs +++ b/crates/atuin-daemon/src/lib.rs @@ -1,5 +1,6 @@ use atuin_client::database::Sqlite as HistoryDatabase; -use atuin_client::{record::sqlite_store::SqliteStore, settings::Settings}; +use atuin_client::record::sqlite_store::SqliteStore; +use atuin_client::settings::{Settings, watcher::global_settings_watcher}; use eyre::Result; pub mod client; @@ -59,6 +60,26 @@ pub async fn boot( // Start all components first (so gRPC services can work) daemon.start_components().await?; + // Spawn config file watcher to reload settings on changes + if let Ok(watcher) = global_settings_watcher() { + let mut settings_rx = watcher.subscribe(); + let watcher_handle = handle.clone(); + tokio::spawn(async move { + tracing::info!("config file watcher started"); + while settings_rx.changed().await.is_ok() { + // Use the already-loaded settings from the watcher + // (avoids parsing the config file twice) + let new_settings = (*settings_rx.borrow()).clone(); + watcher_handle.apply_settings((*new_settings).clone()).await; + } + tracing::debug!("config file watcher stopped"); + }); + } else { + tracing::warn!( + "failed to start config file watcher; settings changes will require daemon restart" + ); + } + // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM let signal_handle = handle.clone(); tokio::spawn(async move { diff --git a/crates/atuin-daemon/src/search/index.rs b/crates/atuin-daemon/src/search/index.rs index ed23f94d..1445871e 100644 --- a/crates/atuin-daemon/src/search/index.rs +++ b/crates/atuin-daemon/src/search/index.rs @@ -13,6 +13,7 @@ use std::{ }; use atuin_client::history::History; +use atuin_client::settings::Search; use dashmap::DashMap; use lasso::{Spur, ThreadedRodeo}; use nucleo::{Injector, Nucleo, pattern}; @@ -56,8 +57,15 @@ impl FrecencyData { /// /// Uses a decay function where more recent commands score higher. /// The formula balances frequency (how often) with recency (how recent). + /// + /// Multipliers allow tuning the relative weights: + /// - `recency_mul`: Multiplier for recency score (default: 1.0) + /// - `frequency_mul`: Multiplier for frequency score (default: 1.0) + /// + /// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight. + /// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc. #[instrument(level = tracing::Level::TRACE, name = "index_frecency_compute")] - pub fn compute(&self, now: i64) -> u32 { + pub fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 { if self.count == 0 { return 0; } @@ -71,21 +79,21 @@ impl FrecencyData { // - Last day: multiplier ~0.5 // - Last week: multiplier ~0.1 // - Older: multiplier approaches 0 - let recency_score = match age_hours { - 0 => 100, - 1..=6 => 90, - 7..=24 => 70, - 25..=72 => 50, - 73..=168 => 30, - 169..=720 => 15, - _ => 5, + let recency_score: f64 = match age_hours { + 0 => 100.0, + 1..=6 => 90.0, + 7..=24 => 70.0, + 25..=72 => 50.0, + 73..=168 => 30.0, + 169..=720 => 15.0, + _ => 5.0, }; // Frequency boost: more uses = higher score (with diminishing returns) - let frequency_score = ((self.count as f64).ln() * 20.0).min(100.0) as u32; + let frequency_score = ((self.count as f64).ln() * 20.0).min(100.0); - // Combined score - recency_score + frequency_score + // Apply multipliers and combine scores, then round to u32 + ((recency_score * recency_mul) + (frequency_score * frequency_mul)).round() as u32 } } @@ -379,13 +387,28 @@ impl SearchIndex { /// /// This should be called by a background task periodically. /// The map is used for scoring search results. + /// + /// Uses multipliers from search settings: + /// - `recency_score_multiplier`: Weight for recency component + /// - `frequency_score_multiplier`: Weight for frequency component + /// - `frecency_score_multiplier`: Overall multiplier for final score #[instrument(skip_all, level = tracing::Level::DEBUG, name = "rebuild_frecency")] - pub async fn rebuild_frecency(&self) { + pub async fn rebuild_frecency(&self, search_settings: &Search) { let now = OffsetDateTime::now_utc().unix_timestamp(); let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new(); + // Clamp multipliers to non-negative values to prevent broken frecency ranking + // (negative values would produce unexpected results when cast to u32) + let recency_mul = search_settings.recency_score_multiplier.max(0.0); + let frequency_mul = search_settings.frequency_score_multiplier.max(0.0); + let frecency_mul = search_settings.frecency_score_multiplier.max(0.0); + for entry in self.commands.iter() { - let frecency = entry.global_frecency.compute(now); + let frecency = entry + .global_frecency + .compute(now, recency_mul, frequency_mul); + // Apply overall frecency multiplier and round to u32 + let frecency = (frecency as f64 * frecency_mul).round() as u32; // Arc::clone is cheap - just increments reference count frecency_map.insert(Arc::clone(entry.key()), frecency); } @@ -466,19 +489,19 @@ mod tests { fn frecency_data_compute() { let now = 1000000i64; - // Recent command + // Recent command (with default multipliers of 1.0) let recent = FrecencyData { count: 5, last_used: now - 60, // 1 minute ago }; - assert!(recent.compute(now) > 100); // High score + assert!(recent.compute(now, 1.0, 1.0) > 100); // High score // Old command let old = FrecencyData { count: 5, last_used: now - 86400 * 30, // 30 days ago }; - assert!(old.compute(now) < recent.compute(now)); + assert!(old.compute(now, 1.0, 1.0) < recent.compute(now, 1.0, 1.0)); // Frequently used old command let frequent_old = FrecencyData { @@ -486,7 +509,50 @@ mod tests { last_used: now - 86400 * 7, // 1 week ago }; // Should still have decent score due to frequency - assert!(frequent_old.compute(now) > 50); + assert!(frequent_old.compute(now, 1.0, 1.0) > 50); + } + + #[test] + fn frecency_data_compute_with_multipliers() { + let now = 1000000i64; + + let data = FrecencyData { + count: 5, + last_used: now - 60, // 1 minute ago (recency_score = 100) + }; + + // Default multipliers (1.0, 1.0) + let default_score = data.compute(now, 1.0, 1.0); + + // Double recency weight + let double_recency = data.compute(now, 2.0, 1.0); + assert!(double_recency > default_score); + + // Double frequency weight + let double_frequency = data.compute(now, 1.0, 2.0); + assert!(double_frequency > default_score); + + // Zero out recency (only frequency counts) + let no_recency = data.compute(now, 0.0, 1.0); + assert!(no_recency < default_score); + + // Zero out frequency (only recency counts) + let no_frequency = data.compute(now, 1.0, 0.0); + assert!(no_frequency < default_score); + + // Zero both (should be zero) + let no_score = data.compute(now, 0.0, 0.0); + assert_eq!(no_score, 0); + + // Fractional multipliers + let half_recency = data.compute(now, 0.5, 1.0); + assert!(half_recency < default_score); + assert!(half_recency > no_recency); + + // 1.5x multiplier + let boost_recency = data.compute(now, 1.5, 1.0); + assert!(boost_recency > default_score); + assert!(boost_recency < double_recency); } #[test] |
