polypixel-memoir-core 0.4.0

Memoir memory substrate as an embeddable Rust library
Documentation
//! End-to-end quickstart for using memoir-core as an embedded library.
//!
//! Walks through the full lifecycle a consumer touches when wiring memoir-core
//! into their own agent:
//!
//! 1. Bring your own Postgres connection and Qdrant client.
//! 2. Build a `memoir_core::Client` from them.
//! 3. Run migrations to provision memoir-core's schema.
//! 4. `remember` a few conversation turns under a scope tuple.
//! 5. `search` for related memories and render via `Display` for system-prompt injection.
//! 6. `recall` a specific memory by pid.
//! 7. `forget` cleanups by Pid and by Scope.
//!
//! # Running
//!
//! ```bash
//! DATABASE_URL=postgres://postgres:postgres@localhost:54321/memoir_service_test \
//! QDRANT_URL=http://localhost:6334 \
//!   cargo run --example library-quickstart -p polypixel-memoir-core
//! ```
//!
//! Both env vars are required. The example uses a unique scope tuple per run
//! so multiple invocations do not collide.
//!
//! # WARNING — do not copy production secrets into a similar file
//!
//! This example reads connection strings from env vars. If you copy this
//! into your own project, keep secrets out of source control — read them
//! from environment, a secrets manager, or a config file ignored by git.

use std::time::Duration;

use memoir_core::client::{Client, DEFAULT_SYSTEM_PROMPT};
use memoir_core::memory::{ForgetTarget, Scope};

type BoxError = Box<dyn std::error::Error + Send + Sync>;

#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> Result<(), BoxError> {
    // Optional but recommended: install a tracing subscriber so the
    // `memoir.embed.*` / `memoir.forget.*` / `memoir.reconcile.*` events
    // appear on stderr while the example runs.
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("memoir_core=info,info")),
        )
        .init();

    let database_url = std::env::var("DATABASE_URL")
        .map_err(|_| "DATABASE_URL must be set (e.g. postgres://postgres:postgres@localhost:54321/memoir_service_test)")?;
    let qdrant_url = std::env::var("QDRANT_URL")
        .map_err(|_| "QDRANT_URL must be set (e.g. http://localhost:6334)")?;

    // Step 1 — build the Client.
    // memoir-core owns every backend connection internally: it builds its own
    // Postgres pool and Qdrant client from the connection strings we hand it,
    // so the consumer never imports or constructs a backend client type.
    // The builder is generated by `bon` on `Client::new`; `.builder()` starts
    // the chain and `.build()` finishes it. `schema` defaults to "memoir";
    // overriding it here for the example so repeated runs are isolated.
    let example_schema = format!("memoir_example_{}", std::process::id());
    println!("→ Building memoir Client (schema = {example_schema})...");
    let client = Client::builder()
        .database_url(database_url)
        .qdrant(qdrant_url)
        .schema(example_schema.clone())
        .system_prompt(DEFAULT_SYSTEM_PROMPT)
        .build()
        .await?;

    // Step 3 — provision the schema + tables.
    // Idempotent: safe to call on every startup. memoir-core's migrations are
    // versioned via sea-orm-migration just like any other crate's.
    println!("→ Applying memoir migrations...");
    client.migrate().await?;

    // Step 3.5 — spawn the worker.
    // memoir-core's write path is persistent: `Client::remember` enqueues an
    // embed job rather than running it inline. The worker drains the queue.
    // Without `spawn_worker`, writes land in the database but never reach
    // the vector index, so search returns nothing.
    println!("→ Spawning background worker...");
    let worker = client.spawn_worker().start().await?;

    // Step 4 — write some conversation turns.
    // Scope is the (agent_id, org_id, user_id) partition. memoir-core never
    // returns rows from a different scope. In a real app, derive the scope
    // from your auth context.
    let scope = Scope {
        agent_id: "example-agent".to_string(),
        org_id: "example-org".to_string(),
        user_id: "example-user".to_string(),
    };

    println!("→ Writing three episodic memories...");
    let _ = client.remember("the user is learning Rust", scope.clone()).await?;
    let _ = client
        .remember("the user prefers the bon crate for builders", scope.clone())
        .await?;
    let _ = client
        .remember("the user is building a memory substrate called Memoir", scope.clone())
        .await?;

    // The async embed substrate indexes the writes in the background. For
    // demo purposes we briefly wait before searching; in a real handler you
    // would just send the response and let the next request pick up the
    // freshly-indexed rows.
    println!("→ Waiting briefly for the background indexer...");
    tokio::time::sleep(Duration::from_secs(2)).await;

    // Step 5 — search for related memories and render via Display.
    // The `Memories` type's `Display` impl emits the configured system prompt
    // (we passed `DEFAULT_SYSTEM_PROMPT`) followed by a bullet list of memory
    // content. Drop this directly into your LLM's system prompt.
    let memories = client
        .search("what is the user working on?", scope.clone())
        .limit(5)
        .await?;

    println!();
    println!("=== Memories (Display rendering) ===");
    println!("{memories}");

    // Step 6 — recall a specific memory by pid.
    // `recall` works at any lifecycle state (pending / indexed / failed),
    // unlike `search` which only returns indexed rows.
    if let Some(first) = memories.list().first() {
        let row = client.recall(&first.pid).await?;
        println!("=== Recalled pid={} ===", row.pid);
        println!("content: {}", row.content);
        println!("kind: {}", row.kind);
        println!("created_at: {}", row.created_at);
        println!();
    }

    // Step 7 — cleanup.
    // forget(Pid) returns the single deleted pid. forget(Scope) bulk-deletes
    // every memory under the scope tuple and returns the full list. The
    // Postgres delete is authoritative; Qdrant cleanup is best-effort with
    // any failure logged at WARN under `memoir.forget.index_delete_failed`.
    println!("→ Cleaning up — forgetting the entire example scope...");
    let deleted = client.forget(ForgetTarget::Scope(scope)).await?;
    println!("Deleted {} memories.", deleted.len());

    // Step 8 — shut the worker down gracefully.
    println!("→ Shutting down worker...");
    worker.shutdown().await;

    println!();
    println!("Done. To run again, just re-invoke the example — each run uses");
    println!("a unique schema name derived from the process id.");

    Ok(())
}