Working with the Facade¶
The Uniko facade is the single public entry point to the memory system. This guide is the full
tour — from building an instance to every operation you'll reach for — with the idioms and the mental
model that tie them together. The Quick Start is the 15-minute
taste; this is the map.
The mental model¶
You program against intentions, not mechanisms. The facade hides the engine (KnowledgeBase, the
ingest pipeline, the query engine) behind a small set of verbs: observe, recall, answer,
forget. Everything hangs off one owning handle.
flowchart LR
U[Uniko] -->|agent| A[Agent]
A -->|session| S[Session]
S -->|observe / ingest| W[(memory)]
A -->|recall / answer / query| W
A -->|data| D[retrieve by id]
A -->|goals| G[goals & tasks]
| Layer | What it is | You get it from |
|---|---|---|
Uniko |
the instance — owns the store, models, pipeline | Uniko::open / builder() |
Agent |
an identity that reads/writes, scopable | memory.agent(id) |
Session |
a conversation thread | agent.session(id) |
The rule of thumb: write through a Session (observe / ingest), read through the Agent
(recall / answer / query), retrieve by id through agent.data(), plan through
agent.goals().
1. Build an instance¶
The zero-config constructors use the benchmark-validated defaults:
use uniko_memory::Uniko;
let memory = Uniko::in_memory().await?; // ephemeral (tests, scratch)
let memory = Uniko::open("./agent-memory").await?; // persistent, file-backed
Reach for the builder to turn on capabilities:
use uniko_memory::{EmbeddingConfig, LlmSpec, Uniko};
let memory = Uniko::builder()
.path("./agent-memory")
.embedding(EmbeddingConfig::bge_small_en_v15()) // swap the embedder
.llm(LlmSpec::openai("answerer", "gpt-4o-mini", None)) // enables answer()
.streaming(true) // enables submit()/flush()
.scope_to_agent() // reads default to the agent's visibility
.build()
.await?;
| Builder method | Effect |
|---|---|
.path(p) / .in_memory() |
Where the store lives. |
.embedding(EmbeddingConfig) |
The embedder (vector search). |
.llm(LlmSpec) |
A generation model — required for answer(). |
.streaming(bool) |
Enables the fire-and-forget submit/flush lane. |
.scope_to_agent() / .scope(RecallScope) |
Default read visibility. |
.extractor(Arc<dyn ModalityExtractor>) |
A pluggable image/audio/video extractor. |
.raw_config(UnikoConfig) |
Drop in a fully-tuned config (see Configuration). |
LlmSpec picks the generation model: openai(alias, model, base_url) (reads OPENAI_API_KEY),
openai_with_key_env(...) for a custom key var, or mistralrs(alias, model) for a local model.
Capabilities, not wiring
You never construct a KnowledgeBase, a pipeline, or a model catalog. The builder is the only
place you make capability choices; everything you don't set runs the defaults.
2. Agents and sessions¶
let agent = memory.agent("assistant"); // an identity; cheap to mint
let mut session = agent.session("chat-42"); // a conversation thread
One instance can mint many agents (alice, bob); each can be scoped to what it may see. A session
groups turns and lets attachments attach to the right message.
3. Write memory¶
Conversation — observe¶
observe is the first-class write: it runs the full ingest pipeline (chunking, entity + observation
extraction) and commits before returning, so the next read sees it.
use uniko_memory::{IngestSource, Turn};
let result = session
.observe(
Turn::new("alice", "here's the spec we discussed")
.addressed_to(vec!["assistant".into()])
.attach(IngestSource::path("spec.pdf")), // a document rides the turn
)
.await?;
// result.message -> the ingested message
// result.attachments -> each attachment, linked to this turn (provenance)
A Turn is a builder: Turn::new(sender, content) plus .id(...) (idempotency), .at(timestamp),
.addressed_to(...), .metadata(k, v), .attach(IngestSource), .attachments(iter).
Attachments carry provenance
A document shared with .attach(...) is linked to the exact message and speaker that shared it —
so later a recalled fact can cite "per spec.pdf, shared by alice." See
Recall & Retrieval.
Standalone documents — ingest¶
To load a corpus with no conversation, use one blob verb and one blob type:
session.ingest(IngestSource::path("handbook.md")).await?; // MIME sniffed
session.ingest(IngestSource::bytes(pdf).with_mime(Mime::parse("application/pdf")?)).await?;
IngestSource::{text, bytes, path} builds the blob; .with_mime(...), .with_id(...),
.with_path(...) override routing. .with_mime(...) takes a Mime (re-exported from
uniko_memory); build one with Mime::parse("application/pdf")? or "application/pdf".parse()?. The
returned IngestOutcome tells you what it became (Artifact / Pdf) and carries the artifact_id
for later retrieval. An unsupported modality surfaces as a UnikoError::Unsupported error, not an
IngestOutcome variant.
High throughput — streaming¶
When you don't need read-after-write, the streaming lane (built with .streaming(true)) trades
immediacy for throughput:
session.submit(Turn::new("alice", "...")).await?; // fire-and-forget
session.submit_source(IngestSource::text("notes")).await?;
session.flush().await?; // barrier: drain the pipeline
4. Read memory — three altitudes¶
The same memory reads at whichever altitude the task needs:
// (a) ranked context, ready to drop into a prompt
let bundle = agent.recall("what's the deadline?").await?; // ContextBundle
// (b) a finished answer (recall + the configured LLM)
let answer = agent.answer("when is the deadline?").await?; // Answer
// (c) exact rows you specify (read-only Cypher)
let rows = agent.query("MATCH (m:Message) RETURN m.content AS c").await?;
recalldoes the retrieval cascade and hands back ranked, deduped, budget-trimmed context.answeradds the LLM call and returns generated text plus the grounding (needs.llm(...)).queryis exact and deterministic — you write the graph pattern, you get rawRecords.
Scoping¶
Every read has an _in twin that confines it to a session, participant, or time window:
use chrono::{Duration, Utc};
use uniko_memory::Scope;
let scope = Scope::default()
.sessions(["chat-42"])
.since(Utc::now() - Duration::days(7));
let bundle = agent.recall_in("deadline", scope.clone()).await?;
let rows = agent.query_in("MATCH (n:Message) WHERE id(n) IN $allow RETURN n", &scope).await?;
recall_in/answer_in apply the scope (and viewer visibility) for you; query_in binds the in-scope
node-ids as $allow for your Cypher. Full treatment in Recall & Retrieval.
5. Provenance and retrieval¶
Every recalled item answers what it is and where it came from:
for item in &bundle.items {
println!("[{:?}{}] {}",
item.kind, // Chunk / Fact / Observation / …
if item.kind.is_derived() { " derived" } else { "" },
item.content);
for src in &item.sources { /* Message | Attachment | Document — the lineage */ }
}
agent.answer(...) carries the same lineage, so answers are citable for free:
let ans = agent.answer("when is the deadline?").await?;
println!("{}", ans.text);
for src in ans.citations() { /* what the answer was grounded in */ }
And agent.data() dereferences those source ids into content — recall is the index lookup, data is
the document fetch:
let view = agent.data().artifact("spec.pdf").await?; // metadata + reassembled text
let bytes = agent.data().artifact_bytes("spec.pdf").await?; // the original blob
let msg = agent.data().message("m-12").await?; // sender / session / attachments
This closes the loop with observe: the artifact_id it returned is the id you fetch by.
6. Goals and tasks¶
agent.goals() is the goal/task lifecycle surface — what the agent is pursuing, did, and plans to do:
use uniko_memory::CreateGoalParams;
let goal = agent.goals().create(CreateGoalParams {
goal_id: Some("g-release".into()),
title: "Ship the release".into(),
..Default::default()
}).await?;
agent.goals().start("g-release").await?;
agent.goals().complete("g-release", Some(serde_json::json!({ "shipped": true }))).await?;
let active = agent.goals().active().await?; // current work
let planned = agent.goals().planned().await?; // future plans
let completed = agent.goals().completed().await?; // history + results
let ctx = agent.goals().context("g-release").await?; // the goal's working subtree
Phases (Planned/Active/Completed/Abandoned) derive from status; completing with a result
records it. Tasks live under goals the same way. See Agent Tools.
7. Reason over memory¶
Beyond retrieval, the agent can reason with the logic surface — rules, what-ifs, abduction:
agent.define_rule("vip", "CREATE RULE vip AS MATCH (p:Participant)... YIELD ...").await?;
let rows = agent.run_rule("vip", &["p"], params).await?;
// hypothetical, guaranteed rollback — the real graph is never touched:
let derived = agent.assume("ASSUME { CREATE (:Fact {subject:'srv'}) }")
.then_query("MATCH (f:Fact {subject:'srv'}) RETURN f")
.run().await?;
Full coverage in Reasoning with Locy.
8. Forget and delete¶
Memory you can't curate is a liability. The verbs split by intent:
session.forget_turn(msg_id).await?; // SOFT: hidden from recall, lineage kept
session.delete_turn(msg_id).await?; // HARD: cascade chunks/observations, splice the thread
session.delete_document(artifact_id).await?;
agent.delete_session("chat-42").await?; // a whole conversation
agent.forget_participant("alice").await?; // GDPR erasure of a person's data
memory.purge().await?; // dev/test wipe
Each returns a DeletionReport of exactly what changed. Forget is soft (redact, keep audit
lineage); delete is hard (cascade the owned subtree). When a derived Fact loses its last
supporting evidence it's soft-invalidated with a recorded reason, not silently dropped.
9. Shut down¶
shutdown needs sole ownership — drop outstanding Agent/Session handles first; the error tells
you if one is still alive.
Putting it together¶
A realistic end-to-end loop — observe a conversation with an attachment, answer with citations, fetch the source, track a goal, then curate:
use uniko_memory::{CreateGoalParams, IngestSource, LlmSpec, Turn, Uniko};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let memory = Uniko::builder()
.in_memory()
.llm(LlmSpec::openai("answerer", "gpt-4o-mini", None))
.build()
.await?;
let agent = memory.agent("assistant");
let mut session = agent.session("chat-1");
// Write: a turn plus the spec it references.
let observed = session
.observe(
Turn::new("alice", "the deadline is in the attached spec")
.id("m-1")
.attach(IngestSource::text("# Spec\n\nDeadline: Friday")),
)
.await?;
// Read: a cited answer.
let answer = agent.answer("when is the deadline?").await?;
println!("{}", answer.text);
for src in answer.citations() {
println!(" cited: {src:?}");
}
// Retrieve: open the source document.
if let uniko_memory::IngestOutcome::Artifact(a) = &observed.attachments[0] {
let doc = agent.data().artifact(&a.artifact_id).await?;
println!("source text: {:?}", doc.map(|d| d.text));
}
// Plan: track and complete a goal.
agent.goals().create(CreateGoalParams {
goal_id: Some("g-1".into()),
title: "Confirm the deadline".into(),
..Default::default()
}).await?;
agent.goals().complete("g-1", Some(serde_json::json!({ "deadline": "Friday" }))).await?;
// Curate: forget a turn, then close cleanly.
session.forget_turn("m-1").await?;
drop(session);
drop(agent);
memory.shutdown().await?;
Ok(())
}
The whole surface at a glance¶
| Stage | Verb(s) | Returns |
|---|---|---|
| Build | Uniko::open / builder().build() |
Uniko |
| Identity | memory.agent(id) / agent.session(id) |
Agent / Session |
| Write | session.observe(Turn) / session.ingest(IngestSource) |
ObserveResult / IngestOutcome |
| Read | agent.recall / answer / query (+ _in) |
ContextBundle / Answer / Vec<Record> |
| Retrieve | agent.data().message / artifact / artifact_bytes |
Option<…View> / Option<Vec<u8>> |
| Plan | agent.goals() (create / transition / read) |
GoalView / TaskView / GoalContext |
| Reason | agent.define_rule / run_rule / assume / abduce |
rows / derivations |
| Curate | forget_* / delete_* / purge |
DeletionReport |
| Close | memory.shutdown() |
() |
Where to go deeper¶
Recall & Retrieval¶
The three altitudes, provenance, citations, and agent.data() in depth.
Agent Tools¶
Goals/tasks and the episode/action/fact recording primitives.
API Reference¶
Every method, signature, and field — the catalog behind this tour.