1use 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
29pub 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
43pub 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
62pub 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 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 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
181pub 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(¶ms)?;
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
200pub 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(¶ms)?;
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
225pub 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
252pub 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 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
278pub 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
327pub 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
365pub 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 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 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
495async 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
570fn 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
578pub(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
599pub(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
616pub(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
625pub(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#[derive(serde::Deserialize)]
858pub struct AdminListParams {
859 pub limit: Option<usize>,
860 pub offset: Option<usize>,
861 pub agent: Option<String>,
863 #[serde(rename = "type")]
865 pub memory_type: Option<String>,
866 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
884pub 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
920pub 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(¶ms)?;
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
934pub 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}