Skip to main content

mentedb_server/
handlers.rs

1//! Axum handler functions for the MenteDB REST API.
2
3use 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};
27
28// ---------------------------------------------------------------------------
29// GET /v1/health
30// ---------------------------------------------------------------------------
31
32/// Health check endpoint. Returns server uptime and status.
33pub 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
42// ---------------------------------------------------------------------------
43// GET /v1/stats
44// ---------------------------------------------------------------------------
45
46/// Returns database statistics (memory count, index size, etc.).
47pub 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
58// ---------------------------------------------------------------------------
59// POST /v1/memories
60// ---------------------------------------------------------------------------
61
62/// Stores a new memory node in the database.
63pub 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        memory_type,
125        embedding,
126        content: content.clone(),
127        created_at: now,
128        accessed_at: now,
129        access_count: 0,
130        salience,
131        confidence,
132        space_id,
133        attributes,
134        tags,
135        valid_from: req.get("valid_from").and_then(|v| v.as_u64()),
136        valid_until: req.get("valid_until").and_then(|v| v.as_u64()),
137    };
138
139    let db = &*state.db;
140    db.store(node).map_err(|e| {
141        error!("store failed: {e}");
142        ApiError::Internal(format!("store failed: {e}"))
143    })?;
144
145    // Auto-extract: if enabled and content looks like a conversation, queue extraction
146    if state.auto_extract
147        && state.extraction_config.is_some()
148        && looks_like_conversation(&content)
149        && let Some(tx) = &state.extraction_tx
150    {
151        let req = crate::extraction_queue::ExtractionRequest {
152            config: state.extraction_config.clone().unwrap(),
153            content: content.clone(),
154            agent_id,
155            space_id,
156            db: state.db.clone(),
157        };
158        if tx.try_send(req).is_err() {
159            tracing::warn!("extraction queue full, skipping auto-extract");
160        }
161    }
162
163    Ok((
164        StatusCode::CREATED,
165        Json(json!({ "id": id.to_string(), "status": "stored" })),
166    ))
167}
168
169// ---------------------------------------------------------------------------
170// GET /v1/memories/:id
171// ---------------------------------------------------------------------------
172
173/// Retrieves a single memory by its UUID.
174pub async fn get_memory(
175    State(state): State<Arc<AppState>>,
176    agent: Option<Extension<AuthenticatedAgent>>,
177    Path(id_str): Path<String>,
178) -> Result<impl IntoResponse, ApiError> {
179    let id: MemoryId = id_str
180        .parse()
181        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
182
183    let db = &*state.db;
184    let node = db
185        .get_memory(id)
186        .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
187
188    if let Some(Extension(ref authed)) = agent {
189        let tid: AgentId = authed
190            .agent_id
191            .parse()
192            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
193        if node.agent_id != tid {
194            return Err(ApiError::Forbidden(
195                "memory belongs to a different agent".into(),
196            ));
197        }
198    }
199
200    Ok(Json(memory_node_to_json(&node)))
201}
202
203// ---------------------------------------------------------------------------
204// DELETE /v1/memories/:id
205// ---------------------------------------------------------------------------
206
207/// Deletes a memory from the database.
208pub async fn forget_memory(
209    State(state): State<Arc<AppState>>,
210    agent: Option<Extension<AuthenticatedAgent>>,
211    Path(id_str): Path<String>,
212) -> Result<impl IntoResponse, ApiError> {
213    let id: MemoryId = id_str
214        .parse()
215        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
216
217    let db = &*state.db;
218    if let Some(Extension(ref authed)) = agent {
219        let tid: AgentId = authed
220            .agent_id
221            .parse()
222            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
223        let window = db.recall("RECALL memories LIMIT 1000").map_err(|e| {
224            error!("recall failed: {e}");
225            ApiError::Internal(format!("recall failed: {e}"))
226        })?;
227        let mut found = false;
228        for block in &window.blocks {
229            for scored in &block.memories {
230                if scored.memory.id == id {
231                    if scored.memory.agent_id != tid {
232                        return Err(ApiError::Forbidden(
233                            "memory belongs to a different agent".into(),
234                        ));
235                    }
236                    found = true;
237                    break;
238                }
239            }
240            if found {
241                break;
242            }
243        }
244        if !found {
245            return Err(ApiError::NotFound(format!("memory {id} not found")));
246        }
247    }
248    db.forget(id).map_err(|e| {
249        error!("forget failed: {e}");
250        ApiError::Internal(format!("forget failed: {e}"))
251    })?;
252
253    Ok(Json(json!({ "status": "deleted" })))
254}
255
256// ---------------------------------------------------------------------------
257// POST /v1/recall
258// ---------------------------------------------------------------------------
259
260/// Recalls memories matching an MQL query string.
261pub async fn recall_memories(
262    State(state): State<Arc<AppState>>,
263    Json(req): Json<Value>,
264) -> Result<impl IntoResponse, ApiError> {
265    let query = req
266        .get("query")
267        .and_then(|v| v.as_str())
268        .ok_or_else(|| ApiError::BadRequest("missing 'query' field".into()))?;
269
270    let db = &*state.db;
271    let window = db.recall(query).map_err(|e| {
272        error!("recall failed: {e}");
273        ApiError::Internal(format!("recall failed: {e}"))
274    })?;
275
276    let memory_count: usize = window.blocks.iter().map(|b| b.memories.len()).sum();
277    Ok(Json(json!({
278        "context": window.format,
279        "total_tokens": window.total_tokens,
280        "memory_count": memory_count,
281    })))
282}
283
284// ---------------------------------------------------------------------------
285// POST /v1/search
286// ---------------------------------------------------------------------------
287
288/// Searches for memories similar to a given embedding vector.
289pub async fn search_similar(
290    State(state): State<Arc<AppState>>,
291    Json(req): Json<Value>,
292) -> Result<impl IntoResponse, ApiError> {
293    let embedding = parse_embedding(&req, true)?;
294    let k = req.get("k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
295
296    let db = &*state.db;
297    let results = db.recall_similar(&embedding, k).map_err(|e| {
298        error!("search failed: {e}");
299        ApiError::Internal(format!("search failed: {e}"))
300    })?;
301
302    let items: Vec<Value> = results
303        .iter()
304        .map(|(id, score)| json!({ "id": id.to_string(), "score": score }))
305        .collect();
306
307    Ok(Json(json!({ "results": items })))
308}
309
310// ---------------------------------------------------------------------------
311// POST /v1/edges
312// ---------------------------------------------------------------------------
313
314/// Creates a typed, weighted edge between two memories.
315pub async fn create_edge(
316    State(state): State<Arc<AppState>>,
317    Json(req): Json<Value>,
318) -> Result<impl IntoResponse, ApiError> {
319    let source = MemoryId(parse_uuid(&req, "source")?);
320    let target = MemoryId(parse_uuid(&req, "target")?);
321
322    let edge_type_str = req
323        .get("edge_type")
324        .and_then(|v| v.as_str())
325        .ok_or_else(|| ApiError::BadRequest("missing 'edge_type'".into()))?;
326
327    let edge_type = parse_edge_type(edge_type_str)
328        .ok_or_else(|| ApiError::BadRequest(format!("unknown edge_type: {edge_type_str}")))?;
329
330    let weight = req.get("weight").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
331
332    let now = std::time::SystemTime::now()
333        .duration_since(std::time::UNIX_EPOCH)
334        .unwrap_or_default()
335        .as_micros() as u64;
336
337    let valid_from = req.get("valid_from").and_then(|v| v.as_u64());
338    let valid_until = req.get("valid_until").and_then(|v| v.as_u64());
339
340    let edge = MemoryEdge {
341        source,
342        target,
343        edge_type,
344        weight,
345        created_at: now,
346        valid_from,
347        valid_until,
348        label: req
349            .get("label")
350            .and_then(|v| v.as_str())
351            .map(|s| s.to_string()),
352    };
353
354    let db = &*state.db;
355    db.relate(edge).map_err(|e| {
356        error!("relate failed: {e}");
357        ApiError::Internal(format!("relate failed: {e}"))
358    })?;
359
360    Ok((StatusCode::CREATED, Json(json!({ "status": "created" }))))
361}
362
363// ---------------------------------------------------------------------------
364// POST /v1/ingest
365// ---------------------------------------------------------------------------
366
367/// Ingest a conversation: extract memories via LLM, filter, and store them.
368pub async fn ingest_conversation(
369    State(state): State<Arc<AppState>>,
370    agent: Option<Extension<AuthenticatedAgent>>,
371    Json(req): Json<Value>,
372) -> Result<impl IntoResponse, ApiError> {
373    let agent_id = AgentId(parse_uuid(&req, "agent_id")?);
374    if let Some(Extension(ref authed)) = agent {
375        let tid: AgentId = authed
376            .agent_id
377            .parse()
378            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
379        if tid != agent_id {
380            return Err(ApiError::Forbidden(
381                "agent_id in request body does not match token".into(),
382            ));
383        }
384    }
385
386    let extraction_config = state.extraction_config.as_ref().ok_or_else(|| {
387        ApiError::ServiceUnavailable(
388            "LLM provider not configured. Set MENTEDB_LLM_PROVIDER env var.".into(),
389        )
390    })?;
391
392    let conversation = req
393        .get("conversation")
394        .and_then(|v| v.as_str())
395        .ok_or_else(|| ApiError::BadRequest("missing or invalid 'conversation'".into()))?;
396
397    let space_id = match req.get("space_id") {
398        Some(v) => v
399            .as_str()
400            .and_then(|s| s.parse::<SpaceId>().ok())
401            .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
402        None => SpaceId::nil(),
403    };
404
405    let stats = run_extraction(
406        extraction_config,
407        conversation,
408        agent_id,
409        space_id,
410        &state.db,
411    )
412    .await?;
413
414    Ok((StatusCode::OK, Json(stats)))
415}
416
417/// Run the extraction pipeline and store accepted memories. Returns JSON stats.
418async fn run_extraction(
419    config: &ExtractionConfig,
420    conversation: &str,
421    agent_id: AgentId,
422    space_id: SpaceId,
423    db: &mentedb::MenteDb,
424) -> Result<Value, ApiError> {
425    let provider = HttpExtractionProvider::new(config.clone()).map_err(|e| {
426        error!("extraction provider init failed: {e}");
427        ApiError::Internal(format!("extraction provider init failed: {e}"))
428    })?;
429
430    let pipeline = ExtractionPipeline::new(provider, config.clone());
431
432    let all_memories = pipeline
433        .extract_from_conversation(conversation)
434        .await
435        .map_err(|e| {
436            error!("extraction failed: {e}");
437            ApiError::Internal(format!("extraction failed: {e}"))
438        })?;
439
440    let total_extracted = all_memories.len();
441    let quality_passed = pipeline.filter_quality(&all_memories);
442    let rejected_low_quality = total_extracted - quality_passed.len();
443
444    let now = std::time::SystemTime::now()
445        .duration_since(std::time::UNIX_EPOCH)
446        .unwrap_or_default()
447        .as_micros() as u64;
448
449    let mut stored_ids = Vec::new();
450    for memory in &quality_passed {
451        let memory_type = map_extraction_type_to_memory_type(&memory.memory_type);
452        let id = MemoryId::new();
453        let node = MemoryNode {
454            id,
455            agent_id,
456            memory_type,
457            embedding: vec![],
458            content: memory.content.clone(),
459            created_at: now,
460            accessed_at: now,
461            access_count: 0,
462            salience: memory.confidence,
463            confidence: memory.confidence,
464            space_id,
465            attributes: std::collections::HashMap::new(),
466            tags: memory.tags.clone(),
467            valid_from: None,
468            valid_until: None,
469        };
470        match db.store(node) {
471            Ok(()) => stored_ids.push(id.to_string()),
472            Err(e) => {
473                tracing::warn!(error = %e, "failed to store extracted memory, skipping");
474            }
475        }
476    }
477
478    Ok(json!({
479        "memories_stored": stored_ids.len(),
480        "rejected_low_quality": rejected_low_quality,
481        "rejected_duplicate": 0,
482        "contradictions": 0,
483        "stored_ids": stored_ids,
484    }))
485}
486
487/// Heuristic: does this content look like a multi-turn conversation?
488fn looks_like_conversation(content: &str) -> bool {
489    let lower = content.to_lowercase();
490    let patterns = ["user:", "assistant:", "human:", "ai:", "\nq:", "\na:"];
491    let matches = patterns.iter().filter(|p| lower.contains(*p)).count();
492    matches >= 2
493}
494
495// ---------------------------------------------------------------------------
496// Helpers
497// ---------------------------------------------------------------------------
498
499fn parse_uuid(val: &Value, field: &str) -> Result<Uuid, ApiError> {
500    val.get(field)
501        .and_then(|v| v.as_str())
502        .ok_or_else(|| ApiError::BadRequest(format!("missing '{field}'")))
503        .and_then(|s| {
504            Uuid::parse_str(s)
505                .map_err(|_| ApiError::BadRequest(format!("invalid UUID for '{field}'")))
506        })
507}
508
509fn parse_memory_type(val: &Value) -> Result<MemoryType, ApiError> {
510    let s = val
511        .get("memory_type")
512        .and_then(|v| v.as_str())
513        .ok_or_else(|| ApiError::BadRequest("missing 'memory_type'".into()))?;
514
515    match s.to_lowercase().as_str() {
516        "episodic" => Ok(MemoryType::Episodic),
517        "semantic" => Ok(MemoryType::Semantic),
518        "procedural" => Ok(MemoryType::Procedural),
519        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
520        "reasoning" => Ok(MemoryType::Reasoning),
521        "correction" => Ok(MemoryType::Correction),
522        _ => Err(ApiError::BadRequest(format!("unknown memory_type: {s}"))),
523    }
524}
525
526fn parse_edge_type(s: &str) -> Option<EdgeType> {
527    match s.to_lowercase().as_str() {
528        "caused" => Some(EdgeType::Caused),
529        "before" => Some(EdgeType::Before),
530        "related" => Some(EdgeType::Related),
531        "contradicts" => Some(EdgeType::Contradicts),
532        "supports" => Some(EdgeType::Supports),
533        "supersedes" => Some(EdgeType::Supersedes),
534        "derived" => Some(EdgeType::Derived),
535        "partof" | "part_of" => Some(EdgeType::PartOf),
536        _ => None,
537    }
538}
539
540fn parse_embedding(val: &Value, required: bool) -> Result<Vec<f32>, ApiError> {
541    match val.get("embedding").and_then(|v| v.as_array()) {
542        Some(arr) => {
543            let mut emb = Vec::with_capacity(arr.len());
544            for v in arr {
545                let f = v.as_f64().ok_or_else(|| {
546                    ApiError::BadRequest("embedding values must be numbers".into())
547                })?;
548                emb.push(f as f32);
549            }
550            Ok(emb)
551        }
552        None if required => Err(ApiError::BadRequest("missing 'embedding' array".into())),
553        None => Ok(vec![]),
554    }
555}
556
557fn parse_attributes(val: Option<&Value>) -> HashMap<String, AttributeValue> {
558    let mut map = HashMap::new();
559    if let Some(Value::Object(obj)) = val {
560        for (k, v) in obj {
561            let av = match v {
562                Value::String(s) => AttributeValue::String(s.clone()),
563                Value::Number(n) => {
564                    if let Some(i) = n.as_i64() {
565                        AttributeValue::Integer(i)
566                    } else if let Some(f) = n.as_f64() {
567                        AttributeValue::Float(f)
568                    } else {
569                        continue;
570                    }
571                }
572                Value::Bool(b) => AttributeValue::Boolean(*b),
573                _ => continue,
574            };
575            map.insert(k.clone(), av);
576        }
577    }
578    map
579}
580
581fn memory_node_to_json(node: &MemoryNode) -> Value {
582    let mut attrs = Map::new();
583    for (k, v) in &node.attributes {
584        let jv = match v {
585            AttributeValue::String(s) => Value::String(s.clone()),
586            AttributeValue::Integer(i) => json!(i),
587            AttributeValue::Float(f) => json!(f),
588            AttributeValue::Boolean(b) => json!(b),
589            AttributeValue::Bytes(b) => json!(b),
590        };
591        attrs.insert(k.clone(), jv);
592    }
593
594    let memory_type_str = format!("{:?}", node.memory_type).to_lowercase();
595
596    json!({
597        "id": node.id.to_string(),
598        "agent_id": node.agent_id.to_string(),
599        "memory_type": memory_type_str,
600        "embedding": node.embedding,
601        "content": node.content,
602        "created_at": node.created_at,
603        "accessed_at": node.accessed_at,
604        "access_count": node.access_count,
605        "salience": node.salience,
606        "confidence": node.confidence,
607        "space_id": node.space_id.to_string(),
608        "attributes": attrs,
609        "tags": node.tags,
610    })
611}
612
613fn parse_permission(s: &str) -> Result<Permission, ApiError> {
614    match s.to_lowercase().as_str() {
615        "read" => Ok(Permission::Read),
616        "write" => Ok(Permission::Write),
617        "readwrite" | "read_write" => Ok(Permission::ReadWrite),
618        "admin" => Ok(Permission::Admin),
619        _ => Err(ApiError::BadRequest(format!("unknown permission: {s}"))),
620    }
621}
622fn resolve_agent_id(
623    agent: &Option<Extension<AuthenticatedAgent>>,
624    body: Option<&Value>,
625) -> Result<AgentId, ApiError> {
626    if let Some(Extension(a)) = agent {
627        return a
628            .agent_id
629            .parse::<AgentId>()
630            .map_err(|_| ApiError::Unauthorized("invalid agent_id in token".into()));
631    }
632    if let Some(val) = body {
633        return Ok(AgentId(parse_uuid(val, "agent_id")?));
634    }
635    Err(ApiError::Unauthorized("no agent_id available".into()))
636}
637fn is_admin_token(agent: &Option<Extension<AuthenticatedAgent>>) -> bool {
638    matches!(agent, Some(Extension(a)) if a.admin)
639}
640pub async fn create_space(
641    State(state): State<Arc<AppState>>,
642    agent: Option<Extension<AuthenticatedAgent>>,
643    Json(req): Json<Value>,
644) -> Result<impl IntoResponse, ApiError> {
645    let agent_id = resolve_agent_id(&agent, Some(&req))?;
646    let name = req
647        .get("name")
648        .and_then(|v| v.as_str())
649        .ok_or_else(|| ApiError::BadRequest("missing 'name' field".into()))?;
650    let mut spaces = state.spaces.write().await;
651    let space = spaces.create_space(name, agent_id);
652    Ok((
653        StatusCode::CREATED,
654        Json(
655            json!({"id": space.id.to_string(), "name": space.name, "owner": space.owner.to_string()}),
656        ),
657    ))
658}
659pub async fn list_spaces(
660    State(state): State<Arc<AppState>>,
661    agent: Option<Extension<AuthenticatedAgent>>,
662) -> Result<impl IntoResponse, ApiError> {
663    let agent_id = resolve_agent_id(&agent, None)?;
664    let spaces = state.spaces.read().await;
665    let list: Vec<Value> = spaces
666        .list_spaces_for_agent(agent_id)
667        .iter()
668        .map(|s| json!({"id": s.id.to_string(), "name": s.name, "owner": s.owner.to_string()}))
669        .collect();
670    Ok(Json(json!({"spaces": list})))
671}
672pub async fn grant_space_access(
673    State(state): State<Arc<AppState>>,
674    agent: Option<Extension<AuthenticatedAgent>>,
675    Path(space_id_str): Path<String>,
676    Json(req): Json<Value>,
677) -> Result<impl IntoResponse, ApiError> {
678    let space_id: SpaceId = space_id_str
679        .parse()
680        .map_err(|_| ApiError::BadRequest("invalid space ID".into()))?;
681    let caller_id = resolve_agent_id(&agent, None)?;
682    let target_agent = AgentId(parse_uuid(&req, "agent_id")?);
683    let perm_str = req
684        .get("permission")
685        .and_then(|v| v.as_str())
686        .ok_or_else(|| ApiError::BadRequest("missing 'permission' field".into()))?;
687    let perm = parse_permission(perm_str)?;
688    let mut spaces = state.spaces.write().await;
689    if !is_admin_token(&agent) && !spaces.check_access(space_id, caller_id, Permission::Admin) {
690        return Err(ApiError::Forbidden(
691            "only space owner or admin can grant access".into(),
692        ));
693    }
694    spaces.grant_access(space_id, target_agent, perm);
695    Ok(Json(json!({"status": "granted"})))
696}