Skip to main content

mentedb_server/
handlers_memory.rs

1//! Memory feature endpoints: hybrid search, heads-up context, history, graph, conflicts, pins, profile, entities, batch store.
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::sync::Arc;
4
5use axum::extract::{Path, Query};
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use axum::routing::{get, post};
9use axum::{Extension, Json, Router};
10use mentedb::injection::{InjectionQuery, SelectionReason};
11use mentedb_core::edge::EdgeType;
12use mentedb_core::error::MenteError;
13use mentedb_core::types::{AgentId, MemoryId, SpaceId, UserId};
14use mentedb_core::{MemoryEdge, MemoryNode};
15use serde_json::{Value, json};
16use tracing::error;
17use uuid::Uuid;
18
19use crate::auth::AuthenticatedAgent;
20use crate::databases::Db;
21use crate::error::ApiError;
22use crate::handlers::{
23    check_body_agent, check_owner, embed_query, memory_id_param, memory_node_to_json,
24    parse_attributes, parse_edge_type, parse_embedding, parse_memory_type, parse_optional_uuid,
25    parse_uuid,
26};
27use crate::state::AppState;
28
29/// Tag the injection path treats as pinned: always injected, never ranked out.
30const PIN_TAG: &str = "scope:always";
31/// Tag marking a pin made by hand, which standing-rule pruning keeps.
32const MANUAL_TAG: &str = "source:manual";
33/// Tag prefix of entity memories.
34const ENTITY_TAG_PREFIX: &str = "entity:";
35
36/// Request size and result size limits for these endpoints.
37#[derive(Clone, Debug)]
38pub struct MemoryApiLimits {
39    /// Results returned by hybrid search when `k` is not given.
40    pub default_k: usize,
41    /// Most results one hybrid search may ask for.
42    pub max_k: usize,
43    /// Hops walked by the neighbors endpoint when `depth` is not given.
44    pub default_neighbor_depth: usize,
45    /// Most hops the neighbors endpoint walks.
46    pub max_neighbor_depth: usize,
47    /// Most memories the neighbors endpoint returns.
48    pub max_neighbors: usize,
49    /// Most predecessors the history endpoint walks.
50    pub max_history_depth: usize,
51    /// Items returned by list endpoints when `limit` is not given.
52    pub default_list: usize,
53    /// Most items one list request may ask for.
54    pub max_list: usize,
55    /// Most memories one batch store may carry.
56    pub max_batch: usize,
57    /// Ranked context items (beyond pins) when `max_items` is not given.
58    pub default_context_items: usize,
59    /// Most ranked context items one request may ask for.
60    pub max_context_items: usize,
61    /// Verbatim episodic items within the context when not given.
62    pub default_context_episodic: usize,
63}
64
65impl Default for MemoryApiLimits {
66    fn default() -> Self {
67        Self {
68            default_k: 10,
69            max_k: 200,
70            default_neighbor_depth: 1,
71            max_neighbor_depth: 4,
72            max_neighbors: 500,
73            max_history_depth: 64,
74            default_list: 50,
75            max_list: 1000,
76            max_batch: 1000,
77            default_context_items: 10,
78            max_context_items: 100,
79            default_context_episodic: 3,
80        }
81    }
82}
83
84type Limits = Extension<Arc<MemoryApiLimits>>;
85type Caller = Option<Extension<AuthenticatedAgent>>;
86
87/// Routes on one database, mounted under `/v1` and `/v1/db/{db}`.
88pub fn routes() -> Router<Arc<AppState>> {
89    routes_with_limits(MemoryApiLimits::default())
90}
91
92/// [`routes`] with custom limits.
93pub fn routes_with_limits(limits: MemoryApiLimits) -> Router<Arc<AppState>> {
94    Router::new()
95        .route("/search/hybrid", post(search_hybrid))
96        .route("/context", post(context))
97        .route("/memories/batch", post(store_batch))
98        .route("/memories/{id}/history", get(history))
99        .route("/memories/{id}/neighbors", get(neighbors))
100        .route("/memories/{id}/pin", post(pin).delete(unpin))
101        .route("/pins", get(list_pins))
102        .route("/conflicts", get(conflicts))
103        .route("/profile", get(profile))
104        .route("/entities", get(entities))
105        .route("/entities/{name}/memories", get(entity_memories))
106        .route("/recent", get(recent))
107        .layer(Extension(Arc::new(limits)))
108}
109
110// ---------------------------------------------------------------------------
111// POST /v1/search/hybrid
112// ---------------------------------------------------------------------------
113
114/// Hybrid recall: vector similarity fused with BM25 keyword matching, at a
115/// point in time, optionally scoped to an agent and a user.
116async fn search_hybrid(
117    agent: Caller,
118    Extension(limits): Limits,
119    db: Db,
120    Json(req): Json<Value>,
121) -> Result<impl IntoResponse, ApiError> {
122    let k = bounded(body_usize(&req, "k")?, limits.default_k, limits.max_k);
123    let query_text = body_str(&req, "query");
124    let embedding = query_embedding(&db, &req, query_text.as_deref()).await?;
125    let at = match req.get("as_of") {
126        Some(v) => v
127            .as_u64()
128            .ok_or_else(|| ApiError::BadRequest("'as_of' must be microseconds".into()))?,
129        None => now_micros(),
130    };
131    let time_range = match req.get("time_range") {
132        Some(v) => {
133            let pair = v
134                .as_array()
135                .filter(|a| a.len() == 2)
136                .and_then(|a| Some((a[0].as_u64()?, a[1].as_u64()?)))
137                .ok_or_else(|| {
138                    ApiError::BadRequest(
139                        "'time_range' must be [from, until] in microseconds".into(),
140                    )
141                })?;
142            Some(pair)
143        }
144        None => None,
145    };
146    let tags = string_array(&req, "tags")?;
147    let tags_or = match req.get("tags_mode").and_then(|v| v.as_str()) {
148        None | Some("and") => false,
149        Some("or") => true,
150        Some(other) => {
151            return Err(ApiError::BadRequest(format!(
152                "unknown tags_mode '{other}': use 'and' or 'or'"
153            )));
154        }
155    };
156    let agent_scope = scope_agent(&agent, parse_optional_uuid(&req, "agent_id")?)?;
157    let user_scope = parse_optional_uuid(&req, "user_id")?.map(UserId);
158    let project = body_str(&req, "project");
159    // Per request ranking options: an MMR diversity weight, the built in
160    // lexical reranker, and relevance first selection (choose the top k on
161    // relevance, then let decayed salience order just those).
162    let mmr_lambda = match req.get("mmr_lambda") {
163        None | Some(Value::Null) => None,
164        Some(v) => Some(
165            v.as_f64()
166                .filter(|l| (0.0..=1.0).contains(l))
167                .ok_or_else(|| {
168                    ApiError::BadRequest("'mmr_lambda' must be between 0 and 1".into())
169                })? as f32,
170        ),
171    };
172    let rerank = body_bool(&req, "rerank")?;
173    let relevance_first = body_bool(&req, "relevance_first")?;
174
175    let handle = db.handle.clone();
176    let results = tokio::task::spawn_blocking(move || {
177        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
178        let lexical = mentedb::reranker::LexicalReranker::default();
179        let opts = mentedb::HybridOptions {
180            mmr_lambda,
181            reranker: rerank.then_some(&lexical as &dyn mentedb::reranker::Reranker),
182            relevance_first,
183            record_access: true,
184        };
185        let hits = handle.recall_hybrid_scoped_with(
186            &embedding,
187            query_text.as_deref(),
188            k,
189            at,
190            (!tag_refs.is_empty()).then_some(tag_refs.as_slice()),
191            tags_or,
192            time_range,
193            agent_scope,
194            user_scope,
195            project.as_deref(),
196            &opts,
197        )?;
198        Ok::<_, MenteError>(
199            hits.into_iter()
200                .filter_map(|(id, score)| {
201                    let node = handle.get_memory(id).ok()?;
202                    Some(with_fields(&node, json!({ "score": score })))
203                })
204                .collect::<Vec<Value>>(),
205        )
206    })
207    .await
208    .map_err(task_failed)?
209    .map_err(|e| engine_error("hybrid search", e))?;
210
211    Ok(Json(json!({
212        "database": db.name,
213        "as_of": at,
214        "results": results,
215    })))
216}
217
218// ---------------------------------------------------------------------------
219// POST /v1/context
220// ---------------------------------------------------------------------------
221
222/// Heads-up context for a prompt: pinned memories plus the relevant ones,
223/// each with its score and the reason it was selected.
224async fn context(
225    agent: Caller,
226    Extension(limits): Limits,
227    db: Db,
228    Json(req): Json<Value>,
229) -> Result<impl IntoResponse, ApiError> {
230    let query_text = body_str(&req, "query");
231    let embedding = query_embedding(&db, &req, query_text.as_deref()).await?;
232    let max_items = bounded(
233        body_usize(&req, "max_items")?,
234        limits.default_context_items,
235        limits.max_context_items,
236    );
237    let max_episodic = body_usize(&req, "max_episodic")?
238        .unwrap_or(limits.default_context_episodic)
239        .min(max_items);
240    let session_id = body_str(&req, "session_id");
241    let project = body_str(&req, "project");
242    let exclude_ids = string_array(&req, "exclude_ids")?
243        .iter()
244        .map(|s| {
245            s.parse::<MemoryId>()
246                .map_err(|_| ApiError::BadRequest(format!("invalid id in 'exclude_ids': {s}")))
247        })
248        .collect::<Result<Vec<_>, _>>()?;
249    let agent_scope = scope_agent(&agent, parse_optional_uuid(&req, "agent_id")?)?;
250    let user_scope = parse_optional_uuid(&req, "user_id")?.map(UserId);
251
252    let handle = db.handle.clone();
253    let selected = tokio::task::spawn_blocking(move || {
254        let query = InjectionQuery {
255            embedding: &embedding,
256            query_text: query_text.as_deref(),
257            session_id: session_id.as_deref(),
258            exclude_ids: &exclude_ids,
259            max_items,
260            max_episodic,
261            agent_id: agent_scope,
262            user_id: user_scope,
263            current_project: project.as_deref(),
264        };
265        handle.recall_for_injection(&query)
266    })
267    .await
268    .map_err(task_failed)?
269    .map_err(|e| engine_error("context recall", e))?;
270
271    let memories: Vec<Value> = selected
272        .iter()
273        .map(|c| {
274            let reason = match c.reason {
275                SelectionReason::Pinned => "pinned",
276                SelectionReason::Relevant => "relevant",
277                SelectionReason::ModeRule => "mode_rule",
278            };
279            with_fields(&c.node, json!({ "score": c.score, "reason": reason }))
280        })
281        .collect();
282    Ok(Json(json!({ "database": db.name, "memories": memories })))
283}
284
285// ---------------------------------------------------------------------------
286// GET /v1/memories/{id}/history
287// ---------------------------------------------------------------------------
288
289/// The chain of older facts this memory replaced, newest predecessor first.
290async fn history(
291    agent: Caller,
292    Extension(limits): Limits,
293    db: Db,
294    Path(params): Path<HashMap<String, String>>,
295    Query(query): Query<HashMap<String, String>>,
296) -> Result<impl IntoResponse, ApiError> {
297    let id = memory_id_param(&params)?;
298    let depth = bounded(
299        query_usize(&query, "depth")?,
300        limits.max_history_depth,
301        limits.max_history_depth,
302    );
303    let node = load_owned(&agent, &db, id)?;
304    let handle = db.handle.clone();
305    let chain = tokio::task::spawn_blocking(move || handle.supersession_history(id, depth))
306        .await
307        .map_err(task_failed)?
308        .map_err(|e| engine_error("history", e))?;
309    Ok(Json(json!({
310        "memory": memory_node_to_json(&node),
311        "history": chain.iter().map(memory_node_to_json).collect::<Vec<_>>(),
312    })))
313}
314
315// ---------------------------------------------------------------------------
316// GET /v1/memories/{id}/neighbors
317// ---------------------------------------------------------------------------
318
319/// Memories within `depth` hops of one memory in the graph, with the edges
320/// between them. `edge_types` (comma separated) limits which edges are walked;
321/// `direction` is `out`, `in` or `both` (the default).
322async fn neighbors(
323    agent: Caller,
324    Extension(limits): Limits,
325    db: Db,
326    Path(params): Path<HashMap<String, String>>,
327    Query(query): Query<HashMap<String, String>>,
328) -> Result<impl IntoResponse, ApiError> {
329    let id = memory_id_param(&params)?;
330    let depth = bounded(
331        query_usize(&query, "depth")?,
332        limits.default_neighbor_depth,
333        limits.max_neighbor_depth,
334    );
335    let edge_types: Vec<EdgeType> = match query.get("edge_types").filter(|s| !s.is_empty()) {
336        Some(list) => list
337            .split(',')
338            .map(|s| {
339                parse_edge_type(s.trim())
340                    .ok_or_else(|| ApiError::BadRequest(format!("unknown edge_type: {s}")))
341            })
342            .collect::<Result<_, _>>()?,
343        None => Vec::new(),
344    };
345    let (outgoing, incoming) = match query.get("direction").map(String::as_str) {
346        None | Some("both") => (true, true),
347        Some("out") => (true, false),
348        Some("in") => (false, true),
349        Some(other) => {
350            return Err(ApiError::BadRequest(format!(
351                "unknown direction '{other}': use 'out', 'in' or 'both'"
352            )));
353        }
354    };
355    let max_nodes = limits.max_neighbors;
356    let owner = token_agent(&agent)?;
357    let center = load_owned(&agent, &db, id)?;
358
359    let handle = db.handle.clone();
360    let (nodes, edges) = tokio::task::spawn_blocking(move || {
361        let wanted = |t: EdgeType| edge_types.is_empty() || edge_types.contains(&t);
362        // Walk under the graph read lock, then release it before loading
363        // memories: the engine's lock order takes the page map first.
364        let (reached, edges) = {
365            let graph = handle.graph().graph();
366            let mut seen: HashSet<MemoryId> = HashSet::from([id]);
367            let mut reached: Vec<(MemoryId, usize)> = Vec::new();
368            let mut edges: Vec<MemoryEdge> = Vec::new();
369            let mut queue = VecDeque::from([(id, 0usize)]);
370            while let Some((node, hops)) = queue.pop_front() {
371                if hops >= depth {
372                    continue;
373                }
374                let mut steps = Vec::new();
375                if outgoing {
376                    for (to, e) in graph.outgoing(node) {
377                        steps.push((to, stored_edge(node, to, &e)));
378                    }
379                }
380                if incoming {
381                    for (from, e) in graph.incoming(node) {
382                        steps.push((from, stored_edge(from, node, &e)));
383                    }
384                }
385                for (next, edge) in steps {
386                    if !wanted(edge.edge_type) {
387                        continue;
388                    }
389                    if seen.contains(&next) {
390                        edges.push(edge);
391                        continue;
392                    }
393                    if reached.len() >= max_nodes {
394                        continue;
395                    }
396                    seen.insert(next);
397                    reached.push((next, hops + 1));
398                    edges.push(edge);
399                    queue.push_back((next, hops + 1));
400                }
401            }
402            (reached, edges)
403        };
404        let mut dedup = HashSet::new();
405        let edges: Vec<MemoryEdge> = edges
406            .into_iter()
407            .filter(|e| dedup.insert((e.source, e.target, e.edge_type)))
408            .collect();
409        let nodes: Vec<(MemoryNode, usize)> = reached
410            .into_iter()
411            .filter_map(|(nid, hops)| handle.get_memory(nid).ok().map(|n| (n, hops)))
412            .filter(|(n, _)| visible(owner, n))
413            .collect();
414        (nodes, edges)
415    })
416    .await
417    .map_err(task_failed)?;
418
419    let shown: HashSet<MemoryId> = nodes
420        .iter()
421        .map(|(n, _)| n.id)
422        .chain(std::iter::once(id))
423        .collect();
424    Ok(Json(json!({
425        "memory": memory_node_to_json(&center),
426        "depth": depth,
427        "neighbors": nodes
428            .iter()
429            .map(|(n, hops)| with_fields(n, json!({ "hops": hops })))
430            .collect::<Vec<_>>(),
431        "edges": edges
432            .iter()
433            .filter(|e| shown.contains(&e.source) && shown.contains(&e.target))
434            .map(edge_to_json)
435            .collect::<Vec<_>>(),
436    })))
437}
438
439// ---------------------------------------------------------------------------
440// GET /v1/conflicts
441// ---------------------------------------------------------------------------
442
443/// Contradiction and supersession edges, with both memories of each pair.
444async fn conflicts(
445    agent: Caller,
446    Extension(limits): Limits,
447    db: Db,
448    Query(query): Query<HashMap<String, String>>,
449) -> Result<impl IntoResponse, ApiError> {
450    let limit = list_limit(&query, &limits)?;
451    let owner = token_agent(&agent)?;
452    let handle = db.handle.clone();
453    let (counts, pairs) = tokio::task::spawn_blocking(move || {
454        let counts = handle.conflict_edge_counts();
455        let mut seen = HashSet::new();
456        let mut pairs = Vec::new();
457        for (source, target, edge_type, weight) in handle.conflict_edges() {
458            if pairs.len() >= limit {
459                break;
460            }
461            // A contradiction is undirected; show each pair once.
462            let key = if edge_type == EdgeType::Contradicts && target < source {
463                (target, source, edge_type)
464            } else {
465                (source, target, edge_type)
466            };
467            if !seen.insert(key) {
468                continue;
469            }
470            let (Ok(a), Ok(b)) = (handle.get_memory(source), handle.get_memory(target)) else {
471                continue;
472            };
473            if visible(owner, &a) && visible(owner, &b) {
474                pairs.push(json!({
475                    "edge_type": edge_type_name(edge_type),
476                    "weight": weight,
477                    "source": memory_node_to_json(&a),
478                    "target": memory_node_to_json(&b),
479                }));
480            }
481        }
482        (counts, pairs)
483    })
484    .await
485    .map_err(task_failed)?;
486    Ok(Json(json!({
487        "contradicts": counts.0,
488        "supersedes": counts.1,
489        "conflicts": pairs,
490    })))
491}
492
493// ---------------------------------------------------------------------------
494// Pins: GET /v1/pins, POST and DELETE /v1/memories/{id}/pin
495// ---------------------------------------------------------------------------
496
497/// Pinned memories, which every heads-up context includes.
498async fn list_pins(
499    agent: Caller,
500    Extension(limits): Limits,
501    db: Db,
502    Query(query): Query<HashMap<String, String>>,
503) -> Result<impl IntoResponse, ApiError> {
504    let limit = list_limit(&query, &limits)?;
505    let owner = token_agent(&agent)?;
506    let handle = db.handle.clone();
507    let pins = tokio::task::spawn_blocking(move || {
508        let ids = handle.memory_ids_with_tag(PIN_TAG);
509        newest_visible(&handle, ids, owner, limit)
510    })
511    .await
512    .map_err(task_failed)?;
513    Ok(Json(json!({ "pins": pins })))
514}
515
516/// Pin a memory so every heads-up context includes it.
517async fn pin(
518    agent: Caller,
519    db: Db,
520    Path(params): Path<HashMap<String, String>>,
521) -> Result<impl IntoResponse, ApiError> {
522    set_pin(agent, db, params, true).await
523}
524
525/// Remove a memory's pin. The memory itself is kept.
526async fn unpin(
527    agent: Caller,
528    db: Db,
529    Path(params): Path<HashMap<String, String>>,
530) -> Result<impl IntoResponse, ApiError> {
531    set_pin(agent, db, params, false).await
532}
533
534async fn set_pin(
535    agent: Caller,
536    db: Db,
537    params: HashMap<String, String>,
538    pinned: bool,
539) -> Result<Json<Value>, ApiError> {
540    let id = memory_id_param(&params)?;
541    let mut node = load_owned(&agent, &db, id)?;
542    if node.tags.iter().any(|t| t == PIN_TAG) != pinned {
543        if pinned {
544            node.tags.push(PIN_TAG.to_string());
545            if !node.tags.iter().any(|t| t == MANUAL_TAG) {
546                node.tags.push(MANUAL_TAG.to_string());
547            }
548        } else {
549            node.tags.retain(|t| t != PIN_TAG);
550        }
551        // Re-storing under the same id replaces the node and re-indexes its
552        // tags, the engine's own path for pinning and un-pinning.
553        let handle = db.handle.clone();
554        tokio::task::spawn_blocking(move || handle.store(node))
555            .await
556            .map_err(task_failed)?
557            .map_err(|e| engine_error("pin", e))?;
558    }
559    let now = db
560        .get_memory(id)
561        .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
562    let is_pinned = now.tags.iter().any(|t| t == PIN_TAG);
563    if is_pinned != pinned {
564        // The engine skips a re-store that duplicates another memory of the
565        // same owner, such as an identical standing rule already pinned.
566        return Err(ApiError::Conflict(format!(
567            "memory {id} duplicates another memory of the same owner; its pin was not changed"
568        )));
569    }
570    Ok(Json(with_fields(&now, json!({ "pinned": is_pinned }))))
571}
572
573// ---------------------------------------------------------------------------
574// GET /v1/profile
575// ---------------------------------------------------------------------------
576
577/// The user profile and the facts it is built from, for one owner pair.
578async fn profile(
579    agent: Caller,
580    db: Db,
581    Query(query): Query<HashMap<String, String>>,
582) -> Result<impl IntoResponse, ApiError> {
583    let user = query_uuid(&query, "user_id")?
584        .map(UserId)
585        .unwrap_or_else(UserId::nil);
586    let owner = scope_agent(&agent, query_uuid(&query, "agent_id")?)?.unwrap_or_else(AgentId::nil);
587    let handle = db.handle.clone();
588    let (profile, facts) = tokio::task::spawn_blocking(move || {
589        (
590            handle.user_profile_for(user, owner),
591            handle.profile_facts(user, owner),
592        )
593    })
594    .await
595    .map_err(task_failed)?;
596    Ok(Json(json!({
597        "user_id": user.to_string(),
598        "agent_id": owner.to_string(),
599        "profile": profile.as_ref().map(memory_node_to_json),
600        "facts": facts,
601    })))
602}
603
604// ---------------------------------------------------------------------------
605// GET /v1/entities, GET /v1/entities/{name}/memories
606// ---------------------------------------------------------------------------
607
608/// Entities named by entity memories, with how many memories carry each,
609/// plus the names the entity resolver knows.
610async fn entities(
611    agent: Caller,
612    Extension(limits): Limits,
613    db: Db,
614    Query(query): Query<HashMap<String, String>>,
615) -> Result<impl IntoResponse, ApiError> {
616    let limit = list_limit(&query, &limits)?;
617    let owner = token_agent(&agent)?;
618    let handle = db.handle.clone();
619    let (counts, known) = tokio::task::spawn_blocking(move || {
620        let mut counts: HashMap<String, usize> = HashMap::new();
621        for node in handle.entity_memories() {
622            if !visible(owner, &node) {
623                continue;
624            }
625            for tag in &node.tags {
626                if let Some(name) = tag.strip_prefix(ENTITY_TAG_PREFIX) {
627                    *counts.entry(name.to_string()).or_default() += 1;
628                }
629            }
630        }
631        (counts, handle.known_entities())
632    })
633    .await
634    .map_err(task_failed)?;
635    let mut names: Vec<(String, usize)> = counts.into_iter().collect();
636    names.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
637    names.truncate(limit);
638    Ok(Json(json!({
639        "entities": names
640            .iter()
641            .map(|(name, n)| json!({ "name": name, "memory_count": n }))
642            .collect::<Vec<_>>(),
643        // The resolver is shared by the whole database, so a caller bound to
644        // one agent does not see it.
645        "resolver": if owner.is_none() { known } else { Vec::new() },
646    })))
647}
648
649/// Memories tagged with an entity, after resolving the name to its canonical
650/// form.
651async fn entity_memories(
652    agent: Caller,
653    Extension(limits): Limits,
654    db: Db,
655    Path(params): Path<HashMap<String, String>>,
656    Query(query): Query<HashMap<String, String>>,
657) -> Result<impl IntoResponse, ApiError> {
658    let name = params
659        .get("name")
660        .map(|s| s.trim().to_string())
661        .filter(|s| !s.is_empty())
662        .ok_or_else(|| ApiError::BadRequest("missing entity name".into()))?;
663    let limit = list_limit(&query, &limits)?;
664    let owner = token_agent(&agent)?;
665    let resolved = db.resolve_entity(&name);
666    let mut tags = vec![format!("{ENTITY_TAG_PREFIX}{}", name.to_lowercase())];
667    let canonical_tag = format!(
668        "{ENTITY_TAG_PREFIX}{}",
669        resolved.canonical.to_lowercase().trim()
670    );
671    if !tags.contains(&canonical_tag) {
672        tags.push(canonical_tag);
673    }
674    let handle = db.handle.clone();
675    let memories = tokio::task::spawn_blocking(move || {
676        let mut ids: Vec<MemoryId> = Vec::new();
677        for tag in &tags {
678            for id in handle.memory_ids_with_tag(tag) {
679                if !ids.contains(&id) {
680                    ids.push(id);
681                }
682            }
683        }
684        newest_visible(&handle, ids, owner, limit)
685    })
686    .await
687    .map_err(task_failed)?;
688    Ok(Json(json!({
689        "name": name,
690        "canonical": resolved.canonical,
691        "confidence": resolved.confidence,
692        "source": format!("{:?}", resolved.source).to_lowercase(),
693        "memories": memories,
694    })))
695}
696
697// ---------------------------------------------------------------------------
698// POST /v1/memories/batch
699// ---------------------------------------------------------------------------
700
701/// Store many memories in one call. Every item is validated before any is
702/// stored. Items without an `embedding` are embedded by the server's embedder
703/// when one is configured.
704async fn store_batch(
705    agent: Caller,
706    Extension(limits): Limits,
707    db: Db,
708    Json(req): Json<Value>,
709) -> Result<impl IntoResponse, ApiError> {
710    let items = req
711        .get("memories")
712        .and_then(|v| v.as_array())
713        .ok_or_else(|| ApiError::BadRequest("missing 'memories' array".into()))?;
714    if items.is_empty() {
715        return Err(ApiError::BadRequest("'memories' is empty".into()));
716    }
717    if items.len() > limits.max_batch {
718        return Err(ApiError::BadRequest(format!(
719            "batch of {} exceeds the limit of {}",
720            items.len(),
721            limits.max_batch
722        )));
723    }
724    let now = now_micros();
725    let nodes = items
726        .iter()
727        .enumerate()
728        .map(|(i, item)| parse_node(&agent, item, now).map_err(|e| at_index(i, e)))
729        .collect::<Result<Vec<_>, _>>()?;
730
731    let handle = db.handle.clone();
732    let ids = tokio::task::spawn_blocking(move || {
733        let mut nodes = nodes;
734        let missing: Vec<usize> = nodes
735            .iter()
736            .enumerate()
737            .filter(|(_, n)| n.embedding.is_empty())
738            .map(|(i, _)| i)
739            .collect();
740        if !missing.is_empty() {
741            let texts: Vec<&str> = missing.iter().map(|&i| nodes[i].content.as_str()).collect();
742            if let Some(vectors) = handle.embed_batch(&texts)? {
743                for (i, v) in missing.into_iter().zip(vectors) {
744                    nodes[i].embedding = v;
745                }
746            }
747        }
748        handle.store_batch(nodes)
749    })
750    .await
751    .map_err(task_failed)?
752    .map_err(|e| match e {
753        MenteError::EmbeddingDimensionMismatch { .. } => ApiError::BadRequest(e.to_string()),
754        e => engine_error("batch store", e),
755    })?;
756
757    Ok((
758        StatusCode::CREATED,
759        Json(json!({
760            "ids": ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
761            "stored": ids.len(),
762        })),
763    ))
764}
765
766/// One batch item as a memory node, with the same fields as `POST /memories`.
767fn parse_node(agent: &Caller, req: &Value, now: u64) -> Result<MemoryNode, ApiError> {
768    let agent_id = AgentId(parse_uuid(req, "agent_id")?);
769    check_body_agent(agent, agent_id)?;
770    let memory_type = parse_memory_type(req)?;
771    let content = req
772        .get("content")
773        .and_then(|v| v.as_str())
774        .ok_or_else(|| ApiError::BadRequest("missing or invalid 'content'".into()))?
775        .to_string();
776    let space_id = match req.get("space_id") {
777        Some(v) => v
778            .as_str()
779            .and_then(|s| s.parse::<SpaceId>().ok())
780            .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
781        None => SpaceId::nil(),
782    };
783    let optional_u64 = |field: &str| match req.get(field) {
784        None | Some(Value::Null) => Ok(None),
785        Some(v) => v
786            .as_u64()
787            .map(Some)
788            .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must be microseconds"))),
789    };
790    Ok(MemoryNode {
791        id: MemoryId::new(),
792        agent_id,
793        user_id: parse_optional_uuid(req, "user_id")?
794            .map(UserId)
795            .unwrap_or_else(UserId::nil),
796        memory_type,
797        embedding: parse_embedding(req, false)?,
798        content,
799        created_at: now,
800        accessed_at: now,
801        access_count: 0,
802        salience: req.get("salience").and_then(|v| v.as_f64()).unwrap_or(0.5) as f32,
803        confidence: req
804            .get("confidence")
805            .and_then(|v| v.as_f64())
806            .unwrap_or(1.0) as f32,
807        space_id,
808        attributes: parse_attributes(req.get("attributes")),
809        tags: string_array(req, "tags")?,
810        valid_from: optional_u64("valid_from")?,
811        valid_until: optional_u64("valid_until")?,
812        context: None,
813    })
814}
815
816fn at_index(i: usize, e: ApiError) -> ApiError {
817    match e {
818        ApiError::BadRequest(m) => ApiError::BadRequest(format!("memories[{i}]: {m}")),
819        ApiError::Forbidden(m) => ApiError::Forbidden(format!("memories[{i}]: {m}")),
820        other => other,
821    }
822}
823
824// ---------------------------------------------------------------------------
825// GET /v1/recent
826// ---------------------------------------------------------------------------
827
828/// The newest memories, newest first.
829async fn recent(
830    agent: Caller,
831    Extension(limits): Limits,
832    db: Db,
833    Query(query): Query<HashMap<String, String>>,
834) -> Result<impl IntoResponse, ApiError> {
835    let limit = list_limit(&query, &limits)?;
836    let owner = token_agent(&agent)?;
837    let handle = db.handle.clone();
838    let memories = tokio::task::spawn_blocking(move || {
839        handle
840            .recall_recent(limit)
841            .iter()
842            .filter(|n| visible(owner, n))
843            .map(memory_node_to_json)
844            .collect::<Vec<_>>()
845    })
846    .await
847    .map_err(task_failed)?;
848    Ok(Json(json!({ "memories": memories })))
849}
850
851// ---------------------------------------------------------------------------
852// Helpers
853// ---------------------------------------------------------------------------
854
855fn now_micros() -> u64 {
856    std::time::SystemTime::now()
857        .duration_since(std::time::UNIX_EPOCH)
858        .unwrap_or_default()
859        .as_micros() as u64
860}
861
862fn task_failed(e: tokio::task::JoinError) -> ApiError {
863    ApiError::Internal(format!("engine task failed: {e}"))
864}
865
866fn engine_error(what: &str, e: MenteError) -> ApiError {
867    error!("{what} failed: {e}");
868    ApiError::Internal(format!("{what} failed: {e}"))
869}
870
871/// `value` when given (at least 1), else `default`, never above `max`.
872fn bounded(value: Option<usize>, default: usize, max: usize) -> usize {
873    value.unwrap_or(default).clamp(1, max.max(1))
874}
875
876fn list_limit(
877    query: &HashMap<String, String>,
878    limits: &MemoryApiLimits,
879) -> Result<usize, ApiError> {
880    Ok(bounded(
881        query_usize(query, "limit")?,
882        limits.default_list,
883        limits.max_list,
884    ))
885}
886
887fn query_usize(query: &HashMap<String, String>, field: &str) -> Result<Option<usize>, ApiError> {
888    query
889        .get(field)
890        .map(|s| {
891            s.parse()
892                .map_err(|_| ApiError::BadRequest(format!("'{field}' must be a whole number")))
893        })
894        .transpose()
895}
896
897fn query_uuid(query: &HashMap<String, String>, field: &str) -> Result<Option<Uuid>, ApiError> {
898    query
899        .get(field)
900        .filter(|s| !s.is_empty())
901        .map(|s| {
902            Uuid::parse_str(s)
903                .map_err(|_| ApiError::BadRequest(format!("invalid UUID for '{field}'")))
904        })
905        .transpose()
906}
907
908fn body_usize(req: &Value, field: &str) -> Result<Option<usize>, ApiError> {
909    req.get(field)
910        .map(|v| {
911            v.as_u64()
912                .map(|n| n as usize)
913                .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must be a whole number")))
914        })
915        .transpose()
916}
917
918fn body_bool(req: &Value, field: &str) -> Result<bool, ApiError> {
919    match req.get(field) {
920        None | Some(Value::Null) => Ok(false),
921        Some(Value::Bool(b)) => Ok(*b),
922        Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be a boolean"))),
923    }
924}
925
926fn body_str(req: &Value, field: &str) -> Option<String> {
927    req.get(field)
928        .and_then(|v| v.as_str())
929        .filter(|s| !s.is_empty())
930        .map(String::from)
931}
932
933fn string_array(req: &Value, field: &str) -> Result<Vec<String>, ApiError> {
934    match req.get(field) {
935        None | Some(Value::Null) => Ok(Vec::new()),
936        Some(Value::Array(items)) => items
937            .iter()
938            .map(|v| {
939                v.as_str()
940                    .map(String::from)
941                    .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must hold strings")))
942            })
943            .collect(),
944        Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be an array"))),
945    }
946}
947
948/// The query vector: the raw `embedding` when sent, else the `query` text
949/// embedded by the server.
950async fn query_embedding(db: &Db, req: &Value, text: Option<&str>) -> Result<Vec<f32>, ApiError> {
951    match (req.get("embedding"), text) {
952        (Some(_), _) => parse_embedding(req, true),
953        (None, Some(text)) => embed_query(db, text).await,
954        (None, None) => Err(ApiError::BadRequest(
955            "send 'query' text or an 'embedding' vector".into(),
956        )),
957    }
958}
959
960/// The agent the token is bound to, unless it is an admin token.
961fn token_agent(agent: &Caller) -> Result<Option<AgentId>, ApiError> {
962    match agent {
963        Some(Extension(authed)) if !authed.admin => authed.agent(),
964        _ => Ok(None),
965    }
966}
967
968/// The agent a read is scoped to: the requested one (which must match a
969/// token bound to an agent), else the token's agent, else none.
970fn scope_agent(agent: &Caller, requested: Option<Uuid>) -> Result<Option<AgentId>, ApiError> {
971    match requested {
972        Some(id) => {
973            check_body_agent(agent, AgentId(id))?;
974            Ok(Some(AgentId(id)))
975        }
976        None => token_agent(agent),
977    }
978}
979
980/// Whether a caller bound to `owner` may see `node`: its own memories and
981/// shared ones owned by no agent.
982fn visible(owner: Option<AgentId>, node: &MemoryNode) -> bool {
983    owner.is_none_or(|a| node.agent_id == a || node.agent_id.is_nil())
984}
985
986/// Load a memory and check the caller may use it.
987fn load_owned(agent: &Caller, db: &Db, id: MemoryId) -> Result<MemoryNode, ApiError> {
988    let node = db
989        .get_memory(id)
990        .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
991    check_owner(agent, &node)?;
992    Ok(node)
993}
994
995/// Load `ids`, keep what the caller may see, newest first, at most `limit`.
996fn newest_visible(
997    db: &mentedb::MenteDb,
998    ids: Vec<MemoryId>,
999    owner: Option<AgentId>,
1000    limit: usize,
1001) -> Vec<Value> {
1002    let mut nodes: Vec<MemoryNode> = ids
1003        .into_iter()
1004        .filter_map(|id| db.get_memory(id).ok())
1005        .filter(|n| visible(owner, n))
1006        .collect();
1007    nodes.sort_by_key(|n| std::cmp::Reverse(n.created_at));
1008    nodes.iter().take(limit).map(memory_node_to_json).collect()
1009}
1010
1011/// A memory as JSON with extra fields merged in.
1012fn with_fields(node: &MemoryNode, extra: Value) -> Value {
1013    let mut out = memory_node_to_json(node);
1014    if let (Value::Object(o), Value::Object(e)) = (&mut out, extra) {
1015        o.extend(e);
1016    }
1017    out
1018}
1019
1020fn stored_edge(source: MemoryId, target: MemoryId, e: &mentedb::graph::StoredEdge) -> MemoryEdge {
1021    MemoryEdge {
1022        source,
1023        target,
1024        edge_type: e.edge_type,
1025        weight: e.weight,
1026        created_at: e.created_at,
1027        valid_from: e.valid_from,
1028        valid_until: e.valid_until,
1029        label: e.label.clone(),
1030    }
1031}
1032
1033fn edge_type_name(t: EdgeType) -> String {
1034    format!("{t:?}").to_lowercase()
1035}
1036
1037fn edge_to_json(e: &MemoryEdge) -> Value {
1038    json!({
1039        "source": e.source.to_string(),
1040        "target": e.target.to_string(),
1041        "edge_type": edge_type_name(e.edge_type),
1042        "weight": e.weight,
1043        "created_at": e.created_at,
1044        "valid_from": e.valid_from,
1045        "valid_until": e.valid_until,
1046        "label": e.label,
1047    })
1048}