velesdb_memory/service.rs
1//! The memory service: five operations over the in-core Agent Memory SDK.
2//!
3//! # Role: the crate's assembler — declared, with a budget
4//!
5//! This module is the ONE place allowed to know every seam at once — store,
6//! embedder, extractor, fusion, autograph, online migration, the working
7//! context bridge — and wire them into the operations the adapters call.
8//! That role is why it stays the crate's densest module (~1 000 lines after
9//! the #2021 cut, against the ~500-NLOC file budget the child-module splits
10//! aim at), and why its size is a *declared* cost rather than an accident:
11//! an assembler that was split by size alone would scatter the wiring it
12//! exists to make readable.
13//!
14//! The budget still binds. Growth goes into child modules that keep private
15//! access (`fused_recall.rs` and `online_migration.rs` are the pattern —
16//! `#[path]` children of `service`, not siblings), never into this file. The
17//! working-context bridge was the last named cut and it is done: it left along
18//! the store-facet line of #1959, as #1967 asked. A change that adds an
19//! operation body here instead of a child module needs to say why in review.
20
21use std::collections::{HashMap, HashSet};
22#[cfg(feature = "persistence")]
23use std::path::Path;
24#[cfg(feature = "persistence")]
25use std::sync::Arc;
26
27use serde_json::{Map, Value};
28
29/// Structured metadata attached to a memory (the `ColumnStore` facet): exact-match
30/// fields like `project`, `author`, `type`, `status`, `date`. `content` and
31/// `_veles_expires_at` are reserved keys. [`crate::storage::AUTO_DATE_FIELD`]
32/// (`_veles_date`) is auto-populated by [`MemoryService::remember_with_ttl`]
33/// with today's date unless already present — see that method's docs.
34pub type Metadata = Map<String, Value>;
35
36use crate::clock;
37use crate::embedder::Embedder;
38use crate::error::MemoryError;
39use crate::extract::{ExtractedAttribute, ExtractedRelation, Extractor};
40use crate::id;
41use crate::model::{
42 ColumnFilter, EntityProfile, EntityRelation, Explanation, Link, MemoryEdge, MemoryNode,
43 Recollection, RememberedExtraction, UnrelateOutcome,
44};
45#[cfg(feature = "persistence")]
46use crate::mutation::MutationObserver;
47#[cfg(feature = "persistence")]
48use crate::storage::NativeStore;
49use crate::storage::{
50 is_reserved_key, strip_reserved_keys, ColumnStore, FactStore, GraphStore, RecallStore,
51 AUTO_DATE_FIELD,
52};
53
54/// [`MemoryService::recall_fused`] and its helpers — split out to keep this
55/// file under the crate's 500-NLOC-per-file budget, same pattern as
56/// `velesdb-core`'s `database/*.rs` split. A child module of `service`, so it
57/// shares full access to `MemoryService`'s private fields and methods.
58#[path = "fused_recall.rs"]
59mod fused_recall;
60
61/// The graph facet — `relate`/`forget`/hubs/`why`/`traverse` — same split,
62/// same reason; see `service_graph.rs`'s module doc.
63#[path = "service_graph.rs"]
64mod graph;
65
66/// The graph facet's *construction* half — `remember*`/`autograph*` and the
67/// `wire_*` plumbing — same split, same reason; see its module doc (#2021).
68#[path = "service_graph_wiring.rs"]
69mod graph_wiring;
70
71#[cfg(feature = "persistence")]
72#[allow(dead_code)]
73#[path = "online_migration.rs"]
74mod online_migration;
75#[cfg(feature = "persistence")]
76pub(crate) use online_migration::recover_startup;
77#[cfg(feature = "mcp")]
78pub(crate) use online_migration::{
79 JobPhase, JobTarget, LiveGenerationSlot, MigrationStartConfig, MigrationStatus,
80 OnlineMigrationManager,
81};
82
83/// [`MemoryService::feedback`] and the recall re-ranking it drives (RL Memory).
84/// A child module of `service`, like [`fused_recall`], so it uses
85/// `MemoryService`'s private `store` directly. Gated on `persistence`: it
86/// builds on `velesdb-core`'s agent SDK (`ReinforcementStrategy`), itself
87/// behind that feature, and a durable learned confidence is meaningless on the
88/// in-memory (WASM) backend.
89#[cfg(feature = "persistence")]
90#[path = "reinforce.rs"]
91mod reinforce;
92
93/// The context compiler's memory bridge (`compile_context`,
94/// `retrieve_context_source`, `context_savings`, working contexts). A child
95/// module of `service`, like [`fused_recall`], so it reuses the private
96/// `store_fact`/`HUB_FIELD` system-fact machinery — compiler system facts
97/// (sources, events, working contexts) are hub-marked so they never surface
98/// in normal recall.
99#[cfg(feature = "context")]
100#[path = "context/memory_bridge.rs"]
101mod memory_bridge;
102
103/// Reserved metadata key marking an entity hub auto-created by
104/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
105/// system `_veles_` prefix so it can never collide with a caller's own metadata,
106/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
107/// Hubs are internal graph scaffolding — they connect facts that share a topic —
108/// so they are excluded from unfiltered recall and from `why` seeds.
109///
110/// Re-exported from [`crate::storage`] rather than spelled out again here: it
111/// is one of the five markers [`crate::storage::INTERNAL_MARKER_FIELDS`]
112/// excludes from `recall_where`, and a second literal could drift from that
113/// list without any test noticing.
114use crate::storage::HUB_FIELD;
115/// Salt mixed into a hub's stable id so the hub id space is disjoint from
116/// natural fact ids: a caller fact whose text happens to equal a hub's display
117/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
118const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
119/// Edge label a hub uses to point back at a fact it tags (the hub → fact
120/// direction). [`fused_recall`] reads this to recognise which edges in a
121/// `why()` walk crossed a hub, so it can weight the reached fact by that
122/// hub's specificity instead of a flat constant.
123const MENTIONS_RELATION: &str = "mentions";
124/// Edge label a fact uses to point at a hub it is tagged with (the fact → hub
125/// direction) — [`MENTIONS_RELATION`]'s bipartite twin, written by
126/// [`MemoryService::remember_extracted`]'s `wire_entity`.
127const ABOUT_RELATION: &str = "about";
128
129/// Local-first agent memory backed by a single `VelesDB` instance.
130///
131/// Generic over the [`Embedder`] so production can use an on-device model while
132/// tests use a deterministic, network-free one, and over the [`FactStore`]
133/// backend `S` so the same orchestration runs over the native, file-backed
134/// engine (the default — nothing changes for existing callers) or any other
135/// backend that implements the storage facets it uses (e.g. an in-memory one
136/// for WASM). Methods needing recall, graph, or columnar capability carry
137/// that facet as an extra bound — a partial backend simply does not have
138/// those methods (#1959).
139///
140/// Two definitions, `persistence`-gated: the default type parameter itself
141/// references [`NativeStore`], which doesn't exist as a type at all without
142/// the feature, so a `persistence`-free build (e.g. `velesdb-wasm`) drops the
143/// default and every caller names its own storage backend explicitly. The
144/// duplication stops at the type-parameter list: the field lists are
145/// identical by contract — what varies per feature is the *type* of
146/// [`GenerationGate`], never the shape of the service — and
147/// `tests/service_field_drift.rs` fails the build of any change that lets
148/// them diverge again (#2017).
149#[cfg(feature = "persistence")]
150pub struct MemoryService<E: Embedder, S: FactStore = NativeStore> {
151 store: S,
152 embedder: E,
153 autograph: Option<crate::extract::DynExtractor>,
154 autograph_queue: AutographQueue,
155 generation_gate: GenerationGate,
156}
157#[cfg(not(feature = "persistence"))]
158pub struct MemoryService<E: Embedder, S: FactStore> {
159 store: S,
160 embedder: E,
161 autograph: Option<crate::extract::DynExtractor>,
162 autograph_queue: AutographQueue,
163 generation_gate: GenerationGate,
164}
165
166#[path = "service_generation.rs"]
167mod service_generation;
168use service_generation::{GenerationGate, GenerationGuard};
169
170/// One deferred autograph: the stored fact a background worker will read for
171/// entities, edges and attributes (#1846).
172// The fields are read only on the worker path, which `spawn_autograph_worker`
173// cfg-gates off wasm32 (no threads there) — without this the wasm check dies
174// on dead_code under -D warnings.
175#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
176struct AutographJob {
177 fact_id: u64,
178 fact: String,
179}
180
181/// The decoupling state of [`MemoryService::autograph`] (#1846).
182///
183/// Empty by default: every construction path starts with autograph running
184/// INLINE, exactly as before — the WASM binding has no threads, library
185/// consumers keep the synchronous contract, and every existing test stays
186/// meaningful. [`MemoryService::spawn_autograph_worker`] fills `tx`, after
187/// which `remember` only ENQUEUES: the caller stops paying the generation on
188/// its response path — 46 s measured for a 12-word fact on the production
189/// daemon, versus a 0.12 s embedding — and the worker wires the graph behind.
190///
191/// `dropped` counts enrichments refused by a FULL queue or skipped by a
192/// closing worker. Non-negotiably visible: a burst that outruns the
193/// extractor loses graph structure, and a loss nobody can see is the exact
194/// defect class #1820 closed for responses.
195///
196/// `closing` is the shutdown latch: the handle's drop raises it BEFORE
197/// removing the sender, so the worker finishes the job in flight and SKIPS
198/// what is still queued (counted, one aggregated warning) instead of
199/// draining a queue of generations — 64 × a 46 s model would hold the
200/// daemon's exit for tens of minutes. Re-armed by each spawn.
201#[derive(Default)]
202// `closing` is read only by the worker/drop path, absent on wasm32 — same
203// rationale as `AutographJob` above.
204#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
205struct AutographQueue {
206 tx: parking_lot::Mutex<Option<std::sync::mpsc::SyncSender<AutographJob>>>,
207 dropped: std::sync::atomic::AtomicU64,
208 /// Enrichments that RAN and failed part-way through wiring — distinct
209 /// from `dropped` (never ran). See `MemoryService::autograph_failed`.
210 failed: std::sync::atomic::AtomicU64,
211 closing: std::sync::atomic::AtomicBool,
212}
213
214/// Join guard for the background autograph worker.
215///
216/// Dropping it raises the closing latch, takes the sender out of the
217/// service, and JOINS the worker: the job in flight completes, the
218/// still-queued ones are SKIPPED — counted in the drop counter, one
219/// aggregated warning — and only then does the drop return. Tests get
220/// determinism; the daemon's shutdown waits for at most ONE generation,
221/// never a queue of them.
222pub struct AutographWorkerHandle {
223 close_queue: Option<Box<dyn FnOnce() + Send + Sync>>,
224 join: Option<std::thread::JoinHandle<()>>,
225}
226
227impl Drop for AutographWorkerHandle {
228 fn drop(&mut self) {
229 if let Some(close) = self.close_queue.take() {
230 close();
231 }
232 if let Some(join) = self.join.take() {
233 let _ = join.join();
234 }
235 }
236}
237
238#[cfg(feature = "persistence")]
239impl<E: Embedder> MemoryService<E, NativeStore> {
240 /// Open (or create) a native, file-backed memory store at `path`, using
241 /// `embedder` for text vectorization. The store never leaves this directory.
242 ///
243 /// # Errors
244 /// Returns [`MemoryError`] if the store cannot be opened or the agent
245 /// memory cannot be initialized for the embedder's dimension.
246 pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
247 let store = NativeStore::open(path, embedder.dimension())?;
248 Ok(Self {
249 store,
250 embedder,
251 autograph: None,
252 autograph_queue: AutographQueue::default(),
253 generation_gate: GenerationGate::new(),
254 })
255 }
256
257 pub(crate) fn install_mutation_observer(
258 &self,
259 observer: Option<Arc<dyn MutationObserver>>,
260 ) -> Result<(), MemoryError> {
261 let _generation = self.generation_gate.write();
262 self.store.set_mutation_observer(observer)
263 }
264
265 pub(crate) fn migration_capture_active(&self) -> bool {
266 self.store.mutation_capture_active()
267 }
268}
269
270impl<E: Embedder, S: FactStore> MemoryService<E, S> {
271 /// Build a service directly over a `store` backend, bypassing
272 /// [`Self::open`]'s filesystem-specific setup — the constructor a
273 /// non-native backend (e.g. `velesdb-wasm`'s in-memory store) uses.
274 pub fn with_store(store: S, embedder: E) -> Self {
275 Self {
276 store,
277 embedder,
278 autograph: None,
279 autograph_queue: AutographQueue::default(),
280 generation_gate: GenerationGate::new(),
281 }
282 }
283
284 fn enter_generation(&self) -> GenerationGuard<'_> {
285 GenerationGuard {
286 _guard: self.generation_gate.read(),
287 }
288 }
289
290 /// Turn on **autograph**: every [`Self::remember`] additionally reads the
291 /// stored fact for entities, entity→entity edges and entity attributes,
292 /// and wires them — so the knowledge graph builds itself from ordinary
293 /// `remember` calls, with no separate [`Self::remember_extracted`].
294 ///
295 /// Opt-in, and off unless this is called. It runs in one of two modes:
296 /// **inline** by default — the enrichment costs one generation per
297 /// `remember`, on the caller's write path, which is a real latency and
298 /// availability change: a memory write that silently depends on a local
299 /// model being up is not a default anyone should inherit — or
300 /// **decoupled** when [`Self::spawn_autograph_worker`] is active, where
301 /// `remember` returns as soon as the fact is durably stored and the
302 /// derived edges lag by one generation (an `entity`/`why` read issued
303 /// immediately after may not see them yet; the fact itself is always
304 /// immediately readable).
305 ///
306 /// The caller's fact is stored **verbatim and first**. Autograph only
307 /// *adds* structure around it; it never rewrites or replaces what the
308 /// caller asked to remember.
309 #[must_use]
310 pub fn with_autograph(mut self, extractor: crate::extract::DynExtractor) -> Self {
311 self.autograph = Some(extractor);
312 self
313 }
314
315 /// Every deterministic rejection, before anything is written: a blank or
316 /// over-long fact, an explicit zero TTL, reserved or oversized metadata,
317 /// and each link's label and target. Run as one pass so a bad input never
318 /// leaves a half-written fact behind.
319 fn validate_write(
320 &self,
321 fact: &str,
322 links: &[Link],
323 metadata: Option<&Metadata>,
324 ttl_seconds: Option<u64>,
325 ) -> Result<(), MemoryError> {
326 validate_fact(fact)?;
327 reject_zero_ttl(ttl_seconds)?;
328 reject_reserved_keys(metadata)?;
329 reject_oversized_metadata(metadata)?;
330 self.validate_links(links)
331 }
332
333 /// Embed the fact and persist it with its date-stamped metadata and TTL.
334 fn write_fact(
335 &self,
336 fact_id: u64,
337 fact: &str,
338 metadata: Option<&Metadata>,
339 ttl_seconds: Option<u64>,
340 ) -> Result<(), MemoryError> {
341 let embedding = self.embedder.embed(fact)?;
342 let stamped = stamp_with_today(metadata);
343 // `ttl_seconds` is already known positive-or-absent: `validate_write`
344 // refuses an explicit `Some(0)` before any of this runs.
345 self.store_fact(fact_id, fact, &embedding, stamped.as_ref(), ttl_seconds)
346 }
347
348 /// Validate EVERY link property — relation label and target existence —
349 /// before any write, so all deterministic link failures happen while
350 /// nothing has been stored or overwritten yet.
351 fn validate_links(&self, links: &[Link]) -> Result<(), MemoryError> {
352 for link in links {
353 validate_relation(&link.relation)?;
354 }
355 self.ensure_link_targets_exist(links)
356 }
357
358 /// Whether an autograph extractor is configured at all.
359 #[must_use]
360 pub fn has_autograph(&self) -> bool {
361 self.autograph.is_some()
362 }
363
364 /// The total number of live tracked facts, internal entity hubs included
365 /// — the store's [`FactStore::count`], relayed for `memory_status`.
366 #[must_use]
367 pub fn fact_count(&self) -> usize {
368 let _generation = self.enter_generation();
369 self.store.count()
370 }
371
372 /// One page of the store's facts, for auditing — "what does my agent
373 /// know?" — which `recall` structurally cannot answer: it ranks by
374 /// resemblance to a query, and what resembles nothing you thought to
375 /// ask stays invisible.
376 ///
377 /// The store hands back raw pages ([`FactStore::list`]); the
378 /// visibility policy is applied here, once, for every backend: internal
379 /// entity hubs are skipped unless `include_internal` (they are the
380 /// graph's scaffolding, not the user's facts), reserved `_veles_*` keys
381 /// are stripped exactly as `recall` strips them (the auto-stamped date
382 /// survives — an audit legitimately asks WHEN), and `filter` keeps only
383 /// facts whose metadata equals every given key. A filtered page may
384 /// come back sparse — the cursor still advances over what was skipped,
385 /// so the WALK stays exhaustive.
386 ///
387 /// # Errors
388 /// Returns [`MemoryError`] if the backend cannot enumerate or the walk
389 /// fails.
390 pub fn list(
391 &self,
392 cursor: Option<u64>,
393 limit: usize,
394 filter: Option<&Metadata>,
395 include_internal: bool,
396 ) -> Result<(Vec<crate::model::ListedMemory>, Option<u64>), MemoryError> {
397 let _generation = self.enter_generation();
398 let limit = crate::limits::clamp_recall_limit(limit.max(1));
399 let (page, next) = self.store.list(cursor, limit)?;
400 let memories = page
401 .into_iter()
402 .filter_map(|fact| audited(fact, filter, include_internal))
403 .collect();
404 Ok((memories, next))
405 }
406}
407
408impl<E: Embedder, S: FactStore> MemoryService<E, S> {
409 /// Cheap "is this fact still stored?" probe gating [`Self::autograph`]'s
410 /// wiring: the same `store.get` existence check [`Self::forget`] and
411 /// [`Self::ensure_exists`] use. A store read error answers `false` —
412 /// autograph must never fabricate structure for a fact it cannot prove is
413 /// still there, and a missing (or unprovable) fact is a clean skip, never
414 /// an error on this deliberately-infallible path.
415 fn fact_exists(&self, fact_id: u64) -> bool {
416 matches!(self.store.get(fact_id), Ok(Some(_)))
417 }
418
419 /// Extract and orient one passage without writing any memory state.
420 ///
421 /// Kept separate from [`Self::store_extraction`] so the MCP durable-job
422 /// worker can persist the model output before the first graph write. A
423 /// restart after that boundary replays stable data instead of generating a
424 /// second, potentially different extraction.
425 pub(crate) fn extract_passage<X: Extractor>(
426 text: &str,
427 extractor: &X,
428 ) -> Result<crate::extract::Extraction, MemoryError> {
429 let text = text.trim();
430 if text.is_empty() {
431 return Err(MemoryError::EmptyFact);
432 }
433 let mut extraction = extractor.extract_graph(text)?;
434 crate::extract::orient_kinship(text, &mut extraction.relations);
435 Ok(extraction)
436 }
437
438 /// Shared resolver for both edge directions: skip the bipartite
439 /// scaffolding labels, resolve each edge's far end (`far_end` picks which
440 /// endpoint that is) to its stored content — at most
441 /// [`crate::limits::MAX_ENTITY_RELATIONS`] of them — and say whether the
442 /// result is a partial view (#1820).
443 ///
444 /// Truncated when either budget bit: the store's raw scan window
445 /// ([`crate::limits::MAX_ENTITY_SCAN_EDGES`]) left edges unread, or a
446 /// typed edge past the resolution cap was seen and dropped. Both cuts
447 /// are the same honest signal — "there is more than this view shows".
448 fn resolve_entity_relations(
449 &self,
450 scanned: crate::model::BoundedMemoryEdges,
451 far_end: impl Fn(&MemoryEdge) -> u64,
452 ) -> Result<(Vec<EntityRelation>, bool), MemoryError> {
453 let mut relations = Vec::new();
454 let mut truncated = scanned.truncated;
455 for edge in scanned.edges {
456 if edge.relation == MENTIONS_RELATION || edge.relation == ABOUT_RELATION {
457 continue;
458 }
459 if relations.len() >= crate::limits::MAX_ENTITY_RELATIONS {
460 truncated = true;
461 break;
462 }
463 let far = far_end(&edge);
464 let content = self.store.get(far)?.map(|(content, _)| content);
465 relations.push(EntityRelation {
466 predicate: edge.relation,
467 target_id: far,
468 target: content.unwrap_or_default(),
469 });
470 }
471 Ok((relations, truncated))
472 }
473
474 /// Merge each extracted attribute into its entity hub's `ColumnStore`
475 /// metadata, so `recall_where` can filter on it (`age >= 15`).
476 ///
477 /// The write goes through `update_metadata`, which **merges** rather than
478 /// replaces. That is the whole point: learning "Theo has a sister" after
479 /// "Theo is 15" must not erase the age. Re-storing the hub payload wholesale
480 /// would silently drop every attribute learned in an earlier session.
481 ///
482 /// Values keep the JSON type the extractor produced. `recall_where`
483 /// compares type-strictly with no coercion, so an age stored as `"15"`
484 /// would never match a numeric filter — no error, just a permanent silent
485 /// miss.
486 ///
487 /// Reserved keys are skipped: a model emitting `content` or a `_veles_`
488 /// key must never be able to overwrite the hub's own content or its
489 /// system flags.
490 fn wire_attributes(
491 &self,
492 attributes: &[ExtractedAttribute],
493 entity_ids: &mut HashMap<String, u64>,
494 ) -> Result<(), MemoryError> {
495 let mut per_entity: HashMap<String, Metadata> = HashMap::new();
496 for attribute in attributes {
497 if is_reserved_key(&attribute.key) {
498 continue;
499 }
500 per_entity
501 .entry(attribute.entity.clone())
502 .or_default()
503 .insert(attribute.key.clone(), attribute.value.clone());
504 }
505 for (entity, meta) in per_entity {
506 if meta.is_empty() {
507 continue;
508 }
509 reject_oversized_metadata(Some(&meta))?;
510 let hub_id = self.entity_hub(&entity, entity_ids)?;
511 self.store.update_metadata(hub_id, &meta)?;
512 }
513 Ok(())
514 }
515
516 /// Get or create the hub memory for a topic, caching its id per call. The
517 /// hub id is a deterministic function of the (normalized) topic, so the same
518 /// topic resolves to the same hub across calls — never a duplicate.
519 fn entity_hub(
520 &self,
521 entity: &str,
522 entity_ids: &mut HashMap<String, u64>,
523 ) -> Result<u64, MemoryError> {
524 let key = entity.trim().to_lowercase();
525 if let Some(&id) = entity_ids.get(&key) {
526 return Ok(id);
527 }
528 let id = self.remember_hub(&key)?;
529 entity_ids.insert(key, id);
530 Ok(id)
531 }
532
533 /// Idempotently store the hub memory for topic `key`. The id is salted so the
534 /// hub id space is disjoint from natural fact ids (no caller fact can collide
535 /// with or overwrite a hub), while the stored content stays human-readable.
536 /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
537 /// it; goes straight to [`Self::store_fact`] to bypass the caller-facing
538 /// reserved-key rejection in [`Self::remember`].
539 fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
540 let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
541 // An existing hub is left exactly as it is. Re-storing it would rewrite
542 // the payload to the bare hub marker and destroy every attribute merged
543 // onto it by an earlier call — learning "Theo has a sister" would erase
544 // "Theo is 15", because a later sentence re-resolves the same hub. The
545 // content is a pure function of `key`, so there is nothing to refresh;
546 // skipping also avoids re-embedding a hub on every single mention.
547 if self.store.get(id)?.is_some() {
548 return Ok(id);
549 }
550 let content = format!("Entity: {key}");
551 let embedding = self.embedder.embed(&content)?;
552 let mut meta = Map::new();
553 meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
554 // Topic hubs are graph anchors — they never expire.
555 self.store_fact(id, &content, &embedding, Some(&meta), None)?;
556 Ok(id)
557 }
558
559 /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
560 fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
561 if self.store.get(id)?.is_none() {
562 return Err(MemoryError::UnknownMemory(id));
563 }
564 Ok(())
565 }
566
567 /// Fail unless every link target already exists (keeps `remember` atomic).
568 fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
569 for link in links {
570 self.ensure_exists(link.target)?;
571 }
572 Ok(())
573 }
574
575 /// Store a fact with any combination of metadata and a durable TTL.
576 fn store_fact(
577 &self,
578 id: u64,
579 fact: &str,
580 embedding: &[f32],
581 metadata: Option<&Metadata>,
582 ttl_seconds: Option<u64>,
583 ) -> Result<(), MemoryError> {
584 match (metadata, ttl_seconds) {
585 (Some(meta), Some(ttl)) => {
586 // ONE write, not two. The previous `store_with_ttl` then
587 // `update_metadata` pair left the fact live and expiring
588 // between the calls: a short TTL could lapse in the gap and
589 // the metadata write then failed with `NotFound(... is
590 // expired ...)` — the caller got an error on a fact that was
591 // valid when they asked for it. Observed with a 1 s TTL on a
592 // loaded machine. Every TTL'd write takes this arm, since the
593 // auto date stamp means `metadata` is always `Some`.
594 self.store
595 .store_with_metadata_and_ttl(id, fact, embedding, meta, ttl)?;
596 }
597 (Some(meta), None) => self.store.store_with_metadata(id, fact, embedding, meta)?,
598 (None, Some(ttl)) => self.store.store_with_ttl(id, fact, embedding, ttl)?,
599 (None, None) => self.store.store(id, fact, embedding)?,
600 }
601 Ok(())
602 }
603
604 /// Recall up to `k` memories semantically similar to `query` (vector facet),
605 /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
606 /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
607 ///
608 /// A highly selective filter may return fewer than `k` hits even when more
609 /// matches exist — raise `k` for fuller coverage with a narrow filter.
610 ///
611 /// Entity hubs created by [`Self::remember_extracted`] are never returned:
612 /// they are internal graph scaffolding, not facts the caller stored.
613 ///
614 /// Each hit carries its caller metadata (`Recollection::metadata`, `None`
615 /// when the fact carries none) — store a date field (e.g. `occurred_at`)
616 /// and it round-trips here, so a caller can sort the result into a
617 /// chronological, date-stamped context without `recall_where`'s explicit
618 /// filters. One extra, single batched lookup covers every returned hit.
619 ///
620 /// # Errors
621 /// Returns [`MemoryError`] if the semantic query or the metadata lookup fails.
622 pub fn recall(
623 &self,
624 query: &str,
625 k: usize,
626 filter: Option<&Metadata>,
627 ) -> Result<Vec<Recollection>, MemoryError>
628 where
629 S: RecallStore,
630 {
631 let _generation = self.enter_generation();
632 self.recall_inner(query, k, filter)
633 }
634
635 fn recall_inner(
636 &self,
637 query: &str,
638 k: usize,
639 filter: Option<&Metadata>,
640 ) -> Result<Vec<Recollection>, MemoryError>
641 where
642 S: RecallStore,
643 {
644 let query = query.trim();
645 if query.is_empty() {
646 return Ok(Vec::new());
647 }
648 reject_reserved_keys(filter)?;
649 let embedding = self.embedder.embed(query)?;
650 let hits = self.search(&embedding, k, filter)?;
651 let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
652 // One raw batched payload lookup (reserved keys included), reused for
653 // BOTH the RL re-rank and the caller-facing metadata below — a single
654 // round trip, not one per concern.
655 let payloads = self.store.get_metadata_batch(&ids)?;
656 // RL Memory: re-order the recalled set by learned confidence. Facts
657 // that never received `feedback` keep their similarity order exactly.
658 #[cfg(feature = "persistence")]
659 let (hits, payloads) = Self::rl_rerank(hits, payloads);
660 Ok(hits
661 .into_iter()
662 .zip(payloads)
663 .map(|((id, score, content), payload)| Recollection {
664 id,
665 score,
666 content,
667 metadata: strip_reserved_keys(payload),
668 })
669 .collect())
670 }
671
672 /// Vector search for up to `k` ids, optionally narrowed by a metadata
673 /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
674 fn search(
675 &self,
676 embedding: &[f32],
677 k: usize,
678 filter: Option<&Metadata>,
679 ) -> Result<Vec<(u64, f32, String)>, MemoryError>
680 where
681 S: RecallStore,
682 {
683 match filter {
684 // An include filter already excludes hubs: a hub's payload
685 // carries only reserved keys (`content`, `_veles_hub`), and
686 // reserved keys are rejected from caller filters, so a non-empty
687 // filter can never match a hub. An EMPTY-but-present filter (`Some({})`, the
688 // natural `{}` idiom at the JS boundary) matches every payload —
689 // hubs included — so it must take the hub-excluding path below,
690 // exactly like an absent filter (same `Some({})` ≡ `None`
691 // convention as `recall_fused`'s graph-side `matches_filter`).
692 Some(meta) if !meta.is_empty() => self.store.query_filtered(embedding, k, meta, 0),
693 // Unfiltered recall must still drop entity hubs explicitly, or a hub
694 // like `Entity: rust` would rank for the topic and evict a real fact.
695 _ => self
696 .store
697 .query_excluding(embedding, k, &hub_exclude_filter()),
698 }
699 }
700
701 /// Fused recall: semantic `NEAR` search combined with structured
702 /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
703 /// not just the equality of [`Self::recall`]. One query spanning the vector
704 /// and column facets (e.g. "most similar facts **with `timestamp` in this
705 /// window**"), which a vector-only or equality-only recall cannot express.
706 ///
707 /// Filter *values* are bound as query parameters (never interpolated), so
708 /// they cannot inject; filter *field names* are validated to be plain
709 /// identifiers. Results come back in similarity order.
710 ///
711 /// **Caller memories only.** The store also holds internal scaffolding —
712 /// the entity hubs of [`Self::remember_extracted`] and the context
713 /// compiler's four artefact classes (stored sources, compilation events,
714 /// working contexts, and the per-project working-context index). They sit
715 /// in the same collection as caller facts and are excluded from every
716 /// result here, whatever the predicate.
717 ///
718 /// That exclusion is applied by the backend against
719 /// [`crate::storage::INTERNAL_MARKER_FIELDS`]; it is NOT a consequence of
720 /// those facts being unfilterable. A caller cannot write a filter naming a
721 /// reserved key, but `field ne value` MATCHES a fact that has no such
722 /// field at all — and scaffolding has none of the caller's columns, so
723 /// before #1737 every `ne` predicate returned all of it.
724 ///
725 /// # Errors
726 /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
727 /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
728 /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
729 pub fn recall_where(
730 &self,
731 query: &str,
732 k: usize,
733 filters: &[ColumnFilter],
734 ) -> Result<Vec<Recollection>, MemoryError>
735 where
736 S: ColumnStore + RecallStore,
737 {
738 let _generation = self.enter_generation();
739 let query = query.trim();
740 if query.is_empty() || k == 0 {
741 return Ok(Vec::new());
742 }
743 // No column predicates = a plain recall: route through [`Self::recall`]
744 // so entity hubs stay excluded — `query_columnar` with an empty filter
745 // set is a bare vector search that would rank internal `Entity:` hub
746 // scaffolding as results (same `[]` ≡ unfiltered convention as
747 // `search`'s empty-map handling).
748 if filters.is_empty() {
749 return self.recall_inner(query, k, None);
750 }
751 let embedding = self.embedder.embed(query)?;
752 self.store.query_columnar(&embedding, k, filters)
753 }
754}
755
756/// The metadata filter that excludes entity hubs from unfiltered recall and
757/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
758/// internal `_veles_hub` scaffolding never surfaces as a result.
759fn hub_exclude_filter() -> Metadata {
760 let mut exclude = Map::new();
761 exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
762 exclude
763}
764
765/// Reject caller-supplied metadata/filters that name a reserved key.
766fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
767 let Some(meta) = metadata else {
768 return Ok(());
769 };
770 for key in meta.keys() {
771 if is_reserved_key(key) {
772 return Err(MemoryError::ReservedKey(key.clone()));
773 }
774 }
775 Ok(())
776}
777
778/// Reject caller-supplied metadata over [`crate::limits::MAX_METADATA_BYTES`]
779/// — the `DoS` guard every `remember` path shares (see
780/// [`MemoryError::MetadataTooLarge`]).
781fn reject_oversized_metadata(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
782 let Some(meta) = metadata else {
783 return Ok(());
784 };
785 let bytes = crate::limits::metadata_bytes(meta);
786 if bytes > crate::limits::MAX_METADATA_BYTES {
787 return Err(MemoryError::MetadataTooLarge {
788 bytes,
789 max: crate::limits::MAX_METADATA_BYTES,
790 });
791 }
792 Ok(())
793}
794
795/// Normalise a TTL supplied as *configuration*: `Some(0)` (and `None`) mean
796/// "no TTL policy" — the fact is stored permanently. Any positive value is
797/// kept as-is.
798///
799/// Deliberately NOT applied to `remember`'s own `ttl_seconds` any more: an
800/// explicit per-call `0` is an intent about one fact ("expire it"), and
801/// silently turning that into "permanent" is the opposite (see
802/// [`reject_zero_ttl`]). A compile policy's `source_ttl_seconds`, on the
803/// other hand, is a knob about a whole server, where `0` reading as "no
804/// policy" is the ordinary, unsurprising meaning.
805///
806/// Gated on `context`: since `remember` stopped calling it, the compile
807/// policy in `context::memory_bridge` is its only caller, and a build
808/// without that feature saw dead code — which `-D warnings` turns into a
809/// failed build, not a warning.
810#[cfg(feature = "context")]
811pub(crate) fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
812 ttl_seconds.filter(|&seconds| seconds > 0)
813}
814
815/// The canonical form of an entity name: trimmed and lowercased, exactly as
816/// an extracted entity is keyed.
817///
818/// Public because a lookup MISS has to echo it too: an adapter that answered
819/// `name: ""` when nothing matched left a caller running several lookups
820/// unable to pair a response with its question (issue #1654). Hit and miss go
821/// through this one function, so the two can never drift.
822/// The audit's per-fact visibility policy, in one place: `None` skips the
823/// fact (internal scaffolding under the default view, or a metadata filter
824/// miss), `Some` carries what the caller may see — reserved keys stripped
825/// exactly as recall strips them, or the raw payload under
826/// `include_internal`.
827fn audited(
828 fact: crate::storage::RawListedFact,
829 filter: Option<&Metadata>,
830 include_internal: bool,
831) -> Option<crate::model::ListedMemory> {
832 if !include_internal && crate::storage::is_internal_scaffolding(&fact.payload) {
833 return None;
834 }
835 let matches = filter.is_none_or(|wanted| {
836 wanted
837 .iter()
838 .all(|(key, value)| fact.payload.get(key) == Some(value))
839 });
840 if !matches {
841 return None;
842 }
843 let metadata = if include_internal {
844 (!fact.payload.is_empty()).then_some(fact.payload)
845 } else {
846 strip_reserved_keys(Some(fact.payload))
847 };
848 Some(crate::model::ListedMemory {
849 id: fact.id,
850 content: fact.content,
851 metadata,
852 })
853}
854
855#[must_use]
856pub fn canonical_entity_name(name: &str) -> String {
857 name.trim().to_lowercase()
858}
859
860/// Refuse a fact that cannot be stored as written: blank, or past the size an
861/// embedding model still accepts.
862///
863/// The size check runs BEFORE [`MemoryService::write_fact`] calls the
864/// embedder, so an over-long fact is reported with its own size and the cap
865/// instead of whatever the backend says — issue #1654 saw `ollama embeddings
866/// call failed`, which names neither.
867fn validate_fact(fact: &str) -> Result<(), MemoryError> {
868 if fact.is_empty() {
869 return Err(MemoryError::EmptyFact);
870 }
871 validate_embeddable(fact)
872}
873
874/// Refuse a text past the size an embedding model still accepts.
875///
876/// Extracted from [`validate_fact`] so every path that embeds CALLER content
877/// answers to the same cap: `remember` refuses (this function), while the
878/// context bridge truncates via [`embeddable_prefix`] — but neither may hand
879/// the backend an oversized text and relay its raw failure, which is how
880/// issue #1654's `ollama embeddings call failed` (naming neither size nor
881/// cap) reached users.
882pub(crate) fn validate_embeddable(text: &str) -> Result<(), MemoryError> {
883 if text.len() > crate::limits::MAX_EMBEDDABLE_TEXT_BYTES {
884 return Err(MemoryError::FactTooLarge {
885 bytes: text.len(),
886 max: crate::limits::MAX_EMBEDDABLE_TEXT_BYTES,
887 });
888 }
889 Ok(())
890}
891
892/// The longest prefix of `text` that fits the embeddable cap without
893/// splitting a UTF-8 character.
894///
895/// For content that must be STORED whole but whose vector only serves
896/// similarity search (context sources: retrieval is hash-addressed, the
897/// vector is a ranking aid), truncating the *embedded* text is the correct
898/// trade — refusing would fail a legitimate compile, and a placeholder
899/// vector would remove the source from semantic recall entirely.
900///
901/// Gated on `context`: the compiler's source writer is its only caller, so a
902/// build without that feature sees dead code, which CI's `-D warnings`
903/// turns into a failed build. Same shape as `positive_ttl` — and only the
904/// per-feature ISOLATION loop catches it, never a feature combination.
905#[cfg(feature = "context")]
906pub(crate) fn embeddable_prefix(text: &str) -> &str {
907 let cap = crate::limits::MAX_EMBEDDABLE_TEXT_BYTES;
908 if text.len() <= cap {
909 return text;
910 }
911 let mut end = cap;
912 while end > 0 && !text.is_char_boundary(end) {
913 end -= 1;
914 }
915 &text[..end]
916}
917
918/// Refuse an explicit per-call TTL of `0`.
919///
920/// `0` used to be normalised to "no expiry", so a caller who meant "expire
921/// immediately" silently got a **permanent** fact — the opposite intent, with
922/// no signal (issue #1654). A TTL supplied as *configuration*
923/// (`McpServer::with_default_ttl`, a compile policy's `source_ttl_seconds`)
924/// still reads `0` as "no TTL policy": that is a default about a whole
925/// server, not an intent about one fact, and it is deliberately untouched.
926fn reject_zero_ttl(ttl_seconds: Option<u64>) -> Result<(), MemoryError> {
927 if ttl_seconds == Some(0) {
928 return Err(MemoryError::ZeroTtl);
929 }
930 Ok(())
931}
932
933/// Refuse a `remember` link that points the fact at itself.
934///
935/// The same rule [`MemoryService::relate`] enforces, applied to the other way
936/// a self-loop can be created: re-remembering existing content yields its
937/// existing id, so a caller CAN name that id as a link target. Without this
938/// the `relate` guard would only close half the door.
939fn reject_self_links(fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
940 if links.iter().any(|link| link.target == fact_id) {
941 return Err(MemoryError::SelfRelation(fact_id));
942 }
943 Ok(())
944}
945
946/// [`MemoryService::remember_with_ttl`]'s auto-date stamp: `metadata` with
947/// today's date added under [`AUTO_DATE_FIELD`], unless `metadata` already
948/// names that key (an explicit, possibly retroactive, caller value is never
949/// overwritten) or no clock is available ([`clock::today_ymd`] returns `None`
950/// on `wasm32-unknown-unknown`). Returns an owned map either way, `None` only
951/// when there is nothing to store at all (no caller metadata AND no clock).
952fn stamp_with_today(metadata: Option<&Metadata>) -> Option<Metadata> {
953 if metadata.is_some_and(|meta| meta.contains_key(AUTO_DATE_FIELD)) {
954 return metadata.cloned();
955 }
956 let Some(today) = clock::today_ymd() else {
957 return metadata.cloned();
958 };
959 let mut stamped = metadata.cloned().unwrap_or_default();
960 stamped.insert(AUTO_DATE_FIELD.to_owned(), Value::from(today));
961 Some(stamped)
962}
963
964/// Maximum byte length for a relation label (prevents oversized graph edge labels
965/// from reaching the storage layer).
966const MAX_RELATION_BYTES: usize = 512;
967
968/// Validate a caller-supplied relation label: non-empty, within the size cap, and
969/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
970/// Unicode. This prevents null bytes and control characters from reaching the
971/// storage layer while permitting natural-language labels like `"decided_in"` or
972/// `"is a friend of"`.
973fn validate_relation(label: &str) -> Result<(), MemoryError> {
974 if label.is_empty() {
975 return Err(MemoryError::InvalidRelation(
976 "relation label must not be empty".to_owned(),
977 ));
978 }
979 if label.len() > MAX_RELATION_BYTES {
980 return Err(MemoryError::InvalidRelation(format!(
981 "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
982 label.len()
983 )));
984 }
985 if label.chars().any(|c| c.is_ascii_control()) {
986 return Err(MemoryError::InvalidRelation(
987 "relation label must not contain ASCII control characters".to_owned(),
988 ));
989 }
990 Ok(())
991}