lantern 0.3.0

Local-first, provenance-aware semantic search for agent activity
Documentation
//! First-class writable memory records.
//!
//! These records are the explicit state layer above raw evidence ingestion:
//! compact facts, preferences, goals, constraints, observations, hypotheses,
//! and task state with inspectable priority/lifecycle fields.

use anyhow::{Context, Result};
use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
use std::str::FromStr;

use crate::inspect::now_unix;
use crate::store::Store;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryKind {
    Fact,
    Preference,
    Goal,
    Constraint,
    TaskState,
    Observation,
    Hypothesis,
}

impl MemoryKind {
    pub fn as_str(self) -> &'static str {
        match self {
            MemoryKind::Fact => "fact",
            MemoryKind::Preference => "preference",
            MemoryKind::Goal => "goal",
            MemoryKind::Constraint => "constraint",
            MemoryKind::TaskState => "task_state",
            MemoryKind::Observation => "observation",
            MemoryKind::Hypothesis => "hypothesis",
        }
    }
}

impl fmt::Display for MemoryKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for MemoryKind {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s {
            "fact" => Ok(MemoryKind::Fact),
            "preference" => Ok(MemoryKind::Preference),
            "goal" => Ok(MemoryKind::Goal),
            "constraint" => Ok(MemoryKind::Constraint),
            "task_state" | "task-state" => Ok(MemoryKind::TaskState),
            "observation" => Ok(MemoryKind::Observation),
            "hypothesis" => Ok(MemoryKind::Hypothesis),
            other => Err(format!("unknown memory kind: {other}")),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryStatus {
    Active,
    Archived,
    Superseded,
}

impl MemoryStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            MemoryStatus::Active => "active",
            MemoryStatus::Archived => "archived",
            MemoryStatus::Superseded => "superseded",
        }
    }
}

impl fmt::Display for MemoryStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for MemoryStatus {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s {
            "active" => Ok(MemoryStatus::Active),
            "archived" => Ok(MemoryStatus::Archived),
            "superseded" => Ok(MemoryStatus::Superseded),
            other => Err(format!("unknown memory status: {other}")),
        }
    }
}

#[derive(Debug, Clone)]
pub struct AddMemoryOptions {
    pub kind: MemoryKind,
    pub scope: String,
    pub content: String,
    pub priority: Option<i64>,
    pub urgency: Option<i64>,
    pub confidence: Option<f64>,
    pub source_refs: Vec<String>,
}

#[derive(Debug, Clone, Default)]
pub struct ListMemoryOptions {
    pub kind: Option<MemoryKind>,
    pub scope: Option<String>,
    pub status: Option<MemoryStatus>,
    pub limit: Option<usize>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryRecord {
    pub id: String,
    pub kind: MemoryKind,
    pub scope: String,
    pub content: String,
    pub priority: i64,
    pub urgency: i64,
    pub confidence: f64,
    pub status: MemoryStatus,
    pub source_refs: Vec<String>,
    pub created_at: i64,
    pub updated_at: i64,
}

#[derive(Debug, Clone, Serialize)]
pub struct MemoryListReport {
    pub records: Vec<MemoryRecord>,
    pub total: usize,
}

fn clamp_priority(n: i64) -> i64 {
    n.clamp(0, 100)
}

fn clamp_confidence(n: f64) -> f64 {
    n.clamp(0.0, 1.0)
}

fn source_refs_json(source_refs: &[String]) -> Result<String> {
    serde_json::to_string(source_refs).context("serializing memory source refs")
}

fn make_id(
    conn: &rusqlite::Connection,
    opts: &AddMemoryOptions,
    created_at: i64,
) -> Result<String> {
    let existing: i64 =
        conn.query_row("SELECT COUNT(*) FROM memory_records", [], |row| row.get(0))?;
    let mut hasher = Sha256::new();
    hasher.update(opts.kind.as_str());
    hasher.update(b"\0");
    hasher.update(opts.scope.as_bytes());
    hasher.update(b"\0");
    hasher.update(opts.content.as_bytes());
    hasher.update(b"\0");
    hasher.update(created_at.to_string().as_bytes());
    hasher.update(b"\0");
    hasher.update(existing.to_string().as_bytes());
    let digest = hasher.finalize();
    Ok(hex::encode(&digest[..16]))
}

pub fn add_memory(store: &Store, opts: AddMemoryOptions) -> Result<MemoryRecord> {
    let conn = store.conn();
    let created_at = now_unix();
    let priority = clamp_priority(opts.priority.unwrap_or(50));
    let urgency = clamp_priority(opts.urgency.unwrap_or(0));
    let confidence = clamp_confidence(opts.confidence.unwrap_or(1.0));
    let refs_json = source_refs_json(&opts.source_refs)?;
    let id = make_id(conn, &opts, created_at)?;

    conn.execute(
        "INSERT INTO memory_records
         (id, kind, scope, content, priority, urgency, confidence, status, source_refs_json, created_at, updated_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, ?9)",
        params![
            id,
            opts.kind.as_str(),
            opts.scope,
            opts.content,
            priority,
            urgency,
            confidence,
            refs_json,
            created_at
        ],
    )
    .context("inserting memory record")?;

    get_memory(store, &id)?.ok_or_else(|| anyhow::anyhow!("memory {id} disappeared after insert"))
}

pub fn get_memory(store: &Store, id: &str) -> Result<Option<MemoryRecord>> {
    store
        .conn()
        .query_row(
            "SELECT id, kind, scope, content, priority, urgency, confidence, status, source_refs_json, created_at, updated_at
             FROM memory_records WHERE id = ?1",
            params![id],
            row_to_memory,
        )
        .optional()
        .map_err(Into::into)
}

pub fn list_memories(store: &Store, opts: ListMemoryOptions) -> Result<MemoryListReport> {
    let kind = opts.kind.map(|k| k.as_str().to_string());
    let status = opts.status.map(|s| s.as_str().to_string());
    let limit = opts.limit.unwrap_or(50).min(i64::MAX as usize) as i64;
    let mut stmt = store.conn().prepare(
        "SELECT id, kind, scope, content, priority, urgency, confidence, status, source_refs_json, created_at, updated_at
         FROM memory_records
         WHERE (?1 IS NULL OR kind = ?1)
           AND (?2 IS NULL OR scope = ?2)
           AND (?3 IS NULL OR status = ?3)
         ORDER BY priority DESC, urgency DESC, updated_at DESC, id ASC
         LIMIT ?4",
    )?;
    let rows = stmt.query_map(params![kind, opts.scope, status, limit], row_to_memory)?;
    let records = rows.collect::<std::result::Result<Vec<_>, _>>()?;
    let total = records.len();
    Ok(MemoryListReport { records, total })
}

pub fn archive_memory(store: &Store, id: &str) -> Result<MemoryRecord> {
    let updated_at = now_unix();
    let rows = store.conn().execute(
        "UPDATE memory_records SET status = 'archived', updated_at = ?1 WHERE id = ?2",
        params![updated_at, id],
    )?;
    if rows == 0 {
        anyhow::bail!("no memory with id {id}");
    }
    get_memory(store, id)?.ok_or_else(|| anyhow::anyhow!("memory {id} disappeared after archive"))
}

fn row_to_memory(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryRecord> {
    let kind_raw: String = row.get(1)?;
    let status_raw: String = row.get(7)?;
    let refs_raw: String = row.get(8)?;
    let kind = MemoryKind::from_str(&kind_raw).map_err(|e| {
        rusqlite::Error::FromSqlConversionFailure(
            1,
            rusqlite::types::Type::Text,
            Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
        )
    })?;
    let status = MemoryStatus::from_str(&status_raw).map_err(|e| {
        rusqlite::Error::FromSqlConversionFailure(
            7,
            rusqlite::types::Type::Text,
            Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
        )
    })?;
    let source_refs = serde_json::from_str(&refs_raw).map_err(|e| {
        rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(e))
    })?;
    Ok(MemoryRecord {
        id: row.get(0)?,
        kind,
        scope: row.get(2)?,
        content: row.get(3)?,
        priority: row.get(4)?,
        urgency: row.get(5)?,
        confidence: row.get(6)?,
        status,
        source_refs,
        created_at: row.get(9)?,
        updated_at: row.get(10)?,
    })
}

pub fn format_record_text(record: &MemoryRecord) -> String {
    format!(
        "memory {} kind={} scope={} status={} priority={} urgency={} confidence={:.3} updated_at={} content={}",
        record.id,
        record.kind,
        record.scope,
        record.status,
        record.priority,
        record.urgency,
        record.confidence,
        record.updated_at,
        record.content
    )
}

pub fn print_record_text(record: &MemoryRecord) {
    println!("{}", format_record_text(record));
}

pub fn print_list_text(report: &MemoryListReport) {
    for record in &report.records {
        println!("{}", format_record_text(record));
    }
}

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