1use std::collections::HashMap;
4use std::sync::Arc;
5
6use crate::auth::AuthenticatedAgent;
7use axum::Extension;
8use axum::Json;
9use axum::extract::{Path, State};
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use mentedb_core::edge::EdgeType;
13use mentedb_core::memory::{AttributeValue, MemoryType};
14use mentedb_core::space::Permission;
15use mentedb_core::{MemoryEdge, MemoryNode};
16use mentedb_extraction::{
17 ExtractionConfig, ExtractionPipeline, HttpExtractionProvider,
18 map_extraction_type_to_memory_type,
19};
20use serde_json::{Map, Value, json};
21use tracing::error;
22use uuid::Uuid;
23
24use crate::error::ApiError;
25use crate::state::AppState;
26use mentedb_core::types::{AgentId, MemoryId, SpaceId, UserId};
27
28pub async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
34 let uptime = state.start_time.elapsed().as_secs();
35 Json(json!({
36 "status": "ok",
37 "version": "0.1.0",
38 "uptime_seconds": uptime,
39 }))
40}
41
42pub async fn stats(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, ApiError> {
48 let uptime = state.start_time.elapsed().as_secs();
49 let db = &*state.db;
50 let memory_count = db.memory_count();
51
52 Ok(Json(json!({
53 "memory_count": memory_count,
54 "uptime_seconds": uptime,
55 })))
56}
57
58pub async fn store_memory(
64 State(state): State<Arc<AppState>>,
65 agent: Option<Extension<AuthenticatedAgent>>,
66 Json(req): Json<Value>,
67) -> Result<impl IntoResponse, ApiError> {
68 let agent_id = AgentId(parse_uuid(&req, "agent_id")?);
69 if let Some(Extension(ref authed)) = agent {
70 let tid: AgentId = authed
71 .agent_id
72 .parse()
73 .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
74 if tid != agent_id {
75 return Err(ApiError::Forbidden(
76 "agent_id in request body does not match token".into(),
77 ));
78 }
79 }
80 let memory_type = parse_memory_type(&req)?;
81
82 let content = req
83 .get("content")
84 .and_then(|v| v.as_str())
85 .ok_or_else(|| ApiError::BadRequest("missing or invalid 'content'".into()))?
86 .to_string();
87
88 let embedding = parse_embedding(&req, false)?;
89
90 let tags: Vec<String> = match req.get("tags").and_then(|v| v.as_array()) {
91 Some(arr) => arr
92 .iter()
93 .filter_map(|v| v.as_str().map(String::from))
94 .collect(),
95 None => vec![],
96 };
97
98 let attributes = parse_attributes(req.get("attributes"));
99
100 let space_id = match req.get("space_id") {
101 Some(v) => v
102 .as_str()
103 .and_then(|s| s.parse::<SpaceId>().ok())
104 .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
105 None => SpaceId::nil(),
106 };
107
108 let now = std::time::SystemTime::now()
109 .duration_since(std::time::UNIX_EPOCH)
110 .unwrap_or_default()
111 .as_micros() as u64;
112
113 let id = MemoryId::new();
114
115 let salience = req.get("salience").and_then(|v| v.as_f64()).unwrap_or(0.5) as f32;
116 let confidence = req
117 .get("confidence")
118 .and_then(|v| v.as_f64())
119 .unwrap_or(1.0) as f32;
120
121 let node = MemoryNode {
122 id,
123 agent_id,
124 user_id: UserId::nil(),
125 memory_type,
126 embedding,
127 content: content.clone(),
128 created_at: now,
129 accessed_at: now,
130 access_count: 0,
131 salience,
132 confidence,
133 space_id,
134 attributes,
135 tags,
136 valid_from: req.get("valid_from").and_then(|v| v.as_u64()),
137 valid_until: req.get("valid_until").and_then(|v| v.as_u64()),
138 };
139
140 let db = &*state.db;
141 db.store(node).map_err(|e| {
142 error!("store failed: {e}");
143 ApiError::Internal(format!("store failed: {e}"))
144 })?;
145
146 if state.auto_extract
148 && state.extraction_config.is_some()
149 && looks_like_conversation(&content)
150 && let Some(tx) = &state.extraction_tx
151 {
152 let req = crate::extraction_queue::ExtractionRequest {
153 config: state.extraction_config.clone().unwrap(),
154 content: content.clone(),
155 agent_id,
156 space_id,
157 db: state.db.clone(),
158 };
159 if tx.try_send(req).is_err() {
160 tracing::warn!("extraction queue full, skipping auto-extract");
161 }
162 }
163
164 Ok((
165 StatusCode::CREATED,
166 Json(json!({ "id": id.to_string(), "status": "stored" })),
167 ))
168}
169
170pub async fn get_memory(
176 State(state): State<Arc<AppState>>,
177 agent: Option<Extension<AuthenticatedAgent>>,
178 Path(id_str): Path<String>,
179) -> Result<impl IntoResponse, ApiError> {
180 let id: MemoryId = id_str
181 .parse()
182 .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
183
184 let db = &*state.db;
185 let node = db
186 .get_memory(id)
187 .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
188
189 if let Some(Extension(ref authed)) = agent {
190 let tid: AgentId = authed
191 .agent_id
192 .parse()
193 .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
194 if node.agent_id != tid {
195 return Err(ApiError::Forbidden(
196 "memory belongs to a different agent".into(),
197 ));
198 }
199 }
200
201 Ok(Json(memory_node_to_json(&node)))
202}
203
204pub async fn forget_memory(
210 State(state): State<Arc<AppState>>,
211 agent: Option<Extension<AuthenticatedAgent>>,
212 Path(id_str): Path<String>,
213) -> Result<impl IntoResponse, ApiError> {
214 let id: MemoryId = id_str
215 .parse()
216 .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
217
218 let db = &*state.db;
219 if let Some(Extension(ref authed)) = agent {
220 let tid: AgentId = authed
221 .agent_id
222 .parse()
223 .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
224 let window = db.recall("RECALL memories LIMIT 1000").map_err(|e| {
225 error!("recall failed: {e}");
226 ApiError::Internal(format!("recall failed: {e}"))
227 })?;
228 let mut found = false;
229 for block in &window.blocks {
230 for scored in &block.memories {
231 if scored.memory.id == id {
232 if scored.memory.agent_id != tid {
233 return Err(ApiError::Forbidden(
234 "memory belongs to a different agent".into(),
235 ));
236 }
237 found = true;
238 break;
239 }
240 }
241 if found {
242 break;
243 }
244 }
245 if !found {
246 return Err(ApiError::NotFound(format!("memory {id} not found")));
247 }
248 }
249 db.forget(id).map_err(|e| {
250 error!("forget failed: {e}");
251 ApiError::Internal(format!("forget failed: {e}"))
252 })?;
253
254 Ok(Json(json!({ "status": "deleted" })))
255}
256
257pub async fn recall_memories(
263 State(state): State<Arc<AppState>>,
264 Json(req): Json<Value>,
265) -> Result<impl IntoResponse, ApiError> {
266 let query = req
267 .get("query")
268 .and_then(|v| v.as_str())
269 .ok_or_else(|| ApiError::BadRequest("missing 'query' field".into()))?;
270
271 let db = &*state.db;
272 let window = db.recall(query).map_err(|e| {
273 error!("recall failed: {e}");
274 ApiError::Internal(format!("recall failed: {e}"))
275 })?;
276
277 let memory_count: usize = window.blocks.iter().map(|b| b.memories.len()).sum();
278 Ok(Json(json!({
279 "context": window.format,
280 "total_tokens": window.total_tokens,
281 "memory_count": memory_count,
282 })))
283}
284
285pub async fn search_similar(
291 State(state): State<Arc<AppState>>,
292 Json(req): Json<Value>,
293) -> Result<impl IntoResponse, ApiError> {
294 let embedding = parse_embedding(&req, true)?;
295 let k = req.get("k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
296
297 let db = &*state.db;
298 let results = db.recall_similar(&embedding, k).map_err(|e| {
299 error!("search failed: {e}");
300 ApiError::Internal(format!("search failed: {e}"))
301 })?;
302
303 let items: Vec<Value> = results
304 .iter()
305 .map(|(id, score)| json!({ "id": id.to_string(), "score": score }))
306 .collect();
307
308 Ok(Json(json!({ "results": items })))
309}
310
311pub async fn create_edge(
317 State(state): State<Arc<AppState>>,
318 Json(req): Json<Value>,
319) -> Result<impl IntoResponse, ApiError> {
320 let source = MemoryId(parse_uuid(&req, "source")?);
321 let target = MemoryId(parse_uuid(&req, "target")?);
322
323 let edge_type_str = req
324 .get("edge_type")
325 .and_then(|v| v.as_str())
326 .ok_or_else(|| ApiError::BadRequest("missing 'edge_type'".into()))?;
327
328 let edge_type = parse_edge_type(edge_type_str)
329 .ok_or_else(|| ApiError::BadRequest(format!("unknown edge_type: {edge_type_str}")))?;
330
331 let weight = req.get("weight").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
332
333 let now = std::time::SystemTime::now()
334 .duration_since(std::time::UNIX_EPOCH)
335 .unwrap_or_default()
336 .as_micros() as u64;
337
338 let valid_from = req.get("valid_from").and_then(|v| v.as_u64());
339 let valid_until = req.get("valid_until").and_then(|v| v.as_u64());
340
341 let edge = MemoryEdge {
342 source,
343 target,
344 edge_type,
345 weight,
346 created_at: now,
347 valid_from,
348 valid_until,
349 label: req
350 .get("label")
351 .and_then(|v| v.as_str())
352 .map(|s| s.to_string()),
353 };
354
355 let db = &*state.db;
356 db.relate(edge).map_err(|e| {
357 error!("relate failed: {e}");
358 ApiError::Internal(format!("relate failed: {e}"))
359 })?;
360
361 Ok((StatusCode::CREATED, Json(json!({ "status": "created" }))))
362}
363
364pub async fn ingest_conversation(
370 State(state): State<Arc<AppState>>,
371 agent: Option<Extension<AuthenticatedAgent>>,
372 Json(req): Json<Value>,
373) -> Result<impl IntoResponse, ApiError> {
374 let agent_id = AgentId(parse_uuid(&req, "agent_id")?);
375 if let Some(Extension(ref authed)) = agent {
376 let tid: AgentId = authed
377 .agent_id
378 .parse()
379 .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
380 if tid != agent_id {
381 return Err(ApiError::Forbidden(
382 "agent_id in request body does not match token".into(),
383 ));
384 }
385 }
386
387 let extraction_config = state.extraction_config.as_ref().ok_or_else(|| {
388 ApiError::ServiceUnavailable(
389 "LLM provider not configured. Set MENTEDB_LLM_PROVIDER env var.".into(),
390 )
391 })?;
392
393 let conversation = req
394 .get("conversation")
395 .and_then(|v| v.as_str())
396 .ok_or_else(|| ApiError::BadRequest("missing or invalid 'conversation'".into()))?;
397
398 let space_id = match req.get("space_id") {
399 Some(v) => v
400 .as_str()
401 .and_then(|s| s.parse::<SpaceId>().ok())
402 .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
403 None => SpaceId::nil(),
404 };
405
406 let stats = run_extraction(
407 extraction_config,
408 conversation,
409 agent_id,
410 space_id,
411 &state.db,
412 )
413 .await?;
414
415 Ok((StatusCode::OK, Json(stats)))
416}
417
418async fn run_extraction(
420 config: &ExtractionConfig,
421 conversation: &str,
422 agent_id: AgentId,
423 space_id: SpaceId,
424 db: &mentedb::MenteDb,
425) -> Result<Value, ApiError> {
426 let provider = HttpExtractionProvider::new(config.clone()).map_err(|e| {
427 error!("extraction provider init failed: {e}");
428 ApiError::Internal(format!("extraction provider init failed: {e}"))
429 })?;
430
431 let pipeline = ExtractionPipeline::new(provider, config.clone());
432
433 let all_memories = pipeline
434 .extract_from_conversation(conversation)
435 .await
436 .map_err(|e| {
437 error!("extraction failed: {e}");
438 ApiError::Internal(format!("extraction failed: {e}"))
439 })?;
440
441 let total_extracted = all_memories.len();
442 let quality_passed = pipeline.filter_quality(&all_memories);
443 let rejected_low_quality = total_extracted - quality_passed.len();
444
445 let now = std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .unwrap_or_default()
448 .as_micros() as u64;
449
450 let mut stored_ids = Vec::new();
451 for memory in &quality_passed {
452 let memory_type = map_extraction_type_to_memory_type(&memory.memory_type);
453 let id = MemoryId::new();
454 let node = MemoryNode {
455 id,
456 agent_id,
457 user_id: UserId::nil(),
458 memory_type,
459 embedding: vec![],
460 content: memory.content.clone(),
461 created_at: now,
462 accessed_at: now,
463 access_count: 0,
464 salience: memory.confidence,
465 confidence: memory.confidence,
466 space_id,
467 attributes: std::collections::HashMap::new(),
468 tags: memory.tags.clone(),
469 valid_from: None,
470 valid_until: None,
471 };
472 match db.store(node) {
473 Ok(()) => stored_ids.push(id.to_string()),
474 Err(e) => {
475 tracing::warn!(error = %e, "failed to store extracted memory, skipping");
476 }
477 }
478 }
479
480 Ok(json!({
481 "memories_stored": stored_ids.len(),
482 "rejected_low_quality": rejected_low_quality,
483 "rejected_duplicate": 0,
484 "contradictions": 0,
485 "stored_ids": stored_ids,
486 }))
487}
488
489fn looks_like_conversation(content: &str) -> bool {
491 let lower = content.to_lowercase();
492 let patterns = ["user:", "assistant:", "human:", "ai:", "\nq:", "\na:"];
493 let matches = patterns.iter().filter(|p| lower.contains(*p)).count();
494 matches >= 2
495}
496
497fn parse_uuid(val: &Value, field: &str) -> Result<Uuid, ApiError> {
502 val.get(field)
503 .and_then(|v| v.as_str())
504 .ok_or_else(|| ApiError::BadRequest(format!("missing '{field}'")))
505 .and_then(|s| {
506 Uuid::parse_str(s)
507 .map_err(|_| ApiError::BadRequest(format!("invalid UUID for '{field}'")))
508 })
509}
510
511fn parse_memory_type(val: &Value) -> Result<MemoryType, ApiError> {
512 let s = val
513 .get("memory_type")
514 .and_then(|v| v.as_str())
515 .ok_or_else(|| ApiError::BadRequest("missing 'memory_type'".into()))?;
516
517 match s.to_lowercase().as_str() {
518 "episodic" => Ok(MemoryType::Episodic),
519 "semantic" => Ok(MemoryType::Semantic),
520 "procedural" => Ok(MemoryType::Procedural),
521 "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
522 "reasoning" => Ok(MemoryType::Reasoning),
523 "correction" => Ok(MemoryType::Correction),
524 _ => Err(ApiError::BadRequest(format!("unknown memory_type: {s}"))),
525 }
526}
527
528fn parse_edge_type(s: &str) -> Option<EdgeType> {
529 match s.to_lowercase().as_str() {
530 "caused" => Some(EdgeType::Caused),
531 "before" => Some(EdgeType::Before),
532 "related" => Some(EdgeType::Related),
533 "contradicts" => Some(EdgeType::Contradicts),
534 "supports" => Some(EdgeType::Supports),
535 "supersedes" => Some(EdgeType::Supersedes),
536 "derived" => Some(EdgeType::Derived),
537 "partof" | "part_of" => Some(EdgeType::PartOf),
538 _ => None,
539 }
540}
541
542fn parse_embedding(val: &Value, required: bool) -> Result<Vec<f32>, ApiError> {
543 match val.get("embedding").and_then(|v| v.as_array()) {
544 Some(arr) => {
545 let mut emb = Vec::with_capacity(arr.len());
546 for v in arr {
547 let f = v.as_f64().ok_or_else(|| {
548 ApiError::BadRequest("embedding values must be numbers".into())
549 })?;
550 emb.push(f as f32);
551 }
552 Ok(emb)
553 }
554 None if required => Err(ApiError::BadRequest("missing 'embedding' array".into())),
555 None => Ok(vec![]),
556 }
557}
558
559fn parse_attributes(val: Option<&Value>) -> HashMap<String, AttributeValue> {
560 let mut map = HashMap::new();
561 if let Some(Value::Object(obj)) = val {
562 for (k, v) in obj {
563 let av = match v {
564 Value::String(s) => AttributeValue::String(s.clone()),
565 Value::Number(n) => {
566 if let Some(i) = n.as_i64() {
567 AttributeValue::Integer(i)
568 } else if let Some(f) = n.as_f64() {
569 AttributeValue::Float(f)
570 } else {
571 continue;
572 }
573 }
574 Value::Bool(b) => AttributeValue::Boolean(*b),
575 _ => continue,
576 };
577 map.insert(k.clone(), av);
578 }
579 }
580 map
581}
582
583fn memory_node_to_json(node: &MemoryNode) -> Value {
584 let mut attrs = Map::new();
585 for (k, v) in &node.attributes {
586 let jv = match v {
587 AttributeValue::String(s) => Value::String(s.clone()),
588 AttributeValue::Integer(i) => json!(i),
589 AttributeValue::Float(f) => json!(f),
590 AttributeValue::Boolean(b) => json!(b),
591 AttributeValue::Bytes(b) => json!(b),
592 };
593 attrs.insert(k.clone(), jv);
594 }
595
596 let memory_type_str = format!("{:?}", node.memory_type).to_lowercase();
597
598 json!({
599 "id": node.id.to_string(),
600 "agent_id": node.agent_id.to_string(),
601 "memory_type": memory_type_str,
602 "embedding": node.embedding,
603 "content": node.content,
604 "created_at": node.created_at,
605 "accessed_at": node.accessed_at,
606 "access_count": node.access_count,
607 "salience": node.salience,
608 "confidence": node.confidence,
609 "space_id": node.space_id.to_string(),
610 "attributes": attrs,
611 "tags": node.tags,
612 })
613}
614
615fn parse_permission(s: &str) -> Result<Permission, ApiError> {
616 match s.to_lowercase().as_str() {
617 "read" => Ok(Permission::Read),
618 "write" => Ok(Permission::Write),
619 "readwrite" | "read_write" => Ok(Permission::ReadWrite),
620 "admin" => Ok(Permission::Admin),
621 _ => Err(ApiError::BadRequest(format!("unknown permission: {s}"))),
622 }
623}
624fn resolve_agent_id(
625 agent: &Option<Extension<AuthenticatedAgent>>,
626 body: Option<&Value>,
627) -> Result<AgentId, ApiError> {
628 if let Some(Extension(a)) = agent {
629 return a
630 .agent_id
631 .parse::<AgentId>()
632 .map_err(|_| ApiError::Unauthorized("invalid agent_id in token".into()));
633 }
634 if let Some(val) = body {
635 return Ok(AgentId(parse_uuid(val, "agent_id")?));
636 }
637 Err(ApiError::Unauthorized("no agent_id available".into()))
638}
639fn is_admin_token(agent: &Option<Extension<AuthenticatedAgent>>) -> bool {
640 matches!(agent, Some(Extension(a)) if a.admin)
641}
642pub async fn create_space(
643 State(state): State<Arc<AppState>>,
644 agent: Option<Extension<AuthenticatedAgent>>,
645 Json(req): Json<Value>,
646) -> Result<impl IntoResponse, ApiError> {
647 let agent_id = resolve_agent_id(&agent, Some(&req))?;
648 let name = req
649 .get("name")
650 .and_then(|v| v.as_str())
651 .ok_or_else(|| ApiError::BadRequest("missing 'name' field".into()))?;
652 let mut spaces = state.spaces.write().await;
653 let space = spaces.create_space(name, agent_id);
654 Ok((
655 StatusCode::CREATED,
656 Json(
657 json!({"id": space.id.to_string(), "name": space.name, "owner": space.owner.to_string()}),
658 ),
659 ))
660}
661pub async fn list_spaces(
662 State(state): State<Arc<AppState>>,
663 agent: Option<Extension<AuthenticatedAgent>>,
664) -> Result<impl IntoResponse, ApiError> {
665 let agent_id = resolve_agent_id(&agent, None)?;
666 let spaces = state.spaces.read().await;
667 let list: Vec<Value> = spaces
668 .list_spaces_for_agent(agent_id)
669 .iter()
670 .map(|s| json!({"id": s.id.to_string(), "name": s.name, "owner": s.owner.to_string()}))
671 .collect();
672 Ok(Json(json!({"spaces": list})))
673}
674pub async fn grant_space_access(
675 State(state): State<Arc<AppState>>,
676 agent: Option<Extension<AuthenticatedAgent>>,
677 Path(space_id_str): Path<String>,
678 Json(req): Json<Value>,
679) -> Result<impl IntoResponse, ApiError> {
680 let space_id: SpaceId = space_id_str
681 .parse()
682 .map_err(|_| ApiError::BadRequest("invalid space ID".into()))?;
683 let caller_id = resolve_agent_id(&agent, None)?;
684 let target_agent = AgentId(parse_uuid(&req, "agent_id")?);
685 let perm_str = req
686 .get("permission")
687 .and_then(|v| v.as_str())
688 .ok_or_else(|| ApiError::BadRequest("missing 'permission' field".into()))?;
689 let perm = parse_permission(perm_str)?;
690 let mut spaces = state.spaces.write().await;
691 if !is_admin_token(&agent) && !spaces.check_access(space_id, caller_id, Permission::Admin) {
692 return Err(ApiError::Forbidden(
693 "only space owner or admin can grant access".into(),
694 ));
695 }
696 spaces.grant_access(space_id, target_agent, perm);
697 Ok(Json(json!({"status": "granted"})))
698}