Skip to main content

velesdb_memory/
service.rs

1//! The memory service: five operations over the in-core Agent Memory SDK.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::sync::Arc;
6
7use serde_json::{json, Map, Value};
8use velesdb_core::agent::AgentMemory;
9use velesdb_core::{Database, SearchResult};
10
11/// Structured metadata attached to a memory (the `ColumnStore` facet): exact-match
12/// fields like `project`, `author`, `type`, `status`, `date`. `content` and
13/// `_veles_expires_at` are reserved keys.
14pub type Metadata = Map<String, Value>;
15
16use crate::embedder::Embedder;
17use crate::error::MemoryError;
18use crate::extract::Extractor;
19use crate::id;
20use crate::model::{ColumnFilter, Explanation, Link, MemoryEdge, MemoryNode, Recollection};
21
22/// Reserved metadata key marking an entity hub auto-created by
23/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
24/// system `_veles_` prefix so it can never collide with a caller's own metadata,
25/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
26/// Hubs are internal graph scaffolding — they connect facts that share a topic —
27/// so they are excluded from unfiltered recall and from `why` seeds.
28const HUB_FIELD: &str = "_veles_hub";
29/// Salt mixed into a hub's stable id so the hub id space is disjoint from
30/// natural fact ids: a caller fact whose text happens to equal a hub's display
31/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
32const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
33
34/// Local-first agent memory backed by a single `VelesDB` instance.
35///
36/// Generic over the [`Embedder`] so production can use an on-device model while
37/// tests use a deterministic, network-free one.
38pub struct MemoryService<E: Embedder> {
39    memory: AgentMemory,
40    embedder: E,
41}
42
43impl<E: Embedder> MemoryService<E> {
44    /// Open (or create) a memory store at `path`, using `embedder` for text
45    /// vectorization. The store never leaves this directory.
46    ///
47    /// # Errors
48    /// Returns [`MemoryError`] if the store cannot be opened or the agent
49    /// memory cannot be initialized for the embedder's dimension.
50    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
51        let db = Arc::new(Database::open(path)?);
52        let memory = AgentMemory::with_dimension(db, embedder.dimension())?;
53        Ok(Self { memory, embedder })
54    }
55
56    /// Remember a `fact`, optionally tagging it with structured `metadata`
57    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
58    /// Returns the stable id of the fact (idempotent on identical content).
59    ///
60    /// Link targets are validated to exist *before* the fact is stored, so a bad
61    /// link never leaves the fact half-written.
62    ///
63    /// # Errors
64    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
65    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
66    /// (`content` or any `_veles_`-prefixed system key),
67    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
68    /// or a storage error if persistence fails.
69    pub fn remember(
70        &self,
71        fact: &str,
72        links: &[Link],
73        metadata: Option<&Metadata>,
74    ) -> Result<u64, MemoryError> {
75        let fact = fact.trim();
76        if fact.is_empty() {
77            return Err(MemoryError::EmptyFact);
78        }
79        reject_reserved_keys(metadata)?;
80        self.ensure_link_targets_exist(links)?;
81        let fact_id = id::stable_id(fact);
82        let embedding = self.embedder.embed(fact)?;
83        self.store(fact_id, fact, &embedding, metadata)?;
84        for link in links {
85            self.memory
86                .semantic()
87                .relate(fact_id, link.target, &link.relation, None)?;
88        }
89        Ok(fact_id)
90    }
91
92    /// Remember a passage of raw `text` by running it through an [`Extractor`]
93    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
94    ///
95    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
96    /// core: each extracted fact is stored (tagged with `metadata`), each salient
97    /// topic becomes a deduplicated hub memory, and every fact is linked to its
98    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
99    /// topic therefore become reachable from one another, so [`Self::why`] has a
100    /// real graph to traverse with no manual `relate()`.
101    ///
102    /// Entity hubs are content-addressed, so the same topic seen across many
103    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
104    /// hubs excluded), in extraction order.
105    ///
106    /// # Errors
107    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
108    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
109    /// if `metadata` names a reserved key, or a storage error if persistence fails.
110    pub fn remember_extracted<X: Extractor>(
111        &self,
112        text: &str,
113        extractor: &X,
114        metadata: Option<&Metadata>,
115    ) -> Result<Vec<u64>, MemoryError> {
116        let text = text.trim();
117        if text.is_empty() {
118            return Err(MemoryError::EmptyFact);
119        }
120        let facts = extractor.extract(text)?;
121        let mut fact_ids = Vec::new();
122        let mut entity_ids: HashMap<String, u64> = HashMap::new();
123        let mut edges: HashSet<(u64, u64)> = HashSet::new();
124        let mut seeded: HashSet<u64> = HashSet::new();
125        for fact in &facts {
126            let content = fact.text.trim();
127            if content.is_empty() {
128                continue;
129            }
130            let fact_id = self.remember(content, &[], metadata)?;
131            fact_ids.push(fact_id);
132            self.wire_entities(
133                fact_id,
134                &fact.entities,
135                &mut entity_ids,
136                &mut edges,
137                &mut seeded,
138            )?;
139        }
140        Ok(fact_ids)
141    }
142
143    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
144    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
145    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
146    /// hop from one fact, through a shared topic, to its sibling facts.
147    fn wire_entities(
148        &self,
149        fact_id: u64,
150        entities: &[String],
151        entity_ids: &mut HashMap<String, u64>,
152        edges: &mut HashSet<(u64, u64)>,
153        seeded: &mut HashSet<u64>,
154    ) -> Result<(), MemoryError> {
155        for entity in entities {
156            // Skip blank or punctuation-only topics: they would persist as junk
157            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
158            if entity.chars().any(char::is_alphanumeric) {
159                self.wire_entity(fact_id, entity, entity_ids, edges, seeded)?;
160            }
161        }
162        Ok(())
163    }
164
165    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
166    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
167    fn wire_entity(
168        &self,
169        fact_id: u64,
170        entity: &str,
171        entity_ids: &mut HashMap<String, u64>,
172        edges: &mut HashSet<(u64, u64)>,
173        seeded: &mut HashSet<u64>,
174    ) -> Result<(), MemoryError> {
175        let entity_id = self.entity_hub(entity, entity_ids)?;
176        if entity_id == fact_id {
177            return Ok(());
178        }
179        // Fold already-persisted edges into the dedup set so re-ingesting the
180        // same text never creates duplicate parallel edges (core `relate` does
181        // not dedup by endpoint+label, only by edge id).
182        self.seed_existing_edges(fact_id, edges, seeded)?;
183        self.seed_existing_edges(entity_id, edges, seeded)?;
184        self.add_edge(fact_id, entity_id, "about", edges)?;
185        self.add_edge(entity_id, fact_id, "mentions", edges)?;
186        Ok(())
187    }
188
189    /// Create the edge `from -> to` labelled `label`, unless `edges` already
190    /// records that endpoint pair (in-call and persisted dedup).
191    fn add_edge(
192        &self,
193        from: u64,
194        to: u64,
195        label: &str,
196        edges: &mut HashSet<(u64, u64)>,
197    ) -> Result<(), MemoryError> {
198        if edges.insert((from, to)) {
199            self.relate(from, to, label)?;
200        }
201        Ok(())
202    }
203
204    /// Load `node`'s already-persisted outgoing edges into `edges` once per call
205    /// (tracked by `seeded`), so the dedup set reflects the stored graph and a
206    /// repeated ingest is idempotent rather than edge-duplicating.
207    fn seed_existing_edges(
208        &self,
209        node: u64,
210        edges: &mut HashSet<(u64, u64)>,
211        seeded: &mut HashSet<u64>,
212    ) -> Result<(), MemoryError> {
213        if !seeded.insert(node) {
214            return Ok(());
215        }
216        for edge in self.memory.semantic().relations(node)? {
217            edges.insert((node, edge.target()));
218        }
219        Ok(())
220    }
221
222    /// Get or create the hub memory for a topic, caching its id per call. The
223    /// hub id is a deterministic function of the (normalized) topic, so the same
224    /// topic resolves to the same hub across calls — never a duplicate.
225    fn entity_hub(
226        &self,
227        entity: &str,
228        entity_ids: &mut HashMap<String, u64>,
229    ) -> Result<u64, MemoryError> {
230        let key = entity.trim().to_lowercase();
231        if let Some(&id) = entity_ids.get(&key) {
232            return Ok(id);
233        }
234        let id = self.remember_hub(&key)?;
235        entity_ids.insert(key, id);
236        Ok(id)
237    }
238
239    /// Idempotently store the hub memory for topic `key`. The id is salted so the
240    /// hub id space is disjoint from natural fact ids (no caller fact can collide
241    /// with or overwrite a hub), while the stored content stays human-readable.
242    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
243    /// it; goes straight to [`Self::store`] to bypass the caller-facing reserved-
244    /// key rejection in [`Self::remember`].
245    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
246        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
247        let content = format!("Entity: {key}");
248        let embedding = self.embedder.embed(&content)?;
249        let mut meta = Map::new();
250        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
251        self.store(id, &content, &embedding, Some(&meta))?;
252        Ok(id)
253    }
254
255    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
256    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
257        if self.memory.semantic().get(id)?.is_none() {
258            return Err(MemoryError::UnknownMemory(id));
259        }
260        Ok(())
261    }
262
263    /// Fail unless every link target already exists (keeps `remember` atomic).
264    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
265        for link in links {
266            self.ensure_exists(link.target)?;
267        }
268        Ok(())
269    }
270
271    /// Store a fact with or without metadata.
272    fn store(
273        &self,
274        id: u64,
275        fact: &str,
276        embedding: &[f32],
277        metadata: Option<&Metadata>,
278    ) -> Result<(), MemoryError> {
279        match metadata {
280            Some(meta) => self
281                .memory
282                .semantic()
283                .store_with_metadata(id, fact, embedding, meta)
284                .map_err(MemoryError::from),
285            None => self
286                .memory
287                .semantic()
288                .store(id, fact, embedding)
289                .map_err(MemoryError::from),
290        }
291    }
292
293    /// Recall up to `k` memories semantically similar to `query` (vector facet),
294    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
295    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
296    ///
297    /// A highly selective filter may return fewer than `k` hits even when more
298    /// matches exist — raise `k` for fuller coverage with a narrow filter.
299    ///
300    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
301    /// they are internal graph scaffolding, not facts the caller stored.
302    ///
303    /// # Errors
304    /// Returns [`MemoryError`] if the semantic query fails.
305    pub fn recall(
306        &self,
307        query: &str,
308        k: usize,
309        filter: Option<&Metadata>,
310    ) -> Result<Vec<Recollection>, MemoryError> {
311        let query = query.trim();
312        if query.is_empty() {
313            return Ok(Vec::new());
314        }
315        reject_reserved_keys(filter)?;
316        let embedding = self.embedder.embed(query)?;
317        let hits = self.search(&embedding, k, filter)?;
318        Ok(hits
319            .into_iter()
320            .map(|(id, score, content)| Recollection { id, score, content })
321            .collect())
322    }
323
324    /// Vector search for up to `k` ids, optionally narrowed by a metadata
325    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
326    fn search(
327        &self,
328        embedding: &[f32],
329        k: usize,
330        filter: Option<&Metadata>,
331    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
332        match filter {
333            // An include filter already excludes hubs: a hub only carries
334            // `{kind: entity}`, so it can never match a user's metadata filter.
335            Some(meta) => self
336                .memory
337                .semantic()
338                .query_filtered(embedding, k, meta, 0)
339                .map_err(MemoryError::from),
340            // Unfiltered recall must still drop entity hubs explicitly, or a hub
341            // like `Entity: rust` would rank for the topic and evict a real fact.
342            None => self
343                .memory
344                .semantic()
345                .query_excluding(embedding, k, &hub_exclude_filter())
346                .map_err(MemoryError::from),
347        }
348    }
349
350    /// Fused recall: semantic `NEAR` search combined with structured
351    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
352    /// not just the equality of [`Self::recall`]. One query spanning the vector
353    /// and column facets (e.g. "most similar facts **with `timestamp` in this
354    /// window**"), which a vector-only or equality-only recall cannot express.
355    ///
356    /// Filter *values* are bound as query parameters (never interpolated), so
357    /// they cannot inject; filter *field names* are validated to be plain
358    /// identifiers. Results come back in similarity order.
359    ///
360    /// # Errors
361    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
362    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
363    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
364    pub fn recall_where(
365        &self,
366        query: &str,
367        k: usize,
368        filters: &[ColumnFilter],
369    ) -> Result<Vec<Recollection>, MemoryError> {
370        let query = query.trim();
371        if query.is_empty() || k == 0 {
372            return Ok(Vec::new());
373        }
374        let embedding = self.embedder.embed(query)?;
375        let (sql, params) = self.build_fused_query(&embedding, k, filters)?;
376        // `build_fused_query` has validated every field name; ensure each one is
377        // indexed so the planner uses a bitmap prefilter instead of an O(n)
378        // post-filter scan. Idempotent and incrementally maintained thereafter.
379        for filter in filters {
380            self.memory
381                .semantic()
382                .ensure_index(&filter.field)
383                .map_err(MemoryError::from)?;
384        }
385        let results = self
386            .memory
387            .query_semantic(&sql, &params)
388            .map_err(MemoryError::from)?;
389        Ok(results.iter().map(to_recollection).collect())
390    }
391
392    /// Build the `VelesQL` for [`Self::recall_where`]: a `NEAR` predicate plus
393    /// one bound parameter per filter, against the semantic collection.
394    fn build_fused_query(
395        &self,
396        embedding: &[f32],
397        k: usize,
398        filters: &[ColumnFilter],
399    ) -> Result<(String, HashMap<String, Value>), MemoryError> {
400        use std::fmt::Write as _;
401        let mut params: HashMap<String, Value> = HashMap::new();
402        params.insert("q".to_string(), json!(embedding));
403        let mut predicate = String::from("vector NEAR $q");
404        for (index, filter) in filters.iter().enumerate() {
405            validate_field(&filter.field)?;
406            validate_scalar(&filter.value)?;
407            let key = format!("p{index}");
408            // Field is a validated identifier; the value is bound, never inlined.
409            let _ = write!(
410                predicate,
411                " AND {} {} ${key}",
412                filter.field,
413                filter.op.as_sql()
414            );
415            params.insert(key, filter.value.clone());
416        }
417        let sql = format!(
418            "SELECT * FROM {} WHERE {predicate} LIMIT {k}",
419            self.memory.semantic().collection_name()
420        );
421        Ok((sql, params))
422    }
423
424    /// Create a typed edge `from -> to`. Returns the edge id.
425    ///
426    /// Both endpoints are validated to exist first, so the tool reports an
427    /// unknown id as client input (`UnknownMemory`) rather than a generic
428    /// storage fault — and the graph never gains an edge dangling off a memory
429    /// that was never stored.
430    ///
431    /// # Errors
432    /// Returns [`MemoryError::UnknownMemory`] if either endpoint is missing, or
433    /// a storage error if the edge cannot be created.
434    pub fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
435        self.ensure_exists(from)?;
436        self.ensure_exists(to)?;
437        self.memory
438            .semantic()
439            .relate(from, to, relation, None)
440            .map_err(MemoryError::from)
441    }
442
443    /// Forget (delete) the memory with `fact_id`.
444    ///
445    /// # Errors
446    /// Returns [`MemoryError`] if the deletion fails.
447    pub fn forget(&self, fact_id: u64) -> Result<(), MemoryError> {
448        self.memory
449            .semantic()
450            .delete(fact_id)
451            .map_err(MemoryError::from)
452    }
453
454    /// Explain a `decision`: find the best-matching memory (optionally scoped to
455    /// a metadata `filter`, e.g. the current project), then walk its typed links
456    /// up to `max_hops` away — fusing the vector, `ColumnStore`, and graph facets.
457    ///
458    /// Returns an empty [`Explanation`] when nothing matches the decision.
459    ///
460    /// # Errors
461    /// Returns [`MemoryError`] if recall or graph traversal fails.
462    pub fn why(
463        &self,
464        decision: &str,
465        max_hops: usize,
466        filter: Option<&Metadata>,
467    ) -> Result<Explanation, MemoryError> {
468        let decision = decision.trim();
469        if decision.is_empty() {
470            return Ok(Explanation::default());
471        }
472        reject_reserved_keys(filter)?;
473        let embedding = self.embedder.embed(decision)?;
474        let seeds = self.search(&embedding, 1, filter)?;
475        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
476            return Ok(Explanation::default());
477        };
478        self.traverse(seed_id, seed_content, max_hops)
479    }
480
481    /// Breadth-first walk over outgoing links from `seed_id`, collecting nodes
482    /// and edges up to `max_hops` away.
483    fn traverse(
484        &self,
485        seed_id: u64,
486        seed_content: String,
487        max_hops: usize,
488    ) -> Result<Explanation, MemoryError> {
489        let mut explanation = Explanation {
490            nodes: vec![MemoryNode {
491                id: seed_id,
492                content: seed_content,
493                hop: 0,
494            }],
495            edges: Vec::new(),
496        };
497        let mut visited: HashSet<u64> = HashSet::from([seed_id]);
498        let mut frontier = vec![seed_id];
499        for hop in 1..=max_hops {
500            let mut next = Vec::new();
501            for node_id in frontier.drain(..) {
502                self.expand(node_id, hop, &mut explanation, &mut visited, &mut next)?;
503            }
504            if next.is_empty() {
505                break;
506            }
507            frontier = next;
508        }
509        Ok(explanation)
510    }
511
512    /// Expand a single node: enqueue unseen targets and record edges. An edge is
513    /// only recorded once its target is a resolved node, so the subgraph never
514    /// contains an edge pointing at a node absent from `nodes` (e.g. a forgotten
515    /// target whose edge outlived it).
516    fn expand(
517        &self,
518        node_id: u64,
519        hop: usize,
520        explanation: &mut Explanation,
521        visited: &mut HashSet<u64>,
522        next: &mut Vec<u64>,
523    ) -> Result<(), MemoryError> {
524        for edge in self.memory.semantic().relations(node_id)? {
525            let target = edge.target();
526            if !visited.contains(&target) {
527                let Some((content, _embedding)) = self.memory.semantic().get(target)? else {
528                    continue; // target no longer exists → drop the dangling edge too
529                };
530                visited.insert(target);
531                explanation.nodes.push(MemoryNode {
532                    id: target,
533                    content,
534                    hop,
535                });
536                next.push(target);
537            }
538            explanation.edges.push(MemoryEdge {
539                from: edge.source(),
540                to: target,
541                relation: edge.label().to_owned(),
542            });
543        }
544        Ok(())
545    }
546}
547
548/// The metadata filter that excludes entity hubs from unfiltered recall and
549/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
550/// internal `_veles_hub` scaffolding never surfaces as a result.
551fn hub_exclude_filter() -> Metadata {
552    let mut exclude = Map::new();
553    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
554    exclude
555}
556
557/// True for metadata keys the memory layer reserves: the engine's `content`
558/// payload, and any `_veles_`-namespaced system key (durable TTL, entity hubs).
559/// Callers may neither set these in `remember` metadata nor filter on them, so
560/// they can't overwrite a system field or collide with internal scaffolding.
561fn is_reserved_key(key: &str) -> bool {
562    key == "content" || key.starts_with("_veles_")
563}
564
565/// Reject caller-supplied metadata/filters that name a reserved key.
566fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
567    let Some(meta) = metadata else {
568        return Ok(());
569    };
570    for key in meta.keys() {
571        if is_reserved_key(key) {
572            return Err(MemoryError::ReservedKey(key.clone()));
573        }
574    }
575    Ok(())
576}
577
578/// Map a core search result to a [`Recollection`], lifting the fact text out of
579/// the reserved `content` payload key.
580fn to_recollection(result: &SearchResult) -> Recollection {
581    let content = result
582        .point
583        .payload
584        .as_ref()
585        .and_then(|payload| payload.get("content"))
586        .and_then(Value::as_str)
587        .unwrap_or_default()
588        .to_owned();
589    Recollection {
590        id: result.point.id,
591        score: result.score,
592        content,
593    }
594}
595
596/// Accept only plain, non-reserved identifier field names, so a filter field
597/// can be safely placed into the query text (its value is always a bound
598/// parameter). Rejects the reserved system columns the docs promise are off
599/// limits: `content` (the fact payload) and any `_veles_`-prefixed engine key
600/// (e.g. durable TTL).
601fn validate_field(field: &str) -> Result<(), MemoryError> {
602    let plain = !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
603    let reserved = field == "content" || field.starts_with("_veles_");
604    if plain && !reserved {
605        Ok(())
606    } else {
607        Err(MemoryError::InvalidFilter(field.to_owned()))
608    }
609}
610
611/// Reject non-scalar filter values. Only strings, numbers, and booleans can be
612/// compared against a `ColumnStore` column; binding an array/object/null would
613/// fail deep in the query engine and surface as an opaque internal error instead
614/// of a clear client-input error.
615fn validate_scalar(value: &Value) -> Result<(), MemoryError> {
616    match value {
617        Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(()),
618        _ => Err(MemoryError::InvalidFilter(format!(
619            "value must be a string, number, or boolean, got {value}"
620        ))),
621    }
622}