Skip to main content

sc/cli/commands/
context.rs

1//! Context item command implementations (save, get, delete, update, tag).
2
3use crate::cli::{GetArgs, SaveArgs, TagCommands, UpdateArgs};
4use crate::config::{default_actor, resolve_db_path, resolve_session_or_suggest};
5use crate::embeddings::{
6    create_embedding_provider, is_embeddings_enabled, prepare_item_text, EmbeddingProvider,
7    Model2VecProvider, SearchMode,
8};
9use crate::error::{Error, Result};
10use crate::storage::SqliteStorage;
11use serde::Serialize;
12use std::path::PathBuf;
13use std::sync::OnceLock;
14
15/// Global Model2Vec provider for inline fast embeddings.
16///
17/// Loaded lazily on first use, then cached for the process lifetime.
18/// Model2Vec is ~0.5ms per embedding, making it suitable for inline use.
19static FAST_PROVIDER: OnceLock<Option<Model2VecProvider>> = OnceLock::new();
20
21/// Get or initialize the fast embedding provider.
22fn get_fast_provider() -> Option<&'static Model2VecProvider> {
23    FAST_PROVIDER
24        .get_or_init(|| {
25            if !is_embeddings_enabled() {
26                return None;
27            }
28            // Try to create Model2Vec provider - returns None if model loading fails
29            Model2VecProvider::try_new()
30        })
31        .as_ref()
32}
33
34/// Generate and store a fast embedding for a context item inline.
35///
36/// This is called synchronously during save to provide immediate semantic search.
37/// Model2Vec generates embeddings in < 1ms, so this adds negligible latency.
38fn store_fast_embedding(
39    storage: &mut SqliteStorage,
40    item_id: &str,
41    key: &str,
42    value: &str,
43    category: Option<&str>,
44) {
45    // Get the fast provider (lazy-loaded)
46    let Some(provider) = get_fast_provider() else {
47        return; // Embeddings disabled or provider unavailable
48    };
49
50    // Prepare text for embedding (same format as quality tier)
51    let text = prepare_item_text(key, value, category);
52
53    // Generate embedding synchronously (Model2Vec is fast enough)
54    // Note: We call the async method but Model2Vec.encode() is actually sync
55    let embedding = {
56        let rt = match tokio::runtime::Runtime::new() {
57            Ok(rt) => rt,
58            Err(_) => return, // Can't create runtime, skip embedding
59        };
60        match rt.block_on(provider.generate_embedding(&text)) {
61            Ok(emb) => emb,
62            Err(_) => return, // Embedding failed, skip silently
63        }
64    };
65
66    // Store the fast embedding
67    let chunk_id = format!("fast_{}_{}", item_id, 0);
68    let model = provider.info().model;
69
70    // Store the chunk (this also updates fast_embedding_status on the item)
71    let _ = storage.store_fast_embedding_chunk(&chunk_id, item_id, 0, &text, &embedding, &model);
72}
73
74/// Output for save command.
75#[derive(Serialize)]
76struct SaveOutput {
77    key: String,
78    category: String,
79    priority: String,
80    session_id: String,
81}
82
83/// Output for get command.
84#[derive(Serialize)]
85struct GetOutput {
86    items: Vec<crate::storage::ContextItem>,
87    count: usize,
88}
89
90/// Output for semantic search.
91#[derive(Serialize)]
92struct SemanticSearchOutput {
93    items: Vec<SemanticSearchItem>,
94    count: usize,
95    query: String,
96    threshold: f32,
97    semantic: bool,
98}
99
100/// A semantic search result item.
101#[derive(Serialize)]
102struct SemanticSearchItem {
103    key: String,
104    value: String,
105    category: String,
106    priority: String,
107    similarity: f32,
108    chunk_text: String,
109}
110
111/// Output for delete command.
112#[derive(Serialize)]
113struct DeleteOutput {
114    key: String,
115    deleted: bool,
116}
117
118/// Execute save command.
119pub fn execute_save(
120    args: &SaveArgs,
121    db_path: Option<&PathBuf>,
122    actor: Option<&str>,
123    session_id: Option<&str>,
124    json: bool,
125) -> Result<()> {
126    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
127        .ok_or(Error::NotInitialized)?;
128
129    if !db_path.exists() {
130        return Err(Error::NotInitialized);
131    }
132
133    let mut storage = SqliteStorage::open(&db_path)?;
134    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
135
136    // Resolve session: explicit flag > status cache > error
137    let resolved_session_id = resolve_session_or_suggest(session_id, &storage)?;
138
139    // Generate item ID
140    let id = format!("item_{}", &uuid::Uuid::new_v4().to_string()[..12]);
141
142    storage.save_context_item(
143        &id,
144        &resolved_session_id,
145        &args.key,
146        &args.value,
147        Some(&args.category),
148        Some(&args.priority),
149        &actor,
150    )?;
151
152    // Generate and store fast embedding inline (< 1ms with Model2Vec)
153    // This enables immediate semantic search while quality embeddings are generated in background
154    store_fast_embedding(
155        &mut storage,
156        &id,
157        &args.key,
158        &args.value,
159        Some(&args.category),
160    );
161
162    // Spawn background process to generate embedding (fire-and-forget)
163    super::embeddings::spawn_background_embedder();
164
165    if crate::is_silent() {
166        println!("{}", args.key);
167        return Ok(());
168    }
169
170    if json {
171        let output = SaveOutput {
172            key: args.key.clone(),
173            category: args.category.clone(),
174            priority: args.priority.clone(),
175            session_id: resolved_session_id.clone(),
176        };
177        println!("{}", serde_json::to_string(&output)?);
178    } else {
179        println!("Saved: {} [{}]", args.key, args.category);
180    }
181
182    Ok(())
183}
184
185/// Execute get command.
186///
187/// Supports two search modes:
188/// - **Keyword search** (default): Filters items by key/value containing the query string
189/// - **Semantic search**: When `--threshold` is specified, uses embedding similarity
190///
191/// Semantic search requires:
192/// - Embeddings enabled (`SAVECONTEXT_EMBEDDINGS_ENABLED=true`)
193/// - An embedding provider (Ollama or HuggingFace)
194/// - Items to have been backfilled with embeddings
195pub fn execute_get(
196    args: &GetArgs,
197    db_path: Option<&PathBuf>,
198    session_id: Option<&str>,
199    json: bool,
200) -> Result<()> {
201    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
202        .ok_or(Error::NotInitialized)?;
203
204    if !db_path.exists() {
205        return Err(Error::NotInitialized);
206    }
207
208    // Check if semantic search is requested (threshold specified with query)
209    let use_semantic = args.threshold.is_some() && args.query.is_some() && is_embeddings_enabled();
210
211    if use_semantic {
212        // Use async runtime for semantic search
213        let rt = tokio::runtime::Runtime::new()
214            .map_err(|e| Error::Other(format!("Failed to create async runtime: {e}")))?;
215
216        return rt.block_on(execute_semantic_search(args, &db_path, session_id, json));
217    }
218
219    // Standard keyword search path
220    let storage = SqliteStorage::open(&db_path)?;
221
222    // Fetch extra for post-filtering and pagination
223    #[allow(clippy::cast_possible_truncation)]
224    let fetch_limit = ((args.limit + args.offset.unwrap_or(0)) * 2).min(1000) as u32;
225
226    // Get items - either from all sessions or current session
227    let items = if args.search_all_sessions {
228        // Search across all sessions
229        storage.get_all_context_items(
230            args.category.as_deref(),
231            args.priority.as_deref(),
232            Some(fetch_limit),
233        )?
234    } else {
235        // Resolve session: explicit flag > status cache > error
236        let resolved_session_id = resolve_session_or_suggest(session_id, &storage)?;
237
238        storage.get_context_items(
239            &resolved_session_id,
240            args.category.as_deref(),
241            args.priority.as_deref(),
242            Some(fetch_limit),
243        )?
244    };
245
246    // Filter by key if specified
247    let items: Vec<_> = if let Some(ref key) = args.key {
248        items.into_iter().filter(|i| i.key == *key).collect()
249    } else if let Some(ref query) = args.query {
250        // Simple keyword search
251        let q = query.to_lowercase();
252        items
253            .into_iter()
254            .filter(|i| {
255                i.key.to_lowercase().contains(&q) || i.value.to_lowercase().contains(&q)
256            })
257            .collect()
258    } else {
259        items
260    };
261
262    // Apply offset and limit
263    let items: Vec<_> = items
264        .into_iter()
265        .skip(args.offset.unwrap_or(0))
266        .take(args.limit)
267        .collect();
268
269    if crate::is_csv() {
270        println!("key,category,priority,value");
271        for item in &items {
272            let val = crate::csv_escape(&item.value);
273            println!("{},{},{},{}", item.key, item.category, item.priority, val);
274        }
275    } else if json {
276        let output = GetOutput {
277            count: items.len(),
278            items,
279        };
280        println!("{}", serde_json::to_string(&output)?);
281    } else if items.is_empty() {
282        println!("No context items found.");
283    } else {
284        println!("Context items ({} found):", items.len());
285        println!();
286        for item in &items {
287            let priority_icon = match item.priority.as_str() {
288                "high" => "!",
289                "low" => "-",
290                _ => " ",
291            };
292            println!("[{}] {} ({})", priority_icon, item.key, item.category);
293            // Truncate long values
294            let display_value = if item.value.len() > 100 {
295                format!("{}...", &item.value[..100])
296            } else {
297                item.value.clone()
298            };
299            println!("    {display_value}");
300            println!();
301        }
302    }
303
304    Ok(())
305}
306
307/// Execute semantic search using embeddings.
308///
309/// This function:
310/// 1. Creates an embedding provider (based on search mode)
311/// 2. Generates an embedding for the query
312/// 3. Performs cosine similarity search in the database
313/// 4. Returns results sorted by similarity
314///
315/// Search modes:
316/// - `Fast`: Uses Model2Vec for instant results (lower accuracy)
317/// - `Quality`: Uses Ollama/HuggingFace for accurate results (slower)
318/// - `Tiered`: Fast candidates then quality re-ranking (default, falls back to quality)
319async fn execute_semantic_search(
320    args: &GetArgs,
321    db_path: &std::path::Path,
322    session_id: Option<&str>,
323    json: bool,
324) -> Result<()> {
325    let query = args.query.as_ref().ok_or_else(|| {
326        Error::InvalidArgument("Query is required for semantic search".to_string())
327    })?;
328
329    let threshold = args.threshold.unwrap_or(0.5) as f32;
330    let search_mode = args.search_mode.unwrap_or_default();
331
332    // Open storage early
333    let storage = SqliteStorage::open(db_path)?;
334
335    // Resolve session if not searching all
336    let session_filter = if args.search_all_sessions {
337        None
338    } else {
339        Some(resolve_session_or_suggest(session_id, &storage)?)
340    };
341
342    // Prepare query text
343    let query_text = prepare_item_text("query", query, None);
344
345    // Perform search based on mode
346    let results = match search_mode {
347        SearchMode::Fast => {
348            // Use Model2Vec for instant results
349            let provider = Model2VecProvider::try_new().ok_or_else(|| {
350                Error::Embedding("Model2Vec not available for fast search".to_string())
351            })?;
352            let query_embedding = provider.generate_embedding(&query_text).await?;
353
354            storage.search_fast_tier(
355                &query_embedding,
356                session_filter.as_deref(),
357                args.limit,
358                threshold,
359            )?
360        }
361        SearchMode::Quality | SearchMode::Tiered => {
362            // Use quality provider (Ollama/HuggingFace)
363            // Note: Tiered mode falls back to Quality for now
364            // Full tiered implementation would do fast candidates + quality re-ranking
365            let provider = create_embedding_provider()
366                .await
367                .ok_or_else(|| Error::Embedding("No quality embedding provider available".to_string()))?;
368            let query_embedding = provider.generate_embedding(&query_text).await?;
369
370            storage.semantic_search(
371                &query_embedding,
372                session_filter.as_deref(),
373                args.limit,
374                threshold,
375            )?
376        }
377    };
378
379    if json {
380        let items: Vec<SemanticSearchItem> = results
381            .iter()
382            .map(|r| SemanticSearchItem {
383                key: r.key.clone(),
384                value: r.value.clone(),
385                category: r.category.clone(),
386                priority: r.priority.clone(),
387                similarity: r.similarity,
388                chunk_text: r.chunk_text.clone(),
389            })
390            .collect();
391
392        let output = SemanticSearchOutput {
393            count: items.len(),
394            items,
395            query: query.clone(),
396            threshold,
397            semantic: true,
398        };
399        println!("{}", serde_json::to_string(&output)?);
400    } else if results.is_empty() {
401        println!("No matching items found (threshold: {:.2}).", threshold);
402        println!();
403        println!("Tips:");
404        println!("  - Try lowering the threshold (e.g., --threshold 0.3)");
405        println!("  - Ensure items have been backfilled: sc embeddings backfill");
406    } else {
407        println!("Semantic search results ({} found, threshold: {:.2}):", results.len(), threshold);
408        println!();
409        for (i, result) in results.iter().enumerate() {
410            let priority_icon = match result.priority.as_str() {
411                "high" => "!",
412                "low" => "-",
413                _ => " ",
414            };
415            println!(
416                "{}. [{:.0}%] [{}] {} ({})",
417                i + 1,
418                result.similarity * 100.0,
419                priority_icon,
420                result.key,
421                result.category
422            );
423            // Show chunk text if different from value
424            let display_text = if result.chunk_text.len() > 100 {
425                format!("{}...", &result.chunk_text[..100])
426            } else {
427                result.chunk_text.clone()
428            };
429            println!("    {display_text}");
430            println!();
431        }
432    }
433
434    Ok(())
435}
436
437/// Execute delete command.
438pub fn execute_delete(
439    key: &str,
440    db_path: Option<&PathBuf>,
441    actor: Option<&str>,
442    session_id: Option<&str>,
443    json: bool,
444) -> Result<()> {
445    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
446        .ok_or(Error::NotInitialized)?;
447
448    if !db_path.exists() {
449        return Err(Error::NotInitialized);
450    }
451
452    let mut storage = SqliteStorage::open(&db_path)?;
453    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
454
455    // Resolve session: explicit flag > status cache > error
456    let resolved_session_id = resolve_session_or_suggest(session_id, &storage)?;
457
458    storage.delete_context_item(&resolved_session_id, key, &actor)?;
459
460    if json {
461        let output = DeleteOutput {
462            key: key.to_string(),
463            deleted: true,
464        };
465        println!("{}", serde_json::to_string(&output)?);
466    } else {
467        println!("Deleted: {key}");
468    }
469
470    Ok(())
471}
472
473/// Output for update command.
474#[derive(Serialize)]
475struct UpdateOutput {
476    key: String,
477    updated: bool,
478}
479
480/// Execute update command.
481pub fn execute_update(
482    args: &UpdateArgs,
483    db_path: Option<&PathBuf>,
484    actor: Option<&str>,
485    session_id: Option<&str>,
486    json: bool,
487) -> Result<()> {
488    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
489        .ok_or(Error::NotInitialized)?;
490
491    if !db_path.exists() {
492        return Err(Error::NotInitialized);
493    }
494
495    // Check if any update field is provided
496    if args.value.is_none()
497        && args.category.is_none()
498        && args.priority.is_none()
499        && args.channel.is_none()
500    {
501        return Err(Error::Config(
502            "At least one of --value, --category, --priority, or --channel must be provided"
503                .to_string(),
504        ));
505    }
506
507    let mut storage = SqliteStorage::open(&db_path)?;
508    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
509
510    // Resolve session: explicit flag > status cache > error
511    let resolved_session_id = resolve_session_or_suggest(session_id, &storage)?;
512
513    storage.update_context_item(
514        &resolved_session_id,
515        &args.key,
516        args.value.as_deref(),
517        args.category.as_deref(),
518        args.priority.as_deref(),
519        args.channel.as_deref(),
520        &actor,
521    )?;
522
523    if json {
524        let output = UpdateOutput {
525            key: args.key.clone(),
526            updated: true,
527        };
528        println!("{}", serde_json::to_string(&output)?);
529    } else {
530        println!("Updated: {}", args.key);
531    }
532
533    Ok(())
534}
535
536/// Output for tag command.
537#[derive(Serialize)]
538struct TagOutput {
539    key: String,
540    action: String,
541    tags: Vec<String>,
542}
543
544/// Execute tag command.
545pub fn execute_tag(
546    command: &TagCommands,
547    db_path: Option<&PathBuf>,
548    actor: Option<&str>,
549    session_id: Option<&str>,
550    json: bool,
551) -> Result<()> {
552    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
553        .ok_or(Error::NotInitialized)?;
554
555    if !db_path.exists() {
556        return Err(Error::NotInitialized);
557    }
558
559    let mut storage = SqliteStorage::open(&db_path)?;
560    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
561
562    // Resolve session: explicit flag > status cache > error
563    let resolved_session_id = resolve_session_or_suggest(session_id, &storage)?;
564
565    match command {
566        TagCommands::Add { key, tags } => {
567            storage.add_tags_to_item(&resolved_session_id, key, tags, &actor)?;
568
569            if json {
570                let output = TagOutput {
571                    key: key.clone(),
572                    action: "add".to_string(),
573                    tags: tags.clone(),
574                };
575                println!("{}", serde_json::to_string(&output)?);
576            } else {
577                println!("Added tags to {}: {}", key, tags.join(", "));
578            }
579        }
580        TagCommands::Remove { key, tags } => {
581            storage.remove_tags_from_item(&resolved_session_id, key, tags, &actor)?;
582
583            if json {
584                let output = TagOutput {
585                    key: key.clone(),
586                    action: "remove".to_string(),
587                    tags: tags.clone(),
588                };
589                println!("{}", serde_json::to_string(&output)?);
590            } else {
591                println!("Removed tags from {}: {}", key, tags.join(", "));
592            }
593        }
594    }
595
596    Ok(())
597}