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