1use 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#[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#[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
66pub 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 last_temporal_capture_id: Option<u64>,
82 temporal_chain: Vec<(u64, u64)>,
84 tool_call_log: Vec<ToolCallRecord>,
86 observation_notes: Vec<ObservationNote>,
88 next_note_id: u64,
90 workspace_manager: super::workspace::VisionWorkspaceManager,
92}
93
94impl VisionSessionManager {
95 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 pub fn store(&self) -> &VisualMemoryStore {
168 &self.store
169 }
170
171 pub fn workspace_manager(&self) -> &super::workspace::VisionWorkspaceManager {
173 &self.workspace_manager
174 }
175
176 pub fn workspace_manager_mut(&mut self) -> &mut super::workspace::VisionWorkspaceManager {
178 &mut self.workspace_manager
179 }
180
181 pub fn current_session_id(&self) -> u32 {
183 self.current_session
184 }
185
186 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 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 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 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 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 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 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, 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 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 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 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 matches.retain(|m| m.id != capture_id);
376 matches.truncate(top_k);
377 Ok(matches)
378 }
379
380 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 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 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 pub fn last_temporal_capture_id(&self) -> Option<u64> {
425 self.last_temporal_capture_id
426 }
427
428 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 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 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 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 pub fn observation_notes(&self) -> &[ObservationNote] {
470 &self.observation_notes
471 }
472
473 pub fn tool_call_log(&self) -> &[ToolCallRecord] {
475 &self.tool_call_log
476 }
477
478 pub fn temporal_chain(&self) -> &[(u64, u64)] {
480 &self.temporal_chain
481 }
482
483 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 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 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
675pub 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 (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}