mempal 0.5.2

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use mempal::core::db::Database;
use mempal::core::types::{
    AnchorKind, BootstrapEvidenceArgs, Drawer, KnowledgeCard, KnowledgeCardEvent,
    KnowledgeCardFilter, KnowledgeEventType, KnowledgeEvidenceLink, KnowledgeEvidenceRole,
    KnowledgeStatus, KnowledgeTier, MemoryDomain, MemoryKind, SourceType, TriggerHints,
};
use serde_json::json;
use tempfile::TempDir;

fn new_db() -> (TempDir, Database) {
    let tmp = TempDir::new().expect("tempdir");
    let db_path = tmp.path().join("palace.db");
    let db = Database::open(&db_path).expect("open db");
    (tmp, db)
}

fn card(id: &str) -> KnowledgeCard {
    KnowledgeCard {
        id: id.to_string(),
        statement: format!("Statement for {id}."),
        content: format!("Detailed rationale for {id}."),
        tier: KnowledgeTier::Shu,
        status: KnowledgeStatus::Promoted,
        domain: MemoryDomain::Project,
        field: "debugging".to_string(),
        anchor_kind: AnchorKind::Repo,
        anchor_id: "repo://mempal".to_string(),
        parent_anchor_id: None,
        scope_constraints: Some("Only applies to Rust code.".to_string()),
        trigger_hints: Some(TriggerHints {
            intent_tags: vec!["debugging".to_string()],
            workflow_bias: vec!["test-first".to_string()],
            tool_needs: vec!["cargo".to_string()],
        }),
        created_at: "1710000000".to_string(),
        updated_at: "1710000000".to_string(),
    }
}

fn insert_evidence_drawer(db: &Database, id: &str) {
    db.insert_drawer(&Drawer::new_bootstrap_evidence(BootstrapEvidenceArgs {
        id: id.to_string(),
        content: "Evidence body.".to_string(),
        wing: "mempal".to_string(),
        room: Some("phase2".to_string()),
        source_file: Some(format!("tests://{id}")),
        source_type: SourceType::Manual,
        added_at: "1710000000".to_string(),
        chunk_index: Some(0),
        importance: 0,
    }))
    .expect("insert evidence drawer");
}

fn insert_knowledge_drawer(db: &Database, id: &str) {
    let mut drawer = Drawer::new_bootstrap_evidence(BootstrapEvidenceArgs {
        id: id.to_string(),
        content: "Knowledge drawer body.".to_string(),
        wing: "mempal".to_string(),
        room: Some("phase2".to_string()),
        source_file: Some(format!("knowledge://project/phase2/{id}")),
        source_type: SourceType::Manual,
        added_at: "1710000000".to_string(),
        chunk_index: Some(0),
        importance: 0,
    });
    drawer.memory_kind = MemoryKind::Knowledge;
    drawer.provenance = None;
    drawer.statement = Some("Knowledge drawer statement.".to_string());
    drawer.tier = Some(KnowledgeTier::Shu);
    drawer.status = Some(KnowledgeStatus::Promoted);
    drawer.supporting_refs = vec!["drawer_ev_linkable".to_string()];
    db.insert_drawer(&drawer).expect("insert knowledge drawer");
}

fn link(
    id: &str,
    card_id: &str,
    drawer_id: &str,
    role: KnowledgeEvidenceRole,
) -> KnowledgeEvidenceLink {
    KnowledgeEvidenceLink {
        id: id.to_string(),
        card_id: card_id.to_string(),
        evidence_drawer_id: drawer_id.to_string(),
        role,
        note: Some("supports the card".to_string()),
        created_at: "1710000000".to_string(),
    }
}

fn event(id: &str, card_id: &str, event_type: KnowledgeEventType) -> KnowledgeCardEvent {
    KnowledgeCardEvent {
        id: id.to_string(),
        card_id: card_id.to_string(),
        event_type,
        from_status: Some(KnowledgeStatus::Candidate),
        to_status: Some(KnowledgeStatus::Promoted),
        reason: "validated by evidence".to_string(),
        actor: Some("codex".to_string()),
        metadata: Some(json!({
            "verification_refs": ["drawer_ev_linkable"],
            "score": 1
        })),
        created_at: "1710000000".to_string(),
    }
}

#[test]
fn test_knowledge_card_insert_get_roundtrip() {
    let (_tmp, db) = new_db();
    let card = card("card_roundtrip");

    db.insert_knowledge_card(&card).expect("insert card");
    let loaded = db
        .get_knowledge_card("card_roundtrip")
        .expect("get card")
        .expect("card exists");

    assert_eq!(loaded, card);
    assert_eq!(
        loaded.trigger_hints.expect("trigger hints").tool_needs,
        vec!["cargo"]
    );
}

#[test]
fn test_knowledge_card_list_filters() {
    let (_tmp, db) = new_db();
    let mut rust_card = card("card_rust");
    rust_card.tier = KnowledgeTier::DaoRen;
    rust_card.status = KnowledgeStatus::Promoted;
    rust_card.domain = MemoryDomain::Project;
    rust_card.field = "software-engineering".to_string();
    rust_card.anchor_kind = AnchorKind::Worktree;
    rust_card.anchor_id = "worktree:///tmp/mempal".to_string();

    let mut global_card = card("card_global");
    global_card.tier = KnowledgeTier::DaoTian;
    global_card.status = KnowledgeStatus::Canonical;
    global_card.domain = MemoryDomain::Global;
    global_card.field = "epistemics".to_string();
    global_card.anchor_kind = AnchorKind::Global;
    global_card.anchor_id = "global://all".to_string();

    db.insert_knowledge_card(&rust_card)
        .expect("insert rust card");
    db.insert_knowledge_card(&global_card)
        .expect("insert global card");

    let by_tier = db
        .list_knowledge_cards(&KnowledgeCardFilter {
            tier: Some(KnowledgeTier::DaoTian),
            ..KnowledgeCardFilter::default()
        })
        .expect("list by tier");
    assert_eq!(by_tier, vec![global_card.clone()]);

    let by_field = db
        .list_knowledge_cards(&KnowledgeCardFilter {
            domain: Some(MemoryDomain::Project),
            field: Some("software-engineering".to_string()),
            anchor_kind: Some(AnchorKind::Worktree),
            anchor_id: Some("worktree:///tmp/mempal".to_string()),
            ..KnowledgeCardFilter::default()
        })
        .expect("list by field");
    assert_eq!(by_field, vec![rust_card]);
}

#[test]
fn test_knowledge_card_update_preserves_identity_and_created_at() {
    let (_tmp, db) = new_db();
    let mut card = card("card_update");
    db.insert_knowledge_card(&card).expect("insert card");

    card.statement = "Updated statement.".to_string();
    card.content = "Updated content.".to_string();
    card.status = KnowledgeStatus::Demoted;
    card.scope_constraints = Some("Updated constraints.".to_string());
    card.trigger_hints = None;
    card.created_at = "9999999999".to_string();
    card.updated_at = "1710009999".to_string();

    assert!(db.update_knowledge_card(&card).expect("update card"));
    let loaded = db
        .get_knowledge_card("card_update")
        .expect("get card")
        .expect("card exists");

    assert_eq!(loaded.id, "card_update");
    assert_eq!(loaded.created_at, "1710000000");
    assert_eq!(loaded.updated_at, "1710009999");
    assert_eq!(loaded.statement, "Updated statement.");
    assert_eq!(loaded.status, KnowledgeStatus::Demoted);
    assert_eq!(loaded.trigger_hints, None);
}

#[test]
fn test_knowledge_evidence_link_requires_evidence_drawer() {
    let (_tmp, db) = new_db();
    db.insert_knowledge_card(&card("card_linkable"))
        .expect("insert card");
    insert_evidence_drawer(&db, "drawer_ev_linkable");
    insert_knowledge_drawer(&db, "drawer_kn_not_evidence");

    db.insert_knowledge_evidence_link(&link(
        "link_ok",
        "card_linkable",
        "drawer_ev_linkable",
        KnowledgeEvidenceRole::Supporting,
    ))
    .expect("link evidence drawer");

    let wrong_kind = db
        .insert_knowledge_evidence_link(&link(
            "link_wrong_kind",
            "card_linkable",
            "drawer_kn_not_evidence",
            KnowledgeEvidenceRole::Supporting,
        ))
        .expect_err("knowledge drawer must not link as evidence");
    assert!(
        wrong_kind
            .to_string()
            .contains("must be an evidence drawer")
    );

    let missing = db
        .insert_knowledge_evidence_link(&link(
            "link_missing",
            "card_linkable",
            "drawer_missing",
            KnowledgeEvidenceRole::Supporting,
        ))
        .expect_err("missing drawer must fail");
    assert!(missing.to_string().contains("does not exist"));
}

#[test]
fn test_knowledge_evidence_links_list_by_card() {
    let (_tmp, db) = new_db();
    db.insert_knowledge_card(&card("card_links_a"))
        .expect("insert card a");
    db.insert_knowledge_card(&card("card_links_b"))
        .expect("insert card b");
    insert_evidence_drawer(&db, "drawer_ev_a");
    insert_evidence_drawer(&db, "drawer_ev_b");

    db.insert_knowledge_evidence_link(&link(
        "link_a",
        "card_links_a",
        "drawer_ev_a",
        KnowledgeEvidenceRole::Supporting,
    ))
    .expect("insert link a");
    db.insert_knowledge_evidence_link(&link(
        "link_b",
        "card_links_b",
        "drawer_ev_b",
        KnowledgeEvidenceRole::Verification,
    ))
    .expect("insert link b");

    let links = db
        .knowledge_evidence_links("card_links_a")
        .expect("list links");
    assert_eq!(links.len(), 1);
    assert_eq!(links[0].id, "link_a");
    assert_eq!(links[0].role, KnowledgeEvidenceRole::Supporting);
}

#[test]
fn test_knowledge_events_append_and_list_by_card() {
    let (_tmp, db) = new_db();
    db.insert_knowledge_card(&card("card_events_a"))
        .expect("insert card a");
    db.insert_knowledge_card(&card("card_events_b"))
        .expect("insert card b");

    db.append_knowledge_event(&event(
        "event_a_created",
        "card_events_a",
        KnowledgeEventType::Created,
    ))
    .expect("append event a");
    db.append_knowledge_event(&event(
        "event_b_created",
        "card_events_b",
        KnowledgeEventType::Created,
    ))
    .expect("append event b");

    let events = db.knowledge_events("card_events_a").expect("list events");
    assert_eq!(events.len(), 1);
    assert_eq!(events[0].id, "event_a_created");
    assert_eq!(events[0].event_type, KnowledgeEventType::Created);
    assert_eq!(events[0].metadata.as_ref().expect("metadata")["score"], 1);
}