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