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/// Run the extraction pipeline and store accepted memories. Returns JSON stats.
420async fn run_extraction(
421    config: &ExtractionConfig,
422    conversation: &str,
423    agent_id: AgentId,
424    space_id: SpaceId,
425    db: &mentedb::MenteDb,
426) -> Result<Value, ApiError> {
427    let provider = HttpExtractionProvider::new(config.clone()).map_err(|e| {
428        error!("extraction provider init failed: {e}");
429        ApiError::Internal(format!("extraction provider init failed: {e}"))
430    })?;
431
432    let pipeline = ExtractionPipeline::new(provider, config.clone());
433
434    let all_memories = pipeline
435        .extract_from_conversation(conversation)
436        .await
437        .map_err(|e| {
438            error!("extraction failed: {e}");
439            ApiError::Internal(format!("extraction failed: {e}"))
440        })?;
441
442    let total_extracted = all_memories.len();
443    let quality_passed = pipeline.filter_quality(&all_memories);
444    let rejected_low_quality = total_extracted - quality_passed.len();
445
446    let now = std::time::SystemTime::now()
447        .duration_since(std::time::UNIX_EPOCH)
448        .unwrap_or_default()
449        .as_micros() as u64;
450
451    let mut stored_ids = Vec::new();
452    for memory in &quality_passed {
453        let memory_type = map_extraction_type_to_memory_type(&memory.memory_type);
454        let id = MemoryId::new();
455        let node = MemoryNode {
456            id,
457            agent_id,
458            user_id: UserId::nil(),
459            memory_type,
460            embedding: vec![],
461            content: memory.content.clone(),
462            created_at: now,
463            accessed_at: now,
464            access_count: 0,
465            salience: memory.confidence,
466            confidence: memory.confidence,
467            space_id,
468            attributes: std::collections::HashMap::new(),
469            tags: memory.tags.clone(),
470            valid_from: None,
471            valid_until: None,
472            context: None,
473        };
474        match db.store(node) {
475            Ok(()) => stored_ids.push(id.to_string()),
476            Err(e) => {
477                tracing::warn!(error = %e, "failed to store extracted memory, skipping");
478            }
479        }
480    }
481
482    Ok(json!({
483        "memories_stored": stored_ids.len(),
484        "rejected_low_quality": rejected_low_quality,
485        "rejected_duplicate": 0,
486        "contradictions": 0,
487        "stored_ids": stored_ids,
488    }))
489}
490
491/// Heuristic: does this content look like a multi-turn conversation?
492fn looks_like_conversation(content: &str) -> bool {
493    let lower = content.to_lowercase();
494    let patterns = ["user:", "assistant:", "human:", "ai:", "\nq:", "\na:"];
495    let matches = patterns.iter().filter(|p| lower.contains(*p)).count();
496    matches >= 2
497}
498
499// ---------------------------------------------------------------------------
500// Helpers
501// ---------------------------------------------------------------------------
502
503fn parse_uuid(val: &Value, field: &str) -> Result<Uuid, ApiError> {
504    val.get(field)
505        .and_then(|v| v.as_str())
506        .ok_or_else(|| ApiError::BadRequest(format!("missing '{field}'")))
507        .and_then(|s| {
508            Uuid::parse_str(s)
509                .map_err(|_| ApiError::BadRequest(format!("invalid UUID for '{field}'")))
510        })
511}
512
513fn parse_memory_type(val: &Value) -> Result<MemoryType, ApiError> {
514    let s = val
515        .get("memory_type")
516        .and_then(|v| v.as_str())
517        .ok_or_else(|| ApiError::BadRequest("missing 'memory_type'".into()))?;
518
519    match s.to_lowercase().as_str() {
520        "episodic" => Ok(MemoryType::Episodic),
521        "semantic" => Ok(MemoryType::Semantic),
522        "procedural" => Ok(MemoryType::Procedural),
523        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
524        "reasoning" => Ok(MemoryType::Reasoning),
525        "correction" => Ok(MemoryType::Correction),
526        _ => Err(ApiError::BadRequest(format!("unknown memory_type: {s}"))),
527    }
528}
529
530fn parse_edge_type(s: &str) -> Option<EdgeType> {
531    match s.to_lowercase().as_str() {
532        "caused" => Some(EdgeType::Caused),
533        "before" => Some(EdgeType::Before),
534        "related" => Some(EdgeType::Related),
535        "contradicts" => Some(EdgeType::Contradicts),
536        "supports" => Some(EdgeType::Supports),
537        "supersedes" => Some(EdgeType::Supersedes),
538        "derived" => Some(EdgeType::Derived),
539        "partof" | "part_of" => Some(EdgeType::PartOf),
540        _ => None,
541    }
542}
543
544fn parse_embedding(val: &Value, required: bool) -> Result<Vec<f32>, ApiError> {
545    match val.get("embedding").and_then(|v| v.as_array()) {
546        Some(arr) => {
547            let mut emb = Vec::with_capacity(arr.len());
548            for v in arr {
549                let f = v.as_f64().ok_or_else(|| {
550                    ApiError::BadRequest("embedding values must be numbers".into())
551                })?;
552                emb.push(f as f32);
553            }
554            Ok(emb)
555        }
556        None if required => Err(ApiError::BadRequest("missing 'embedding' array".into())),
557        None => Ok(vec![]),
558    }
559}
560
561fn parse_attributes(val: Option<&Value>) -> HashMap<String, AttributeValue> {
562    let mut map = HashMap::new();
563    if let Some(Value::Object(obj)) = val {
564        for (k, v) in obj {
565            let av = match v {
566                Value::String(s) => AttributeValue::String(s.clone()),
567                Value::Number(n) => {
568                    if let Some(i) = n.as_i64() {
569                        AttributeValue::Integer(i)
570                    } else if let Some(f) = n.as_f64() {
571                        AttributeValue::Float(f)
572                    } else {
573                        continue;
574                    }
575                }
576                Value::Bool(b) => AttributeValue::Boolean(*b),
577                _ => continue,
578            };
579            map.insert(k.clone(), av);
580        }
581    }
582    map
583}
584
585fn memory_node_to_json(node: &MemoryNode) -> Value {
586    let mut attrs = Map::new();
587    for (k, v) in &node.attributes {
588        let jv = match v {
589            AttributeValue::String(s) => Value::String(s.clone()),
590            AttributeValue::Integer(i) => json!(i),
591            AttributeValue::Float(f) => json!(f),
592            AttributeValue::Boolean(b) => json!(b),
593            AttributeValue::Bytes(b) => json!(b),
594        };
595        attrs.insert(k.clone(), jv);
596    }
597
598    let memory_type_str = format!("{:?}", node.memory_type).to_lowercase();
599
600    json!({
601        "id": node.id.to_string(),
602        "agent_id": node.agent_id.to_string(),
603        "memory_type": memory_type_str,
604        "embedding": node.embedding,
605        "content": node.content,
606        "created_at": node.created_at,
607        "accessed_at": node.accessed_at,
608        "access_count": node.access_count,
609        "salience": node.salience,
610        "confidence": node.confidence,
611        "space_id": node.space_id.to_string(),
612        "attributes": attrs,
613        "tags": node.tags,
614    })
615}
616
617fn parse_permission(s: &str) -> Result<Permission, ApiError> {
618    match s.to_lowercase().as_str() {
619        "read" => Ok(Permission::Read),
620        "write" => Ok(Permission::Write),
621        "readwrite" | "read_write" => Ok(Permission::ReadWrite),
622        "admin" => Ok(Permission::Admin),
623        _ => Err(ApiError::BadRequest(format!("unknown permission: {s}"))),
624    }
625}
626fn resolve_agent_id(
627    agent: &Option<Extension<AuthenticatedAgent>>,
628    body: Option<&Value>,
629) -> Result<AgentId, ApiError> {
630    if let Some(Extension(a)) = agent {
631        return a
632            .agent_id
633            .parse::<AgentId>()
634            .map_err(|_| ApiError::Unauthorized("invalid agent_id in token".into()));
635    }
636    if let Some(val) = body {
637        return Ok(AgentId(parse_uuid(val, "agent_id")?));
638    }
639    Err(ApiError::Unauthorized("no agent_id available".into()))
640}
641fn is_admin_token(agent: &Option<Extension<AuthenticatedAgent>>) -> bool {
642    matches!(agent, Some(Extension(a)) if a.admin)
643}
644pub async fn create_space(
645    State(state): State<Arc<AppState>>,
646    agent: Option<Extension<AuthenticatedAgent>>,
647    Json(req): Json<Value>,
648) -> Result<impl IntoResponse, ApiError> {
649    let agent_id = resolve_agent_id(&agent, Some(&req))?;
650    let name = req
651        .get("name")
652        .and_then(|v| v.as_str())
653        .ok_or_else(|| ApiError::BadRequest("missing 'name' field".into()))?;
654    let mut spaces = state.spaces.write().await;
655    let space = spaces.create_space(name, agent_id);
656    Ok((
657        StatusCode::CREATED,
658        Json(
659            json!({"id": space.id.to_string(), "name": space.name, "owner": space.owner.to_string()}),
660        ),
661    ))
662}
663pub async fn list_spaces(
664    State(state): State<Arc<AppState>>,
665    agent: Option<Extension<AuthenticatedAgent>>,
666) -> Result<impl IntoResponse, ApiError> {
667    let agent_id = resolve_agent_id(&agent, None)?;
668    let spaces = state.spaces.read().await;
669    let list: Vec<Value> = spaces
670        .list_spaces_for_agent(agent_id)
671        .iter()
672        .map(|s| json!({"id": s.id.to_string(), "name": s.name, "owner": s.owner.to_string()}))
673        .collect();
674    Ok(Json(json!({"spaces": list})))
675}
676pub async fn grant_space_access(
677    State(state): State<Arc<AppState>>,
678    agent: Option<Extension<AuthenticatedAgent>>,
679    Path(space_id_str): Path<String>,
680    Json(req): Json<Value>,
681) -> Result<impl IntoResponse, ApiError> {
682    let space_id: SpaceId = space_id_str
683        .parse()
684        .map_err(|_| ApiError::BadRequest("invalid space ID".into()))?;
685    let caller_id = resolve_agent_id(&agent, None)?;
686    let target_agent = AgentId(parse_uuid(&req, "agent_id")?);
687    let perm_str = req
688        .get("permission")
689        .and_then(|v| v.as_str())
690        .ok_or_else(|| ApiError::BadRequest("missing 'permission' field".into()))?;
691    let perm = parse_permission(perm_str)?;
692    let mut spaces = state.spaces.write().await;
693    if !is_admin_token(&agent) && !spaces.check_access(space_id, caller_id, Permission::Admin) {
694        return Err(ApiError::Forbidden(
695            "only space owner or admin can grant access".into(),
696        ));
697    }
698    spaces.grant_access(space_id, target_agent, perm);
699    Ok(Json(json!({"status": "granted"})))
700}
701
702// ---------------------------------------------------------------------------
703// Admin endpoints (gated by the admin key, for the bundled /console).
704// ---------------------------------------------------------------------------
705
706/// Query params for GET /v1/admin/memories.
707#[derive(serde::Deserialize)]
708pub struct AdminListParams {
709    pub limit: Option<usize>,
710    pub offset: Option<usize>,
711    /// Filter by owning agent UUID.
712    pub agent: Option<String>,
713    /// Filter by memory type name.
714    #[serde(rename = "type")]
715    pub memory_type: Option<String>,
716    /// Case-insensitive content substring.
717    pub q: Option<String>,
718}
719
720fn memory_type_from_str(s: &str) -> Result<MemoryType, ApiError> {
721    match s.to_lowercase().as_str() {
722        "episodic" => Ok(MemoryType::Episodic),
723        "semantic" => Ok(MemoryType::Semantic),
724        "procedural" => Ok(MemoryType::Procedural),
725        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
726        "reasoning" => Ok(MemoryType::Reasoning),
727        "correction" => Ok(MemoryType::Correction),
728        other => Err(ApiError::BadRequest(format!(
729            "unknown memory type: {other}"
730        ))),
731    }
732}
733
734/// GET /v1/admin/memories: a bounded, paginated page of memories for the console.
735pub async fn admin_list_memories(
736    State(state): State<Arc<AppState>>,
737    headers: HeaderMap,
738    Query(p): Query<AdminListParams>,
739) -> Result<impl IntoResponse, ApiError> {
740    crate::auth::admin_authorized(&headers, &state.admin_key)?;
741    let limit = p.limit.unwrap_or(50).clamp(1, 200);
742    let offset = p.offset.unwrap_or(0);
743    let agent = match p.agent.as_deref().filter(|s| !s.is_empty()) {
744        Some(s) => {
745            Some(AgentId(s.parse().map_err(|_| {
746                ApiError::BadRequest("invalid agent UUID".into())
747            })?))
748        }
749        None => None,
750    };
751    let mtype = match p.memory_type.as_deref().filter(|s| !s.is_empty()) {
752        Some(s) => Some(memory_type_from_str(s)?),
753        None => None,
754    };
755    let q = p.q.as_deref().filter(|s| !s.is_empty());
756
757    let (total, nodes) = state
758        .db
759        .list_memories(limit, offset, agent, mtype, q)
760        .map_err(|e| ApiError::Internal(e.to_string()))?;
761    let memories: Vec<_> = nodes.iter().map(memory_node_to_json).collect();
762    Ok(Json(json!({
763        "total": total,
764        "limit": limit,
765        "offset": offset,
766        "memories": memories,
767    })))
768}
769
770/// DELETE /v1/admin/memories/{id}: forget a memory from the console.
771pub async fn admin_delete_memory(
772    State(state): State<Arc<AppState>>,
773    headers: HeaderMap,
774    Path(id_str): Path<String>,
775) -> Result<impl IntoResponse, ApiError> {
776    crate::auth::admin_authorized(&headers, &state.admin_key)?;
777    let id: MemoryId = id_str
778        .parse()
779        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
780    state
781        .db
782        .forget(id)
783        .map_err(|e| ApiError::Internal(e.to_string()))?;
784    Ok(Json(json!({ "status": "deleted", "id": id.to_string() })))
785}
786
787/// POST /v1/admin/mql: run an MQL query, return the scored matches. Powers the
788/// console query box and the `mentedb` CLI's remote mode.
789pub async fn admin_run_mql(
790    State(state): State<Arc<AppState>>,
791    headers: HeaderMap,
792    Json(body): Json<Value>,
793) -> Result<impl IntoResponse, ApiError> {
794    crate::auth::admin_authorized(&headers, &state.admin_key)?;
795    let mql = body
796        .get("mql")
797        .and_then(|v| v.as_str())
798        .ok_or_else(|| ApiError::BadRequest("missing 'mql'".into()))?;
799    let scored = state
800        .db
801        .query(mql)
802        .map_err(|e| ApiError::BadRequest(format!("query error: {e}")))?;
803    let memories: Vec<Value> = scored
804        .iter()
805        .map(|s| {
806            let mut j = memory_node_to_json(&s.memory);
807            if let Value::Object(ref mut m) = j {
808                m.insert("score".into(), json!(s.score));
809            }
810            j
811        })
812        .collect();
813    Ok(Json(
814        json!({ "count": memories.len(), "memories": memories }),
815    ))
816}