lantern 0.2.4

Local-first, provenance-aware semantic search for agent activity
Documentation
//! Display full provenance and chunk text for a single indexed source.
//!
//! `show` accepts either a full source id or an unambiguous prefix (like
//! git's short hashes) so agents and humans can copy the 32-character id
//! printed by inspect or trim it down for interactive use. The rendered
//! output reuses `export::ExportedSource` so JSON consumers see the same
//! shape they'd get from a single-source `export`.

use anyhow::{Context, Result};
use rusqlite::params;

use crate::export::{ExportedSource, load_source};
use crate::inspect::{ago, now_unix};
use crate::store::Store;

pub fn show(store: &Store, id_or_prefix: &str) -> Result<ExportedSource> {
    let trimmed = id_or_prefix.trim();
    if trimmed.is_empty() {
        anyhow::bail!("source id must not be empty");
    }

    let conn = store.conn();
    let candidates: Vec<String> = {
        let mut stmt =
            conn.prepare("SELECT id FROM sources WHERE id LIKE ?1 || '%' ORDER BY id LIMIT 2")?;
        let rows = stmt.query_map(params![trimmed], |row| row.get::<_, String>(0))?;
        rows.collect::<Result<Vec<_>, _>>()?
    };

    match candidates.len() {
        0 => anyhow::bail!("no source matches id {trimmed:?}"),
        1 => load_source(conn, &candidates[0])
            .with_context(|| format!("loading source {}", candidates[0])),
        _ => anyhow::bail!(
            "source id {trimmed:?} is ambiguous; try a longer prefix (matched at least {} sources)",
            candidates.len()
        ),
    }
}

fn chunk_metadata_line(chunk: &crate::export::ExportedChunk) -> Option<String> {
    let mut parts = Vec::new();
    if let Some(role) = &chunk.role {
        parts.push(format!("role={role}"));
    }
    if let Some(session_id) = &chunk.session_id {
        parts.push(format!("session={session_id}"));
    }
    if let Some(turn_id) = &chunk.turn_id {
        parts.push(format!("turn={turn_id}"));
    }
    if let Some(tool_name) = &chunk.tool_name {
        parts.push(format!("tool={tool_name}"));
    }
    if let Some(ts) = chunk.timestamp_unix {
        parts.push(format!("ts={ts}"));
    }
    if chunk.access_count != 0 {
        parts.push(format!("access_count={}", chunk.access_count));
    }
    if let Some(last) = chunk.last_accessed_at {
        parts.push(format!("last_accessed_at={last}"));
    }
    if let Some(decay_at) = chunk.access_decay_at {
        parts.push(format!("access_decay_at={decay_at}"));
    }
    if chunk.feedback_score != 0 {
        parts.push(format!("feedback_score={}", chunk.feedback_score));
    }
    if parts.is_empty() {
        None
    } else {
        Some(parts.join(" "))
    }
}

pub fn print_text(source: &ExportedSource) {
    println!("source:   {}", source.source_id);
    println!("uri:      {}", source.uri);
    if let Some(p) = &source.path {
        println!("path:     {p}");
    }
    println!("kind:     {}", source.kind);
    println!("bytes:    {}", source.bytes);
    println!("sha256:   {}", source.content_sha256);
    let now = now_unix();
    println!(
        "ingested: {} ({})",
        source.ingested_at,
        ago(now, source.ingested_at)
    );
    if let Some(m) = source.mtime_unix {
        println!("mtime:    {m} ({})", ago(now, m));
    }
    println!("chunks:   {}", source.chunks.len());

    for chunk in &source.chunks {
        println!();
        println!(
            "--- chunk {ord} [bytes {start}..{end}, chars {chars}, sha {sha}] ---",
            ord = chunk.ordinal,
            start = chunk.byte_start,
            end = chunk.byte_end,
            chars = chunk.char_count,
            sha = &chunk.sha256[..12.min(chunk.sha256.len())],
        );
        if let Some(meta) = chunk_metadata_line(chunk) {
            println!("{meta}");
        }
        print!("{}", chunk.text);
        if !chunk.text.ends_with('\n') {
            println!();
        }
    }
}

pub fn print_json(source: &ExportedSource) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(source)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::chunk_metadata_line;
    use crate::export::ExportedChunk;

    fn chunk_with_metadata(access_count: i64, feedback_score: i64) -> ExportedChunk {
        ExportedChunk {
            chunk_id: "chunk-1".into(),
            ordinal: 0,
            byte_start: 0,
            byte_end: 12,
            char_count: 12,
            sha256: "abcdef0123456789".into(),
            text: "hello world".into(),
            role: Some("assistant".into()),
            session_id: Some("sess-7".into()),
            turn_id: Some("turn-9".into()),
            tool_name: Some("search".into()),
            timestamp_unix: Some(1_700_000_003),
            access_count,
            last_accessed_at: Some(1_700_000_500),
            access_decay_at: Some(1_700_000_800),
            feedback_score,
        }
    }

    #[test]
    fn chunk_metadata_line_includes_access_and_feedback_signals() {
        let meta = chunk_metadata_line(&chunk_with_metadata(7, -2)).unwrap();
        assert!(meta.contains("access_count=7"), "{meta}");
        assert!(meta.contains("last_accessed_at=1700000500"), "{meta}");
        assert!(meta.contains("access_decay_at=1700000800"), "{meta}");
        assert!(meta.contains("feedback_score=-2"), "{meta}");
    }

    #[test]
    fn chunk_metadata_line_omits_zero_access_and_feedback() {
        let meta = chunk_metadata_line(&chunk_with_metadata(0, 0)).unwrap();
        assert!(!meta.contains("access_count="), "{meta}");
        assert!(!meta.contains("feedback_score="), "{meta}");
        assert!(meta.contains("last_accessed_at=1700000500"), "{meta}");
    }
}