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