Skip to main content

agentic_vision/cli/
commands.rs

1//! CLI command implementations for the `avis` binary.
2
3use std::path::Path;
4
5use crate::storage::{AvisReader, AvisWriter};
6use crate::types::{VisionResult, VisualMemoryStore};
7
8/// Create a new empty .avis file.
9pub fn cmd_create(path: &Path, dimension: u32) -> VisionResult<()> {
10    let store = VisualMemoryStore::new(dimension);
11    AvisWriter::write_to_file(&store, path)?;
12    println!("Created {}", path.display());
13    Ok(())
14}
15
16/// Display information about an .avis file.
17pub fn cmd_info(path: &Path, json: bool) -> VisionResult<()> {
18    let store = AvisReader::read_from_file(path)?;
19    let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
20
21    if json {
22        let info = serde_json::json!({
23            "file": path.display().to_string(),
24            "observations": store.count(),
25            "embedding_dim": store.embedding_dim,
26            "sessions": store.session_count,
27            "next_id": store.next_id,
28            "created_at": store.created_at,
29            "updated_at": store.updated_at,
30            "file_bytes": file_size,
31        });
32        println!(
33            "{}",
34            serde_json::to_string_pretty(&info).unwrap_or_default()
35        );
36    } else {
37        println!("File:          {}", path.display());
38        println!("Observations:  {}", store.count());
39        println!("Embedding dim: {}", store.embedding_dim);
40        println!("Sessions:      {}", store.session_count);
41        println!("Next ID:       {}", store.next_id);
42        println!("Created:       {}", format_ts(store.created_at));
43        println!("Updated:       {}", format_ts(store.updated_at));
44        println!("File size:     {} bytes", file_size);
45    }
46
47    Ok(())
48}
49
50/// Capture an image and add it to the .avis store.
51pub fn cmd_capture(
52    path: &Path,
53    source_path: &str,
54    labels: Vec<String>,
55    description: Option<String>,
56    model_path: Option<&str>,
57    json: bool,
58) -> VisionResult<()> {
59    let mut store = if path.exists() {
60        AvisReader::read_from_file(path)?
61    } else {
62        VisualMemoryStore::new(crate::EMBEDDING_DIM)
63    };
64
65    let (img, source) = crate::capture_from_file(source_path)?;
66    let thumbnail = crate::generate_thumbnail(&img);
67    let mut engine = crate::EmbeddingEngine::new(model_path)?;
68    let embedding = engine.embed(&img)?;
69
70    let (width, height) = (img.width(), img.height());
71    let obs = crate::types::VisualObservation {
72        id: 0,
73        timestamp: now_secs(),
74        session_id: 0,
75        source,
76        embedding,
77        thumbnail,
78        metadata: crate::types::ObservationMeta {
79            width: std::cmp::min(width, 512),
80            height: std::cmp::min(height, 512),
81            original_width: width,
82            original_height: height,
83            labels,
84            description,
85            quality_score: compute_quality(width, height),
86        },
87        memory_link: None,
88    };
89
90    let id = store.add(obs);
91    AvisWriter::write_to_file(&store, path)?;
92
93    if json {
94        let out = serde_json::json!({ "id": id, "file": path.display().to_string() });
95        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
96    } else {
97        println!("Captured observation {} -> {}", id, path.display());
98    }
99
100    Ok(())
101}
102
103/// Query observations with filters.
104pub fn cmd_query(
105    path: &Path,
106    session: Option<u32>,
107    labels: Option<Vec<String>>,
108    limit: usize,
109    json: bool,
110) -> VisionResult<()> {
111    let store = AvisReader::read_from_file(path)?;
112    let mut results: Vec<_> = store.observations.iter().collect();
113
114    if let Some(sid) = session {
115        results.retain(|o| o.session_id == sid);
116    }
117    if let Some(ref lbls) = labels {
118        results.retain(|o| lbls.iter().any(|l| o.metadata.labels.contains(l)));
119    }
120
121    results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
122    results.truncate(limit);
123
124    if json {
125        let items: Vec<_> = results.iter().map(|o| obs_summary(o)).collect();
126        println!(
127            "{}",
128            serde_json::to_string_pretty(&items).unwrap_or_default()
129        );
130    } else {
131        if results.is_empty() {
132            println!("No observations found.");
133            return Ok(());
134        }
135        for o in &results {
136            println!(
137                "  [{:>4}]  session={}  {}x{}  labels={:?}  q={:.2}  {}",
138                o.id,
139                o.session_id,
140                o.metadata.original_width,
141                o.metadata.original_height,
142                o.metadata.labels,
143                o.metadata.quality_score,
144                format_ts(o.timestamp),
145            );
146        }
147        println!("{} observation(s)", results.len());
148    }
149
150    Ok(())
151}
152
153/// Find visually similar captures.
154pub fn cmd_similar(
155    path: &Path,
156    capture_id: u64,
157    top_k: usize,
158    min_similarity: f32,
159    json: bool,
160) -> VisionResult<()> {
161    let store = AvisReader::read_from_file(path)?;
162    let obs = store
163        .get(capture_id)
164        .ok_or(crate::types::VisionError::CaptureNotFound(capture_id))?;
165
166    let matches = crate::find_similar(&obs.embedding, &store.observations, top_k, min_similarity);
167
168    if json {
169        println!(
170            "{}",
171            serde_json::to_string_pretty(&matches).unwrap_or_default()
172        );
173    } else {
174        if matches.is_empty() {
175            println!("No similar captures found.");
176            return Ok(());
177        }
178        for m in &matches {
179            println!("  [{:>4}]  similarity={:.4}", m.id, m.similarity);
180        }
181    }
182
183    Ok(())
184}
185
186/// Compare two captures.
187pub fn cmd_compare(path: &Path, id_a: u64, id_b: u64, json: bool) -> VisionResult<()> {
188    let store = AvisReader::read_from_file(path)?;
189    let a = store
190        .get(id_a)
191        .ok_or(crate::types::VisionError::CaptureNotFound(id_a))?;
192    let b = store
193        .get(id_b)
194        .ok_or(crate::types::VisionError::CaptureNotFound(id_b))?;
195
196    let sim = crate::cosine_similarity(&a.embedding, &b.embedding);
197
198    if json {
199        let out = serde_json::json!({
200            "id_a": id_a, "id_b": id_b, "similarity": sim, "is_same": sim > 0.95,
201        });
202        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
203    } else {
204        println!("Compare {} vs {}", id_a, id_b);
205        println!("  Embedding similarity: {:.4}", sim);
206        println!(
207            "  Same image:           {}",
208            if sim > 0.95 { "yes" } else { "no" }
209        );
210    }
211
212    Ok(())
213}
214
215/// Pixel-level diff between two captures.
216pub fn cmd_diff(path: &Path, id_a: u64, id_b: u64, json: bool) -> VisionResult<()> {
217    let store = AvisReader::read_from_file(path)?;
218    let a = store
219        .get(id_a)
220        .ok_or(crate::types::VisionError::CaptureNotFound(id_a))?;
221    let b = store
222        .get(id_b)
223        .ok_or(crate::types::VisionError::CaptureNotFound(id_b))?;
224
225    let img_a = image::load_from_memory(&a.thumbnail).map_err(crate::types::VisionError::Image)?;
226    let img_b = image::load_from_memory(&b.thumbnail).map_err(crate::types::VisionError::Image)?;
227
228    let diff = crate::compute_diff(id_a, id_b, &img_a, &img_b)?;
229
230    if json {
231        println!(
232            "{}",
233            serde_json::to_string_pretty(&diff).unwrap_or_default()
234        );
235    } else {
236        println!("Diff {} vs {}", id_a, id_b);
237        println!("  Similarity:      {:.4}", diff.similarity);
238        println!("  Pixel diff:      {:.2}%", diff.pixel_diff_ratio * 100.0);
239        println!("  Changed regions: {}", diff.changed_regions.len());
240        for (i, r) in diff.changed_regions.iter().enumerate() {
241            println!("    [{}] x={} y={} {}x{}", i, r.x, r.y, r.w, r.h);
242        }
243    }
244
245    Ok(())
246}
247
248/// Health / quality report.
249pub fn cmd_health(
250    path: &Path,
251    stale_hours: u64,
252    low_quality_threshold: f32,
253    max_examples: usize,
254    json: bool,
255) -> VisionResult<()> {
256    let store = AvisReader::read_from_file(path)?;
257    let now = now_secs();
258    let stale_cutoff = now.saturating_sub(stale_hours * 3600);
259
260    let low_quality: Vec<u64> = store
261        .observations
262        .iter()
263        .filter(|o| o.metadata.quality_score < low_quality_threshold)
264        .take(max_examples)
265        .map(|o| o.id)
266        .collect();
267
268    let stale: Vec<u64> = store
269        .observations
270        .iter()
271        .filter(|o| o.timestamp < stale_cutoff)
272        .take(max_examples)
273        .map(|o| o.id)
274        .collect();
275
276    let unlinked: Vec<u64> = store
277        .observations
278        .iter()
279        .filter(|o| o.memory_link.is_none())
280        .take(max_examples)
281        .map(|o| o.id)
282        .collect();
283
284    let unlabeled: Vec<u64> = store
285        .observations
286        .iter()
287        .filter(|o| o.metadata.labels.is_empty())
288        .take(max_examples)
289        .map(|o| o.id)
290        .collect();
291
292    let status = if low_quality.is_empty() && stale.is_empty() {
293        "pass"
294    } else if low_quality.len() > 5 || stale.len() > 10 {
295        "fail"
296    } else {
297        "warn"
298    };
299
300    if json {
301        let out = serde_json::json!({
302            "status": status,
303            "total_observations": store.count(),
304            "low_quality_ids": low_quality,
305            "stale_ids": stale,
306            "unlinked_memory_ids": unlinked,
307            "unlabeled_ids": unlabeled,
308        });
309        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
310    } else {
311        println!("Health: {}", status.to_uppercase());
312        println!("  Total observations: {}", store.count());
313        println!(
314            "  Low quality (< {:.2}): {}",
315            low_quality_threshold,
316            low_quality.len()
317        );
318        println!("  Stale (> {}h):        {}", stale_hours, stale.len());
319        println!("  Unlinked memory:     {}", unlinked.len());
320        println!("  Unlabeled:           {}", unlabeled.len());
321    }
322
323    Ok(())
324}
325
326/// Link a capture to a memory node.
327pub fn cmd_link(path: &Path, capture_id: u64, memory_node_id: u64, json: bool) -> VisionResult<()> {
328    let mut store = AvisReader::read_from_file(path)?;
329    let obs = store
330        .get_mut(capture_id)
331        .ok_or(crate::types::VisionError::CaptureNotFound(capture_id))?;
332
333    obs.memory_link = Some(memory_node_id);
334    AvisWriter::write_to_file(&store, path)?;
335
336    if json {
337        let out = serde_json::json!({ "status": "linked", "capture_id": capture_id, "memory_node_id": memory_node_id });
338        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
339    } else {
340        println!(
341            "Linked capture {} -> memory node {}",
342            capture_id, memory_node_id
343        );
344    }
345
346    Ok(())
347}
348
349/// Display statistics about the store.
350pub fn cmd_stats(path: &Path, json: bool) -> VisionResult<()> {
351    let store = AvisReader::read_from_file(path)?;
352    let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
353
354    let total = store.count();
355    let linked = store
356        .observations
357        .iter()
358        .filter(|o| o.memory_link.is_some())
359        .count();
360    let labeled = store
361        .observations
362        .iter()
363        .filter(|o| !o.metadata.labels.is_empty())
364        .count();
365    let avg_quality = if total > 0 {
366        store
367            .observations
368            .iter()
369            .map(|o| o.metadata.quality_score)
370            .sum::<f32>()
371            / total as f32
372    } else {
373        0.0
374    };
375
376    let mut sessions = std::collections::HashSet::new();
377    for o in &store.observations {
378        sessions.insert(o.session_id);
379    }
380
381    if json {
382        let out = serde_json::json!({
383            "observations": total, "sessions": sessions.len(),
384            "linked_to_memory": linked, "labeled": labeled,
385            "avg_quality": avg_quality, "embedding_dim": store.embedding_dim,
386            "file_bytes": file_size,
387        });
388        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
389    } else {
390        println!("Observations:     {}", total);
391        println!("Sessions:         {}", sessions.len());
392        println!("Linked to memory: {}", linked);
393        println!("Labeled:          {}", labeled);
394        println!("Avg quality:      {:.3}", avg_quality);
395        println!("Embedding dim:    {}", store.embedding_dim);
396        println!("File size:        {} bytes", file_size);
397    }
398
399    Ok(())
400}
401
402/// Export the store as JSON.
403pub fn cmd_export(path: &Path, pretty: bool) -> VisionResult<()> {
404    let store = AvisReader::read_from_file(path)?;
405    let items: Vec<_> = store.observations.iter().map(obs_full).collect();
406
407    let output = if pretty {
408        serde_json::to_string_pretty(&items)
409    } else {
410        serde_json::to_string(&items)
411    };
412
413    println!("{}", output.unwrap_or_else(|_| "[]".to_string()));
414    Ok(())
415}
416
417// -- Helpers --
418
419fn now_secs() -> u64 {
420    std::time::SystemTime::now()
421        .duration_since(std::time::UNIX_EPOCH)
422        .unwrap_or_default()
423        .as_secs()
424}
425
426fn format_ts(ts: u64) -> String {
427    chrono::DateTime::from_timestamp(ts as i64, 0)
428        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
429        .unwrap_or_else(|| ts.to_string())
430}
431
432fn compute_quality(w: u32, h: u32) -> f32 {
433    let pixels = (w as f64) * (h as f64);
434    let max_pixels = 1920.0 * 1080.0;
435    (pixels / max_pixels).min(1.0) as f32
436}
437
438fn obs_summary(o: &crate::types::VisualObservation) -> serde_json::Value {
439    serde_json::json!({
440        "id": o.id, "session_id": o.session_id, "timestamp": o.timestamp,
441        "width": o.metadata.original_width, "height": o.metadata.original_height,
442        "labels": o.metadata.labels, "description": o.metadata.description,
443        "quality_score": o.metadata.quality_score, "memory_link": o.memory_link,
444    })
445}
446
447fn obs_full(o: &crate::types::VisualObservation) -> serde_json::Value {
448    serde_json::json!({
449        "id": o.id, "session_id": o.session_id, "timestamp": o.timestamp,
450        "source": o.source, "embedding_len": o.embedding.len(),
451        "thumbnail_bytes": o.thumbnail.len(), "metadata": o.metadata,
452        "memory_link": o.memory_link,
453    })
454}