Skip to main content

agentic_vision_mcp/session/
manager.rs

1//! Visual memory session lifecycle, file I/O, and session tracking.
2
3use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use image::GenericImageView;
7
8use agentic_vision::{
9    capture_from_base64, capture_from_file, compute_diff, cosine_similarity, find_similar,
10    generate_thumbnail, AvisReader, AvisWriter, CaptureSource, EmbeddingEngine, ObservationMeta,
11    Rect, SimilarityMatch, VisualDiff, VisualMemoryStore, VisualObservation, EMBEDDING_DIM,
12};
13
14use crate::types::{McpError, McpResult};
15
16const DEFAULT_AUTO_SAVE_SECS: u64 = 30;
17const DEFAULT_STORAGE_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
18const DEFAULT_STORAGE_BUDGET_HORIZON_YEARS: u32 = 20;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21enum StorageBudgetMode {
22    AutoRollup,
23    Warn,
24    Off,
25}
26
27impl StorageBudgetMode {
28    fn from_env(name: &str) -> Self {
29        let raw = read_env_string(name).unwrap_or_else(|| "auto-rollup".to_string());
30        match raw.trim().to_ascii_lowercase().as_str() {
31            "warn" => Self::Warn,
32            "off" | "disabled" | "none" => Self::Off,
33            _ => Self::AutoRollup,
34        }
35    }
36
37    fn as_str(self) -> &'static str {
38        match self {
39            Self::AutoRollup => "auto-rollup",
40            Self::Warn => "warn",
41            Self::Off => "off",
42        }
43    }
44}
45
46/// Record of a tool call with context.
47#[derive(Debug, Clone)]
48pub struct ToolCallRecord {
49    pub tool_name: String,
50    pub summary: String,
51    pub timestamp: u64,
52    pub capture_id: Option<u64>,
53}
54
55/// An observation context note from the observation_log tool.
56#[derive(Debug, Clone)]
57pub struct ObservationNote {
58    pub id: u64,
59    pub intent: String,
60    pub observation: Option<String>,
61    pub related_capture_id: Option<u64>,
62    pub topic: Option<String>,
63    pub timestamp: u64,
64}
65
66/// Manages the visual memory lifecycle, file I/O, and session state.
67pub struct VisionSessionManager {
68    store: VisualMemoryStore,
69    engine: EmbeddingEngine,
70    file_path: PathBuf,
71    current_session: u32,
72    dirty: bool,
73    last_save: Instant,
74    auto_save_interval: Duration,
75    storage_budget_mode: StorageBudgetMode,
76    storage_budget_max_bytes: u64,
77    storage_budget_horizon_years: u32,
78    storage_budget_target_fraction: f32,
79    storage_budget_rollup_count: u64,
80    /// ID of the last capture/note in the temporal chain for this session.
81    last_temporal_capture_id: Option<u64>,
82    /// Temporal chain links: (prev_id, next_id) within this session.
83    temporal_chain: Vec<(u64, u64)>,
84    /// Log of tool calls with context summaries.
85    tool_call_log: Vec<ToolCallRecord>,
86    /// Context entries from observation_log tool.
87    observation_notes: Vec<ObservationNote>,
88    /// Counter for observation note IDs.
89    next_note_id: u64,
90    /// Multi-context workspace manager for cross-vision queries.
91    workspace_manager: super::workspace::VisionWorkspaceManager,
92}
93
94impl VisionSessionManager {
95    /// Open or create a vision file at the given path.
96    pub fn open(path: &str, model_path: Option<&str>) -> McpResult<Self> {
97        let file_path = PathBuf::from(path);
98
99        let store = if file_path.exists() {
100            tracing::info!("Opening existing vision file: {}", file_path.display());
101            AvisReader::read_from_file(&file_path)
102                .map_err(|e| McpError::VisionError(format!("Failed to read vision file: {e}")))?
103        } else {
104            tracing::info!("Creating new vision file: {}", file_path.display());
105            if let Some(parent) = file_path.parent() {
106                std::fs::create_dir_all(parent).map_err(|e| {
107                    McpError::Io(std::io::Error::other(format!(
108                        "Failed to create directory {}: {e}",
109                        parent.display()
110                    )))
111                })?;
112            }
113            VisualMemoryStore::new(EMBEDDING_DIM)
114        };
115
116        let current_session = store.session_count + 1;
117
118        let engine = EmbeddingEngine::new(model_path).map_err(|e| {
119            McpError::VisionError(format!("Failed to initialize embedding engine: {e}"))
120        })?;
121
122        tracing::info!(
123            "Session {} started. Store has {} observations. Embedding model: {}",
124            current_session,
125            store.count(),
126            if engine.has_model() {
127                "loaded"
128            } else {
129                "fallback"
130            }
131        );
132
133        let storage_budget_mode = StorageBudgetMode::from_env("CORTEX_STORAGE_BUDGET_MODE");
134        let storage_budget_max_bytes =
135            read_env_u64("CORTEX_STORAGE_BUDGET_BYTES", DEFAULT_STORAGE_BUDGET_BYTES).max(1);
136        let storage_budget_horizon_years = read_env_u32(
137            "CORTEX_STORAGE_BUDGET_HORIZON_YEARS",
138            DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
139        )
140        .max(1);
141        let storage_budget_target_fraction =
142            read_env_f32("CORTEX_STORAGE_BUDGET_TARGET_FRACTION", 0.85).clamp(0.50, 0.99);
143
144        Ok(Self {
145            store,
146            engine,
147            file_path,
148            current_session,
149            dirty: false,
150            last_save: Instant::now(),
151            auto_save_interval: Duration::from_secs(DEFAULT_AUTO_SAVE_SECS),
152            storage_budget_mode,
153            storage_budget_max_bytes,
154            storage_budget_horizon_years,
155            storage_budget_target_fraction,
156            storage_budget_rollup_count: 0,
157            last_temporal_capture_id: None,
158            temporal_chain: Vec::new(),
159            tool_call_log: Vec::new(),
160            observation_notes: Vec::new(),
161            next_note_id: 1,
162            workspace_manager: super::workspace::VisionWorkspaceManager::new(),
163        })
164    }
165
166    /// Get the visual memory store.
167    pub fn store(&self) -> &VisualMemoryStore {
168        &self.store
169    }
170
171    /// Get the workspace manager (immutable).
172    pub fn workspace_manager(&self) -> &super::workspace::VisionWorkspaceManager {
173        &self.workspace_manager
174    }
175
176    /// Get the workspace manager (mutable).
177    pub fn workspace_manager_mut(&mut self) -> &mut super::workspace::VisionWorkspaceManager {
178        &mut self.workspace_manager
179    }
180
181    /// Current session ID.
182    pub fn current_session_id(&self) -> u32 {
183        self.current_session
184    }
185
186    /// Start a new session.
187    pub fn start_session(&mut self, explicit_id: Option<u32>) -> McpResult<u32> {
188        let session_id = explicit_id.unwrap_or(self.current_session + 1);
189        self.current_session = session_id;
190        self.store.session_count = self.store.session_count.max(session_id);
191        // Reset temporal chain and context logs for the new session.
192        self.last_temporal_capture_id = None;
193        self.temporal_chain.clear();
194        self.tool_call_log.clear();
195        self.observation_notes.clear();
196        tracing::info!("Started session {session_id}");
197        Ok(session_id)
198    }
199
200    /// End the current session.
201    pub fn end_session(&mut self) -> McpResult<u32> {
202        let session_id = self.current_session;
203        self.save()?;
204        self.maybe_enforce_storage_budget()?;
205        tracing::info!("Ended session {session_id}");
206        Ok(session_id)
207    }
208
209    /// Capture an image from a file or base64 source.
210    pub fn capture(
211        &mut self,
212        source_type: &str,
213        source_data: &str,
214        mime: Option<&str>,
215        labels: Vec<String>,
216        description: Option<String>,
217        _extract_ocr: bool,
218    ) -> McpResult<CaptureResult> {
219        let (img, source) = match source_type {
220            "file" => capture_from_file(source_data)
221                .map_err(|e| McpError::VisionError(format!("Failed to capture from file: {e}")))?,
222            "base64" => {
223                let m = mime.unwrap_or("image/png");
224                capture_from_base64(source_data, m)
225                    .map_err(|e| McpError::VisionError(format!("Failed to decode base64: {e}")))?
226            }
227            _ => {
228                return Err(McpError::InvalidParams(format!(
229                    "Unsupported source type: {source_type}. Use 'file' or 'base64'."
230                )));
231            }
232        };
233
234        self.store_capture(img, source, labels, description)
235    }
236
237    /// Capture a screenshot and store it in visual memory.
238    pub fn capture_screenshot(
239        &mut self,
240        region: Option<Rect>,
241        labels: Vec<String>,
242        description: Option<String>,
243        _extract_ocr: bool,
244    ) -> McpResult<CaptureResult> {
245        let (img, source) = agentic_vision::capture_screenshot(region)
246            .map_err(|e| McpError::VisionError(format!("Screenshot capture failed: {e}")))?;
247
248        self.store_capture(img, source, labels, description)
249    }
250
251    /// Capture an image from the clipboard and store it in visual memory.
252    pub fn capture_clipboard(
253        &mut self,
254        labels: Vec<String>,
255        description: Option<String>,
256        _extract_ocr: bool,
257    ) -> McpResult<CaptureResult> {
258        let (img, source) = agentic_vision::capture_clipboard()
259            .map_err(|e| McpError::VisionError(format!("Clipboard capture failed: {e}")))?;
260
261        self.store_capture(img, source, labels, description)
262    }
263
264    /// Internal: process a captured image and store it as an observation.
265    fn store_capture(
266        &mut self,
267        img: image::DynamicImage,
268        source: CaptureSource,
269        labels: Vec<String>,
270        description: Option<String>,
271    ) -> McpResult<CaptureResult> {
272        let (orig_w, orig_h) = img.dimensions();
273        let thumbnail = generate_thumbnail(&img);
274        let thumb_img = image::load_from_memory(&thumbnail)
275            .map_err(|e| McpError::VisionError(format!("Failed to load thumbnail: {e}")))?;
276        let (thumb_w, thumb_h) = thumb_img.dimensions();
277
278        let embedding = self
279            .engine
280            .embed(&img)
281            .map_err(|e| McpError::VisionError(format!("Embedding failed: {e}")))?;
282
283        let sanitized_labels: Vec<String> = labels
284            .into_iter()
285            .map(|v| sanitize_metadata_text(&v))
286            .collect();
287        let sanitized_description = description.map(|d| sanitize_metadata_text(&d));
288        let quality_score = compute_quality_score(
289            orig_w,
290            orig_h,
291            sanitized_labels.len(),
292            sanitized_description.is_some(),
293            self.engine.has_model(),
294        );
295
296        let now = std::time::SystemTime::now()
297            .duration_since(std::time::UNIX_EPOCH)
298            .unwrap_or_default()
299            .as_secs();
300
301        let obs = VisualObservation {
302            id: 0, // assigned by store
303            timestamp: now,
304            session_id: self.current_session,
305            source,
306            embedding,
307            thumbnail,
308            metadata: ObservationMeta {
309                width: thumb_w,
310                height: thumb_h,
311                original_width: orig_w,
312                original_height: orig_h,
313                labels: sanitized_labels,
314                description: sanitized_description,
315                quality_score,
316            },
317            memory_link: None,
318        };
319
320        let id = self.store.add(obs);
321        self.dirty = true;
322
323        // Link into the temporal chain.
324        if let Some(prev) = self.last_temporal_capture_id {
325            self.temporal_chain.push((prev, id));
326        }
327        self.last_temporal_capture_id = Some(id);
328
329        self.maybe_auto_save()?;
330        self.maybe_enforce_storage_budget()?;
331
332        Ok(CaptureResult {
333            capture_id: id,
334            timestamp: now,
335            width: orig_w,
336            height: orig_h,
337            embedding_dims: EMBEDDING_DIM,
338            quality_score,
339        })
340    }
341
342    /// Compare two captures by cosine similarity.
343    pub fn compare(&self, id_a: u64, id_b: u64) -> McpResult<f32> {
344        let a = self
345            .store
346            .get(id_a)
347            .ok_or(McpError::CaptureNotFound(id_a))?;
348        let b = self
349            .store
350            .get(id_b)
351            .ok_or(McpError::CaptureNotFound(id_b))?;
352
353        Ok(cosine_similarity(&a.embedding, &b.embedding))
354    }
355
356    /// Find similar captures.
357    pub fn find_similar(
358        &self,
359        capture_id: u64,
360        top_k: usize,
361        min_similarity: f32,
362    ) -> McpResult<Vec<SimilarityMatch>> {
363        let obs = self
364            .store
365            .get(capture_id)
366            .ok_or(McpError::CaptureNotFound(capture_id))?;
367
368        let mut matches = find_similar(
369            &obs.embedding,
370            &self.store.observations,
371            top_k + 1,
372            min_similarity,
373        );
374        // Remove self from results
375        matches.retain(|m| m.id != capture_id);
376        matches.truncate(top_k);
377        Ok(matches)
378    }
379
380    /// Find similar by raw embedding.
381    pub fn find_similar_by_embedding(
382        &self,
383        embedding: &[f32],
384        top_k: usize,
385        min_similarity: f32,
386    ) -> Vec<SimilarityMatch> {
387        find_similar(embedding, &self.store.observations, top_k, min_similarity)
388    }
389
390    /// Compute visual diff between two captures.
391    pub fn diff(&self, id_a: u64, id_b: u64) -> McpResult<VisualDiff> {
392        let a = self
393            .store
394            .get(id_a)
395            .ok_or(McpError::CaptureNotFound(id_a))?;
396        let b = self
397            .store
398            .get(id_b)
399            .ok_or(McpError::CaptureNotFound(id_b))?;
400
401        let img_a = image::load_from_memory(&a.thumbnail)
402            .map_err(|e| McpError::VisionError(format!("Failed to load thumbnail A: {e}")))?;
403        let img_b = image::load_from_memory(&b.thumbnail)
404            .map_err(|e| McpError::VisionError(format!("Failed to load thumbnail B: {e}")))?;
405
406        compute_diff(id_a, id_b, &img_a, &img_b)
407            .map_err(|e| McpError::VisionError(format!("Diff failed: {e}")))
408    }
409
410    /// Link a capture to a memory node.
411    pub fn link(&mut self, capture_id: u64, memory_node_id: u64) -> McpResult<()> {
412        let obs = self
413            .store
414            .get_mut(capture_id)
415            .ok_or(McpError::CaptureNotFound(capture_id))?;
416        obs.memory_link = Some(memory_node_id);
417        self.dirty = true;
418        Ok(())
419    }
420
421    // ── Temporal chain & context capture ────────────────────────────────
422
423    /// ID of the last node in the temporal chain for this session.
424    pub fn last_temporal_capture_id(&self) -> Option<u64> {
425        self.last_temporal_capture_id
426    }
427
428    /// Advance the temporal chain pointer to a new node.
429    pub fn advance_temporal_chain(&mut self, id: u64) {
430        if let Some(prev) = self.last_temporal_capture_id {
431            self.temporal_chain.push((prev, id));
432        }
433        self.last_temporal_capture_id = Some(id);
434    }
435
436    /// Record a tool call with context.
437    pub fn log_tool_call(&mut self, record: ToolCallRecord) {
438        tracing::info!(
439            "[context] tool={} summary={} capture={:?}",
440            record.tool_name,
441            record.summary,
442            record.capture_id
443        );
444        self.tool_call_log.push(record);
445    }
446
447    /// Add an observation context note. Returns the note ID.
448    pub fn add_observation_note(&mut self, mut note: ObservationNote) -> u64 {
449        let id = self.next_note_id;
450        self.next_note_id += 1;
451        note.id = id;
452        tracing::info!(
453            "[context] observation_note id={} intent={} topic={:?}",
454            id,
455            note.intent,
456            note.topic
457        );
458        // Link into the temporal chain using a note-space ID (offset to avoid collisions).
459        let chain_id = id + 10_000_000;
460        if let Some(prev) = self.last_temporal_capture_id {
461            self.temporal_chain.push((prev, chain_id));
462        }
463        self.last_temporal_capture_id = Some(chain_id);
464        self.observation_notes.push(note);
465        id
466    }
467
468    /// Access the observation notes for this session.
469    pub fn observation_notes(&self) -> &[ObservationNote] {
470        &self.observation_notes
471    }
472
473    /// Access the tool call log for this session.
474    pub fn tool_call_log(&self) -> &[ToolCallRecord] {
475        &self.tool_call_log
476    }
477
478    /// Access the temporal chain for this session.
479    pub fn temporal_chain(&self) -> &[(u64, u64)] {
480        &self.temporal_chain
481    }
482
483    /// Save to file.
484    pub fn save(&mut self) -> McpResult<()> {
485        if !self.dirty {
486            return Ok(());
487        }
488
489        AvisWriter::write_to_file(&self.store, &self.file_path)
490            .map_err(|e| McpError::VisionError(format!("Failed to write vision file: {e}")))?;
491
492        self.dirty = false;
493        self.last_save = Instant::now();
494        tracing::debug!("Saved vision file: {}", self.file_path.display());
495        Ok(())
496    }
497
498    fn maybe_auto_save(&mut self) -> McpResult<()> {
499        if self.dirty && self.last_save.elapsed() >= self.auto_save_interval {
500            self.save()?;
501        }
502        Ok(())
503    }
504
505    pub fn file_path(&self) -> &PathBuf {
506        &self.file_path
507    }
508
509    pub fn storage_budget_status(&self) -> VisionStorageBudgetStatus {
510        let current_size = self.current_file_size_bytes();
511        let projected = self.projected_file_size_bytes(current_size);
512        let over_budget = current_size > self.storage_budget_max_bytes
513            || projected
514                .map(|v| v > self.storage_budget_max_bytes)
515                .unwrap_or(false);
516
517        VisionStorageBudgetStatus {
518            mode: self.storage_budget_mode.as_str().to_string(),
519            max_bytes: self.storage_budget_max_bytes,
520            horizon_years: self.storage_budget_horizon_years,
521            target_fraction: self.storage_budget_target_fraction,
522            current_size_bytes: current_size,
523            projected_size_bytes: projected,
524            over_budget,
525            rollup_count: self.storage_budget_rollup_count,
526        }
527    }
528
529    fn maybe_enforce_storage_budget(&mut self) -> McpResult<()> {
530        if self.storage_budget_mode == StorageBudgetMode::Off {
531            return Ok(());
532        }
533
534        if self.current_file_size_bytes() == 0 && self.dirty {
535            self.save()?;
536        }
537
538        let current_size = self.current_file_size_bytes();
539        if current_size == 0 {
540            return Ok(());
541        }
542        let projected = self.projected_file_size_bytes(current_size);
543        let over_current = current_size > self.storage_budget_max_bytes;
544        let over_projected = projected
545            .map(|v| v > self.storage_budget_max_bytes)
546            .unwrap_or(false);
547        if !over_current && !over_projected {
548            return Ok(());
549        }
550
551        if self.storage_budget_mode == StorageBudgetMode::Warn {
552            tracing::warn!(
553                "AVIS storage budget warning: current={} projected={:?} limit={}",
554                current_size,
555                projected,
556                self.storage_budget_max_bytes
557            );
558            return Ok(());
559        }
560
561        let target_bytes = ((self.storage_budget_max_bytes as f64
562            * self.storage_budget_target_fraction as f64)
563            .round() as u64)
564            .max(1);
565        let mut pruned = 0usize;
566
567        loop {
568            let current = self.current_file_size_bytes();
569            if current <= target_bytes {
570                break;
571            }
572            let Some(idx) = self.select_prune_candidate() else {
573                break;
574            };
575            self.store.observations.remove(idx);
576            self.store.updated_at = std::time::SystemTime::now()
577                .duration_since(std::time::UNIX_EPOCH)
578                .unwrap_or_default()
579                .as_secs();
580            self.dirty = true;
581            self.save()?;
582            pruned = pruned.saturating_add(1);
583        }
584
585        if pruned > 0 {
586            self.storage_budget_rollup_count = self
587                .storage_budget_rollup_count
588                .saturating_add(pruned as u64);
589            tracing::info!(
590                "AVIS storage budget rollup: pruned={} current_size={} limit={}",
591                pruned,
592                self.current_file_size_bytes(),
593                self.storage_budget_max_bytes
594            );
595        } else {
596            tracing::warn!(
597                "AVIS storage budget exceeded but no prune candidate available (current={} projected={:?} limit={})",
598                current_size,
599                projected,
600                self.storage_budget_max_bytes
601            );
602        }
603
604        Ok(())
605    }
606
607    fn select_prune_candidate(&self) -> Option<usize> {
608        // Prefer non-linked captures from completed sessions (oldest first).
609        let mut best: Option<(usize, u64, f32)> = None;
610        for (idx, obs) in self.store.observations.iter().enumerate() {
611            if obs.session_id >= self.current_session || obs.memory_link.is_some() {
612                continue;
613            }
614            let score = obs.metadata.quality_score;
615            match best {
616                None => best = Some((idx, obs.timestamp, score)),
617                Some((_, ts, q)) => {
618                    if obs.timestamp < ts || (obs.timestamp == ts && score < q) {
619                        best = Some((idx, obs.timestamp, score));
620                    }
621                }
622            }
623        }
624        if let Some((idx, _, _)) = best {
625            return Some(idx);
626        }
627
628        // Fallback: oldest capture in completed sessions.
629        self.store
630            .observations
631            .iter()
632            .enumerate()
633            .filter(|(_, obs)| obs.session_id < self.current_session)
634            .min_by_key(|(_, obs)| obs.timestamp)
635            .map(|(idx, _)| idx)
636    }
637
638    fn current_file_size_bytes(&self) -> u64 {
639        std::fs::metadata(&self.file_path)
640            .map(|m| m.len())
641            .unwrap_or(0)
642    }
643
644    fn projected_file_size_bytes(&self, current_size: u64) -> Option<u64> {
645        if current_size == 0 || self.store.observations.len() < 2 {
646            return None;
647        }
648        let mut min_ts = u64::MAX;
649        let mut max_ts = 0u64;
650        for obs in &self.store.observations {
651            min_ts = min_ts.min(obs.timestamp);
652            max_ts = max_ts.max(obs.timestamp);
653        }
654        if min_ts == u64::MAX || max_ts <= min_ts {
655            return None;
656        }
657        let span_secs = (max_ts - min_ts).max(7 * 24 * 3600) as f64;
658        let per_sec = current_size as f64 / span_secs;
659        let horizon_secs = (self.storage_budget_horizon_years as f64) * 365.25 * 24.0 * 3600.0;
660        let projected = (per_sec * horizon_secs).round();
661        Some(projected.max(0.0).min(u64::MAX as f64) as u64)
662    }
663}
664
665impl Drop for VisionSessionManager {
666    fn drop(&mut self) {
667        if self.dirty {
668            if let Err(e) = self.save() {
669                tracing::error!("Failed to save on drop: {e}");
670            }
671        }
672    }
673}
674
675/// Result of a capture operation.
676pub struct CaptureResult {
677    pub capture_id: u64,
678    pub timestamp: u64,
679    pub width: u32,
680    pub height: u32,
681    pub embedding_dims: u32,
682    pub quality_score: f32,
683}
684
685#[derive(Debug, Clone, serde::Serialize)]
686pub struct VisionStorageBudgetStatus {
687    pub mode: String,
688    pub max_bytes: u64,
689    pub horizon_years: u32,
690    pub target_fraction: f32,
691    pub current_size_bytes: u64,
692    pub projected_size_bytes: Option<u64>,
693    pub over_budget: bool,
694    pub rollup_count: u64,
695}
696
697fn read_env_string(name: &str) -> Option<String> {
698    std::env::var(name).ok().map(|v| v.trim().to_string())
699}
700
701fn read_env_u64(name: &str, default_value: u64) -> u64 {
702    std::env::var(name)
703        .ok()
704        .and_then(|v| v.parse::<u64>().ok())
705        .unwrap_or(default_value)
706}
707
708fn read_env_u32(name: &str, default_value: u32) -> u32 {
709    std::env::var(name)
710        .ok()
711        .and_then(|v| v.parse::<u32>().ok())
712        .unwrap_or(default_value)
713}
714
715fn read_env_f32(name: &str, default_value: f32) -> f32 {
716    std::env::var(name)
717        .ok()
718        .and_then(|v| v.parse::<f32>().ok())
719        .unwrap_or(default_value)
720}
721
722fn sanitize_metadata_text(raw: &str) -> String {
723    raw.split_whitespace()
724        .map(|token| {
725            if looks_like_email(token) {
726                "[redacted-email]".to_string()
727            } else if looks_like_secret(token) {
728                "[redacted-secret]".to_string()
729            } else if looks_like_local_path(token) {
730                "[redacted-path]".to_string()
731            } else {
732                token.to_string()
733            }
734        })
735        .collect::<Vec<_>>()
736        .join(" ")
737}
738
739fn looks_like_email(token: &str) -> bool {
740    token.contains('@') && token.contains('.')
741}
742
743fn looks_like_secret(token: &str) -> bool {
744    let t = token.trim_matches(|c: char| ",.;:()[]{}<>\"'".contains(c));
745    if t.starts_with("sk-") && t.len() >= 12 {
746        return true;
747    }
748    if t.len() >= 32 && t.chars().all(|c| c.is_ascii_hexdigit()) {
749        return true;
750    }
751    t.to_ascii_lowercase().contains("token=") && t.len() >= 16
752}
753
754fn looks_like_local_path(token: &str) -> bool {
755    token.starts_with("/Users/")
756        || token.starts_with("/home/")
757        || token.starts_with("C:\\")
758        || token.starts_with("D:\\")
759}
760
761fn compute_quality_score(
762    width: u32,
763    height: u32,
764    label_count: usize,
765    has_description: bool,
766    model_available: bool,
767) -> f32 {
768    let px = width as f32 * height as f32;
769    let resolution_score = (px / (1280.0 * 720.0)).clamp(0.0, 1.0);
770    let label_score = (label_count as f32 / 6.0).clamp(0.0, 1.0);
771    let description_score = if has_description { 1.0 } else { 0.0 };
772    let model_score = if model_available { 1.0 } else { 0.35 };
773
774    // Weighted blend focused on actionable retrieval quality.
775    (0.35 * resolution_score + 0.20 * label_score + 0.20 * description_score + 0.25 * model_score)
776        .clamp(0.0, 1.0)
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    fn make_obs(session_id: u32, timestamp: u64, linked: bool) -> VisualObservation {
784        VisualObservation {
785            id: 0,
786            timestamp,
787            session_id,
788            source: CaptureSource::Clipboard,
789            embedding: vec![0.0; EMBEDDING_DIM as usize],
790            thumbnail: vec![1u8; 1024],
791            metadata: ObservationMeta {
792                width: 64,
793                height: 64,
794                original_width: 512,
795                original_height: 512,
796                labels: vec!["test".to_string()],
797                description: Some("observation".to_string()),
798                quality_score: 0.4,
799            },
800            memory_link: if linked { Some(42) } else { None },
801        }
802    }
803
804    #[test]
805    fn budget_projection_available_with_timeline() {
806        let dir = tempfile::tempdir().expect("tempdir");
807        let path = dir.path().join("vision-projection.avis");
808        let mut manager =
809            VisionSessionManager::open(path.to_str().expect("path"), None).expect("open");
810
811        manager.store.add(make_obs(1, 1_700_000_000, false));
812        manager
813            .store
814            .add(make_obs(1, 1_700_000_000 + 15 * 24 * 3600, false));
815        manager.dirty = true;
816        manager.save().expect("save");
817
818        let size = manager.current_file_size_bytes();
819        let projected = manager.projected_file_size_bytes(size);
820        assert!(size > 0);
821        assert!(projected.is_some());
822    }
823
824    #[test]
825    fn budget_auto_rollup_prunes_completed_sessions() {
826        let dir = tempfile::tempdir().expect("tempdir");
827        let path = dir.path().join("vision-rollup.avis");
828        let mut manager =
829            VisionSessionManager::open(path.to_str().expect("path"), None).expect("open");
830
831        manager.store.add(make_obs(1, 1_700_000_000, false));
832        manager.store.add(make_obs(1, 1_700_000_001, false));
833        manager.start_session(Some(2)).expect("session");
834        manager.dirty = true;
835        manager.save().expect("save");
836
837        let before = manager.store.count();
838        manager.storage_budget_mode = StorageBudgetMode::AutoRollup;
839        manager.storage_budget_max_bytes = 1;
840        manager.storage_budget_target_fraction = 0.5;
841
842        manager
843            .maybe_enforce_storage_budget()
844            .expect("enforce budget");
845
846        assert!(manager.store.count() < before);
847        assert!(manager.storage_budget_rollup_count >= 1);
848    }
849}