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, Query, State};
10use axum::http::{HeaderMap, 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
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        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        context: None,
139    };
140
141    let db = &*state.db;
142    db.store(node).map_err(|e| {
143        error!("store failed: {e}");
144        ApiError::Internal(format!("store failed: {e}"))
145    })?;
146
147    // Auto-extract: if enabled and content looks like a conversation, queue extraction
148    if state.auto_extract
149        && state.extraction_config.is_some()
150        && looks_like_conversation(&content)
151        && let Some(tx) = &state.extraction_tx
152    {
153        let req = crate::extraction_queue::ExtractionRequest {
154            config: state.extraction_config.clone().unwrap(),
155            content: content.clone(),
156            agent_id,
157            space_id,
158            db: state.db.clone(),
159        };
160        if tx.try_send(req).is_err() {
161            tracing::warn!("extraction queue full, skipping auto-extract");
162        }
163    }
164
165    Ok((
166        StatusCode::CREATED,
167        Json(json!({ "id": id.to_string(), "status": "stored" })),
168    ))
169}
170
171// ---------------------------------------------------------------------------
172// GET /v1/memories/:id
173// ---------------------------------------------------------------------------
174
175/// Retrieves a single memory by its UUID.
176pub async fn get_memory(
177    State(state): State<Arc<AppState>>,
178    agent: Option<Extension<AuthenticatedAgent>>,
179    Path(id_str): Path<String>,
180) -> Result<impl IntoResponse, ApiError> {
181    let id: MemoryId = id_str
182        .parse()
183        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
184
185    let db = &*state.db;
186    let node = db
187        .get_memory(id)
188        .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
189
190    if let Some(Extension(ref authed)) = agent {
191        let tid: AgentId = authed
192            .agent_id
193            .parse()
194            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
195        if node.agent_id != tid {
196            return Err(ApiError::Forbidden(
197                "memory belongs to a different agent".into(),
198            ));
199        }
200    }
201
202    Ok(Json(memory_node_to_json(&node)))
203}
204
205// ---------------------------------------------------------------------------
206// DELETE /v1/memories/:id
207// ---------------------------------------------------------------------------
208
209/// Deletes a memory from the database.
210pub async fn forget_memory(
211    State(state): State<Arc<AppState>>,
212    agent: Option<Extension<AuthenticatedAgent>>,
213    Path(id_str): Path<String>,
214) -> Result<impl IntoResponse, ApiError> {
215    let id: MemoryId = id_str
216        .parse()
217        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
218
219    let db = &*state.db;
220    if let Some(Extension(ref authed)) = agent {
221        let tid: AgentId = authed
222            .agent_id
223            .parse()
224            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
225        let window = db.recall("RECALL memories LIMIT 1000").map_err(|e| {
226            error!("recall failed: {e}");
227            ApiError::Internal(format!("recall failed: {e}"))
228        })?;
229        let mut found = false;
230        for block in &window.blocks {
231            for scored in &block.memories {
232                if scored.memory.id == id {
233                    if scored.memory.agent_id != tid {
234                        return Err(ApiError::Forbidden(
235                            "memory belongs to a different agent".into(),
236                        ));
237                    }
238                    found = true;
239                    break;
240                }
241            }
242            if found {
243                break;
244            }
245        }
246        if !found {
247            return Err(ApiError::NotFound(format!("memory {id} not found")));
248        }
249    }
250    db.forget(id).map_err(|e| {
251        error!("forget failed: {e}");
252        ApiError::Internal(format!("forget failed: {e}"))
253    })?;
254
255    Ok(Json(json!({ "status": "deleted" })))
256}
257
258// ---------------------------------------------------------------------------
259// POST /v1/recall
260// ---------------------------------------------------------------------------
261
262/// Recalls memories matching an MQL query string.
263pub async fn recall_memories(
264    State(state): State<Arc<AppState>>,
265    Json(req): Json<Value>,
266) -> Result<impl IntoResponse, ApiError> {
267    let query = req
268        .get("query")
269        .and_then(|v| v.as_str())
270        .ok_or_else(|| ApiError::BadRequest("missing 'query' field".into()))?;
271
272    let db = &*state.db;
273    let window = db.recall(query).map_err(|e| {
274        error!("recall failed: {e}");
275        ApiError::Internal(format!("recall failed: {e}"))
276    })?;
277
278    let memory_count: usize = window.blocks.iter().map(|b| b.memories.len()).sum();
279    Ok(Json(json!({
280        "context": window.format,
281        "total_tokens": window.total_tokens,
282        "memory_count": memory_count,
283    })))
284}
285
286// ---------------------------------------------------------------------------
287// POST /v1/search
288// ---------------------------------------------------------------------------
289
290/// Searches for memories similar to a given embedding vector.
291pub async fn search_similar(
292    State(state): State<Arc<AppState>>,
293    Json(req): Json<Value>,
294) -> Result<impl IntoResponse, ApiError> {
295    let embedding = parse_embedding(&req, true)?;
296    let k = req.get("k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
297
298    let db = &*state.db;
299    let results = db.recall_similar(&embedding, k).map_err(|e| {
300        error!("search failed: {e}");
301        ApiError::Internal(format!("search failed: {e}"))
302    })?;
303
304    let items: Vec<Value> = results
305        .iter()
306        .map(|(id, score)| json!({ "id": id.to_string(), "score": score }))
307        .collect();
308
309    Ok(Json(json!({ "results": items })))
310}
311
312// ---------------------------------------------------------------------------
313// POST /v1/edges
314// ---------------------------------------------------------------------------
315
316/// Creates a typed, weighted edge between two memories.
317pub async fn create_edge(
318    State(state): State<Arc<AppState>>,
319    Json(req): Json<Value>,
320) -> Result<impl IntoResponse, ApiError> {
321    let source = MemoryId(parse_uuid(&req, "source")?);
322    let target = MemoryId(parse_uuid(&req, "target")?);
323
324    let edge_type_str = req
325        .get("edge_type")
326        .and_then(|v| v.as_str())
327        .ok_or_else(|| ApiError::BadRequest("missing 'edge_type'".into()))?;
328
329    let edge_type = parse_edge_type(edge_type_str)
330        .ok_or_else(|| ApiError::BadRequest(format!("unknown edge_type: {edge_type_str}")))?;
331
332    let weight = req.get("weight").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
333
334    let now = std::time::SystemTime::now()
335        .duration_since(std::time::UNIX_EPOCH)
336        .unwrap_or_default()
337        .as_micros() as u64;
338
339    let valid_from = req.get("valid_from").and_then(|v| v.as_u64());
340    let valid_until = req.get("valid_until").and_then(|v| v.as_u64());
341
342    let edge = MemoryEdge {
343        source,
344        target,
345        edge_type,
346        weight,
347        created_at: now,
348        valid_from,
349        valid_until,
350        label: req
351            .get("label")
352            .and_then(|v| v.as_str())
353            .map(|s| s.to_string()),
354    };
355
356    let db = &*state.db;
357    db.relate(edge).map_err(|e| {
358        error!("relate failed: {e}");
359        ApiError::Internal(format!("relate failed: {e}"))
360    })?;
361
362    Ok((StatusCode::CREATED, Json(json!({ "status": "created" }))))
363}
364
365// ---------------------------------------------------------------------------
366// POST /v1/ingest
367// ---------------------------------------------------------------------------
368
369/// Ingest a conversation: extract memories via LLM, filter, and store them.
370pub async fn ingest_conversation(
371    State(state): State<Arc<AppState>>,
372    agent: Option<Extension<AuthenticatedAgent>>,
373    Json(req): Json<Value>,
374) -> Result<impl IntoResponse, ApiError> {
375    let agent_id = AgentId(parse_uuid(&req, "agent_id")?);
376    if let Some(Extension(ref authed)) = agent {
377        let tid: AgentId = authed
378            .agent_id
379            .parse()
380            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
381        if tid != agent_id {
382            return Err(ApiError::Forbidden(
383                "agent_id in request body does not match token".into(),
384            ));
385        }
386    }
387
388    let extraction_config = state.extraction_config.as_ref().ok_or_else(|| {
389        ApiError::ServiceUnavailable(
390            "LLM provider not configured. Set MENTEDB_LLM_PROVIDER env var.".into(),
391        )
392    })?;
393
394    let conversation = req
395        .get("conversation")
396        .and_then(|v| v.as_str())
397        .ok_or_else(|| ApiError::BadRequest("missing or invalid 'conversation'".into()))?;
398
399    let space_id = match req.get("space_id") {
400        Some(v) => v
401            .as_str()
402            .and_then(|s| s.parse::<SpaceId>().ok())
403            .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
404        None => SpaceId::nil(),
405    };
406
407    let stats = run_extraction(
408        extraction_config,
409        conversation,
410        agent_id,
411        space_id,
412        &state.db,
413    )
414    .await?;
415
416    Ok((StatusCode::OK, Json(stats)))
417}
418
419// ---------------------------------------------------------------------------
420// POST /v1/process_turn
421// ---------------------------------------------------------------------------
422
423/// Run the full cognitive turn pipeline: embed, recall context, store the
424/// turn, reconcile, analyze. The same engine call the hosted platform serves;
425/// self-hosters get the one-call pipeline instead of assembling it from the
426/// store and recall primitives. Delta serving is tracked per agent across
427/// requests via `AppState::turn_trackers`.
428pub async fn process_turn(
429    State(state): State<Arc<AppState>>,
430    agent: Option<Extension<AuthenticatedAgent>>,
431    Json(req): Json<Value>,
432) -> Result<impl IntoResponse, ApiError> {
433    let user_message = req
434        .get("user_message")
435        .and_then(|v| v.as_str())
436        .ok_or_else(|| ApiError::BadRequest("missing or invalid 'user_message'".into()))?
437        .to_string();
438    let assistant_response = req
439        .get("assistant_response")
440        .and_then(|v| v.as_str())
441        .filter(|s| !s.is_empty())
442        .map(|s| s.to_string());
443    let turn_id = req.get("turn_id").and_then(|v| v.as_u64()).unwrap_or(0);
444    let project_context = req
445        .get("project_context")
446        .and_then(|v| v.as_str())
447        .filter(|s| !s.is_empty())
448        .map(|s| s.to_string());
449    let session_id = req
450        .get("session_id")
451        .and_then(|v| v.as_str())
452        .filter(|s| !s.is_empty())
453        .map(|s| s.to_string());
454    let agent_id = req
455        .get("agent_id")
456        .and_then(|v| v.as_str())
457        .filter(|s| !s.is_empty())
458        .map(|s| {
459            Uuid::parse_str(s).map_err(|_| ApiError::BadRequest("invalid 'agent_id' UUID".into()))
460        })
461        .transpose()?;
462    let user_id = req
463        .get("user_id")
464        .and_then(|v| v.as_str())
465        .filter(|s| !s.is_empty())
466        .map(|s| {
467            Uuid::parse_str(s).map_err(|_| ApiError::BadRequest("invalid 'user_id' UUID".into()))
468        })
469        .transpose()?;
470
471    // A scoped token must only process turns for its own agent.
472    if let (Some(Extension(authed)), Some(aid)) = (&agent, agent_id) {
473        let tid: AgentId = authed
474            .agent_id
475            .parse()
476            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
477        if tid != AgentId(aid) {
478            return Err(ApiError::Forbidden(
479                "agent_id in request body does not match token".into(),
480            ));
481        }
482    }
483
484    let input = mentedb::process_turn::ProcessTurnInput {
485        user_message,
486        assistant_response,
487        turn_id,
488        project_context,
489        agent_id,
490        user_id,
491        session_id,
492    };
493
494    // The tracker is keyed by agent (nil for the shared/global agent) so a
495    // multi-agent server keeps one delta stream per agent. Taken out of the
496    // map for the duration of the blocking call, then put back, so the mutex
497    // is never held across the engine turn.
498    let tracker_key = agent_id.unwrap_or_else(Uuid::nil).to_string();
499    let mut tracker = {
500        let mut map = state.turn_trackers.lock().await;
501        map.remove(&tracker_key)
502            .unwrap_or_else(mentedb::context::DeltaTracker::new)
503    };
504
505    let db = Arc::clone(&state.db);
506    let joined = tokio::task::spawn_blocking(move || {
507        let result = db.process_turn(&input, &mut tracker);
508        (result, tracker)
509    })
510    .await
511    .map_err(|e| ApiError::Internal(format!("process_turn task failed: {e}")))?;
512    let (result, tracker) = joined;
513    {
514        let mut map = state.turn_trackers.lock().await;
515        map.insert(tracker_key, tracker);
516    }
517    let result = result.map_err(|e| {
518        error!("process_turn failed: {e}");
519        ApiError::Internal(format!("process_turn failed: {e}"))
520    })?;
521
522    let context: Vec<Value> = result
523        .context
524        .iter()
525        .map(|sm| {
526            json!({
527                "id": sm.memory.id.to_string(),
528                "content": sm.memory.content,
529                "memory_type": format!("{:?}", sm.memory.memory_type).to_lowercase(),
530                "relevance_score": sm.score,
531                "created_at": sm.memory.created_at,
532            })
533        })
534        .collect();
535
536    Ok((
537        StatusCode::OK,
538        Json(json!({
539            "ok": true,
540            "context": context,
541            "cache_hit": result.cache_hit,
542            "stored_ids": result.stored_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
543            "episodic_id": result.episodic_id.map(|id| id.to_string()),
544            "contradictions": result.contradiction_count,
545            "facts_extracted": result.facts_extracted,
546            "edges_created": result.edges_created,
547            "inference_actions": result.inference_actions,
548            "pain_warnings": result.pain_warnings.len(),
549            "predicted_topics": result.predicted_topics,
550            "delta_added": result.delta_added.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
551            "delta_removed": result.delta_removed.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
552        })),
553    ))
554}
555
556/// Run the extraction pipeline and store accepted memories. Returns JSON stats.
557async fn run_extraction(
558    config: &ExtractionConfig,
559    conversation: &str,
560    agent_id: AgentId,
561    space_id: SpaceId,
562    db: &mentedb::MenteDb,
563) -> Result<Value, ApiError> {
564    let provider = HttpExtractionProvider::new(config.clone()).map_err(|e| {
565        error!("extraction provider init failed: {e}");
566        ApiError::Internal(format!("extraction provider init failed: {e}"))
567    })?;
568
569    let pipeline = ExtractionPipeline::new(provider, config.clone());
570
571    let all_memories = pipeline
572        .extract_from_conversation(conversation)
573        .await
574        .map_err(|e| {
575            error!("extraction failed: {e}");
576            ApiError::Internal(format!("extraction failed: {e}"))
577        })?;
578
579    let total_extracted = all_memories.len();
580    let quality_passed = pipeline.filter_quality(&all_memories);
581    let rejected_low_quality = total_extracted - quality_passed.len();
582
583    let now = std::time::SystemTime::now()
584        .duration_since(std::time::UNIX_EPOCH)
585        .unwrap_or_default()
586        .as_micros() as u64;
587
588    let mut stored_ids = Vec::new();
589    for memory in &quality_passed {
590        let memory_type = map_extraction_type_to_memory_type(&memory.memory_type);
591        let id = MemoryId::new();
592        let node = MemoryNode {
593            id,
594            agent_id,
595            user_id: UserId::nil(),
596            memory_type,
597            embedding: vec![],
598            content: memory.content.clone(),
599            created_at: now,
600            accessed_at: now,
601            access_count: 0,
602            salience: memory.confidence,
603            confidence: memory.confidence,
604            space_id,
605            attributes: std::collections::HashMap::new(),
606            tags: memory.tags.clone(),
607            valid_from: None,
608            valid_until: None,
609            context: None,
610        };
611        match db.store(node) {
612            Ok(()) => stored_ids.push(id.to_string()),
613            Err(e) => {
614                tracing::warn!(error = %e, "failed to store extracted memory, skipping");
615            }
616        }
617    }
618
619    Ok(json!({
620        "memories_stored": stored_ids.len(),
621        "rejected_low_quality": rejected_low_quality,
622        "rejected_duplicate": 0,
623        "contradictions": 0,
624        "stored_ids": stored_ids,
625    }))
626}
627
628/// Heuristic: does this content look like a multi-turn conversation?
629fn looks_like_conversation(content: &str) -> bool {
630    let lower = content.to_lowercase();
631    let patterns = ["user:", "assistant:", "human:", "ai:", "\nq:", "\na:"];
632    let matches = patterns.iter().filter(|p| lower.contains(*p)).count();
633    matches >= 2
634}
635
636// ---------------------------------------------------------------------------
637// Helpers
638// ---------------------------------------------------------------------------
639
640fn parse_uuid(val: &Value, field: &str) -> Result<Uuid, ApiError> {
641    val.get(field)
642        .and_then(|v| v.as_str())
643        .ok_or_else(|| ApiError::BadRequest(format!("missing '{field}'")))
644        .and_then(|s| {
645            Uuid::parse_str(s)
646                .map_err(|_| ApiError::BadRequest(format!("invalid UUID for '{field}'")))
647        })
648}
649
650fn parse_memory_type(val: &Value) -> Result<MemoryType, ApiError> {
651    let s = val
652        .get("memory_type")
653        .and_then(|v| v.as_str())
654        .ok_or_else(|| ApiError::BadRequest("missing 'memory_type'".into()))?;
655
656    match s.to_lowercase().as_str() {
657        "episodic" => Ok(MemoryType::Episodic),
658        "semantic" => Ok(MemoryType::Semantic),
659        "procedural" => Ok(MemoryType::Procedural),
660        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
661        "reasoning" => Ok(MemoryType::Reasoning),
662        "correction" => Ok(MemoryType::Correction),
663        _ => Err(ApiError::BadRequest(format!("unknown memory_type: {s}"))),
664    }
665}
666
667fn parse_edge_type(s: &str) -> Option<EdgeType> {
668    match s.to_lowercase().as_str() {
669        "caused" => Some(EdgeType::Caused),
670        "before" => Some(EdgeType::Before),
671        "related" => Some(EdgeType::Related),
672        "contradicts" => Some(EdgeType::Contradicts),
673        "supports" => Some(EdgeType::Supports),
674        "supersedes" => Some(EdgeType::Supersedes),
675        "derived" => Some(EdgeType::Derived),
676        "partof" | "part_of" => Some(EdgeType::PartOf),
677        _ => None,
678    }
679}
680
681fn parse_embedding(val: &Value, required: bool) -> Result<Vec<f32>, ApiError> {
682    match val.get("embedding").and_then(|v| v.as_array()) {
683        Some(arr) => {
684            let mut emb = Vec::with_capacity(arr.len());
685            for v in arr {
686                let f = v.as_f64().ok_or_else(|| {
687                    ApiError::BadRequest("embedding values must be numbers".into())
688                })?;
689                emb.push(f as f32);
690            }
691            Ok(emb)
692        }
693        None if required => Err(ApiError::BadRequest("missing 'embedding' array".into())),
694        None => Ok(vec![]),
695    }
696}
697
698fn parse_attributes(val: Option<&Value>) -> HashMap<String, AttributeValue> {
699    let mut map = HashMap::new();
700    if let Some(Value::Object(obj)) = val {
701        for (k, v) in obj {
702            let av = match v {
703                Value::String(s) => AttributeValue::String(s.clone()),
704                Value::Number(n) => {
705                    if let Some(i) = n.as_i64() {
706                        AttributeValue::Integer(i)
707                    } else if let Some(f) = n.as_f64() {
708                        AttributeValue::Float(f)
709                    } else {
710                        continue;
711                    }
712                }
713                Value::Bool(b) => AttributeValue::Boolean(*b),
714                _ => continue,
715            };
716            map.insert(k.clone(), av);
717        }
718    }
719    map
720}
721
722fn memory_node_to_json(node: &MemoryNode) -> Value {
723    let mut attrs = Map::new();
724    for (k, v) in &node.attributes {
725        let jv = match v {
726            AttributeValue::String(s) => Value::String(s.clone()),
727            AttributeValue::Integer(i) => json!(i),
728            AttributeValue::Float(f) => json!(f),
729            AttributeValue::Boolean(b) => json!(b),
730            AttributeValue::Bytes(b) => json!(b),
731        };
732        attrs.insert(k.clone(), jv);
733    }
734
735    let memory_type_str = format!("{:?}", node.memory_type).to_lowercase();
736
737    json!({
738        "id": node.id.to_string(),
739        "agent_id": node.agent_id.to_string(),
740        "memory_type": memory_type_str,
741        "embedding": node.embedding,
742        "content": node.content,
743        "created_at": node.created_at,
744        "accessed_at": node.accessed_at,
745        "access_count": node.access_count,
746        "salience": node.salience,
747        "confidence": node.confidence,
748        "space_id": node.space_id.to_string(),
749        "attributes": attrs,
750        "tags": node.tags,
751    })
752}
753
754fn parse_permission(s: &str) -> Result<Permission, ApiError> {
755    match s.to_lowercase().as_str() {
756        "read" => Ok(Permission::Read),
757        "write" => Ok(Permission::Write),
758        "readwrite" | "read_write" => Ok(Permission::ReadWrite),
759        "admin" => Ok(Permission::Admin),
760        _ => Err(ApiError::BadRequest(format!("unknown permission: {s}"))),
761    }
762}
763fn resolve_agent_id(
764    agent: &Option<Extension<AuthenticatedAgent>>,
765    body: Option<&Value>,
766) -> Result<AgentId, ApiError> {
767    if let Some(Extension(a)) = agent {
768        return a
769            .agent_id
770            .parse::<AgentId>()
771            .map_err(|_| ApiError::Unauthorized("invalid agent_id in token".into()));
772    }
773    if let Some(val) = body {
774        return Ok(AgentId(parse_uuid(val, "agent_id")?));
775    }
776    Err(ApiError::Unauthorized("no agent_id available".into()))
777}
778fn is_admin_token(agent: &Option<Extension<AuthenticatedAgent>>) -> bool {
779    matches!(agent, Some(Extension(a)) if a.admin)
780}
781pub async fn create_space(
782    State(state): State<Arc<AppState>>,
783    agent: Option<Extension<AuthenticatedAgent>>,
784    Json(req): Json<Value>,
785) -> Result<impl IntoResponse, ApiError> {
786    let agent_id = resolve_agent_id(&agent, Some(&req))?;
787    let name = req
788        .get("name")
789        .and_then(|v| v.as_str())
790        .ok_or_else(|| ApiError::BadRequest("missing 'name' field".into()))?;
791    let mut spaces = state.spaces.write().await;
792    let space = spaces.create_space(name, agent_id);
793    Ok((
794        StatusCode::CREATED,
795        Json(
796            json!({"id": space.id.to_string(), "name": space.name, "owner": space.owner.to_string()}),
797        ),
798    ))
799}
800pub async fn list_spaces(
801    State(state): State<Arc<AppState>>,
802    agent: Option<Extension<AuthenticatedAgent>>,
803) -> Result<impl IntoResponse, ApiError> {
804    let agent_id = resolve_agent_id(&agent, None)?;
805    let spaces = state.spaces.read().await;
806    let list: Vec<Value> = spaces
807        .list_spaces_for_agent(agent_id)
808        .iter()
809        .map(|s| json!({"id": s.id.to_string(), "name": s.name, "owner": s.owner.to_string()}))
810        .collect();
811    Ok(Json(json!({"spaces": list})))
812}
813pub async fn grant_space_access(
814    State(state): State<Arc<AppState>>,
815    agent: Option<Extension<AuthenticatedAgent>>,
816    Path(space_id_str): Path<String>,
817    Json(req): Json<Value>,
818) -> Result<impl IntoResponse, ApiError> {
819    let space_id: SpaceId = space_id_str
820        .parse()
821        .map_err(|_| ApiError::BadRequest("invalid space ID".into()))?;
822    let caller_id = resolve_agent_id(&agent, None)?;
823    let target_agent = AgentId(parse_uuid(&req, "agent_id")?);
824    let perm_str = req
825        .get("permission")
826        .and_then(|v| v.as_str())
827        .ok_or_else(|| ApiError::BadRequest("missing 'permission' field".into()))?;
828    let perm = parse_permission(perm_str)?;
829    let mut spaces = state.spaces.write().await;
830    if !is_admin_token(&agent) && !spaces.check_access(space_id, caller_id, Permission::Admin) {
831        return Err(ApiError::Forbidden(
832            "only space owner or admin can grant access".into(),
833        ));
834    }
835    spaces.grant_access(space_id, target_agent, perm);
836    Ok(Json(json!({"status": "granted"})))
837}
838
839// ---------------------------------------------------------------------------
840// Admin endpoints (gated by the admin key, for the bundled /console).
841// ---------------------------------------------------------------------------
842
843/// Query params for GET /v1/admin/memories.
844#[derive(serde::Deserialize)]
845pub struct AdminListParams {
846    pub limit: Option<usize>,
847    pub offset: Option<usize>,
848    /// Filter by owning agent UUID.
849    pub agent: Option<String>,
850    /// Filter by memory type name.
851    #[serde(rename = "type")]
852    pub memory_type: Option<String>,
853    /// Case-insensitive content substring.
854    pub q: Option<String>,
855}
856
857fn memory_type_from_str(s: &str) -> Result<MemoryType, ApiError> {
858    match s.to_lowercase().as_str() {
859        "episodic" => Ok(MemoryType::Episodic),
860        "semantic" => Ok(MemoryType::Semantic),
861        "procedural" => Ok(MemoryType::Procedural),
862        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
863        "reasoning" => Ok(MemoryType::Reasoning),
864        "correction" => Ok(MemoryType::Correction),
865        other => Err(ApiError::BadRequest(format!(
866            "unknown memory type: {other}"
867        ))),
868    }
869}
870
871/// GET /v1/admin/memories: a bounded, paginated page of memories for the console.
872pub async fn admin_list_memories(
873    State(state): State<Arc<AppState>>,
874    headers: HeaderMap,
875    Query(p): Query<AdminListParams>,
876) -> Result<impl IntoResponse, ApiError> {
877    crate::auth::admin_authorized(&headers, &state.admin_key)?;
878    let limit = p.limit.unwrap_or(50).clamp(1, 200);
879    let offset = p.offset.unwrap_or(0);
880    let agent = match p.agent.as_deref().filter(|s| !s.is_empty()) {
881        Some(s) => {
882            Some(AgentId(s.parse().map_err(|_| {
883                ApiError::BadRequest("invalid agent UUID".into())
884            })?))
885        }
886        None => None,
887    };
888    let mtype = match p.memory_type.as_deref().filter(|s| !s.is_empty()) {
889        Some(s) => Some(memory_type_from_str(s)?),
890        None => None,
891    };
892    let q = p.q.as_deref().filter(|s| !s.is_empty());
893
894    let (total, nodes) = state
895        .db
896        .list_memories(limit, offset, agent, mtype, q)
897        .map_err(|e| ApiError::Internal(e.to_string()))?;
898    let memories: Vec<_> = nodes.iter().map(memory_node_to_json).collect();
899    Ok(Json(json!({
900        "total": total,
901        "limit": limit,
902        "offset": offset,
903        "memories": memories,
904    })))
905}
906
907/// DELETE /v1/admin/memories/{id}: forget a memory from the console.
908pub async fn admin_delete_memory(
909    State(state): State<Arc<AppState>>,
910    headers: HeaderMap,
911    Path(id_str): Path<String>,
912) -> Result<impl IntoResponse, ApiError> {
913    crate::auth::admin_authorized(&headers, &state.admin_key)?;
914    let id: MemoryId = id_str
915        .parse()
916        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
917    state
918        .db
919        .forget(id)
920        .map_err(|e| ApiError::Internal(e.to_string()))?;
921    Ok(Json(json!({ "status": "deleted", "id": id.to_string() })))
922}
923
924/// POST /v1/admin/mql: run an MQL query, return the scored matches. Powers the
925/// console query box and the `mentedb` CLI's remote mode.
926pub async fn admin_run_mql(
927    State(state): State<Arc<AppState>>,
928    headers: HeaderMap,
929    Json(body): Json<Value>,
930) -> Result<impl IntoResponse, ApiError> {
931    crate::auth::admin_authorized(&headers, &state.admin_key)?;
932    let mql = body
933        .get("mql")
934        .and_then(|v| v.as_str())
935        .ok_or_else(|| ApiError::BadRequest("missing 'mql'".into()))?;
936    let scored = state
937        .db
938        .query(mql)
939        .map_err(|e| ApiError::BadRequest(format!("query error: {e}")))?;
940    let memories: Vec<Value> = scored
941        .iter()
942        .map(|s| {
943            let mut j = memory_node_to_json(&s.memory);
944            if let Value::Object(ref mut m) = j {
945                m.insert("score".into(), json!(s.score));
946            }
947            j
948        })
949        .collect();
950    Ok(Json(
951        json!({ "count": memories.len(), "memories": memories }),
952    ))
953}