mempal 0.5.4

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::core::db::{Database, DbError};
use crate::core::types::{
    Drawer, KnowledgeCard, KnowledgeCardEvent, KnowledgeCardFilter, KnowledgeEventType,
    KnowledgeEvidenceLink, KnowledgeEvidenceRole,
};
use crate::core::utils::current_timestamp;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeCardBackfillReport {
    pub ready_count: usize,
    pub skipped_count: usize,
    pub already_exists_count: usize,
    pub candidates: Vec<KnowledgeCardBackfillCandidate>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeCardBackfillCandidate {
    pub source_drawer_id: String,
    pub prospective_card_id: String,
    pub status: KnowledgeCardBackfillStatus,
    pub reasons: Vec<String>,
    pub statement: Option<String>,
    pub tier: Option<String>,
    pub knowledge_status: Option<String>,
    pub domain: String,
    pub field: String,
    pub anchor_kind: String,
    pub anchor_id: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KnowledgeCardBackfillStatus {
    Ready,
    Skipped,
    AlreadyExists,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeCardBackfillApplyOptions {
    pub execute: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeCardBackfillApplyResult {
    pub dry_run: bool,
    pub ready_count: usize,
    pub skipped_count: usize,
    pub already_exists_count: usize,
    pub created_count: usize,
    pub linked_count: usize,
    pub event_count: usize,
    pub link_errors: Vec<KnowledgeCardBackfillLinkError>,
    pub candidates: Vec<KnowledgeCardBackfillCandidate>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeCardBackfillLinkError {
    pub card_id: String,
    pub evidence_drawer_id: String,
    pub role: String,
    pub error: String,
}

pub fn build_backfill_report(
    db: &Database,
    filter: &KnowledgeCardFilter,
) -> Result<KnowledgeCardBackfillReport, DbError> {
    let drawers = db.list_knowledge_drawers_for_card_backfill(filter)?;
    let mut candidates = Vec::with_capacity(drawers.len());
    let mut ready_count = 0;
    let mut skipped_count = 0;
    let mut already_exists_count = 0;

    for drawer in drawers {
        let candidate = classify_drawer(db, drawer)?;
        match candidate.status {
            KnowledgeCardBackfillStatus::Ready => ready_count += 1,
            KnowledgeCardBackfillStatus::Skipped => skipped_count += 1,
            KnowledgeCardBackfillStatus::AlreadyExists => already_exists_count += 1,
        }
        candidates.push(candidate);
    }

    Ok(KnowledgeCardBackfillReport {
        ready_count,
        skipped_count,
        already_exists_count,
        candidates,
    })
}

pub fn apply_backfill(
    db: &Database,
    filter: &KnowledgeCardFilter,
    options: KnowledgeCardBackfillApplyOptions,
) -> Result<KnowledgeCardBackfillApplyResult, DbError> {
    let drawers = db.list_knowledge_drawers_for_card_backfill(filter)?;
    let mut candidates = Vec::with_capacity(drawers.len());
    let mut ready_count = 0;
    let mut skipped_count = 0;
    let mut already_exists_count = 0;
    let mut created_count = 0;
    let mut linked_count = 0;
    let mut event_count = 0;
    let mut link_errors = Vec::new();

    for drawer in drawers {
        let candidate = classify_drawer(db, drawer.clone())?;
        match candidate.status {
            KnowledgeCardBackfillStatus::Ready => {
                ready_count += 1;
                if options.execute {
                    let card_id = candidate.prospective_card_id.clone();
                    create_card_and_event(db, &drawer, &card_id)?;
                    created_count += 1;
                    event_count += 1;
                    for (role, evidence_drawer_id) in evidence_refs_by_role(&drawer) {
                        let link = KnowledgeEvidenceLink {
                            id: prospective_link_id(&card_id, &evidence_drawer_id, &role),
                            card_id: card_id.clone(),
                            evidence_drawer_id: evidence_drawer_id.clone(),
                            role: role.clone(),
                            note: Some(format!("backfilled from {}", drawer.id)),
                            created_at: current_timestamp(),
                        };
                        match db.insert_knowledge_evidence_link(&link) {
                            Ok(()) => linked_count += 1,
                            Err(error) => link_errors.push(KnowledgeCardBackfillLinkError {
                                card_id: card_id.clone(),
                                evidence_drawer_id: evidence_drawer_id.clone(),
                                role: evidence_role_slug(&role).to_string(),
                                error: error.to_string(),
                            }),
                        }
                    }
                }
            }
            KnowledgeCardBackfillStatus::Skipped => skipped_count += 1,
            KnowledgeCardBackfillStatus::AlreadyExists => already_exists_count += 1,
        }
        candidates.push(candidate);
    }

    Ok(KnowledgeCardBackfillApplyResult {
        dry_run: !options.execute,
        ready_count,
        skipped_count,
        already_exists_count,
        created_count,
        linked_count,
        event_count,
        link_errors,
        candidates,
    })
}

pub fn prospective_card_id(source_drawer_id: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(b"knowledge-card-backfill-v1");
    hasher.update([0]);
    hasher.update(source_drawer_id.as_bytes());
    let digest = format!("{:x}", hasher.finalize());
    format!("card_{}", &digest[..16])
}

fn prospective_link_id(
    card_id: &str,
    evidence_drawer_id: &str,
    role: &KnowledgeEvidenceRole,
) -> String {
    let mut hasher = Sha256::new();
    hasher.update(b"knowledge-card-backfill-link-v1");
    hasher.update([0]);
    hasher.update(card_id.as_bytes());
    hasher.update([0]);
    hasher.update(evidence_drawer_id.as_bytes());
    hasher.update([0]);
    hasher.update(evidence_role_slug(role).as_bytes());
    let digest = format!("{:x}", hasher.finalize());
    format!("link_{}", &digest[..16])
}

fn classify_drawer(
    db: &Database,
    drawer: Drawer,
) -> Result<KnowledgeCardBackfillCandidate, DbError> {
    let prospective_card_id = prospective_card_id(&drawer.id);
    let reasons = skip_reasons(&drawer);
    let status = if !reasons.is_empty() {
        KnowledgeCardBackfillStatus::Skipped
    } else if db.get_knowledge_card(&prospective_card_id)?.is_some() {
        KnowledgeCardBackfillStatus::AlreadyExists
    } else {
        KnowledgeCardBackfillStatus::Ready
    };

    Ok(KnowledgeCardBackfillCandidate {
        source_drawer_id: drawer.id,
        prospective_card_id,
        status,
        reasons,
        statement: drawer.statement,
        tier: drawer
            .tier
            .as_ref()
            .map(knowledge_tier_slug)
            .map(str::to_string),
        knowledge_status: drawer
            .status
            .as_ref()
            .map(knowledge_status_slug)
            .map(str::to_string),
        domain: memory_domain_slug(&drawer.domain).to_string(),
        field: drawer.field,
        anchor_kind: anchor_kind_slug(&drawer.anchor_kind).to_string(),
        anchor_id: drawer.anchor_id,
    })
}

fn create_card_and_event(db: &Database, drawer: &Drawer, card_id: &str) -> Result<(), DbError> {
    let now = current_timestamp();
    let card = KnowledgeCard {
        id: card_id.to_string(),
        statement: drawer.statement.clone().unwrap_or_default(),
        content: drawer.content.clone(),
        tier: drawer.tier.clone().expect("ready candidate must have tier"),
        status: drawer
            .status
            .clone()
            .expect("ready candidate must have status"),
        domain: drawer.domain.clone(),
        field: drawer.field.clone(),
        anchor_kind: drawer.anchor_kind.clone(),
        anchor_id: drawer.anchor_id.clone(),
        parent_anchor_id: drawer.parent_anchor_id.clone(),
        scope_constraints: drawer.scope_constraints.clone(),
        trigger_hints: drawer.trigger_hints.clone(),
        created_at: now.clone(),
        updated_at: now.clone(),
    };
    let event = KnowledgeCardEvent {
        id: prospective_created_event_id(card_id),
        card_id: card_id.to_string(),
        event_type: KnowledgeEventType::Created,
        from_status: None,
        to_status: drawer.status.clone(),
        reason: format!("backfilled from Stage-1 knowledge drawer {}", drawer.id),
        actor: Some("mempal".to_string()),
        metadata: Some(serde_json::json!({
            "source_drawer_id": drawer.id,
            "source_file": drawer.source_file,
        })),
        created_at: now,
    };

    db.conn().execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
    let result = db
        .insert_knowledge_card(&card)
        .and_then(|()| db.append_knowledge_event(&event));
    match result {
        Ok(()) => {
            db.conn().execute_batch("COMMIT")?;
            Ok(())
        }
        Err(error) => {
            let _ = db.conn().execute_batch("ROLLBACK");
            Err(error)
        }
    }
}

fn prospective_created_event_id(card_id: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(b"knowledge-card-backfill-created-event-v1");
    hasher.update([0]);
    hasher.update(card_id.as_bytes());
    let digest = format!("{:x}", hasher.finalize());
    format!("event_{}", &digest[..16])
}

fn evidence_refs_by_role(drawer: &Drawer) -> Vec<(KnowledgeEvidenceRole, String)> {
    let mut refs = Vec::new();
    refs.extend(
        drawer
            .supporting_refs
            .iter()
            .cloned()
            .map(|id| (KnowledgeEvidenceRole::Supporting, id)),
    );
    refs.extend(
        drawer
            .verification_refs
            .iter()
            .cloned()
            .map(|id| (KnowledgeEvidenceRole::Verification, id)),
    );
    refs.extend(
        drawer
            .counterexample_refs
            .iter()
            .cloned()
            .map(|id| (KnowledgeEvidenceRole::Counterexample, id)),
    );
    refs.extend(
        drawer
            .teaching_refs
            .iter()
            .cloned()
            .map(|id| (KnowledgeEvidenceRole::Teaching, id)),
    );
    refs
}

fn skip_reasons(drawer: &Drawer) -> Vec<String> {
    let mut reasons = Vec::new();
    if drawer
        .statement
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .is_none()
    {
        reasons.push("missing statement".to_string());
    }
    if drawer.tier.is_none() {
        reasons.push("missing tier".to_string());
    }
    if drawer.status.is_none() {
        reasons.push("missing status".to_string());
    }
    if drawer.field.trim().is_empty() {
        reasons.push("missing field".to_string());
    }
    if drawer.anchor_id.trim().is_empty() {
        reasons.push("missing anchor_id".to_string());
    }
    reasons
}

fn evidence_role_slug(value: &KnowledgeEvidenceRole) -> &'static str {
    match value {
        KnowledgeEvidenceRole::Supporting => "supporting",
        KnowledgeEvidenceRole::Verification => "verification",
        KnowledgeEvidenceRole::Counterexample => "counterexample",
        KnowledgeEvidenceRole::Teaching => "teaching",
    }
}

fn memory_domain_slug(value: &crate::core::types::MemoryDomain) -> &'static str {
    match value {
        crate::core::types::MemoryDomain::Project => "project",
        crate::core::types::MemoryDomain::Agent => "agent",
        crate::core::types::MemoryDomain::Skill => "skill",
        crate::core::types::MemoryDomain::Global => "global",
    }
}

fn knowledge_tier_slug(value: &crate::core::types::KnowledgeTier) -> &'static str {
    match value {
        crate::core::types::KnowledgeTier::Qi => "qi",
        crate::core::types::KnowledgeTier::Shu => "shu",
        crate::core::types::KnowledgeTier::DaoRen => "dao_ren",
        crate::core::types::KnowledgeTier::DaoTian => "dao_tian",
    }
}

fn knowledge_status_slug(value: &crate::core::types::KnowledgeStatus) -> &'static str {
    match value {
        crate::core::types::KnowledgeStatus::Candidate => "candidate",
        crate::core::types::KnowledgeStatus::Promoted => "promoted",
        crate::core::types::KnowledgeStatus::Canonical => "canonical",
        crate::core::types::KnowledgeStatus::Demoted => "demoted",
        crate::core::types::KnowledgeStatus::Retired => "retired",
    }
}

fn anchor_kind_slug(value: &crate::core::types::AnchorKind) -> &'static str {
    match value {
        crate::core::types::AnchorKind::Global => "global",
        crate::core::types::AnchorKind::Repo => "repo",
        crate::core::types::AnchorKind::Worktree => "worktree",
    }
}