Skip to main content

sc/cli/commands/
prime.rs

1//! Prime command implementation.
2//!
3//! Generates a context primer for AI coding agents by aggregating
4//! session state, issues, memory, and optionally Claude Code transcripts
5//! into a single injectable context block.
6//!
7//! This is a **read-only** command — it never mutates the database.
8
9use crate::config::{current_git_branch, resolve_db_path, resolve_project_path, resolve_session_or_suggest};
10use crate::embeddings::{is_embeddings_enabled, EmbeddingProvider, Model2VecProvider};
11use crate::error::{Error, Result};
12use crate::storage::{ContextItem, SqliteStorage};
13use serde::Serialize;
14use std::fs;
15use std::path::PathBuf;
16use std::sync::OnceLock;
17use tracing::{debug, warn};
18
19/// Limits for prime context items
20const HIGH_PRIORITY_LIMIT: u32 = 10;
21const DECISION_LIMIT: u32 = 10;
22const REMINDER_LIMIT: u32 = 10;
23const PROGRESS_LIMIT: u32 = 5;
24const READY_ISSUES_LIMIT: u32 = 10;
25const MEMORY_DISPLAY_LIMIT: usize = 20;
26
27/// Smart prime defaults
28const MMR_LAMBDA: f64 = 0.7;
29const HEADER_TOKEN_RESERVE: usize = 200;
30
31// ============================================================================
32// JSON Output Structures
33// ============================================================================
34
35#[derive(Serialize)]
36struct PrimeOutput {
37    session: SessionInfo,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    git: Option<GitInfo>,
40    context: ContextBlock,
41    issues: IssueBlock,
42    memory: Vec<MemoryEntry>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    transcript: Option<TranscriptBlock>,
45    command_reference: Vec<CmdRef>,
46}
47
48#[derive(Serialize)]
49struct SessionInfo {
50    id: String,
51    name: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    description: Option<String>,
54    status: String,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    branch: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    project_path: Option<String>,
59}
60
61#[derive(Serialize)]
62struct GitInfo {
63    branch: String,
64    changed_files: Vec<String>,
65}
66
67#[derive(Serialize)]
68struct ContextBlock {
69    high_priority: Vec<ContextEntry>,
70    decisions: Vec<ContextEntry>,
71    reminders: Vec<ContextEntry>,
72    recent_progress: Vec<ContextEntry>,
73    total_items: usize,
74}
75
76#[derive(Serialize)]
77struct ContextEntry {
78    key: String,
79    value: String,
80    category: String,
81    priority: String,
82}
83
84#[derive(Serialize)]
85struct IssueBlock {
86    active: Vec<IssueSummary>,
87    ready: Vec<IssueSummary>,
88    total_open: usize,
89}
90
91#[derive(Serialize)]
92struct IssueSummary {
93    #[serde(skip_serializing_if = "Option::is_none")]
94    short_id: Option<String>,
95    title: String,
96    status: String,
97    priority: i32,
98    issue_type: String,
99}
100
101#[derive(Serialize)]
102struct MemoryEntry {
103    key: String,
104    value: String,
105    category: String,
106}
107
108#[derive(Serialize, Clone)]
109struct TranscriptBlock {
110    source: String,
111    entries: Vec<TranscriptEntry>,
112}
113
114#[derive(Serialize, Clone)]
115struct TranscriptEntry {
116    summary: String,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    timestamp: Option<String>,
119}
120
121#[derive(Serialize, Clone)]
122struct CmdRef {
123    cmd: String,
124    desc: String,
125}
126
127// ============================================================================
128// Smart Prime Structures
129// ============================================================================
130
131struct ScoredItem {
132    item: ContextItem,
133    score: f64,
134    token_estimate: usize,
135    embedding: Option<Vec<f32>>,
136}
137
138struct SmartConfig {
139    budget: usize,
140    decay_half_life_days: f64,
141    query_embedding: Option<Vec<f32>>,
142    mmr_lambda: f64,
143}
144
145#[derive(Serialize)]
146struct SmartPrimeOutput {
147    stats: SmartPrimeStats,
148    scored_context: Vec<ScoredContextEntry>,
149    issues: IssueBlock,
150    memory: Vec<MemoryEntry>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    transcript: Option<TranscriptBlock>,
153    command_reference: Vec<CmdRef>,
154}
155
156#[derive(Serialize)]
157struct SmartPrimeStats {
158    total_items: usize,
159    selected_items: usize,
160    tokens_used: usize,
161    tokens_budget: usize,
162    embeddings_available: bool,
163    mmr_applied: bool,
164    query_boosted: bool,
165}
166
167#[derive(Serialize)]
168struct ScoredContextEntry {
169    key: String,
170    value: String,
171    category: String,
172    priority: String,
173    score: f64,
174    token_estimate: usize,
175}
176
177/// Lazy-init Model2Vec provider for query embedding generation.
178static FAST_PROVIDER: OnceLock<Option<Model2VecProvider>> = OnceLock::new();
179
180fn get_fast_provider() -> Option<&'static Model2VecProvider> {
181    FAST_PROVIDER
182        .get_or_init(|| {
183            if !is_embeddings_enabled() {
184                return None;
185            }
186            Model2VecProvider::try_new()
187        })
188        .as_ref()
189}
190
191// ============================================================================
192// Execute
193// ============================================================================
194
195/// Execute the prime command.
196#[allow(clippy::too_many_arguments)]
197pub fn execute(
198    db_path: Option<&PathBuf>,
199    session_id: Option<&str>,
200    json: bool,
201    include_transcript: bool,
202    transcript_limit: usize,
203    compact: bool,
204    smart: bool,
205    budget: usize,
206    query: Option<&str>,
207    decay_days: u32,
208) -> Result<()> {
209    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
210
211    if !db_path.exists() {
212        return Err(Error::NotInitialized);
213    }
214
215    let storage = SqliteStorage::open(&db_path)?;
216
217    // Resolve session via TTY-keyed status cache
218    let sid = resolve_session_or_suggest(session_id, &storage)?;
219    let session = storage
220        .get_session(&sid)?
221        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
222
223    let project_path = session
224        .project_path
225        .clone()
226        .or_else(|| resolve_project_path(&storage, None).ok())
227        .unwrap_or_else(|| ".".to_string());
228
229    // Git info
230    let git_branch = current_git_branch();
231    let git_status = get_git_status();
232
233    // Smart mode: scoring pipeline with embedding-powered ranking
234    if smart {
235        return execute_smart(
236            &storage, &session, &project_path, &git_branch, &git_status,
237            json, compact, include_transcript, transcript_limit,
238            budget, query, decay_days,
239        );
240    }
241
242    // Context items (read-only queries)
243    let all_items = storage.get_context_items(&session.id, None, None, Some(1000))?;
244    let high_priority =
245        storage.get_context_items(&session.id, None, Some("high"), Some(HIGH_PRIORITY_LIMIT))?;
246    let decisions =
247        storage.get_context_items(&session.id, Some("decision"), None, Some(DECISION_LIMIT))?;
248    let reminders =
249        storage.get_context_items(&session.id, Some("reminder"), None, Some(REMINDER_LIMIT))?;
250    let progress =
251        storage.get_context_items(&session.id, Some("progress"), None, Some(PROGRESS_LIMIT))?;
252
253    // Issues
254    let active_issues =
255        storage.list_issues(&project_path, Some("in_progress"), None, Some(READY_ISSUES_LIMIT))?;
256    let ready_issues = storage.get_ready_issues(&project_path, READY_ISSUES_LIMIT)?;
257    let all_open_issues = storage.list_issues(&project_path, None, None, Some(1000))?;
258
259    // Memory
260    let memory_items = storage.list_memory(&project_path, None)?;
261
262    // Transcript (optional, never fails the command)
263    let transcript = if include_transcript {
264        parse_claude_transcripts(&project_path, transcript_limit)
265    } else {
266        None
267    };
268
269    let cmd_ref = build_command_reference();
270
271    if json {
272        let output = PrimeOutput {
273            session: SessionInfo {
274                id: session.id.clone(),
275                name: session.name.clone(),
276                description: session.description.clone(),
277                status: session.status.clone(),
278                branch: session.branch.clone(),
279                project_path: session.project_path.clone(),
280            },
281            git: git_branch.as_ref().map(|branch| {
282                let files: Vec<String> = git_status
283                    .as_ref()
284                    .map(|s| {
285                        s.lines()
286                            .take(20)
287                            .map(|l| l.trim().to_string())
288                            .collect()
289                    })
290                    .unwrap_or_default();
291                GitInfo {
292                    branch: branch.clone(),
293                    changed_files: files,
294                }
295            }),
296            context: ContextBlock {
297                high_priority: high_priority.iter().map(to_context_entry).collect(),
298                decisions: decisions.iter().map(to_context_entry).collect(),
299                reminders: reminders.iter().map(to_context_entry).collect(),
300                recent_progress: progress.iter().map(to_context_entry).collect(),
301                total_items: all_items.len(),
302            },
303            issues: IssueBlock {
304                active: active_issues.iter().map(to_issue_summary).collect(),
305                ready: ready_issues.iter().map(to_issue_summary).collect(),
306                total_open: all_open_issues.len(),
307            },
308            memory: memory_items
309                .iter()
310                .take(MEMORY_DISPLAY_LIMIT)
311                .map(|m| MemoryEntry {
312                    key: m.key.clone(),
313                    value: m.value.clone(),
314                    category: m.category.clone(),
315                })
316                .collect(),
317            transcript,
318            command_reference: cmd_ref,
319        };
320        println!("{}", serde_json::to_string_pretty(&output)?);
321    } else if compact {
322        print_compact(
323            &session,
324            &git_branch,
325            &git_status,
326            &high_priority,
327            &decisions,
328            &reminders,
329            &progress,
330            &active_issues,
331            &ready_issues,
332            &all_open_issues,
333            &memory_items,
334            &transcript,
335            all_items.len(),
336            &cmd_ref,
337        );
338    } else {
339        print_full(
340            &session,
341            &git_branch,
342            &git_status,
343            &high_priority,
344            &decisions,
345            &reminders,
346            &progress,
347            &active_issues,
348            &ready_issues,
349            &all_open_issues,
350            &memory_items,
351            &transcript,
352            all_items.len(),
353            &cmd_ref,
354        );
355    }
356
357    Ok(())
358}
359
360// ============================================================================
361// Smart Prime Pipeline
362// ============================================================================
363
364#[allow(clippy::too_many_arguments)]
365fn execute_smart(
366    storage: &SqliteStorage,
367    session: &crate::storage::Session,
368    project_path: &str,
369    git_branch: &Option<String>,
370    git_status: &Option<String>,
371    json: bool,
372    compact: bool,
373    include_transcript: bool,
374    transcript_limit: usize,
375    budget: usize,
376    query: Option<&str>,
377    decay_days: u32,
378) -> Result<()> {
379    let now_ms = chrono::Utc::now().timestamp_millis();
380    let half_life = decay_days as f64;
381
382    // Step 1: Fetch all items + embeddings in one query
383    let items_with_embeddings = storage.get_items_with_fast_embeddings(&session.id)?;
384    let total_items = items_with_embeddings.len();
385    let embeddings_available = items_with_embeddings.iter().any(|(_, e)| e.is_some());
386
387    // Generate query embedding if --query provided
388    let query_embedding = query.and_then(|q| generate_query_embedding(q));
389    let query_boosted = query_embedding.is_some();
390
391    let config = SmartConfig {
392        budget,
393        decay_half_life_days: half_life,
394        query_embedding,
395        mmr_lambda: MMR_LAMBDA,
396    };
397
398    // Step 2: Score each item
399    let mut scored: Vec<ScoredItem> = items_with_embeddings
400        .into_iter()
401        .map(|(item, embedding)| {
402            let td = temporal_decay(item.updated_at, now_ms, config.decay_half_life_days);
403            let pw = priority_weight(&item.priority);
404            let cw = category_weight(&item.category);
405            let sb = semantic_boost(
406                embedding.as_deref(),
407                config.query_embedding.as_deref(),
408            );
409            let score = td * pw * cw * sb;
410            let token_estimate = estimate_tokens(&item.key, &item.value);
411
412            ScoredItem { item, score, token_estimate, embedding }
413        })
414        .collect();
415
416    // Step 3: Sort by score descending
417    scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
418
419    // Step 4: MMR diversity re-ranking (only when embeddings exist)
420    let mmr_applied = embeddings_available;
421    if mmr_applied {
422        scored = apply_mmr(scored, config.mmr_lambda);
423    }
424
425    // Step 5: Greedy token-budget packing
426    let packed = pack_to_budget(scored, config.budget);
427    let selected_items = packed.len();
428    let tokens_used: usize = packed.iter().map(|s| s.token_estimate).sum::<usize>() + HEADER_TOKEN_RESERVE;
429
430    let stats = SmartPrimeStats {
431        total_items,
432        selected_items,
433        tokens_used,
434        tokens_budget: config.budget,
435        embeddings_available,
436        mmr_applied,
437        query_boosted,
438    };
439
440    // Fetch shared data (issues, memory, transcript)
441    let active_issues =
442        storage.list_issues(project_path, Some("in_progress"), None, Some(READY_ISSUES_LIMIT))?;
443    let ready_issues = storage.get_ready_issues(project_path, READY_ISSUES_LIMIT)?;
444    let all_open_issues = storage.list_issues(project_path, None, None, Some(1000))?;
445    let memory_items = storage.list_memory(project_path, None)?;
446    let transcript = if include_transcript {
447        parse_claude_transcripts(project_path, transcript_limit)
448    } else {
449        None
450    };
451    let cmd_ref = build_command_reference();
452
453    if json {
454        output_smart_json(&stats, &packed, &active_issues, &ready_issues, &all_open_issues, &memory_items, &transcript, &cmd_ref)?;
455    } else if compact {
456        output_smart_compact(session, git_branch, &stats, &packed, &active_issues, &ready_issues, &all_open_issues, &memory_items, &transcript, &cmd_ref);
457    } else {
458        output_smart_terminal(session, git_branch, git_status, &stats, &packed, &active_issues, &ready_issues, &all_open_issues, &memory_items, &transcript, &cmd_ref);
459    }
460
461    Ok(())
462}
463
464// ============================================================================
465// Scoring Functions
466// ============================================================================
467
468/// Exponential temporal decay based on item age.
469///
470/// Returns 1.0 for items updated just now, 0.5 at half_life_days, 0.25 at 2x half_life.
471fn temporal_decay(updated_at_ms: i64, now_ms: i64, half_life_days: f64) -> f64 {
472    let age_days = (now_ms - updated_at_ms) as f64 / 86_400_000.0;
473    if age_days <= 0.0 {
474        return 1.0;
475    }
476    let lambda = 2.0_f64.ln() / half_life_days;
477    (-lambda * age_days).exp()
478}
479
480/// Weight by priority level.
481fn priority_weight(priority: &str) -> f64 {
482    match priority {
483        "high" => 3.0,
484        "normal" => 1.0,
485        "low" => 0.5,
486        _ => 1.0,
487    }
488}
489
490/// Weight by category importance.
491fn category_weight(category: &str) -> f64 {
492    match category {
493        "decision" => 2.0,
494        "reminder" => 1.5,
495        "progress" => 1.0,
496        "note" => 0.5,
497        _ => 1.0,
498    }
499}
500
501/// Semantic boost when a query embedding is provided.
502///
503/// sim=1.0 -> 2.5x boost, sim=0.0 -> 1.0x (neutral), sim=-1.0 -> penalty (0.5x minimum via clamp)
504fn semantic_boost(item_emb: Option<&[f32]>, query_emb: Option<&[f32]>) -> f64 {
505    match (item_emb, query_emb) {
506        (Some(a), Some(b)) => (1.0 + cosine_similarity_f64(a, b) * 1.5).max(0.5),
507        _ => 1.0,
508    }
509}
510
511/// Estimate token count for a context item.
512fn estimate_tokens(key: &str, value: &str) -> usize {
513    (key.len() + value.len() + 20) / 4
514}
515
516/// Cosine similarity between two f32 vectors.
517fn cosine_similarity_f64(a: &[f32], b: &[f32]) -> f64 {
518    if a.len() != b.len() || a.is_empty() {
519        return 0.0;
520    }
521    let mut dot = 0.0_f64;
522    let mut norm_a = 0.0_f64;
523    let mut norm_b = 0.0_f64;
524    for (x, y) in a.iter().zip(b.iter()) {
525        let xf = *x as f64;
526        let yf = *y as f64;
527        dot += xf * yf;
528        norm_a += xf * xf;
529        norm_b += yf * yf;
530    }
531    let denom = norm_a.sqrt() * norm_b.sqrt();
532    if denom < 1e-10 {
533        0.0
534    } else {
535        dot / denom
536    }
537}
538
539/// Generate an embedding for the query string using the fast provider.
540fn generate_query_embedding(query: &str) -> Option<Vec<f32>> {
541    let provider = get_fast_provider()?;
542    let rt = tokio::runtime::Runtime::new().ok()?;
543    match rt.block_on(provider.generate_embedding(query)) {
544        Ok(emb) => {
545            debug!(query, dim = emb.len(), "Generated query embedding for smart prime");
546            Some(emb)
547        }
548        Err(e) => {
549            warn!(query, error = %e, "Failed to generate query embedding");
550            None
551        }
552    }
553}
554
555// ============================================================================
556// MMR Diversity Re-ranking
557// ============================================================================
558
559/// Maximal Marginal Relevance: re-rank items to balance relevance and diversity.
560///
561/// Items without embeddings are appended after MMR-ranked items in their original score order.
562fn apply_mmr(items: Vec<ScoredItem>, lambda: f64) -> Vec<ScoredItem> {
563    // Separate items with and without embeddings
564    let mut with_emb: Vec<ScoredItem> = Vec::new();
565    let mut without_emb: Vec<ScoredItem> = Vec::new();
566
567    for item in items {
568        if item.embedding.is_some() {
569            with_emb.push(item);
570        } else {
571            without_emb.push(item);
572        }
573    }
574
575    if with_emb.is_empty() {
576        // No embeddings — return original order (already sorted by score)
577        without_emb.extend(with_emb);
578        return without_emb;
579    }
580
581    // Normalize scores to [0, 1] for MMR
582    let max_score = with_emb.iter().map(|s| s.score).fold(f64::NEG_INFINITY, f64::max);
583    let min_score = with_emb.iter().map(|s| s.score).fold(f64::INFINITY, f64::min);
584    let score_range = (max_score - min_score).max(1e-10);
585
586    let mut selected: Vec<ScoredItem> = Vec::new();
587    let mut candidates = with_emb;
588
589    while !candidates.is_empty() {
590        let mut best_idx = 0;
591        let mut best_mmr = f64::NEG_INFINITY;
592
593        for (i, candidate) in candidates.iter().enumerate() {
594            let relevance = (candidate.score - min_score) / score_range;
595
596            // Max similarity to any already-selected item
597            let max_sim = if selected.is_empty() {
598                0.0
599            } else {
600                selected
601                    .iter()
602                    .filter_map(|s| {
603                        let c_emb = candidate.embedding.as_deref()?;
604                        let s_emb = s.embedding.as_deref()?;
605                        Some(cosine_similarity_f64(c_emb, s_emb))
606                    })
607                    .fold(f64::NEG_INFINITY, f64::max)
608                    .max(0.0) // clamp negative similarities
609            };
610
611            let mmr = lambda * relevance - (1.0 - lambda) * max_sim;
612            if mmr > best_mmr {
613                best_mmr = mmr;
614                best_idx = i;
615            }
616        }
617
618        selected.push(candidates.remove(best_idx));
619    }
620
621    // Append items without embeddings at the end
622    selected.extend(without_emb);
623    selected
624}
625
626// ============================================================================
627// Token Budget Packing
628// ============================================================================
629
630/// Greedy packing: include items in rank order that fit within the token budget.
631///
632/// Uses `continue` (not `break`) so smaller items further down can fill gaps.
633fn pack_to_budget(items: Vec<ScoredItem>, budget: usize) -> Vec<ScoredItem> {
634    let available = budget.saturating_sub(HEADER_TOKEN_RESERVE);
635    let mut used = 0usize;
636    let mut packed = Vec::new();
637
638    for item in items {
639        if used + item.token_estimate <= available {
640            used += item.token_estimate;
641            packed.push(item);
642        }
643        // continue — smaller items may still fit
644    }
645
646    packed
647}
648
649// ============================================================================
650// Smart Output Formatters
651// ============================================================================
652
653fn output_smart_json(
654    stats: &SmartPrimeStats,
655    items: &[ScoredItem],
656    active_issues: &[crate::storage::Issue],
657    ready_issues: &[crate::storage::Issue],
658    all_open: &[crate::storage::Issue],
659    memory: &[crate::storage::Memory],
660    transcript: &Option<TranscriptBlock>,
661    cmd_ref: &[CmdRef],
662) -> Result<()> {
663    let output = SmartPrimeOutput {
664        stats: SmartPrimeStats {
665            total_items: stats.total_items,
666            selected_items: stats.selected_items,
667            tokens_used: stats.tokens_used,
668            tokens_budget: stats.tokens_budget,
669            embeddings_available: stats.embeddings_available,
670            mmr_applied: stats.mmr_applied,
671            query_boosted: stats.query_boosted,
672        },
673        scored_context: items
674            .iter()
675            .map(|s| ScoredContextEntry {
676                key: s.item.key.clone(),
677                value: s.item.value.clone(),
678                category: s.item.category.clone(),
679                priority: s.item.priority.clone(),
680                score: (s.score * 100.0).round() / 100.0, // 2 decimal places
681                token_estimate: s.token_estimate,
682            })
683            .collect(),
684        issues: IssueBlock {
685            active: active_issues.iter().map(to_issue_summary).collect(),
686            ready: ready_issues.iter().map(to_issue_summary).collect(),
687            total_open: all_open.len(),
688        },
689        memory: memory
690            .iter()
691            .take(MEMORY_DISPLAY_LIMIT)
692            .map(|m| MemoryEntry {
693                key: m.key.clone(),
694                value: m.value.clone(),
695                category: m.category.clone(),
696            })
697            .collect(),
698        transcript: transcript.clone(),
699        command_reference: cmd_ref.to_vec(),
700    };
701    println!("{}", serde_json::to_string_pretty(&output)?);
702    Ok(())
703}
704
705#[allow(clippy::too_many_arguments)]
706fn output_smart_compact(
707    session: &crate::storage::Session,
708    git_branch: &Option<String>,
709    stats: &SmartPrimeStats,
710    items: &[ScoredItem],
711    active_issues: &[crate::storage::Issue],
712    ready_issues: &[crate::storage::Issue],
713    all_open: &[crate::storage::Issue],
714    memory: &[crate::storage::Memory],
715    transcript: &Option<TranscriptBlock>,
716    cmd_ref: &[CmdRef],
717) {
718    println!("# SaveContext Smart Prime");
719    print!("Session: \"{}\" ({})", session.name, session.status);
720    if let Some(branch) = git_branch {
721        print!(" | Branch: {branch}");
722    }
723    println!(" | {} items", stats.total_items);
724    println!(
725        "Budget: {}/{} tokens | {} selected | MMR: {}",
726        stats.tokens_used,
727        stats.tokens_budget,
728        stats.selected_items,
729        if stats.mmr_applied { "yes" } else { "no" }
730    );
731    println!();
732
733    if !items.is_empty() {
734        println!("## Context (ranked by relevance)");
735        for s in items {
736            println!(
737                "- [{:.2}] {}: {} [{}/{}]",
738                s.score,
739                s.item.key,
740                truncate(&s.item.value, 100),
741                s.item.category,
742                s.item.priority
743            );
744        }
745        println!();
746    }
747
748    if !active_issues.is_empty() || !ready_issues.is_empty() {
749        println!("## Issues ({} open)", all_open.len());
750        for issue in active_issues {
751            let id = issue.short_id.as_deref().unwrap_or("??");
752            println!(
753                "- [{}] {} ({}/P{})",
754                id, issue.title, issue.status, issue.priority
755            );
756        }
757        for issue in ready_issues.iter().take(5) {
758            let id = issue.short_id.as_deref().unwrap_or("??");
759            println!("- [{}] {} (ready/P{})", id, issue.title, issue.priority);
760        }
761        println!();
762    }
763
764    if !memory.is_empty() {
765        println!("## Memory");
766        for item in memory.iter().take(10) {
767            println!("- {} [{}]: {}", item.key, item.category, truncate(&item.value, 80));
768        }
769        println!();
770    }
771
772    if let Some(t) = transcript {
773        println!("## Recent Transcripts");
774        for entry in &t.entries {
775            println!("- {}", truncate(&entry.summary, 120));
776        }
777        println!();
778    }
779
780    println!("## Quick Reference");
781    for c in cmd_ref {
782        println!("- `{}` -- {}", c.cmd, c.desc);
783    }
784}
785
786#[allow(clippy::too_many_arguments)]
787fn output_smart_terminal(
788    session: &crate::storage::Session,
789    git_branch: &Option<String>,
790    git_status: &Option<String>,
791    stats: &SmartPrimeStats,
792    items: &[ScoredItem],
793    active_issues: &[crate::storage::Issue],
794    ready_issues: &[crate::storage::Issue],
795    all_open: &[crate::storage::Issue],
796    memory: &[crate::storage::Memory],
797    transcript: &Option<TranscriptBlock>,
798    cmd_ref: &[CmdRef],
799) {
800    use colored::Colorize;
801
802    println!();
803    println!(
804        "{}",
805        "━━━ SaveContext Smart Prime ━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta().bold()
806    );
807    println!();
808
809    // Session
810    println!("{}", "Session".cyan().bold());
811    println!("  Name:    {}", session.name);
812    println!("  Status:  {}", session.status);
813    if let Some(branch) = git_branch {
814        println!("  Branch:  {}", branch);
815    }
816    println!();
817
818    // Stats
819    println!("{}", "Smart Stats".cyan().bold());
820    println!(
821        "  Budget:     {}/{} tokens",
822        stats.tokens_used, stats.tokens_budget
823    );
824    println!(
825        "  Selected:   {}/{} items",
826        stats.selected_items, stats.total_items
827    );
828    println!(
829        "  Embeddings: {}",
830        if stats.embeddings_available { "yes" } else { "no" }
831    );
832    println!("  MMR:        {}", if stats.mmr_applied { "yes" } else { "no" });
833    println!(
834        "  Query:      {}",
835        if stats.query_boosted { "boosted" } else { "none" }
836    );
837    println!();
838
839    // Git
840    if let Some(status) = git_status {
841        let lines: Vec<&str> = status.lines().take(10).collect();
842        if !lines.is_empty() {
843            println!("{}", "Git Changes".cyan().bold());
844            for line in &lines {
845                println!("  {line}");
846            }
847            println!();
848        }
849    }
850
851    // Ranked context items
852    if !items.is_empty() {
853        println!("{}", "Context (ranked)".yellow().bold());
854        for s in items {
855            let score_str = format!("[{:.2}]", s.score);
856            let meta = format!("[{}/{}]", s.item.category, s.item.priority);
857            println!(
858                "  {} {} {} {}",
859                score_str.yellow(),
860                s.item.key.bold(),
861                meta.dimmed(),
862                truncate(&s.item.value, 60)
863            );
864        }
865        println!();
866    }
867
868    // Issues
869    if !active_issues.is_empty() || !ready_issues.is_empty() {
870        println!(
871            "{} ({} open)",
872            "Issues".cyan().bold(),
873            all_open.len()
874        );
875        for issue in active_issues {
876            let id = issue.short_id.as_deref().unwrap_or("??");
877            println!(
878                "    {} {} {} {}",
879                id.cyan(),
880                issue.title,
881                format!("[{}]", issue.issue_type).dimmed(),
882                format!("P{}", issue.priority).dimmed()
883            );
884        }
885        for issue in ready_issues.iter().take(5) {
886            let id = issue.short_id.as_deref().unwrap_or("??");
887            println!(
888                "    {} {} {} {}",
889                id.dimmed(),
890                issue.title,
891                format!("[{}]", issue.issue_type).dimmed(),
892                format!("P{}", issue.priority).dimmed()
893            );
894        }
895        println!();
896    }
897
898    // Memory
899    if !memory.is_empty() {
900        println!("{}", "Project Memory".cyan().bold());
901        for item in memory.iter().take(10) {
902            println!(
903                "  {} {} {}",
904                item.key.bold(),
905                format!("[{}]", item.category).dimmed(),
906                truncate(&item.value, 60)
907            );
908        }
909        println!();
910    }
911
912    // Transcript
913    if let Some(t) = transcript {
914        println!("{}", "Recent Transcripts".magenta().bold());
915        for entry in &t.entries {
916            if let Some(ts) = &entry.timestamp {
917                println!("  {} {}", ts.dimmed(), truncate(&entry.summary, 100));
918            } else {
919                println!("  {}", truncate(&entry.summary, 100));
920            }
921        }
922        println!();
923    }
924
925    // Command reference
926    println!("{}", "Quick Reference".dimmed().bold());
927    for c in cmd_ref {
928        println!("  {} {}", c.cmd.cyan(), format!("# {}", c.desc).dimmed());
929    }
930    println!();
931    println!(
932        "{}",
933        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta()
934    );
935    println!();
936}
937
938// ============================================================================
939// Transcript Parsing
940// ============================================================================
941
942/// Parse Claude Code transcript files for conversation summaries.
943///
944/// Claude Code stores session transcripts at:
945///   `~/.claude/projects/<encoded-path>/<uuid>.jsonl`
946///
947/// Where `<encoded-path>` replaces `/` with `-` in the project path.
948/// Each line is a JSON object; lines with `"type": "summary"` contain
949/// conversation summaries from previous sessions.
950fn parse_claude_transcripts(project_path: &str, limit: usize) -> Option<TranscriptBlock> {
951    let home = directories::BaseDirs::new()?.home_dir().to_path_buf();
952    let encoded_path = encode_project_path(project_path);
953    let transcript_dir = home.join(".claude").join("projects").join(&encoded_path);
954
955    if !transcript_dir.exists() {
956        return None;
957    }
958
959    // Find .jsonl files, sorted by modification time (most recent first)
960    let mut jsonl_files: Vec<_> = fs::read_dir(&transcript_dir)
961        .ok()?
962        .filter_map(|entry| {
963            let entry = entry.ok()?;
964            let path = entry.path();
965            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
966                let modified = entry.metadata().ok()?.modified().ok()?;
967                Some((path, modified))
968            } else {
969                None
970            }
971        })
972        .collect();
973
974    jsonl_files.sort_by(|a, b| b.1.cmp(&a.1));
975
976    let mut entries = Vec::new();
977
978    // Scan files from most recent, collecting summary entries
979    for (path, _) in &jsonl_files {
980        if entries.len() >= limit {
981            break;
982        }
983
984        let content = match fs::read_to_string(path) {
985            Ok(c) => c,
986            Err(_) => continue,
987        };
988
989        for line in content.lines().rev() {
990            if entries.len() >= limit {
991                break;
992            }
993
994            let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
995                continue;
996            };
997
998            // Look for summary entries
999            if val.get("type").and_then(|t| t.as_str()) == Some("summary") {
1000                if let Some(summary) = val.get("summary").and_then(|s| s.as_str()) {
1001                    let timestamp = val
1002                        .get("timestamp")
1003                        .and_then(|t| t.as_str())
1004                        .map(ToString::to_string);
1005                    entries.push(TranscriptEntry {
1006                        summary: truncate(summary, 500),
1007                        timestamp,
1008                    });
1009                }
1010            }
1011        }
1012    }
1013
1014    if entries.is_empty() {
1015        return None;
1016    }
1017
1018    Some(TranscriptBlock {
1019        source: transcript_dir.to_string_lossy().to_string(),
1020        entries,
1021    })
1022}
1023
1024/// Encode a project path for Claude Code's directory naming.
1025///
1026/// Replaces `/` with `-` to match Claude Code's convention:
1027///   `/Users/shane/code/project` → `-Users-shane-code-project`
1028fn encode_project_path(path: &str) -> String {
1029    path.replace('/', "-")
1030}
1031
1032// ============================================================================
1033// Command Reference
1034// ============================================================================
1035
1036fn build_command_reference() -> Vec<CmdRef> {
1037    vec![
1038        CmdRef {
1039            cmd: "sc save <key> <value> -c <cat> -p <pri>".into(),
1040            desc: "Save context item".into(),
1041        },
1042        CmdRef {
1043            cmd: "sc get -s <query>".into(),
1044            desc: "Search context items".into(),
1045        },
1046        CmdRef {
1047            cmd: "sc issue create <title> -t <type> -p <pri>".into(),
1048            desc: "Create issue".into(),
1049        },
1050        CmdRef {
1051            cmd: "sc issue list -s <status>".into(),
1052            desc: "List issues".into(),
1053        },
1054        CmdRef {
1055            cmd: "sc issue complete <id>".into(),
1056            desc: "Complete issue".into(),
1057        },
1058        CmdRef {
1059            cmd: "sc issue claim <id>".into(),
1060            desc: "Claim issue".into(),
1061        },
1062        CmdRef {
1063            cmd: "sc status".into(),
1064            desc: "Show session status".into(),
1065        },
1066        CmdRef {
1067            cmd: "sc checkpoint create <name>".into(),
1068            desc: "Create checkpoint".into(),
1069        },
1070        CmdRef {
1071            cmd: "sc memory save <key> <value>".into(),
1072            desc: "Save project memory".into(),
1073        },
1074        CmdRef {
1075            cmd: "sc compaction".into(),
1076            desc: "Prepare for context compaction".into(),
1077        },
1078    ]
1079}
1080
1081// ============================================================================
1082// Converters
1083// ============================================================================
1084
1085fn to_context_entry(item: &crate::storage::ContextItem) -> ContextEntry {
1086    ContextEntry {
1087        key: item.key.clone(),
1088        value: item.value.clone(),
1089        category: item.category.clone(),
1090        priority: item.priority.clone(),
1091    }
1092}
1093
1094fn to_issue_summary(issue: &crate::storage::Issue) -> IssueSummary {
1095    IssueSummary {
1096        short_id: issue.short_id.clone(),
1097        title: issue.title.clone(),
1098        status: issue.status.clone(),
1099        priority: issue.priority,
1100        issue_type: issue.issue_type.clone(),
1101    }
1102}
1103
1104// ============================================================================
1105// Human-Readable Output (Full)
1106// ============================================================================
1107
1108#[allow(clippy::too_many_arguments)]
1109fn print_full(
1110    session: &crate::storage::Session,
1111    git_branch: &Option<String>,
1112    git_status: &Option<String>,
1113    high_priority: &[crate::storage::ContextItem],
1114    decisions: &[crate::storage::ContextItem],
1115    reminders: &[crate::storage::ContextItem],
1116    progress: &[crate::storage::ContextItem],
1117    active_issues: &[crate::storage::Issue],
1118    ready_issues: &[crate::storage::Issue],
1119    all_open: &[crate::storage::Issue],
1120    memory: &[crate::storage::Memory],
1121    transcript: &Option<TranscriptBlock>,
1122    total_items: usize,
1123    cmd_ref: &[CmdRef],
1124) {
1125    use colored::Colorize;
1126
1127    println!();
1128    println!(
1129        "{}",
1130        "━━━ SaveContext Prime ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta().bold()
1131    );
1132    println!();
1133
1134    // Session
1135    println!("{}", "Session".cyan().bold());
1136    println!("  Name:    {}", session.name);
1137    if let Some(desc) = &session.description {
1138        println!("  Desc:    {}", desc);
1139    }
1140    println!("  Status:  {}", session.status);
1141    if let Some(branch) = git_branch {
1142        println!("  Branch:  {}", branch);
1143    }
1144    println!("  Items:   {total_items}");
1145    println!();
1146
1147    // Git
1148    if let Some(status) = git_status {
1149        let lines: Vec<&str> = status.lines().take(10).collect();
1150        if !lines.is_empty() {
1151            println!("{}", "Git Changes".cyan().bold());
1152            for line in &lines {
1153                println!("  {line}");
1154            }
1155            println!();
1156        }
1157    }
1158
1159    // High priority
1160    if !high_priority.is_empty() {
1161        println!("{}", "High Priority".red().bold());
1162        for item in high_priority.iter().take(5) {
1163            println!(
1164                "  {} {} {}",
1165                "•".red(),
1166                item.key,
1167                format!("[{}]", item.category).dimmed()
1168            );
1169            println!("    {}", truncate(&item.value, 80));
1170        }
1171        println!();
1172    }
1173
1174    // Decisions
1175    if !decisions.is_empty() {
1176        println!("{}", "Key Decisions".yellow().bold());
1177        for item in decisions.iter().take(5) {
1178            println!("  {} {}", "•".yellow(), item.key);
1179            println!("    {}", truncate(&item.value, 80));
1180        }
1181        println!();
1182    }
1183
1184    // Reminders
1185    if !reminders.is_empty() {
1186        println!("{}", "Reminders".blue().bold());
1187        for item in reminders.iter().take(5) {
1188            println!("  {} {}", "•".blue(), item.key);
1189            println!("    {}", truncate(&item.value, 80));
1190        }
1191        println!();
1192    }
1193
1194    // Progress
1195    if !progress.is_empty() {
1196        println!("{}", "Recent Progress".green().bold());
1197        for item in progress {
1198            println!("  {} {}", "✓".green(), item.key);
1199            println!("    {}", truncate(&item.value, 80));
1200        }
1201        println!();
1202    }
1203
1204    // Issues
1205    if !active_issues.is_empty() || !ready_issues.is_empty() {
1206        println!(
1207            "{} ({} open)",
1208            "Issues".cyan().bold(),
1209            all_open.len()
1210        );
1211
1212        if !active_issues.is_empty() {
1213            println!("  {}", "In Progress:".bold());
1214            for issue in active_issues {
1215                let id = issue.short_id.as_deref().unwrap_or("??");
1216                println!(
1217                    "    {} {} {} {}",
1218                    id.cyan(),
1219                    issue.title,
1220                    format!("[{}]", issue.issue_type).dimmed(),
1221                    format!("P{}", issue.priority).dimmed()
1222                );
1223            }
1224        }
1225
1226        if !ready_issues.is_empty() {
1227            println!("  {}", "Ready:".bold());
1228            for issue in ready_issues.iter().take(5) {
1229                let id = issue.short_id.as_deref().unwrap_or("??");
1230                println!(
1231                    "    {} {} {} {}",
1232                    id.dimmed(),
1233                    issue.title,
1234                    format!("[{}]", issue.issue_type).dimmed(),
1235                    format!("P{}", issue.priority).dimmed()
1236                );
1237            }
1238        }
1239        println!();
1240    }
1241
1242    // Memory
1243    if !memory.is_empty() {
1244        println!("{}", "Project Memory".cyan().bold());
1245        for item in memory.iter().take(10) {
1246            println!(
1247                "  {} {} {}",
1248                item.key.bold(),
1249                format!("[{}]", item.category).dimmed(),
1250                truncate(&item.value, 60)
1251            );
1252        }
1253        println!();
1254    }
1255
1256    // Transcript
1257    if let Some(t) = transcript {
1258        println!("{}", "Recent Transcripts".magenta().bold());
1259        for entry in &t.entries {
1260            if let Some(ts) = &entry.timestamp {
1261                println!("  {} {}", ts.dimmed(), truncate(&entry.summary, 100));
1262            } else {
1263                println!("  {}", truncate(&entry.summary, 100));
1264            }
1265        }
1266        println!();
1267    }
1268
1269    // Command reference
1270    println!("{}", "Quick Reference".dimmed().bold());
1271    for c in cmd_ref {
1272        println!("  {} {}", c.cmd.cyan(), format!("# {}", c.desc).dimmed());
1273    }
1274    println!();
1275    println!(
1276        "{}",
1277        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta()
1278    );
1279    println!();
1280}
1281
1282// ============================================================================
1283// Human-Readable Output (Compact — for agent injection)
1284// ============================================================================
1285
1286#[allow(clippy::too_many_arguments)]
1287fn print_compact(
1288    session: &crate::storage::Session,
1289    git_branch: &Option<String>,
1290    _git_status: &Option<String>,
1291    high_priority: &[crate::storage::ContextItem],
1292    decisions: &[crate::storage::ContextItem],
1293    reminders: &[crate::storage::ContextItem],
1294    _progress: &[crate::storage::ContextItem],
1295    active_issues: &[crate::storage::Issue],
1296    ready_issues: &[crate::storage::Issue],
1297    all_open: &[crate::storage::Issue],
1298    memory: &[crate::storage::Memory],
1299    transcript: &Option<TranscriptBlock>,
1300    total_items: usize,
1301    cmd_ref: &[CmdRef],
1302) {
1303    // Compact markdown format for direct agent injection
1304    println!("# SaveContext Prime");
1305    print!("Session: \"{}\" ({})", session.name, session.status);
1306    if let Some(branch) = git_branch {
1307        print!(" | Branch: {branch}");
1308    }
1309    println!(" | {total_items} context items");
1310    println!();
1311
1312    if !high_priority.is_empty() {
1313        println!("## High Priority");
1314        for item in high_priority.iter().take(5) {
1315            println!(
1316                "- {}: {} [{}]",
1317                item.key,
1318                truncate(&item.value, 100),
1319                item.category
1320            );
1321        }
1322        println!();
1323    }
1324
1325    if !decisions.is_empty() {
1326        println!("## Decisions");
1327        for item in decisions.iter().take(5) {
1328            println!("- {}: {}", item.key, truncate(&item.value, 100));
1329        }
1330        println!();
1331    }
1332
1333    if !reminders.is_empty() {
1334        println!("## Reminders");
1335        for item in reminders.iter().take(5) {
1336            println!("- {}: {}", item.key, truncate(&item.value, 100));
1337        }
1338        println!();
1339    }
1340
1341    if !active_issues.is_empty() || !ready_issues.is_empty() {
1342        println!("## Issues ({} open)", all_open.len());
1343        for issue in active_issues {
1344            let id = issue.short_id.as_deref().unwrap_or("??");
1345            println!(
1346                "- [{}] {} ({}/P{})",
1347                id, issue.title, issue.status, issue.priority
1348            );
1349        }
1350        for issue in ready_issues.iter().take(5) {
1351            let id = issue.short_id.as_deref().unwrap_or("??");
1352            println!("- [{}] {} (ready/P{})", id, issue.title, issue.priority);
1353        }
1354        println!();
1355    }
1356
1357    if !memory.is_empty() {
1358        println!("## Memory");
1359        for item in memory.iter().take(10) {
1360            println!("- {} [{}]: {}", item.key, item.category, truncate(&item.value, 80));
1361        }
1362        println!();
1363    }
1364
1365    if let Some(t) = transcript {
1366        println!("## Recent Transcripts");
1367        for entry in &t.entries {
1368            println!("- {}", truncate(&entry.summary, 120));
1369        }
1370        println!();
1371    }
1372
1373    println!("## Quick Reference");
1374    for c in cmd_ref {
1375        println!("- `{}` — {}", c.cmd, c.desc);
1376    }
1377}
1378
1379// ============================================================================
1380// Helpers
1381// ============================================================================
1382
1383/// Get current git status output.
1384fn get_git_status() -> Option<String> {
1385    std::process::Command::new("git")
1386        .args(["status", "--porcelain"])
1387        .output()
1388        .ok()
1389        .filter(|output| output.status.success())
1390        .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1391}
1392
1393/// Truncate a string to max length with ellipsis.
1394fn truncate(s: &str, max_len: usize) -> String {
1395    // Work on first line only to avoid multi-line blowup
1396    let first_line = s.lines().next().unwrap_or(s);
1397    if first_line.len() <= max_len {
1398        first_line.to_string()
1399    } else {
1400        format!("{}...", &first_line[..max_len.saturating_sub(3)])
1401    }
1402}
1403
1404// ============================================================================
1405// Tests
1406// ============================================================================
1407
1408#[cfg(test)]
1409mod tests {
1410    use super::*;
1411
1412    #[test]
1413    fn test_temporal_decay_now() {
1414        let now = 1_700_000_000_000i64;
1415        assert!((temporal_decay(now, now, 14.0) - 1.0).abs() < 1e-10);
1416    }
1417
1418    #[test]
1419    fn test_temporal_decay_half_life() {
1420        let now = 1_700_000_000_000i64;
1421        let fourteen_days_ago = now - 14 * 86_400_000;
1422        let decay = temporal_decay(fourteen_days_ago, now, 14.0);
1423        assert!((decay - 0.5).abs() < 0.01, "Expected ~0.5, got {decay}");
1424    }
1425
1426    #[test]
1427    fn test_temporal_decay_double_half_life() {
1428        let now = 1_700_000_000_000i64;
1429        let twenty_eight_days_ago = now - 28 * 86_400_000;
1430        let decay = temporal_decay(twenty_eight_days_ago, now, 14.0);
1431        assert!((decay - 0.25).abs() < 0.01, "Expected ~0.25, got {decay}");
1432    }
1433
1434    #[test]
1435    fn test_temporal_decay_future_item() {
1436        let now = 1_700_000_000_000i64;
1437        // Item from the future should return 1.0
1438        assert!((temporal_decay(now + 1000, now, 14.0) - 1.0).abs() < 1e-10);
1439    }
1440
1441    #[test]
1442    fn test_priority_weight_values() {
1443        assert!((priority_weight("high") - 3.0).abs() < 1e-10);
1444        assert!((priority_weight("normal") - 1.0).abs() < 1e-10);
1445        assert!((priority_weight("low") - 0.5).abs() < 1e-10);
1446        assert!((priority_weight("unknown") - 1.0).abs() < 1e-10);
1447    }
1448
1449    #[test]
1450    fn test_category_weight_values() {
1451        assert!((category_weight("decision") - 2.0).abs() < 1e-10);
1452        assert!((category_weight("reminder") - 1.5).abs() < 1e-10);
1453        assert!((category_weight("progress") - 1.0).abs() < 1e-10);
1454        assert!((category_weight("note") - 0.5).abs() < 1e-10);
1455        assert!((category_weight("other") - 1.0).abs() < 1e-10);
1456    }
1457
1458    #[test]
1459    fn test_semantic_boost_no_embeddings() {
1460        assert!((semantic_boost(None, None) - 1.0).abs() < 1e-10);
1461        assert!((semantic_boost(Some(&[1.0, 0.0]), None) - 1.0).abs() < 1e-10);
1462        assert!((semantic_boost(None, Some(&[1.0, 0.0])) - 1.0).abs() < 1e-10);
1463    }
1464
1465    #[test]
1466    fn test_semantic_boost_identical() {
1467        let emb = vec![1.0, 0.0, 0.0];
1468        let boost = semantic_boost(Some(&emb), Some(&emb));
1469        // cos_sim = 1.0, boost = 1.0 + 1.0 * 1.5 = 2.5
1470        assert!((boost - 2.5).abs() < 0.01, "Expected 2.5, got {boost}");
1471    }
1472
1473    #[test]
1474    fn test_semantic_boost_orthogonal() {
1475        let a = vec![1.0, 0.0, 0.0];
1476        let b = vec![0.0, 1.0, 0.0];
1477        let boost = semantic_boost(Some(&a), Some(&b));
1478        // cos_sim = 0.0, boost = 1.0
1479        assert!((boost - 1.0).abs() < 0.01, "Expected 1.0, got {boost}");
1480    }
1481
1482    #[test]
1483    fn test_cosine_similarity_identical() {
1484        let v = vec![1.0f32, 2.0, 3.0];
1485        let sim = cosine_similarity_f64(&v, &v);
1486        assert!((sim - 1.0).abs() < 1e-6, "Expected 1.0, got {sim}");
1487    }
1488
1489    #[test]
1490    fn test_cosine_similarity_orthogonal() {
1491        let a = vec![1.0f32, 0.0];
1492        let b = vec![0.0f32, 1.0];
1493        let sim = cosine_similarity_f64(&a, &b);
1494        assert!(sim.abs() < 1e-6, "Expected 0.0, got {sim}");
1495    }
1496
1497    #[test]
1498    fn test_cosine_similarity_opposite() {
1499        let a = vec![1.0f32, 0.0];
1500        let b = vec![-1.0f32, 0.0];
1501        let sim = cosine_similarity_f64(&a, &b);
1502        assert!((sim - (-1.0)).abs() < 1e-6, "Expected -1.0, got {sim}");
1503    }
1504
1505    #[test]
1506    fn test_cosine_similarity_empty() {
1507        let sim = cosine_similarity_f64(&[], &[]);
1508        assert!((sim - 0.0).abs() < 1e-10);
1509    }
1510
1511    #[test]
1512    fn test_cosine_similarity_mismatched_length() {
1513        let a = vec![1.0f32, 2.0];
1514        let b = vec![1.0f32];
1515        assert!((cosine_similarity_f64(&a, &b) - 0.0).abs() < 1e-10);
1516    }
1517
1518    #[test]
1519    fn test_estimate_tokens() {
1520        // (3 + 5 + 20) / 4 = 7
1521        assert_eq!(estimate_tokens("key", "value"), 7);
1522        // (0 + 0 + 20) / 4 = 5
1523        assert_eq!(estimate_tokens("", ""), 5);
1524    }
1525
1526    fn make_scored_item(key: &str, value: &str, score: f64, embedding: Option<Vec<f32>>) -> ScoredItem {
1527        ScoredItem {
1528            item: ContextItem {
1529                id: format!("id_{key}"),
1530                session_id: "sess_test".to_string(),
1531                key: key.to_string(),
1532                value: value.to_string(),
1533                category: "note".to_string(),
1534                priority: "normal".to_string(),
1535                channel: None,
1536                tags: None,
1537                size: value.len() as i64,
1538                created_at: 0,
1539                updated_at: 0,
1540            },
1541            score,
1542            token_estimate: estimate_tokens(key, value),
1543            embedding,
1544        }
1545    }
1546
1547    #[test]
1548    fn test_pack_to_budget_all_fit() {
1549        let items = vec![
1550            make_scored_item("a", "short", 3.0, None),
1551            make_scored_item("b", "also short", 2.0, None),
1552        ];
1553        let packed = pack_to_budget(items, 4000);
1554        assert_eq!(packed.len(), 2);
1555    }
1556
1557    #[test]
1558    fn test_pack_to_budget_overflow() {
1559        // Create items that exceed budget
1560        let big_value = "x".repeat(4000);
1561        let items = vec![
1562            make_scored_item("a", &big_value, 3.0, None),
1563            make_scored_item("b", "fits", 2.0, None),
1564        ];
1565        let packed = pack_to_budget(items, 500);
1566        // Big item won't fit, but small item should
1567        assert_eq!(packed.len(), 1);
1568        assert_eq!(packed[0].item.key, "b");
1569    }
1570
1571    #[test]
1572    fn test_pack_to_budget_empty() {
1573        let packed = pack_to_budget(vec![], 4000);
1574        assert!(packed.is_empty());
1575    }
1576
1577    #[test]
1578    fn test_mmr_no_embeddings() {
1579        let items = vec![
1580            make_scored_item("a", "one", 3.0, None),
1581            make_scored_item("b", "two", 2.0, None),
1582        ];
1583        let result = apply_mmr(items, 0.7);
1584        // Without embeddings, should preserve order
1585        assert_eq!(result.len(), 2);
1586        assert_eq!(result[0].item.key, "a");
1587        assert_eq!(result[1].item.key, "b");
1588    }
1589
1590    #[test]
1591    fn test_mmr_with_embeddings_preserves_count() {
1592        let items = vec![
1593            make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0, 0.0])),
1594            make_scored_item("b", "two", 2.0, Some(vec![0.0, 1.0, 0.0])),
1595            make_scored_item("c", "three", 1.0, Some(vec![0.0, 0.0, 1.0])),
1596        ];
1597        let result = apply_mmr(items, 0.7);
1598        assert_eq!(result.len(), 3);
1599    }
1600
1601    #[test]
1602    fn test_mmr_diverse_items_keep_order() {
1603        // Three orthogonal items — diversity doesn't change relevance order
1604        let items = vec![
1605            make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0, 0.0])),
1606            make_scored_item("b", "two", 2.0, Some(vec![0.0, 1.0, 0.0])),
1607            make_scored_item("c", "three", 1.0, Some(vec![0.0, 0.0, 1.0])),
1608        ];
1609        let result = apply_mmr(items, 0.7);
1610        assert_eq!(result[0].item.key, "a");
1611    }
1612
1613    #[test]
1614    fn test_mmr_penalizes_duplicates() {
1615        // "b" is a near-duplicate of "a" (same embedding), "c" is diverse
1616        // Scores are close enough that diversity penalty should flip the order
1617        let items = vec![
1618            make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0])),
1619            make_scored_item("b", "two", 2.5, Some(vec![1.0, 0.0])), // near-dup of a
1620            make_scored_item("c", "three", 2.5, Some(vec![0.0, 1.0])), // diverse, same score
1621        ];
1622        let result = apply_mmr(items, 0.7);
1623        // After "a" is selected, "c" should rank above "b" due to diversity
1624        // Both have same relevance, but "b" has max_sim=1.0 to "a" while "c" has 0.0
1625        assert_eq!(result[0].item.key, "a");
1626        assert_eq!(result[1].item.key, "c", "Diverse item should rank above near-duplicate");
1627    }
1628
1629    #[test]
1630    fn test_mmr_mixed_embeddings() {
1631        // Items with and without embeddings
1632        let items = vec![
1633            make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0])),
1634            make_scored_item("b", "two", 2.0, None), // no embedding
1635            make_scored_item("c", "three", 1.0, Some(vec![0.0, 1.0])),
1636        ];
1637        let result = apply_mmr(items, 0.7);
1638        assert_eq!(result.len(), 3);
1639        // Items without embeddings go at the end
1640        assert_eq!(result[2].item.key, "b");
1641    }
1642}