memnite 0.2.1

Event-sourced memory engine for AI agents: append-only log, SQLite projections, staleness, conflict graph. CLI binary.
Documentation
use memnite_cli::{AddSpec, App, EventCtx, UpdatePatch};
use memnite_core::Scope;

fn ctx() -> EventCtx {
    EventCtx {
        ts: "2026-06-27T12:00:00Z".to_string(),
        engine: "cli".to_string(),
        machine: "test".to_string(),
    }
}

fn ctx_at(ts: &str) -> EventCtx {
    EventCtx {
        ts: ts.to_string(),
        engine: "cli".to_string(),
        machine: "test".to_string(),
    }
}

fn spec(title: &str) -> AddSpec {
    AddSpec {
        title: title.to_string(),
        body: "body".to_string(),
        mem_type: "decision".to_string(),
        scope: Scope::Agent,
        project: "proj".to_string(),
        topic_key: None,
        anchors: vec![],
        tags: vec![],
    }
}

#[test]
fn export_writes_one_json_line_per_event() {
    let dir = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();
    let app = App::open(dir.path()).unwrap();
    app.add(spec("m1"), root.path(), &ctx()).unwrap();
    app.add(spec("m2"), root.path(), &ctx()).unwrap();

    let mut buf = Vec::new();
    let n = app.export(None, &mut buf).unwrap();
    assert_eq!(n, 2);

    let text = String::from_utf8(buf).unwrap();
    let lines: Vec<&str> = text.lines().collect();
    assert_eq!(lines.len(), 2);
    let l0: memnite_core::Event = serde_json::from_str(lines[0]).unwrap();
    let l1: memnite_core::Event = serde_json::from_str(lines[1]).unwrap();
    assert!(l0.lamport <= l1.lamport);
}

#[test]
fn export_since_filters_by_lamport() {
    let dir = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();
    let app = App::open(dir.path()).unwrap();
    app.add(spec("m1"), root.path(), &ctx()).unwrap(); // lamport 1
    app.add(spec("m2"), root.path(), &ctx()).unwrap(); // lamport 2
    app.add(spec("m3"), root.path(), &ctx()).unwrap(); // lamport 3

    let mut all = Vec::new();
    assert_eq!(app.export(None, &mut all).unwrap(), 3);

    let mut some = Vec::new();
    assert_eq!(app.export(Some(2), &mut some).unwrap(), 2); // lamport >= 2
}

fn patch_title(t: &str) -> UpdatePatch {
    UpdatePatch {
        title: Some(t.to_string()),
        ..Default::default()
    }
}

#[test]
fn export_import_roundtrip_into_empty() {
    let dira = tempfile::tempdir().unwrap();
    let dirb = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();
    let a = App::open(dira.path()).unwrap();
    a.add(spec("voyage embeddings"), root.path(), &ctx())
        .unwrap();
    a.add(spec("stripe billing"), root.path(), &ctx()).unwrap();

    let mut bundle = Vec::new();
    a.export(None, &mut bundle).unwrap();

    let b = App::open(dirb.path()).unwrap();
    let sum = b.import(bundle.as_slice()).unwrap();
    assert_eq!(sum.imported, 2);

    assert_eq!(b.search("voyage").unwrap().len(), 1);
    assert_eq!(b.search("stripe").unwrap().len(), 1);
}

#[test]
fn import_is_idempotent() {
    let dira = tempfile::tempdir().unwrap();
    let dirb = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();
    let a = App::open(dira.path()).unwrap();
    a.add(spec("m1"), root.path(), &ctx()).unwrap();
    a.add(spec("m2"), root.path(), &ctx()).unwrap();
    let mut bundle = Vec::new();
    a.export(None, &mut bundle).unwrap();

    let b = App::open(dirb.path()).unwrap();
    let s1 = b.import(bundle.as_slice()).unwrap();
    assert_eq!(s1.imported, 2);
    assert_eq!(s1.duplicates, 0);

    let s2 = b.import(bundle.as_slice()).unwrap();
    assert_eq!(s2.imported, 0);
    assert_eq!(s2.duplicates, 2);
}

#[test]
fn import_skips_corrupt_lines() {
    let dira = tempfile::tempdir().unwrap();
    let dirb = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();
    let a = App::open(dira.path()).unwrap();
    a.add(spec("good"), root.path(), &ctx()).unwrap();
    let mut bundle = Vec::new();
    a.export(None, &mut bundle).unwrap();
    let mut text = String::from_utf8(bundle).unwrap();
    text.push_str("this is not json\n");

    let b = App::open(dirb.path()).unwrap();
    let sum = b.import(text.as_bytes()).unwrap();
    assert_eq!(sum.imported, 1);
    assert_eq!(sum.corrupt, 1);
}

#[test]
fn import_event_with_lower_lamport_rebuilds_correctly() {
    let root = tempfile::tempdir().unwrap();
    let dira = tempfile::tempdir().unwrap();
    let dirb = tempfile::tempdir().unwrap();

    let a = App::open(dira.path()).unwrap();
    a.add(spec("a-mem"), root.path(), &ctx()).unwrap(); // lamport 1
    let mut bundle = Vec::new();
    a.export(None, &mut bundle).unwrap();

    let b = App::open(dirb.path()).unwrap();
    b.add(spec("b-mem-1"), root.path(), &ctx()).unwrap(); // lamport 1
    b.add(spec("b-mem-2"), root.path(), &ctx()).unwrap(); // lamport 2
    b.import(bundle.as_slice()).unwrap();

    assert_eq!(b.search("a-mem").unwrap().len(), 1);
    assert_eq!(b.search("b-mem-1").unwrap().len(), 1);
    assert_eq!(b.search("b-mem-2").unwrap().len(), 1);
}

#[test]
fn merge_converges_deterministically() {
    let dira = tempfile::tempdir().unwrap();
    let dirb = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();

    let a = App::open(dira.path()).unwrap();
    let id = a.add(spec("base"), root.path(), &ctx()).unwrap().memory_id;

    let mut seed = Vec::new();
    a.export(None, &mut seed).unwrap();
    let b = App::open(dirb.path()).unwrap();
    b.import(seed.as_slice()).unwrap();

    a.update(&id, patch_title("from-A"), root.path(), &ctx())
        .unwrap();
    b.update(&id, patch_title("from-B"), root.path(), &ctx())
        .unwrap();

    let mut ba = Vec::new();
    a.export(None, &mut ba).unwrap();
    let mut bb = Vec::new();
    b.export(None, &mut bb).unwrap();
    a.import(bb.as_slice()).unwrap();
    b.import(ba.as_slice()).unwrap();

    let ta = a.get(&id).unwrap().unwrap().title;
    let tb = b.get(&id).unwrap().unwrap().title;
    assert_eq!(ta, tb);
    assert!(ta == "from-A" || ta == "from-B");
}

#[test]
fn import_empty_bundle_is_noop() {
    let dir = tempfile::tempdir().unwrap();
    let app = App::open(dir.path()).unwrap();
    let sum = app.import(b"".as_slice()).unwrap();
    assert_eq!(sum.imported, 0);
    assert_eq!(sum.duplicates, 0);
    assert_eq!(sum.corrupt, 0);
}

#[test]
fn import_blank_lines_only_is_noop() {
    let dir = tempfile::tempdir().unwrap();
    let app = App::open(dir.path()).unwrap();
    let sum = app.import(b"\n  \n\n".as_slice()).unwrap();
    assert_eq!(sum.imported, 0);
    assert_eq!(sum.corrupt, 0);
}

#[test]
fn reviewed_event_converges_across_sync() {
    let dira = tempfile::tempdir().unwrap();
    let dirb = tempfile::tempdir().unwrap();
    let root = tempfile::tempdir().unwrap();

    // machine A: add an OLD decision, then mark it reviewed at a recent ts
    let a = App::open(dira.path()).unwrap();
    let id = a
        .add(
            spec("ancient decision"),
            root.path(),
            &ctx_at("2020-01-01T00:00:00Z"),
        )
        .unwrap()
        .memory_id;
    a.mark(&id, &ctx_at("2026-07-01T00:00:00Z")).unwrap();

    // export A's full log -> import into fresh machine B (import rebuilds projection)
    let mut buf = Vec::new();
    a.export(None, &mut buf).unwrap();
    let b = App::open(dirb.path()).unwrap();
    b.import(std::io::Cursor::new(buf)).unwrap();

    // review converged on B
    let on_b = b.get(&id).unwrap().unwrap();
    assert_eq!(on_b.updated_ts, "2026-07-01T00:00:00Z");
    assert_eq!(on_b.status, "stable");
    assert!(b
        .review_list("2026-07-10T00:00:00Z", None, None)
        .unwrap()
        .is_empty());
}