mempal 0.5.1

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;

use anyhow::{Context, Result, bail};
use serde::Serialize;

use crate::core::{
    db::Database,
    types::{Drawer, KnowledgeStatus, KnowledgeTier, MemoryKind},
    utils::current_timestamp,
};
use crate::knowledge_gate::{GateReport, evaluate_gate_for_drawer};

#[derive(Debug, Clone)]
pub struct PromoteRequest {
    pub drawer_id: String,
    pub status: String,
    pub verification_refs: Vec<String>,
    pub reason: String,
    pub reviewer: Option<String>,
    pub allow_counterexamples: bool,
    pub enforce_gate: bool,
}

#[derive(Debug, Clone)]
pub struct DemoteRequest {
    pub drawer_id: String,
    pub status: String,
    pub evidence_refs: Vec<String>,
    pub reason: String,
    pub reason_type: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct PromoteOutcome {
    pub drawer_id: String,
    pub old_status: String,
    pub new_status: String,
    pub verification_refs: Vec<String>,
    pub gate: Option<GateReport>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DemoteOutcome {
    pub drawer_id: String,
    pub old_status: String,
    pub new_status: String,
    pub counterexample_refs: Vec<String>,
}

pub fn promote_knowledge(db: &Database, request: PromoteRequest) -> Result<PromoteOutcome> {
    let target_status = parse_lifecycle_status(&request.status)?;
    if !matches!(
        target_status,
        KnowledgeStatus::Promoted | KnowledgeStatus::Canonical
    ) {
        bail!("promote status must be promoted or canonical");
    }
    let mut drawer = load_lifecycle_knowledge_drawer(db, &request.drawer_id)?;
    validate_tier_status(
        drawer.tier.as_ref().expect("knowledge drawer has tier"),
        &target_status,
    )?;
    validate_lifecycle_refs(db, &request.verification_refs)?;

    let old_status = drawer.status.clone().expect("knowledge drawer has status");
    append_unique_refs(&mut drawer.verification_refs, &request.verification_refs);
    let gate = if request.enforce_gate {
        let gate = evaluate_gate_for_drawer(
            db,
            &drawer,
            &target_status,
            request.reviewer.as_deref(),
            request.allow_counterexamples,
        )?;
        if !gate.allowed {
            bail!("promotion gate failed: {}", gate.reasons.join("; "));
        }
        Some(gate)
    } else {
        None
    };

    db.update_knowledge_lifecycle(
        &request.drawer_id,
        &target_status,
        &drawer.verification_refs,
        &drawer.counterexample_refs,
    )
    .context("failed to update knowledge lifecycle")?;
    append_audit_entry(
        db,
        "knowledge_promote",
        &serde_json::json!({
            "drawer_id": request.drawer_id,
            "old_status": knowledge_status_slug(&old_status),
            "new_status": knowledge_status_slug(&target_status),
            "verification_refs": request.verification_refs,
            "reason": request.reason,
            "reviewer": request.reviewer,
        }),
    )
    .context("failed to append audit log")?;

    Ok(PromoteOutcome {
        drawer_id: drawer.id,
        old_status: knowledge_status_slug(&old_status).to_string(),
        new_status: knowledge_status_slug(&target_status).to_string(),
        verification_refs: drawer.verification_refs,
        gate,
    })
}

pub fn demote_knowledge(db: &Database, request: DemoteRequest) -> Result<DemoteOutcome> {
    let target_status = parse_lifecycle_status(&request.status)?;
    if !matches!(
        target_status,
        KnowledgeStatus::Demoted | KnowledgeStatus::Retired
    ) {
        bail!("demote status must be demoted or retired");
    }
    validate_demote_reason_type(&request.reason_type)?;
    let mut drawer = load_lifecycle_knowledge_drawer(db, &request.drawer_id)?;
    validate_tier_status(
        drawer.tier.as_ref().expect("knowledge drawer has tier"),
        &target_status,
    )?;
    validate_lifecycle_refs(db, &request.evidence_refs)?;

    let old_status = drawer.status.clone().expect("knowledge drawer has status");
    append_unique_refs(&mut drawer.counterexample_refs, &request.evidence_refs);
    db.update_knowledge_lifecycle(
        &request.drawer_id,
        &target_status,
        &drawer.verification_refs,
        &drawer.counterexample_refs,
    )
    .context("failed to update knowledge lifecycle")?;
    append_audit_entry(
        db,
        "knowledge_demote",
        &serde_json::json!({
            "drawer_id": request.drawer_id,
            "old_status": knowledge_status_slug(&old_status),
            "new_status": knowledge_status_slug(&target_status),
            "evidence_refs": request.evidence_refs,
            "reason": request.reason,
            "reason_type": request.reason_type,
        }),
    )
    .context("failed to append audit log")?;

    Ok(DemoteOutcome {
        drawer_id: drawer.id,
        old_status: knowledge_status_slug(&old_status).to_string(),
        new_status: knowledge_status_slug(&target_status).to_string(),
        counterexample_refs: drawer.counterexample_refs,
    })
}

fn load_lifecycle_knowledge_drawer(db: &Database, drawer_id: &str) -> Result<Drawer> {
    let drawer = db
        .get_drawer(drawer_id)
        .context("failed to look up drawer")?
        .with_context(|| format!("drawer not found: {drawer_id}"))?;
    if drawer.memory_kind != MemoryKind::Knowledge {
        bail!("knowledge lifecycle requires a knowledge drawer");
    }
    if drawer.tier.is_none() || drawer.status.is_none() {
        bail!("knowledge lifecycle requires tier and status metadata");
    }
    Ok(drawer)
}

fn parse_lifecycle_status(value: &str) -> Result<KnowledgeStatus> {
    match value.trim() {
        "candidate" => Ok(KnowledgeStatus::Candidate),
        "promoted" => Ok(KnowledgeStatus::Promoted),
        "canonical" => Ok(KnowledgeStatus::Canonical),
        "demoted" => Ok(KnowledgeStatus::Demoted),
        "retired" => Ok(KnowledgeStatus::Retired),
        other => bail!("unsupported knowledge status: {other}"),
    }
}

fn validate_tier_status(tier: &KnowledgeTier, status: &KnowledgeStatus) -> Result<()> {
    let allowed = match tier {
        KnowledgeTier::DaoTian => &[KnowledgeStatus::Canonical, KnowledgeStatus::Demoted][..],
        KnowledgeTier::DaoRen => &[
            KnowledgeStatus::Candidate,
            KnowledgeStatus::Promoted,
            KnowledgeStatus::Demoted,
            KnowledgeStatus::Retired,
        ][..],
        KnowledgeTier::Shu => &[
            KnowledgeStatus::Promoted,
            KnowledgeStatus::Demoted,
            KnowledgeStatus::Retired,
        ][..],
        KnowledgeTier::Qi => &[
            KnowledgeStatus::Candidate,
            KnowledgeStatus::Promoted,
            KnowledgeStatus::Demoted,
            KnowledgeStatus::Retired,
        ][..],
    };

    if allowed.contains(status) {
        return Ok(());
    }

    match tier {
        KnowledgeTier::DaoTian => bail!("dao_tian only allows canonical or demoted"),
        KnowledgeTier::DaoRen => {
            bail!("dao_ren only allows candidate, promoted, demoted, or retired")
        }
        KnowledgeTier::Shu => bail!("shu only allows promoted, demoted, or retired"),
        KnowledgeTier::Qi => bail!("qi only allows candidate, promoted, demoted, or retired"),
    }
}

fn validate_demote_reason_type(value: &str) -> Result<()> {
    match value.trim() {
        "contradicted" | "obsolete" | "superseded" | "out_of_scope" | "unsafe" => Ok(()),
        other => bail!("unsupported demote reason_type: {other}"),
    }
}

fn validate_lifecycle_refs(db: &Database, refs: &[String]) -> Result<()> {
    if refs.is_empty() {
        bail!("at least one lifecycle evidence ref is required");
    }
    for drawer_id in refs {
        if !drawer_id.starts_with("drawer_") {
            bail!("lifecycle refs must contain drawer ids");
        }
        let drawer = db
            .get_drawer(drawer_id)
            .with_context(|| format!("failed to load ref drawer {drawer_id}"))?
            .with_context(|| format!("ref drawer not found: {drawer_id}"))?;
        if drawer.memory_kind != MemoryKind::Evidence {
            bail!("lifecycle refs must point to evidence drawers");
        }
    }
    Ok(())
}

fn append_unique_refs(target: &mut Vec<String>, refs: &[String]) {
    for item in refs {
        if !target.iter().any(|existing| existing == item) {
            target.push(item.clone());
        }
    }
}

fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
    let audit_path = db
        .path()
        .parent()
        .map(|parent| parent.join("audit.jsonl"))
        .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&audit_path)
        .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
    let entry = serde_json::json!({
        "timestamp": current_timestamp(),
        "command": command,
        "details": details,
    });
    writeln!(file, "{entry}")
        .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
    Ok(())
}

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