1use 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
29const PIN_TAG: &str = "scope:always";
31const MANUAL_TAG: &str = "source:manual";
33const ENTITY_TAG_PREFIX: &str = "entity:";
35
36#[derive(Clone, Debug)]
38pub struct MemoryApiLimits {
39 pub default_k: usize,
41 pub max_k: usize,
43 pub default_neighbor_depth: usize,
45 pub max_neighbor_depth: usize,
47 pub max_neighbors: usize,
49 pub max_history_depth: usize,
51 pub default_list: usize,
53 pub max_list: usize,
55 pub max_batch: usize,
57 pub default_context_items: usize,
59 pub max_context_items: usize,
61 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
87pub fn routes() -> Router<Arc<AppState>> {
89 routes_with_limits(MemoryApiLimits::default())
90}
91
92pub 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
110async 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 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
218async 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
285async 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(¶ms)?;
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
315async 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(¶ms)?;
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 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(¢er),
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
439async 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 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
493async 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
516async 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
525async 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(¶ms)?;
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 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 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
573async 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
604async 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 "resolver": if owner.is_none() { known } else { Vec::new() },
646 })))
647}
648
649async 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
697async 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
766fn 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
824async 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
851fn 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
871fn 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
948async 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
960fn 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
968fn 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
980fn visible(owner: Option<AgentId>, node: &MemoryNode) -> bool {
983 owner.is_none_or(|a| node.agent_id == a || node.agent_id.is_nil())
984}
985
986fn 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
995fn 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
1011fn 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}