1use 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
15static FAST_PROVIDER: OnceLock<Option<Model2VecProvider>> = OnceLock::new();
20
21fn get_fast_provider() -> Option<&'static Model2VecProvider> {
23 FAST_PROVIDER
24 .get_or_init(|| {
25 if !is_embeddings_enabled() {
26 return None;
27 }
28 Model2VecProvider::try_new()
30 })
31 .as_ref()
32}
33
34fn store_fast_embedding(
39 storage: &mut SqliteStorage,
40 item_id: &str,
41 key: &str,
42 value: &str,
43 category: Option<&str>,
44) {
45 let Some(provider) = get_fast_provider() else {
47 return; };
49
50 let text = prepare_item_text(key, value, category);
52
53 let embedding = {
56 let rt = match tokio::runtime::Runtime::new() {
57 Ok(rt) => rt,
58 Err(_) => return, };
60 match rt.block_on(provider.generate_embedding(&text)) {
61 Ok(emb) => emb,
62 Err(_) => return, }
64 };
65
66 let chunk_id = format!("fast_{}_{}", item_id, 0);
68 let model = provider.info().model;
69
70 let _ = storage.store_fast_embedding_chunk(&chunk_id, item_id, 0, &text, &embedding, &model);
72}
73
74#[derive(Serialize)]
76struct SaveOutput {
77 key: String,
78 category: String,
79 priority: String,
80 session_id: String,
81}
82
83#[derive(Serialize)]
85struct GetOutput {
86 items: Vec<crate::storage::ContextItem>,
87 count: usize,
88}
89
90#[derive(Serialize)]
92struct SemanticSearchOutput {
93 items: Vec<SemanticSearchItem>,
94 count: usize,
95 query: String,
96 threshold: f32,
97 semantic: bool,
98}
99
100#[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#[derive(Serialize)]
113struct DeleteOutput {
114 key: String,
115 deleted: bool,
116}
117
118pub 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 let resolved_session_id = resolve_session_or_suggest(session_id, &storage)?;
138
139 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 store_fast_embedding(
155 &mut storage,
156 &id,
157 &args.key,
158 &args.value,
159 Some(&args.category),
160 );
161
162 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
185pub 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 let use_semantic = args.threshold.is_some() && args.query.is_some() && is_embeddings_enabled();
210
211 if use_semantic {
212 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 let storage = SqliteStorage::open(&db_path)?;
221
222 #[allow(clippy::cast_possible_truncation)]
224 let fetch_limit = ((args.limit + args.offset.unwrap_or(0)) * 2).min(1000) as u32;
225
226 let items = if args.search_all_sessions {
228 storage.get_all_context_items(
230 args.category.as_deref(),
231 args.priority.as_deref(),
232 Some(fetch_limit),
233 )?
234 } else {
235 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 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 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 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 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
307async 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 let storage = SqliteStorage::open(db_path)?;
334
335 let session_filter = if args.search_all_sessions {
337 None
338 } else {
339 Some(resolve_session_or_suggest(session_id, &storage)?)
340 };
341
342 let query_text = prepare_item_text("query", query, None);
344
345 let results = match search_mode {
347 SearchMode::Fast => {
348 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 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 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
437pub 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 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#[derive(Serialize)]
475struct UpdateOutput {
476 key: String,
477 updated: bool,
478}
479
480pub 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 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 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#[derive(Serialize)]
538struct TagOutput {
539 key: String,
540 action: String,
541 tags: Vec<String>,
542}
543
544pub 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 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}