aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-05 08:36:31 -0800
committerGitHub <noreply@github.com>2026-03-05 17:36:31 +0100
commite6ab243dfde79c50ce5661b630ed26b9a1504dae (patch)
treea8bd99d3a22f6592d91fad7766574310e7fc1dbc /crates
parentfeat: initial draft of atuin-shell (#3206) (diff)
downloadatuin-e6ab243dfde79c50ce5661b630ed26b9a1504dae.zip
feat: Allow setting multipliers for frequency, recency, and frecency scores (#3235)
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/Cargo.toml1
-rw-r--r--crates/atuin-client/src/settings.rs20
-rw-r--r--crates/atuin-client/src/settings/watcher.rs256
-rw-r--r--crates/atuin-daemon/src/components/search.rs27
-rw-r--r--crates/atuin-daemon/src/daemon.rs14
-rw-r--r--crates/atuin-daemon/src/lib.rs23
-rw-r--r--crates/atuin-daemon/src/search/index.rs102
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]