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, ¶ms)
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}