Skip to main content

talon_cli/mcp/background/
embed.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::mcp::state::McpServerState;
5
6const DEFAULT_INTERVAL_SECS: u64 = 1800; // 30 minutes
7
8/// Spawns a background thread that runs pending-chunk embedding on a fixed interval.
9///
10/// The ticker starts immediately at MCP startup and runs independently of the
11/// vault watcher or any hook activity. If the embedding sidecar is unavailable,
12/// the error is recorded in diagnostics and the ticker continues on the next tick.
13///
14/// If the thread fails to spawn, the error is silently ignored; the MCP
15/// server continues without the ticker.
16pub fn spawn_embed_ticker(state: Arc<McpServerState>) {
17    let _ = std::thread::Builder::new()
18        .name("talon-embed-ticker".to_owned())
19        .spawn(move || {
20            if let Err(e) = run_embed_ticker(&state) {
21                let mut err = state
22                    .diagnostics
23                    .last_embed_error
24                    .lock()
25                    .unwrap_or_else(std::sync::PoisonError::into_inner);
26                *err = Some(format!("embed ticker error: {e:#}"));
27            }
28        });
29}
30
31fn run_embed_ticker(state: &Arc<McpServerState>) -> color_eyre::eyre::Result<()> {
32    let interval = Duration::from_secs(DEFAULT_INTERVAL_SECS);
33    loop {
34        std::thread::sleep(interval);
35        if let Err(e) = run_embed_tick(state) {
36            let mut err = state
37                .diagnostics
38                .last_embed_error
39                .lock()
40                .unwrap_or_else(std::sync::PoisonError::into_inner);
41            *err = Some(format!("embed tick error: {e:#}"));
42        } else {
43            let mut err = state
44                .diagnostics
45                .last_embed_error
46                .lock()
47                .unwrap_or_else(std::sync::PoisonError::into_inner);
48            *err = None;
49        }
50    }
51}
52
53fn run_embed_tick(state: &Arc<McpServerState>) -> color_eyre::eyre::Result<()> {
54    use color_eyre::eyre::WrapErr as _;
55    use talon_core::{
56        EmbeddingClient, embed::EmbedPassOptions, open_database, vec_ext::register_sqlite_vec,
57    };
58
59    register_sqlite_vec().wrap_err("registering sqlite-vec extension")?;
60    let conn = open_database(&state.config.db_path)
61        .wrap_err_with(|| format!("opening index at {}", state.config.db_path.display()))?;
62
63    let opts = EmbedPassOptions {
64        force: false,
65        restrict_paths: Vec::new(),
66        chunk_embedding_model: state.config.config.embedding.model.clone(),
67        document_embedding_model: state.config.config.embedding.document_model().to_owned(),
68    };
69
70    let client = EmbeddingClient::from_config(
71        &state.config.config.embedding,
72        &state.config.config.credentials,
73    )
74    .wrap_err("building embedding client")?;
75
76    talon_core::embed::run_embed_pass(&conn, &client, &opts)
77        .map(|_| ())
78        .wrap_err("embedding pending chunks")
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn embed_ticker_interval_is_thirty_minutes() {
87        assert_eq!(DEFAULT_INTERVAL_SECS, 1800);
88    }
89}