1use crate::{AppState, DaemonEvent};
14use axum::{
15 body::Body,
16 extract::{Path as AxumPath, Query, State},
17 http::{header, HeaderValue, Request, StatusCode},
18 response::{IntoResponse, Response},
19 routing::{delete, get, post},
20 Json, Router,
21};
22use rust_embed::RustEmbed;
23use serde::{Deserialize, Serialize};
24use serde_json::{json, Value};
25use std::collections::HashSet;
26use std::sync::Arc;
27use trusty_common::memory_core::community::KnowledgeGap;
28use trusty_common::memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
29use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
30use trusty_common::memory_core::retrieval::{
31 recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
32 recall_with_default_embedder, RecallResult,
33};
34use trusty_common::memory_core::store::kg::Triple;
35use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
36use trusty_common::{ChatEvent, ChatMessage, ToolDef};
37use uuid::Uuid;
38
39#[derive(RustEmbed)]
46#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
51struct WebAssets;
52
53pub fn router() -> Router<AppState> {
59 let router = Router::new()
64 .route("/api/v1/status", get(status))
65 .route("/api/v1/config", get(config))
66 .route("/api/v1/palaces", get(list_palaces).post(create_palace))
67 .route("/api/v1/palaces/{id}", get(get_palace_handler))
68 .route(
69 "/api/v1/palaces/{id}/drawers",
70 get(list_drawers).post(create_drawer),
71 )
72 .route(
73 "/api/v1/palaces/{id}/drawers/{drawer_id}",
74 delete(delete_drawer),
75 )
76 .route(
82 "/api/v1/palaces/{id}/memories",
83 get(list_drawers).post(create_drawer),
84 )
85 .route(
86 "/api/v1/palaces/{id}/memories/{drawer_id}",
87 delete(delete_drawer),
88 )
89 .route("/api/v1/palaces/{id}/recall", get(recall_handler))
90 .route("/api/v1/recall", get(recall_all_handler))
91 .route("/api/v1/palaces/{id}/kg", get(kg_query).post(kg_assert))
92 .route("/api/v1/palaces/{id}/kg/subjects", get(kg_list_subjects))
93 .route(
94 "/api/v1/palaces/{id}/kg/subjects_with_counts",
95 get(kg_list_subjects_with_counts),
96 )
97 .route("/api/v1/palaces/{id}/kg/all", get(kg_list_all))
98 .route("/api/v1/palaces/{id}/kg/count", get(kg_count))
99 .route(
100 "/api/v1/palaces/{id}/dream/status",
101 get(palace_dream_status),
102 )
103 .route("/api/v1/dream/status", get(dream_status))
104 .route("/api/v1/dream/run", post(dream_run))
105 .route("/api/v1/kg/gaps", get(kg_gaps_handler))
106 .route("/api/v1/kg/prompt-context", get(prompt_context_handler))
107 .route("/api/v1/kg/aliases", post(add_alias_handler))
108 .route(
109 "/api/v1/kg/prompt-facts",
110 get(list_prompt_facts_handler).delete(remove_prompt_fact_handler),
111 )
112 .route("/api/v1/chat", post(chat_handler))
113 .route("/api/v1/chat/providers", get(list_providers))
114 .route(
115 "/api/v1/palaces/{id}/chat/sessions",
116 get(list_chat_sessions).post(create_chat_session),
117 )
118 .route(
119 "/api/v1/palaces/{id}/chat/sessions/{session_id}",
120 get(get_chat_session).delete(delete_chat_session),
121 )
122 .route("/health", get(health))
123 .route("/api/v1/logs/tail", get(logs_tail))
124 .route("/api/v1/admin/stop", post(admin_stop))
125 .fallback(static_handler);
126
127 trusty_common::server::with_standard_middleware(router)
128}
129
130#[derive(serde::Serialize)]
146struct HealthResponse {
147 status: String,
152 #[serde(skip_serializing_if = "Option::is_none")]
156 detail: Option<String>,
157 version: &'static str,
158 rss_mb: u64,
161 disk_bytes: u64,
165 cpu_pct: f32,
169 uptime_secs: u64,
171 #[serde(skip_serializing_if = "Option::is_none")]
177 addr: Option<String>,
178}
179
180async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
201 let (rss_mb, cpu_pct) = {
202 let mut metrics = state.sys_metrics.lock().await;
203 metrics.sample()
204 };
205 let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
206 let uptime_secs = state.started_at.elapsed().as_secs();
207 let addr = state.bound_addr.get().map(|a| a.to_string());
208
209 let (status, detail) = match run_health_round_trip(&state).await {
210 Ok(()) => ("ok".to_string(), None),
211 Err(HealthProbeError::NoPalaces) => ("ok".to_string(), None),
212 Err(err) => {
213 tracing::warn!("/health round-trip degraded: {err}");
214 ("degraded".to_string(), Some(err.to_string()))
215 }
216 };
217
218 Json(HealthResponse {
219 status,
220 detail,
221 version: env!("CARGO_PKG_VERSION"),
222 rss_mb,
223 disk_bytes,
224 cpu_pct,
225 uptime_secs,
226 addr,
227 })
228}
229
230#[derive(Debug, thiserror::Error)]
241enum HealthProbeError {
242 #[error("no palaces present (skipped round-trip)")]
243 NoPalaces,
244 #[error("list palaces failed: {0}")]
245 ListPalaces(String),
246 #[error("open palace failed: {0}")]
247 OpenPalace(String),
248 #[error("store failed: {0}")]
249 Store(String),
250 #[error("recall failed: {0}")]
251 Recall(String),
252 #[error("recall did not return the probe drawer (id={0})")]
253 ProbeMissing(Uuid),
254 #[error("delete probe drawer failed: {0}")]
255 Delete(String),
256}
257
258async fn run_health_round_trip(state: &AppState) -> Result<(), HealthProbeError> {
275 let palaces = PalaceRegistry::list_palaces(&state.data_root)
276 .map_err(|e| HealthProbeError::ListPalaces(format!("{e:#}")))?;
277 let Some(palace) = palaces.into_iter().next() else {
278 return Err(HealthProbeError::NoPalaces);
279 };
280 let handle = state
281 .registry
282 .open_palace(&state.data_root, &palace.id)
283 .map_err(|e| HealthProbeError::OpenPalace(format!("{e:#}")))?;
284
285 let probe_token = Uuid::new_v4();
290 let probe_content = format!("__trusty_memory_healthcheck__ probe {probe_token}");
291
292 let drawer_id = handle
293 .remember(
294 probe_content.clone(),
295 RoomType::General,
296 vec!["healthcheck".to_string()],
297 0.0,
298 )
299 .await
300 .map_err(|e| HealthProbeError::Store(format!("{e:#}")))?;
301
302 let recall_result = recall_with_default_embedder(&handle, &probe_content, 5).await;
303
304 let delete_result = handle.forget(drawer_id).await;
308
309 match recall_result {
310 Ok(hits) => {
311 if !hits.iter().any(|hit| hit.drawer.id == drawer_id) {
312 return Err(HealthProbeError::ProbeMissing(drawer_id));
313 }
314 }
315 Err(e) => return Err(HealthProbeError::Recall(format!("{e:#}"))),
316 }
317
318 delete_result.map_err(|e| HealthProbeError::Delete(format!("{e:#}")))?;
319 Ok(())
320}
321
322const DEFAULT_LOGS_TAIL_N: usize = 100;
329
330const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
333
334fn default_logs_tail_n() -> usize {
335 DEFAULT_LOGS_TAIL_N
336}
337
338#[derive(serde::Deserialize)]
347struct LogsTailParams {
348 #[serde(default = "default_logs_tail_n")]
349 n: usize,
350}
351
352async fn logs_tail(
364 State(state): State<AppState>,
365 Query(params): Query<LogsTailParams>,
366) -> Json<Value> {
367 let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
368 let lines = state.log_buffer.tail(n);
369 Json(serde_json::json!({
370 "lines": lines,
371 "total": state.log_buffer.len(),
372 }))
373}
374
375async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
386 tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
387 tokio::spawn(async {
388 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
389 std::process::exit(0);
390 });
391 Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
392}
393
394async fn static_handler(req: Request<Body>) -> Response {
406 let path = req.uri().path().trim_start_matches('/').to_string();
407
408 if path.starts_with("api/") {
409 return (StatusCode::NOT_FOUND, "not found").into_response();
410 }
411
412 serve_embedded(&path).unwrap_or_else(|| {
413 serve_embedded("index.html")
415 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
416 })
417}
418
419fn serve_embedded(path: &str) -> Option<Response> {
420 let path = if path.is_empty() { "index.html" } else { path };
421 let asset = WebAssets::get(path)?;
422 let mime = mime_guess::from_path(path).first_or_octet_stream();
423 let body = Body::from(asset.data.into_owned());
424 let mut resp = Response::new(body);
425 resp.headers_mut().insert(
426 header::CONTENT_TYPE,
427 HeaderValue::from_str(mime.as_ref())
428 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
429 );
430 Some(resp)
431}
432
433#[derive(Serialize)]
438struct StatusPayload {
439 version: String,
440 palace_count: usize,
441 default_palace: Option<String>,
442 data_root: String,
443 total_drawers: usize,
444 total_vectors: usize,
445 total_kg_triples: usize,
446}
447
448async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
449 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
450 let palace_count = palaces.len();
451 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
452 for p in &palaces {
453 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
454 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
455 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
456 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
457 }
458 }
459 Json(StatusPayload {
460 version: state.version.clone(),
461 palace_count,
462 default_palace: state.default_palace.clone(),
463 data_root: state.data_root.display().to_string(),
464 total_drawers,
465 total_vectors,
466 total_kg_triples,
467 })
468}
469
470#[derive(Serialize)]
471struct ConfigPayload {
472 openrouter_configured: bool,
473 model: String,
474 data_root: String,
475}
476
477async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
478 let cfg = load_user_config().unwrap_or_default();
479 Json(ConfigPayload {
480 openrouter_configured: !cfg.openrouter_api_key.is_empty(),
481 model: cfg.openrouter_model,
482 data_root: state.data_root.display().to_string(),
483 })
484}
485
486#[derive(Deserialize, Default, Clone)]
489struct UserConfigMin {
490 #[serde(default)]
491 openrouter: OpenRouterMin,
492 #[serde(default)]
493 local_model: LocalModelMin,
494 }
496
497#[derive(Deserialize, Default, Clone)]
498struct OpenRouterMin {
499 #[serde(default)]
500 api_key: String,
501 #[serde(default)]
502 model: String,
503}
504
505#[derive(Deserialize, Clone)]
506struct LocalModelMin {
507 #[serde(default = "default_local_enabled")]
508 enabled: bool,
509 #[serde(default = "default_local_base_url")]
510 base_url: String,
511 #[serde(default = "default_local_model")]
512 model: String,
513}
514
515fn default_local_enabled() -> bool {
516 true
517}
518fn default_local_base_url() -> String {
519 "http://localhost:11434".to_string()
520}
521fn default_local_model() -> String {
522 "llama3.2".to_string()
523}
524
525impl Default for LocalModelMin {
526 fn default() -> Self {
527 Self {
528 enabled: default_local_enabled(),
529 base_url: default_local_base_url(),
530 model: default_local_model(),
531 }
532 }
533}
534
535#[derive(Clone)]
536pub(crate) struct LoadedUserConfig {
537 pub(crate) openrouter_api_key: String,
538 pub(crate) openrouter_model: String,
539 pub(crate) local_model: trusty_common::LocalModelConfig,
540}
541
542impl Default for LoadedUserConfig {
543 fn default() -> Self {
544 Self {
545 openrouter_api_key: String::new(),
546 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
547 local_model: trusty_common::LocalModelConfig::default(),
548 }
549 }
550}
551
552pub(crate) fn load_user_config() -> Option<LoadedUserConfig> {
553 let home = dirs::home_dir()?;
554 let path = home.join(".trusty-memory").join("config.toml");
555 if !path.exists() {
556 return Some(LoadedUserConfig::default());
557 }
558 let raw = std::fs::read_to_string(&path).ok()?;
559 let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
560 let model = if parsed.openrouter.model.is_empty() {
561 "anthropic/claude-3-5-sonnet".to_string()
562 } else {
563 parsed.openrouter.model
564 };
565 Some(LoadedUserConfig {
566 openrouter_api_key: parsed.openrouter.api_key,
567 openrouter_model: model,
568 local_model: trusty_common::LocalModelConfig {
569 enabled: parsed.local_model.enabled,
570 base_url: parsed.local_model.base_url,
571 model: parsed.local_model.model,
572 },
573 })
574}
575
576#[derive(Serialize)]
581struct PalaceInfo {
582 id: String,
583 name: String,
584 description: Option<String>,
585 drawer_count: usize,
586 vector_count: usize,
587 kg_triple_count: usize,
588 wing_count: usize,
589 created_at: chrono::DateTime<chrono::Utc>,
590 last_write_at: Option<chrono::DateTime<chrono::Utc>>,
600 #[serde(default)]
608 node_count: u64,
609 #[serde(default)]
615 edge_count: u64,
616 #[serde(default)]
624 community_count: u64,
625 #[serde(default)]
634 is_compacting: bool,
635}
636
637fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
647 let (
648 drawer_count,
649 vector_count,
650 kg_triple_count,
651 wing_count,
652 last_write_at,
653 node_count,
654 edge_count,
655 community_count,
656 is_compacting,
657 ) = if let Some(h) = handle {
658 let drawers = h.drawers.read();
659 let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
660 let last_write = drawers.iter().map(|d| d.created_at).max();
661 (
662 drawers.len(),
663 h.vector_store.index_size(),
664 h.kg.count_active_triples(),
665 distinct_rooms.len(),
666 last_write,
667 h.kg.node_count() as u64,
668 h.kg.edge_count() as u64,
669 h.kg.community_count() as u64,
670 h.is_compacting(),
671 )
672 } else {
673 (0, 0, 0, 0, None, 0, 0, 0, false)
674 };
675 PalaceInfo {
676 id: palace.id.0.clone(),
677 name: palace.name.clone(),
678 description: palace.description.clone(),
679 drawer_count,
680 vector_count,
681 kg_triple_count,
682 wing_count,
683 created_at: palace.created_at,
684 last_write_at,
685 node_count,
686 edge_count,
687 community_count,
688 is_compacting,
689 }
690}
691
692async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
693 let palaces = PalaceRegistry::list_palaces(&state.data_root)
694 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
695 let mut out = Vec::with_capacity(palaces.len());
696 for p in palaces {
697 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
698 out.push(palace_info_from(&p, handle.as_ref()));
699 }
700 Ok(Json(out))
701}
702
703#[derive(Deserialize)]
704struct CreatePalaceBody {
705 name: String,
706 #[serde(default)]
707 description: Option<String>,
708}
709
710async fn create_palace(
711 State(state): State<AppState>,
712 Json(body): Json<CreatePalaceBody>,
713) -> Result<Json<Value>, ApiError> {
714 let name = body.name.trim().to_string();
715 if name.is_empty() {
716 return Err(ApiError::bad_request("name is required"));
717 }
718 let id = PalaceId::new(&name);
719 let palace = Palace {
720 id: id.clone(),
721 name: name.clone(),
722 description: body.description.filter(|s| !s.is_empty()),
723 created_at: chrono::Utc::now(),
724 data_dir: state.data_root.join(&name),
725 };
726 state
727 .registry
728 .create_palace(&state.data_root, palace)
729 .map_err(|e| ApiError::internal(format!("create palace: {e:#}")))?;
730 state.emit(DaemonEvent::PalaceCreated {
731 id: name.clone(),
732 name: name.clone(),
733 });
734 Ok(Json(json!({ "id": name })))
735}
736
737async fn get_palace_handler(
738 State(state): State<AppState>,
739 AxumPath(id): AxumPath<String>,
740) -> Result<Json<PalaceInfo>, ApiError> {
741 let palaces = PalaceRegistry::list_palaces(&state.data_root)
742 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
743 let palace = palaces
744 .into_iter()
745 .find(|p| p.id.0 == id)
746 .ok_or_else(|| ApiError::not_found(format!("palace not found: {id}")))?;
747 let handle = state
748 .registry
749 .open_palace(&state.data_root, &palace.id)
750 .ok();
751 Ok(Json(palace_info_from(&palace, handle.as_ref())))
752}
753
754#[derive(Deserialize)]
759struct ListDrawersQuery {
760 #[serde(default)]
761 room: Option<String>,
762 #[serde(default)]
763 tag: Option<String>,
764 #[serde(default)]
765 limit: Option<usize>,
766}
767
768async fn list_drawers(
769 State(state): State<AppState>,
770 AxumPath(id): AxumPath<String>,
771 Query(q): Query<ListDrawersQuery>,
772) -> Result<Json<Value>, ApiError> {
773 let handle = open_handle(&state, &id)?;
774 let room = q.room.as_deref().map(RoomType::parse);
775 let drawers = handle.list_drawers(room, q.tag.clone(), q.limit.unwrap_or(50));
776 Ok(Json(serde_json::to_value(drawers).unwrap_or(json!([]))))
777}
778
779#[derive(Deserialize)]
780struct CreateDrawerBody {
781 content: String,
782 #[serde(default)]
783 room: Option<String>,
784 #[serde(default)]
785 tags: Vec<String>,
786 #[serde(default)]
787 importance: Option<f32>,
788}
789
790const DRAWER_PREVIEW_MAX_CHARS: usize = 80;
797
798fn drawer_content_preview(content: &str) -> String {
808 let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
809 if normalised.chars().count() <= DRAWER_PREVIEW_MAX_CHARS {
810 normalised
811 } else {
812 let kept: String = normalised
813 .chars()
814 .take(DRAWER_PREVIEW_MAX_CHARS.saturating_sub(1))
815 .collect();
816 format!("{kept}…")
817 }
818}
819
820async fn create_drawer(
821 State(state): State<AppState>,
822 AxumPath(id): AxumPath<String>,
823 Json(body): Json<CreateDrawerBody>,
824) -> Result<Json<Value>, ApiError> {
825 let handle = open_handle(&state, &id)?;
826 let room = body
827 .room
828 .as_deref()
829 .map(RoomType::parse)
830 .unwrap_or(RoomType::General);
831 let importance = body.importance.unwrap_or(0.5);
832 let content_preview = drawer_content_preview(&body.content);
835 let drawer_id = handle
836 .remember(body.content, room, body.tags, importance)
837 .await
838 .map_err(|e| ApiError::internal(format!("remember: {e:#}")))?;
839 let drawer_count = handle.drawers.read().len();
840 let palace_name = PalaceRegistry::list_palaces(&state.data_root)
841 .ok()
842 .and_then(|ps| ps.into_iter().find(|p| p.id.0 == id).map(|p| p.name))
843 .unwrap_or_else(|| id.clone());
844 state.emit(DaemonEvent::DrawerAdded {
845 palace_id: id.clone(),
846 palace_name,
847 drawer_count,
848 timestamp: chrono::Utc::now(),
849 content_preview,
850 });
851 state.emit(aggregate_status_event(&state));
852 Ok(Json(json!({ "id": drawer_id })))
853}
854
855async fn delete_drawer(
856 State(state): State<AppState>,
857 AxumPath((id, drawer_id)): AxumPath<(String, String)>,
858) -> Result<StatusCode, ApiError> {
859 let handle = open_handle(&state, &id)?;
860 let uuid = Uuid::parse_str(&drawer_id)
861 .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
862 handle
863 .forget(uuid)
864 .await
865 .map_err(|e| ApiError::internal(format!("forget: {e:#}")))?;
866 let drawer_count = handle.drawers.read().len();
867 state.emit(DaemonEvent::DrawerDeleted {
868 palace_id: id.clone(),
869 drawer_count,
870 });
871 state.emit(aggregate_status_event(&state));
872 Ok(StatusCode::NO_CONTENT)
873}
874
875fn aggregate_status_event(state: &AppState) -> DaemonEvent {
884 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
885 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
886 for p in &palaces {
887 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
888 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
889 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
890 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
891 }
892 }
893 DaemonEvent::StatusChanged {
894 total_drawers,
895 total_vectors,
896 total_kg_triples,
897 }
898}
899
900#[derive(Deserialize)]
905struct RecallQuery {
906 q: String,
907 #[serde(default)]
908 top_k: Option<usize>,
909 #[serde(default)]
910 deep: Option<bool>,
911}
912
913async fn recall_handler(
914 State(state): State<AppState>,
915 AxumPath(id): AxumPath<String>,
916 Query(q): Query<RecallQuery>,
917) -> Result<Json<Value>, ApiError> {
918 let handle = open_handle(&state, &id)?;
919 let top_k = q.top_k.unwrap_or(10);
920 let results = if q.deep.unwrap_or(false) {
921 recall_deep_with_default_embedder(&handle, &q.q, top_k).await
922 } else {
923 recall_with_default_embedder(&handle, &q.q, top_k).await
924 }
925 .map_err(|e| ApiError::internal(format!("recall: {e:#}")))?;
926
927 let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
928 Ok(Json(json!(payload)))
929}
930
931fn recall_entry_json(r: RecallResult) -> Value {
948 let mut obj = match serde_json::to_value(&r.drawer) {
949 Ok(Value::Object(map)) => map,
950 _ => serde_json::Map::new(),
951 };
952 obj.insert("score".to_string(), json!(r.score));
953 obj.insert("layer".to_string(), json!(r.layer));
954 Value::Object(obj)
955}
956
957async fn recall_all_handler(
971 State(state): State<AppState>,
972 Query(q): Query<RecallQuery>,
973) -> Result<Json<Value>, ApiError> {
974 let top_k = q.top_k.unwrap_or(10);
975 let deep = q.deep.unwrap_or(false);
976 let value = execute_recall_all(&state, &q.q, top_k, deep).await;
977 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
978 return Err(ApiError::internal(err.to_string()));
979 }
980 Ok(Json(value))
981}
982
983#[derive(Deserialize)]
988struct KgQueryParams {
989 subject: String,
990}
991
992async fn kg_query(
993 State(state): State<AppState>,
994 AxumPath(id): AxumPath<String>,
995 Query(q): Query<KgQueryParams>,
996) -> Result<Json<Vec<Triple>>, ApiError> {
997 let handle = open_handle(&state, &id)?;
998 let triples = handle
999 .kg
1000 .query_active(&q.subject)
1001 .await
1002 .map_err(|e| ApiError::internal(format!("kg query: {e:#}")))?;
1003 Ok(Json(triples))
1004}
1005
1006#[derive(Deserialize)]
1007struct KgAssertBody {
1008 subject: String,
1009 predicate: String,
1010 object: String,
1011 #[serde(default)]
1012 confidence: Option<f32>,
1013 #[serde(default)]
1014 provenance: Option<String>,
1015}
1016
1017async fn kg_assert(
1018 State(state): State<AppState>,
1019 AxumPath(id): AxumPath<String>,
1020 Json(body): Json<KgAssertBody>,
1021) -> Result<StatusCode, ApiError> {
1022 let handle = open_handle(&state, &id)?;
1023 let triple = Triple {
1024 subject: body.subject,
1025 predicate: body.predicate,
1026 object: body.object,
1027 valid_from: chrono::Utc::now(),
1028 valid_to: None,
1029 confidence: body.confidence.unwrap_or(1.0),
1030 provenance: body.provenance,
1031 };
1032 handle
1033 .kg
1034 .assert(triple)
1035 .await
1036 .map_err(|e| ApiError::internal(format!("kg assert: {e:#}")))?;
1037 Ok(StatusCode::NO_CONTENT)
1038}
1039
1040const DEFAULT_KG_LIST_LIMIT: usize = 50;
1045
1046const MAX_KG_LIST_LIMIT: usize = 200;
1051
1052fn default_kg_list_limit() -> usize {
1053 DEFAULT_KG_LIST_LIMIT
1054}
1055
1056#[derive(Deserialize)]
1065struct KgListSubjectsParams {
1066 #[serde(default = "default_kg_list_limit")]
1067 limit: usize,
1068}
1069
1070async fn kg_list_subjects(
1080 State(state): State<AppState>,
1081 AxumPath(id): AxumPath<String>,
1082 Query(q): Query<KgListSubjectsParams>,
1083) -> Result<Json<Vec<String>>, ApiError> {
1084 let handle = open_handle(&state, &id)?;
1085 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1086 let subjects = handle
1087 .kg
1088 .list_subjects(limit)
1089 .map_err(|e| ApiError::internal(format!("kg list_subjects: {e:#}")))?;
1090 Ok(Json(subjects))
1091}
1092
1093async fn kg_list_subjects_with_counts(
1105 State(state): State<AppState>,
1106 AxumPath(id): AxumPath<String>,
1107 Query(q): Query<KgListSubjectsParams>,
1108) -> Result<Json<Vec<Value>>, ApiError> {
1109 let handle = open_handle(&state, &id)?;
1110 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1111 let rows = handle
1112 .kg
1113 .list_subjects_with_counts(limit)
1114 .map_err(|e| ApiError::internal(format!("kg list_subjects_with_counts: {e:#}")))?;
1115 let out: Vec<Value> = rows
1116 .into_iter()
1117 .map(|(subject, count)| json!({ "subject": subject, "count": count }))
1118 .collect();
1119 Ok(Json(out))
1120}
1121
1122#[derive(Deserialize)]
1129struct KgListAllParams {
1130 #[serde(default = "default_kg_list_limit")]
1131 limit: usize,
1132 #[serde(default)]
1133 offset: usize,
1134}
1135
1136async fn kg_list_all(
1145 State(state): State<AppState>,
1146 AxumPath(id): AxumPath<String>,
1147 Query(q): Query<KgListAllParams>,
1148) -> Result<Json<Vec<Triple>>, ApiError> {
1149 let handle = open_handle(&state, &id)?;
1150 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1151 let triples = handle
1152 .kg
1153 .list_active(limit, q.offset)
1154 .await
1155 .map_err(|e| ApiError::internal(format!("kg list_active: {e:#}")))?;
1156 Ok(Json(triples))
1157}
1158
1159async fn kg_count(
1167 State(state): State<AppState>,
1168 AxumPath(id): AxumPath<String>,
1169) -> Result<Json<Value>, ApiError> {
1170 let handle = open_handle(&state, &id)?;
1171 let active = handle.kg.count_active_triples();
1172 Ok(Json(json!({ "active": active })))
1173}
1174
1175#[derive(Serialize, Default)]
1182struct DreamStatusPayload {
1183 last_run_at: Option<chrono::DateTime<chrono::Utc>>,
1184 merged: usize,
1185 pruned: usize,
1186 compacted: usize,
1187 closets_updated: usize,
1188 duration_ms: u64,
1189}
1190
1191impl From<PersistedDreamStats> for DreamStatusPayload {
1192 fn from(p: PersistedDreamStats) -> Self {
1193 Self {
1194 last_run_at: Some(p.last_run_at),
1195 merged: p.stats.merged,
1196 pruned: p.stats.pruned,
1197 compacted: p.stats.compacted,
1198 closets_updated: p.stats.closets_updated,
1199 duration_ms: p.stats.duration_ms,
1200 }
1201 }
1202}
1203
1204async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
1213 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1214 let mut out = DreamStatusPayload::default();
1215 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1216 for p in palaces {
1217 let data_dir = state.data_root.join(p.id.as_str());
1218 let snap = match PersistedDreamStats::load(&data_dir) {
1219 Ok(Some(s)) => s,
1220 _ => continue,
1221 };
1222 out.merged = out.merged.saturating_add(snap.stats.merged);
1223 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1224 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1225 out.closets_updated = out
1226 .closets_updated
1227 .saturating_add(snap.stats.closets_updated);
1228 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1229 latest = match latest {
1230 Some(t) if t >= snap.last_run_at => Some(t),
1231 _ => Some(snap.last_run_at),
1232 };
1233 }
1234 out.last_run_at = latest;
1235 Json(out)
1236}
1237
1238async fn palace_dream_status(
1240 State(state): State<AppState>,
1241 AxumPath(id): AxumPath<String>,
1242) -> Result<Json<DreamStatusPayload>, ApiError> {
1243 let data_dir = state.data_root.join(&id);
1244 if !data_dir.exists() {
1245 return Err(ApiError::not_found(format!("palace not found: {id}")));
1246 }
1247 let payload = match PersistedDreamStats::load(&data_dir) {
1248 Ok(Some(s)) => s.into(),
1249 Ok(None) => DreamStatusPayload::default(),
1250 Err(e) => return Err(ApiError::internal(format!("read dream stats: {e:#}"))),
1251 };
1252 Ok(Json(payload))
1253}
1254
1255async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
1265 let palaces = PalaceRegistry::list_palaces(&state.data_root)
1266 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
1267 let dreamer = Dreamer::new(DreamConfig::default());
1268 let mut out = DreamStatusPayload::default();
1269 for p in palaces {
1270 let handle = match state.registry.open_palace(&state.data_root, &p.id) {
1271 Ok(h) => h,
1272 Err(e) => {
1273 tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
1274 continue;
1275 }
1276 };
1277 match dreamer.dream_cycle(&handle).await {
1278 Ok(stats) => {
1279 out.merged = out.merged.saturating_add(stats.merged);
1280 out.pruned = out.pruned.saturating_add(stats.pruned);
1281 out.compacted = out.compacted.saturating_add(stats.compacted);
1282 out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
1283 out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
1284 }
1285 Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
1286 }
1287 refresh_gaps_cache(&state, &handle).await;
1292 }
1293 out.last_run_at = Some(chrono::Utc::now());
1294 state.emit(DaemonEvent::DreamCompleted {
1295 palace_id: None,
1296 merged: out.merged,
1297 pruned: out.pruned,
1298 compacted: out.compacted,
1299 closets_updated: out.closets_updated,
1300 duration_ms: out.duration_ms,
1301 });
1302 state.emit(aggregate_status_event(&state));
1303 Ok(Json(out))
1304}
1305
1306#[derive(Serialize, Debug, Clone)]
1320pub struct KnowledgeGapResponse {
1321 pub entities: Vec<String>,
1322 pub internal_density: f32,
1323 pub external_bridges: usize,
1324 pub suggested_exploration: String,
1325}
1326
1327impl From<KnowledgeGap> for KnowledgeGapResponse {
1328 fn from(g: KnowledgeGap) -> Self {
1329 Self {
1330 entities: g.entities,
1331 internal_density: g.internal_density,
1332 external_bridges: g.external_bridges,
1333 suggested_exploration: g.suggested_exploration,
1334 }
1335 }
1336}
1337
1338#[derive(Deserialize)]
1339struct KgGapsQuery {
1340 #[serde(default)]
1341 palace: Option<String>,
1342}
1343
1344async fn kg_gaps_handler(
1359 State(state): State<AppState>,
1360 Query(q): Query<KgGapsQuery>,
1361) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
1362 let palace_name = q
1363 .palace
1364 .clone()
1365 .or_else(|| state.default_palace.clone())
1366 .ok_or_else(|| {
1367 ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
1368 })?;
1369
1370 let _handle = open_handle(&state, &palace_name)?;
1374
1375 let pid = PalaceId::new(&palace_name);
1376 let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
1377 let body: Vec<KnowledgeGapResponse> =
1378 gaps.into_iter().map(KnowledgeGapResponse::from).collect();
1379 Ok(Json(body))
1380}
1381
1382#[derive(Deserialize)]
1396struct PromptFactsQuery {
1397 #[serde(default)]
1402 #[allow(dead_code)]
1403 palace: Option<String>,
1404}
1405
1406#[derive(Deserialize)]
1415struct AddAliasRequest {
1416 short: String,
1417 full: String,
1418 #[serde(default)]
1419 palace: Option<String>,
1420}
1421
1422#[derive(Serialize)]
1430struct PromptFactRow {
1431 subject: String,
1432 predicate: String,
1433 object: String,
1434}
1435
1436#[derive(Deserialize)]
1447struct RemovePromptFactQuery {
1448 subject: String,
1449 predicate: String,
1450 #[serde(default)]
1451 #[allow(dead_code)]
1452 object: Option<String>,
1453 #[serde(default)]
1454 #[allow(dead_code)]
1455 palace: Option<String>,
1456}
1457
1458async fn prompt_context_handler(
1469 State(state): State<AppState>,
1470 Query(_q): Query<PromptFactsQuery>,
1471) -> Result<Response, ApiError> {
1472 let cache_snapshot = {
1473 let guard = state
1474 .prompt_context_cache
1475 .read()
1476 .map_err(|e| ApiError::internal(format!("prompt cache lock poisoned: {e}")))?;
1477 guard.clone()
1478 };
1479 let body = if cache_snapshot.formatted.is_empty() {
1480 "No prompt facts stored yet.".to_string()
1481 } else {
1482 cache_snapshot.formatted
1483 };
1484 let mut resp = body.into_response();
1485 resp.headers_mut().insert(
1486 header::CONTENT_TYPE,
1487 HeaderValue::from_static("text/plain; charset=utf-8"),
1488 );
1489 Ok(resp)
1490}
1491
1492async fn add_alias_handler(
1502 State(state): State<AppState>,
1503 Json(req): Json<AddAliasRequest>,
1504) -> Result<Json<Value>, ApiError> {
1505 if req.short.is_empty() || req.full.is_empty() {
1506 return Err(ApiError::bad_request("short and full are required"));
1507 }
1508 let palace_name = req
1509 .palace
1510 .clone()
1511 .or_else(|| state.default_palace.clone())
1512 .ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
1513 let handle = open_handle(&state, &palace_name)?;
1514 let triple = Triple {
1515 subject: req.short.clone(),
1516 predicate: "is_alias_for".to_string(),
1517 object: req.full.clone(),
1518 valid_from: chrono::Utc::now(),
1519 valid_to: None,
1520 confidence: 1.0,
1521 provenance: Some("add_alias_http".to_string()),
1522 };
1523 handle
1524 .kg
1525 .assert(triple)
1526 .await
1527 .map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
1528 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1529 tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
1530 }
1531 Ok(Json(json!({
1532 "subject": req.short,
1533 "predicate": "is_alias_for",
1534 "object": req.full,
1535 "palace": palace_name,
1536 })))
1537}
1538
1539async fn list_prompt_facts_handler(
1548 State(state): State<AppState>,
1549 Query(_q): Query<PromptFactsQuery>,
1550) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
1551 let triples = crate::prompt_facts::gather_hot_triples(&state)
1552 .await
1553 .map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
1554 let rows: Vec<PromptFactRow> = triples
1555 .into_iter()
1556 .map(|(subject, predicate, object)| PromptFactRow {
1557 subject,
1558 predicate,
1559 object,
1560 })
1561 .collect();
1562 Ok(Json(rows))
1563}
1564
1565async fn remove_prompt_fact_handler(
1576 State(state): State<AppState>,
1577 Query(q): Query<RemovePromptFactQuery>,
1578) -> Result<Json<Value>, ApiError> {
1579 if q.subject.is_empty() || q.predicate.is_empty() {
1580 return Err(ApiError::bad_request("subject and predicate are required"));
1581 }
1582 let mut closed_total: usize = 0;
1583 for palace_id in state.registry.list() {
1584 if let Some(handle) = state.registry.get(&palace_id) {
1585 match handle.kg.retract(&q.subject, &q.predicate).await {
1586 Ok(n) => closed_total += n,
1587 Err(e) => tracing::warn!(
1588 palace = %palace_id.as_str(),
1589 "HTTP retract failed: {e:#}",
1590 ),
1591 }
1592 }
1593 }
1594 if closed_total > 0 {
1595 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1596 tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
1597 }
1598 Ok(Json(json!({"removed": true, "closed": closed_total})))
1599 } else {
1600 Ok(Json(json!({"removed": false, "reason": "not found"})))
1601 }
1602}
1603
1604async fn refresh_gaps_cache(state: &AppState, handle: &Arc<PalaceHandle>) {
1617 let mut gaps = handle.kg.knowledge_gaps();
1618 if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") {
1623 if !api_key.is_empty() {
1624 for gap in gaps.iter_mut() {
1625 if let Some(enriched) = enrich_gap_exploration(&api_key, gap).await {
1626 gap.suggested_exploration = enriched;
1627 }
1628 }
1629 }
1630 }
1631 let gap_count = gaps.len();
1632 state.registry.set_gaps(handle.id.clone(), gaps);
1633 tracing::debug!(palace = %handle.id, gaps = gap_count, "community gaps updated");
1634}
1635
1636async fn enrich_gap_exploration(api_key: &str, gap: &KnowledgeGap) -> Option<String> {
1651 let preview: Vec<&str> = gap.entities.iter().take(5).map(String::as_str).collect();
1654 if preview.is_empty() {
1655 return None;
1656 }
1657 let entities = preview.join(", ");
1658 let user = format!(
1659 "Given these related entities from a knowledge graph: {entities}. \
1660 Suggest one specific research question (single sentence, under 25 words) \
1661 that would help fill gaps in this knowledge cluster. Return only the question."
1662 );
1663 let messages = vec![trusty_common::ChatMessage {
1664 role: "user".to_string(),
1665 content: user,
1666 tool_call_id: None,
1667 tool_calls: None,
1668 }];
1669 #[allow(deprecated)]
1673 let res = trusty_common::openrouter_chat(api_key, "openai/gpt-4o-mini", messages).await;
1674 match res {
1675 Ok(text) => {
1676 let trimmed = text.trim().to_string();
1677 if trimmed.is_empty() {
1678 None
1679 } else {
1680 Some(trimmed)
1681 }
1682 }
1683 Err(e) => {
1684 tracing::debug!("openrouter gap enrichment failed (using template): {e:#}");
1685 None
1686 }
1687 }
1688}
1689
1690#[derive(Deserialize)]
1695struct ChatBody {
1696 #[serde(default)]
1697 palace_id: Option<String>,
1698 message: String,
1699 #[serde(default)]
1700 history: Vec<ChatMessage>,
1701 #[serde(default)]
1703 session_id: Option<String>,
1704}
1705
1706const MAX_TOOL_ROUNDS: usize = 10;
1712
1713fn all_tools() -> Vec<ToolDef> {
1722 vec![
1723 ToolDef {
1724 name: "list_palaces".into(),
1725 description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
1726 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1727 },
1728 ToolDef {
1729 name: "get_palace".into(),
1730 description: "Get details for a specific palace by id.".into(),
1731 parameters: json!({
1732 "type": "object",
1733 "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
1734 "required": ["palace_id"],
1735 }),
1736 },
1737 ToolDef {
1738 name: "recall_memories".into(),
1739 description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
1740 parameters: json!({
1741 "type": "object",
1742 "properties": {
1743 "palace_id": { "type": "string" },
1744 "query": { "type": "string", "description": "Free-text query" },
1745 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
1746 },
1747 "required": ["palace_id", "query"],
1748 }),
1749 },
1750 ToolDef {
1751 name: "list_drawers".into(),
1752 description: "List all drawers (memories) in a palace, most recent first.".into(),
1753 parameters: json!({
1754 "type": "object",
1755 "properties": { "palace_id": { "type": "string" } },
1756 "required": ["palace_id"],
1757 }),
1758 },
1759 ToolDef {
1760 name: "kg_query".into(),
1761 description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
1762 parameters: json!({
1763 "type": "object",
1764 "properties": {
1765 "palace_id": { "type": "string" },
1766 "subject": { "type": "string" }
1767 },
1768 "required": ["palace_id", "subject"],
1769 }),
1770 },
1771 ToolDef {
1772 name: "get_config".into(),
1773 description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
1774 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1775 },
1776 ToolDef {
1777 name: "get_status".into(),
1778 description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
1779 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1780 },
1781 ToolDef {
1782 name: "get_dream_status".into(),
1783 description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
1784 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1785 },
1786 ToolDef {
1787 name: "get_palace_dream_status".into(),
1788 description: "Get dreamer activity stats for a specific palace.".into(),
1789 parameters: json!({
1790 "type": "object",
1791 "properties": { "palace_id": { "type": "string" } },
1792 "required": ["palace_id"],
1793 }),
1794 },
1795 ToolDef {
1796 name: "create_memory".into(),
1797 description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
1798 parameters: json!({
1799 "type": "object",
1800 "properties": {
1801 "palace_id": { "type": "string" },
1802 "content": { "type": "string", "description": "Verbatim memory text" },
1803 "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
1804 "tags": { "type": "array", "items": { "type": "string" } },
1805 "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
1806 },
1807 "required": ["palace_id", "content"],
1808 }),
1809 },
1810 ToolDef {
1811 name: "kg_assert".into(),
1812 description: "Assert a knowledge-graph triple. Any prior active triple with the same (subject, predicate) is closed out (valid_to set to now) before the new one is inserted.".into(),
1813 parameters: json!({
1814 "type": "object",
1815 "properties": {
1816 "palace_id": { "type": "string" },
1817 "subject": { "type": "string" },
1818 "predicate": { "type": "string" },
1819 "object": { "type": "string" },
1820 "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
1821 },
1822 "required": ["palace_id", "subject", "predicate", "object"],
1823 }),
1824 },
1825 ToolDef {
1826 name: "memory_recall_all".into(),
1827 description: "Semantic search across ALL palaces simultaneously. Returns the top-k most relevant drawers ranked by similarity, regardless of which palace they belong to. Each result includes a `palace_id` field identifying its source.".into(),
1828 parameters: json!({
1829 "type": "object",
1830 "properties": {
1831 "q": { "type": "string", "description": "Free-text query" },
1832 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
1833 "deep": { "type": "boolean", "default": false }
1834 },
1835 "required": ["q"],
1836 }),
1837 },
1838 ]
1839}
1840
1841async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
1852 let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
1853 match name {
1854 "list_palaces" => execute_list_palaces(state).await,
1855 "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1856 Some(id) => execute_get_palace(state, id).await,
1857 None => json!({ "error": "missing required argument: palace_id" }),
1858 },
1859 "recall_memories" => {
1860 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1861 let q = parsed.get("query").and_then(|v| v.as_str());
1862 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1863 match (pid, q) {
1864 (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
1865 _ => json!({ "error": "missing required argument(s): palace_id, query" }),
1866 }
1867 }
1868 "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1869 Some(id) => execute_list_drawers(state, id).await,
1870 None => json!({ "error": "missing required argument: palace_id" }),
1871 },
1872 "kg_query" => {
1873 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1874 let subj = parsed.get("subject").and_then(|v| v.as_str());
1875 match (pid, subj) {
1876 (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
1877 _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
1878 }
1879 }
1880 "get_config" => execute_get_config(state),
1881 "get_status" => execute_get_status(state).await,
1882 "get_dream_status" => execute_get_dream_status(state).await,
1883 "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1884 Some(id) => execute_get_palace_dream_status(state, id).await,
1885 None => json!({ "error": "missing required argument: palace_id" }),
1886 },
1887 "create_memory" => {
1888 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1889 let content = parsed.get("content").and_then(|v| v.as_str());
1890 let room = parsed.get("room").and_then(|v| v.as_str());
1891 let tags: Vec<String> = parsed
1892 .get("tags")
1893 .and_then(|v| v.as_array())
1894 .map(|arr| {
1895 arr.iter()
1896 .filter_map(|t| t.as_str().map(|s| s.to_string()))
1897 .collect()
1898 })
1899 .unwrap_or_default();
1900 let importance = parsed
1901 .get("importance")
1902 .and_then(|v| v.as_f64())
1903 .map(|f| f as f32)
1904 .unwrap_or(0.5);
1905 match (pid, content) {
1906 (Some(p), Some(c)) => {
1907 execute_create_memory(state, p, c, room, tags, importance).await
1908 }
1909 _ => json!({ "error": "missing required argument(s): palace_id, content" }),
1910 }
1911 }
1912 "kg_assert" => {
1913 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1914 let subj = parsed.get("subject").and_then(|v| v.as_str());
1915 let pred = parsed.get("predicate").and_then(|v| v.as_str());
1916 let obj = parsed.get("object").and_then(|v| v.as_str());
1917 let conf = parsed
1918 .get("confidence")
1919 .and_then(|v| v.as_f64())
1920 .map(|f| f as f32)
1921 .unwrap_or(1.0);
1922 match (pid, subj, pred, obj) {
1923 (Some(p), Some(s), Some(pr), Some(o)) => {
1924 execute_kg_assert(state, p, s, pr, o, conf).await
1925 }
1926 _ => json!({
1927 "error": "missing required argument(s): palace_id, subject, predicate, object"
1928 }),
1929 }
1930 }
1931 "memory_recall_all" => {
1932 let q = parsed.get("q").and_then(|v| v.as_str());
1933 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1934 let deep = parsed
1935 .get("deep")
1936 .and_then(|v| v.as_bool())
1937 .unwrap_or(false);
1938 match q {
1939 Some(q) => execute_recall_all(state, q, top_k, deep).await,
1940 None => json!({ "error": "missing required argument: q" }),
1941 }
1942 }
1943 _ => json!({ "error": format!("unknown tool: {name}") }),
1944 }
1945}
1946
1947async fn execute_list_palaces(state: &AppState) -> Value {
1948 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1949 Ok(v) => v,
1950 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1951 };
1952 let out: Vec<Value> = palaces
1953 .into_iter()
1954 .map(|p| {
1955 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1956 let info = palace_info_from(&p, handle.as_ref());
1957 serde_json::to_value(info).unwrap_or(json!({}))
1958 })
1959 .collect();
1960 json!(out)
1961}
1962
1963async fn execute_get_palace(state: &AppState, id: &str) -> Value {
1964 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1965 Ok(v) => v,
1966 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1967 };
1968 match palaces.into_iter().find(|p| p.id.0 == id) {
1969 Some(p) => {
1970 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1971 serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
1972 }
1973 None => json!({ "error": format!("palace not found: {id}") }),
1974 }
1975}
1976
1977async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
1978 let handle = match state
1979 .registry
1980 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1981 {
1982 Ok(h) => h,
1983 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1984 };
1985 match recall_with_default_embedder(&handle, query, top_k).await {
1986 Ok(hits) => json!(hits
1987 .into_iter()
1988 .map(|r| json!({
1989 "drawer_id": r.drawer.id.to_string(),
1990 "content": r.drawer.content,
1991 "importance": r.drawer.importance,
1992 "tags": r.drawer.tags,
1993 "score": r.score,
1994 "layer": r.layer,
1995 }))
1996 .collect::<Vec<_>>()),
1997 Err(e) => json!({ "error": format!("recall: {e:#}") }),
1998 }
1999}
2000
2001async fn execute_recall_all(state: &AppState, query: &str, top_k: usize, deep: bool) -> Value {
2012 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
2013 Ok(v) => v,
2014 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
2015 };
2016 let mut handles = Vec::with_capacity(palaces.len());
2017 for p in &palaces {
2018 match state.registry.open_palace(&state.data_root, &p.id) {
2019 Ok(h) => handles.push(h),
2020 Err(e) => {
2021 tracing::warn!(palace = %p.id, "execute_recall_all: open failed: {e:#}");
2022 }
2023 }
2024 }
2025 if handles.is_empty() {
2026 return json!([]);
2027 }
2028 match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
2029 Ok(results) => json!(results
2030 .into_iter()
2031 .map(|r| json!({
2032 "palace_id": r.palace_id,
2033 "drawer_id": r.result.drawer.id.to_string(),
2034 "content": r.result.drawer.content,
2035 "importance": r.result.drawer.importance,
2036 "tags": r.result.drawer.tags,
2037 "score": r.result.score,
2038 "layer": r.result.layer,
2039 }))
2040 .collect::<Vec<_>>()),
2041 Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
2042 }
2043}
2044
2045async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
2046 let handle = match state
2047 .registry
2048 .open_palace(&state.data_root, &PalaceId::new(palace_id))
2049 {
2050 Ok(h) => h,
2051 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2052 };
2053 let drawers = handle.list_drawers(None, None, 200);
2054 serde_json::to_value(drawers).unwrap_or(json!([]))
2055}
2056
2057async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
2058 let handle = match state
2059 .registry
2060 .open_palace(&state.data_root, &PalaceId::new(palace_id))
2061 {
2062 Ok(h) => h,
2063 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2064 };
2065 match handle.kg.query_active(subject).await {
2066 Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
2067 Err(e) => json!({ "error": format!("kg query: {e:#}") }),
2068 }
2069}
2070
2071fn execute_get_config(state: &AppState) -> Value {
2072 let cfg = load_user_config().unwrap_or_default();
2073 json!({
2074 "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
2075 "openrouter_model": cfg.openrouter_model,
2076 "local_model": {
2077 "enabled": cfg.local_model.enabled,
2078 "base_url": cfg.local_model.base_url,
2079 "model": cfg.local_model.model,
2080 },
2081 "data_root": state.data_root.display().to_string(),
2082 })
2083}
2084
2085async fn execute_get_status(state: &AppState) -> Value {
2086 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2087 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
2088 for p in &palaces {
2089 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
2090 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
2091 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
2092 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
2093 }
2094 }
2095 json!({
2096 "version": state.version,
2097 "palace_count": palaces.len(),
2098 "default_palace": state.default_palace,
2099 "data_root": state.data_root.display().to_string(),
2100 "total_drawers": total_drawers,
2101 "total_vectors": total_vectors,
2102 "total_kg_triples": total_kg_triples,
2103 })
2104}
2105
2106async fn execute_get_dream_status(state: &AppState) -> Value {
2107 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2108 let mut out = DreamStatusPayload::default();
2109 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
2110 for p in palaces {
2111 let data_dir = state.data_root.join(p.id.as_str());
2112 let snap = match PersistedDreamStats::load(&data_dir) {
2113 Ok(Some(s)) => s,
2114 _ => continue,
2115 };
2116 out.merged = out.merged.saturating_add(snap.stats.merged);
2117 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
2118 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
2119 out.closets_updated = out
2120 .closets_updated
2121 .saturating_add(snap.stats.closets_updated);
2122 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
2123 latest = match latest {
2124 Some(t) if t >= snap.last_run_at => Some(t),
2125 _ => Some(snap.last_run_at),
2126 };
2127 }
2128 out.last_run_at = latest;
2129 serde_json::to_value(out).unwrap_or(json!({}))
2130}
2131
2132async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
2133 let data_dir = state.data_root.join(palace_id);
2134 if !data_dir.exists() {
2135 return json!({ "error": format!("palace not found: {palace_id}") });
2136 }
2137 match PersistedDreamStats::load(&data_dir) {
2138 Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
2139 Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
2140 Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
2141 }
2142}
2143
2144async fn execute_create_memory(
2145 state: &AppState,
2146 palace_id: &str,
2147 content: &str,
2148 room: Option<&str>,
2149 tags: Vec<String>,
2150 importance: f32,
2151) -> Value {
2152 let handle = match state
2153 .registry
2154 .open_palace(&state.data_root, &PalaceId::new(palace_id))
2155 {
2156 Ok(h) => h,
2157 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2158 };
2159 let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
2160 match handle
2161 .remember(content.to_string(), room, tags, importance)
2162 .await
2163 {
2164 Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
2165 Err(e) => json!({ "error": format!("remember: {e:#}") }),
2166 }
2167}
2168
2169async fn execute_kg_assert(
2170 state: &AppState,
2171 palace_id: &str,
2172 subject: &str,
2173 predicate: &str,
2174 object: &str,
2175 confidence: f32,
2176) -> Value {
2177 let handle = match state
2178 .registry
2179 .open_palace(&state.data_root, &PalaceId::new(palace_id))
2180 {
2181 Ok(h) => h,
2182 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2183 };
2184 let triple = Triple {
2185 subject: subject.to_string(),
2186 predicate: predicate.to_string(),
2187 object: object.to_string(),
2188 valid_from: chrono::Utc::now(),
2189 valid_to: None,
2190 confidence,
2191 provenance: Some("chat:assistant".to_string()),
2192 };
2193 match handle.kg.assert(triple).await {
2194 Ok(()) => json!({ "status": "asserted" }),
2195 Err(e) => json!({ "error": format!("kg assert: {e:#}") }),
2196 }
2197}
2198
2199async fn chat_handler(State(state): State<AppState>, Json(body): Json<ChatBody>) -> Response {
2200 let Some(provider) = state.chat_provider().await else {
2202 return (
2203 StatusCode::PRECONDITION_FAILED,
2204 "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
2205 )
2206 .into_response();
2207 };
2208
2209 let palace_id = body
2211 .palace_id
2212 .clone()
2213 .or_else(|| state.default_palace.clone())
2214 .unwrap_or_default();
2215
2216 let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
2218 let store = match state.session_store(&palace_id) {
2219 Ok(s) => s,
2220 Err(e) => {
2221 tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
2222 return (
2223 StatusCode::INTERNAL_SERVER_ERROR,
2224 format!("session store: {e:#}"),
2225 )
2226 .into_response();
2227 }
2228 };
2229 match body.session_id.clone() {
2230 Some(sid) => match store.get_session(&sid) {
2231 Ok(Some(s)) => (
2232 Some(sid),
2233 s.history
2234 .into_iter()
2235 .map(|m| ChatMessage {
2236 role: m.role,
2237 content: m.content,
2238 tool_call_id: None,
2239 tool_calls: None,
2240 })
2241 .collect(),
2242 ),
2243 _ => (Some(sid), body.history.clone()),
2244 },
2245 None => {
2246 let new_id = store.create_session(None).unwrap_or_else(|e| {
2247 tracing::warn!("create_session failed: {e:#}");
2248 String::new()
2249 });
2250 (
2251 if new_id.is_empty() {
2252 None
2253 } else {
2254 Some(new_id)
2255 },
2256 body.history.clone(),
2257 )
2258 }
2259 }
2260 } else {
2261 (None, body.history.clone())
2262 };
2263
2264 let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2267 let palace_count = all_palaces.len();
2268 let palace_roster: String = all_palaces
2269 .iter()
2270 .map(|p| format!("- {} (id: {})", p.name, p.id.0))
2271 .collect::<Vec<_>>()
2272 .join("\n");
2273
2274 let cfg = load_user_config().unwrap_or_default();
2277 let active_provider_name = state
2278 .chat_provider()
2279 .await
2280 .map(|p| p.name().to_string())
2281 .unwrap_or_else(|| "none".to_string());
2282 let dream_snapshot = execute_get_dream_status(&state).await;
2283
2284 let selected_palace_meta = if palace_id.is_empty() {
2287 None
2288 } else {
2289 all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
2290 };
2291
2292 let mut palace_block = String::new();
2293 let mut context = String::new();
2294 let mut palace_display_name = palace_id.clone();
2295
2296 if !palace_id.is_empty() {
2297 if let Ok(handle) = state
2298 .registry
2299 .open_palace(&state.data_root, &PalaceId::new(&palace_id))
2300 {
2301 let drawer_count = handle.drawers.read().len();
2303 let vector_count = handle.vector_store.index_size();
2304 let kg_triple_count = handle.kg.count_active_triples();
2305
2306 let (name, description) = match &selected_palace_meta {
2308 Some(p) => (p.name.clone(), p.description.clone()),
2309 None => (palace_id.clone(), None),
2310 };
2311 palace_display_name = name.clone();
2312
2313 palace_block.push_str(&format!(
2314 "Currently selected palace:\n\
2315 - id: {id}\n\
2316 - name: {name}\n",
2317 id = palace_id,
2318 name = name,
2319 ));
2320 if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
2321 palace_block.push_str(&format!("- description: {desc}\n"));
2322 }
2323 palace_block.push_str(&format!(
2324 "- drawers: {drawer_count}\n\
2325 - vectors: {vector_count}\n\
2326 - kg_triples: {kg_triple_count}\n",
2327 ));
2328 let identity_trimmed = handle.identity.trim();
2329 if !identity_trimmed.is_empty() {
2330 palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
2331 }
2332
2333 if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
2334 for r in hits.iter().take(5) {
2335 context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content));
2336 }
2337 }
2338 }
2339 }
2340
2341 let mut system = String::new();
2345 system.push_str(&format!(
2346 "You are the assistant for trusty-memory, a machine-wide AI memory \
2347 service running locally on this user's machine. trusty-memory stores \
2348 knowledge in named \"palaces\" — isolated memory namespaces, each with \
2349 its own vector index (usearch HNSW) and temporal knowledge graph \
2350 (SQLite). Memories are organized as Palace -> Wing -> Room -> Closet \
2351 -> Drawer, where a Drawer is an atomic memory unit.\n\
2352 There are currently {palace_count} palace(s) on this machine.\n",
2353 ));
2354 if !palace_roster.is_empty() {
2355 system.push_str(&format!("Palaces:\n{palace_roster}\n"));
2356 }
2357 system.push('\n');
2358
2359 system.push_str(&format!(
2361 "System configuration:\n\
2362 - active chat provider: {active_provider_name}\n\
2363 - openrouter model: {or_model}\n\
2364 - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
2365 - data root: {data_root}\n\n",
2366 or_model = cfg.openrouter_model,
2367 local_model = cfg.local_model.model,
2368 local_url = cfg.local_model.base_url,
2369 local_enabled = cfg.local_model.enabled,
2370 data_root = state.data_root.display(),
2371 ));
2372
2373 system.push_str(&format!(
2375 "Global dream status (background memory maintenance):\n{}\n\n",
2376 dream_snapshot,
2377 ));
2378
2379 if !palace_block.is_empty() {
2380 system.push_str(&palace_block);
2381 system.push('\n');
2382 }
2383
2384 if !context.is_empty() {
2385 system.push_str(&format!(
2386 "Relevant memories from the '{palace_display_name}' palace \
2387 (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
2388 {context}\n",
2389 ));
2390 }
2391
2392 system.push_str(
2393 "You have a set of tools to introspect and modify this trusty-memory \
2394 daemon. Prefer calling a tool over guessing — e.g. call \
2395 `list_palaces` rather than relying on the roster above if you need \
2396 live counts, and call `recall_memories` to search for facts you \
2397 don't have in context. When the user asks about \"palaces\", they \
2398 mean trusty-memory palaces (memory namespaces on this machine), not \
2399 architectural palaces like Versailles. If a tool returns an error, \
2400 report it honestly and don't fabricate results.",
2401 );
2402
2403 history.push(ChatMessage {
2405 role: "user".to_string(),
2406 content: body.message.clone(),
2407 tool_call_id: None,
2408 tool_calls: None,
2409 });
2410
2411 let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
2412 messages.push(ChatMessage {
2413 role: "system".to_string(),
2414 content: system,
2415 tool_call_id: None,
2416 tool_calls: None,
2417 });
2418 messages.extend(history.iter().cloned());
2419
2420 let tools = all_tools();
2421 let (sse_tx, sse_rx) =
2422 tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
2423
2424 let session_store = if !palace_id.is_empty() && session_id.is_some() {
2426 state.session_store(&palace_id).ok()
2427 } else {
2428 None
2429 };
2430 let persist_session_id = session_id.clone();
2431
2432 let loop_state = state.clone();
2435 tokio::spawn(async move {
2436 if let Some(sid) = persist_session_id.as_deref() {
2439 let frame = format!("data: {}\n\n", json!({ "session_id": sid }));
2440 if sse_tx
2441 .send(Ok(axum::body::Bytes::from(frame)))
2442 .await
2443 .is_err()
2444 {
2445 return;
2446 }
2447 }
2448
2449 let mut final_assistant_text = String::new();
2450 let mut stream_err: Option<String> = None;
2451
2452 for round in 0..MAX_TOOL_ROUNDS {
2453 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
2454 let messages_clone = messages.clone();
2455 let tools_clone = tools.clone();
2456 let provider_clone = provider.clone();
2457 let stream_handle = tokio::spawn(async move {
2458 provider_clone
2459 .chat_stream(messages_clone, tools_clone, event_tx)
2460 .await
2461 });
2462
2463 let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
2464 let mut round_assistant_text = String::new();
2465
2466 while let Some(event) = event_rx.recv().await {
2467 match event {
2468 ChatEvent::Delta(text) => {
2469 round_assistant_text.push_str(&text);
2470 let frame = format!("data: {}\n\n", json!({ "delta": text }));
2471 if sse_tx
2472 .send(Ok(axum::body::Bytes::from(frame)))
2473 .await
2474 .is_err()
2475 {
2476 return;
2477 }
2478 }
2479 ChatEvent::ToolCall(tc) => {
2480 let frame = format!(
2481 "data: {}\n\n",
2482 json!({ "tool_call": {
2483 "id": tc.id,
2484 "name": tc.name,
2485 "arguments": tc.arguments,
2486 }})
2487 );
2488 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
2489 tool_calls_this_round.push(tc);
2490 }
2491 ChatEvent::Done => break,
2492 ChatEvent::Error(e) => {
2493 stream_err = Some(e);
2494 break;
2495 }
2496 }
2497 }
2498
2499 match stream_handle.await {
2501 Ok(Ok(())) => {}
2502 Ok(Err(e)) => stream_err = Some(e.to_string()),
2503 Err(e) => stream_err = Some(format!("join: {e}")),
2504 }
2505
2506 if stream_err.is_some() {
2507 break;
2508 }
2509
2510 final_assistant_text.push_str(&round_assistant_text);
2511
2512 if tool_calls_this_round.is_empty() {
2513 break;
2515 }
2516
2517 let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
2519 .iter()
2520 .map(|tc| {
2521 json!({
2522 "id": tc.id,
2523 "type": "function",
2524 "function": { "name": tc.name, "arguments": tc.arguments },
2525 })
2526 })
2527 .collect();
2528 messages.push(ChatMessage {
2529 role: "assistant".to_string(),
2530 content: round_assistant_text,
2531 tool_call_id: None,
2532 tool_calls: Some(assistant_tool_calls_json),
2533 });
2534
2535 for tc in &tool_calls_this_round {
2538 let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
2539 let result_str = result.to_string();
2540 let frame = format!(
2541 "data: {}\n\n",
2542 json!({ "tool_result": {
2543 "id": tc.id,
2544 "name": tc.name,
2545 "content": &result_str,
2546 }})
2547 );
2548 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
2549 messages.push(ChatMessage {
2550 role: "tool".to_string(),
2551 content: result_str,
2552 tool_call_id: Some(tc.id.clone()),
2553 tool_calls: None,
2554 });
2555 }
2556
2557 if round + 1 == MAX_TOOL_ROUNDS {
2559 tracing::warn!(
2560 "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
2561 MAX_TOOL_ROUNDS
2562 );
2563 }
2564 }
2565
2566 if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
2569 if !final_assistant_text.is_empty() {
2570 history.push(ChatMessage {
2571 role: "assistant".into(),
2572 content: final_assistant_text,
2573 tool_call_id: None,
2574 tool_calls: None,
2575 });
2576 }
2577 let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
2578 history
2579 .iter()
2580 .map(
2581 |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
2582 role: m.role.clone(),
2583 content: m.content.clone(),
2584 },
2585 )
2586 .collect();
2587 if let Err(e) = store.upsert_session(sid, &core_history) {
2588 tracing::warn!("upsert_session failed: {e:#}");
2589 }
2590 }
2591
2592 match stream_err {
2593 None => {
2594 let _ = sse_tx
2595 .send(Ok(axum::body::Bytes::from("data: [DONE]\n\n")))
2596 .await;
2597 }
2598 Some(e) => {
2599 let out = format!("data: {}\n\n", json!({ "error": e }));
2600 let _ = sse_tx.send(Ok(axum::body::Bytes::from(out))).await;
2601 }
2602 }
2603 });
2604
2605 let stream = tokio_stream::wrappers::ReceiverStream::new(sse_rx);
2606
2607 Response::builder()
2608 .header("Content-Type", "text/event-stream")
2609 .header("Cache-Control", "no-cache")
2610 .body(Body::from_stream(stream))
2611 .expect("static SSE response builds")
2612}
2613
2614async fn list_providers(State(state): State<AppState>) -> Json<Value> {
2627 let cfg = load_user_config().unwrap_or_default();
2628 let ollama_available = if cfg.local_model.enabled {
2629 trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
2630 .await
2631 .is_some()
2632 } else {
2633 false
2634 };
2635 let openrouter_available = !cfg.openrouter_api_key.is_empty();
2636 let active = state.chat_provider().await.map(|p| p.name().to_string());
2637 Json(json!({
2638 "providers": [
2639 {
2640 "name": "ollama",
2641 "model": cfg.local_model.model,
2642 "available": ollama_available,
2643 },
2644 {
2645 "name": "openrouter",
2646 "model": cfg.openrouter_model,
2647 "available": openrouter_available,
2648 }
2649 ],
2650 "active": active,
2651 }))
2652}
2653
2654#[derive(Deserialize, Default)]
2655struct CreateSessionBody {
2656 #[serde(default)]
2657 title: Option<String>,
2658}
2659
2660async fn create_chat_session(
2661 State(state): State<AppState>,
2662 AxumPath(id): AxumPath<String>,
2663 body: Option<Json<CreateSessionBody>>,
2664) -> Result<Json<Value>, ApiError> {
2665 let store = state
2666 .session_store(&id)
2667 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2668 let title = body.and_then(|b| b.0.title);
2669 let sid = store
2670 .create_session(title)
2671 .map_err(|e| ApiError::internal(format!("create session: {e:#}")))?;
2672 Ok(Json(json!({ "id": sid })))
2673}
2674
2675async fn list_chat_sessions(
2676 State(state): State<AppState>,
2677 AxumPath(id): AxumPath<String>,
2678) -> Result<Json<Value>, ApiError> {
2679 let store = state
2680 .session_store(&id)
2681 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2682 let metas = store
2683 .list_sessions()
2684 .map_err(|e| ApiError::internal(format!("list sessions: {e:#}")))?;
2685 Ok(Json(serde_json::to_value(metas).unwrap_or(json!([]))))
2686}
2687
2688async fn get_chat_session(
2689 State(state): State<AppState>,
2690 AxumPath((id, session_id)): AxumPath<(String, String)>,
2691) -> Result<Json<Value>, ApiError> {
2692 let store = state
2693 .session_store(&id)
2694 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2695 let s = store
2696 .get_session(&session_id)
2697 .map_err(|e| ApiError::internal(format!("get session: {e:#}")))?
2698 .ok_or_else(|| ApiError::not_found(format!("session not found: {session_id}")))?;
2699 Ok(Json(serde_json::to_value(s).unwrap_or(json!({}))))
2700}
2701
2702async fn delete_chat_session(
2703 State(state): State<AppState>,
2704 AxumPath((id, session_id)): AxumPath<(String, String)>,
2705) -> Result<StatusCode, ApiError> {
2706 let store = state
2707 .session_store(&id)
2708 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2709 store
2710 .delete_session(&session_id)
2711 .map_err(|e| ApiError::internal(format!("delete session: {e:#}")))?;
2712 Ok(StatusCode::NO_CONTENT)
2713}
2714
2715fn open_handle(
2720 state: &AppState,
2721 id: &str,
2722) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
2723 state
2724 .registry
2725 .open_palace(&state.data_root, &PalaceId::new(id))
2726 .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
2727}
2728
2729struct ApiError {
2731 status: StatusCode,
2732 message: String,
2733}
2734
2735impl ApiError {
2736 fn bad_request(msg: impl Into<String>) -> Self {
2737 Self {
2738 status: StatusCode::BAD_REQUEST,
2739 message: msg.into(),
2740 }
2741 }
2742 fn not_found(msg: impl Into<String>) -> Self {
2743 Self {
2744 status: StatusCode::NOT_FOUND,
2745 message: msg.into(),
2746 }
2747 }
2748 fn internal(msg: impl Into<String>) -> Self {
2749 Self {
2750 status: StatusCode::INTERNAL_SERVER_ERROR,
2751 message: msg.into(),
2752 }
2753 }
2754}
2755
2756impl IntoResponse for ApiError {
2757 fn into_response(self) -> Response {
2758 (self.status, Json(json!({ "error": self.message }))).into_response()
2759 }
2760}
2761
2762#[cfg(test)]
2763mod tests {
2764 use super::*;
2765 use axum::body::to_bytes;
2766 use axum::http::Request;
2767 use tower::util::ServiceExt;
2768
2769 fn test_state() -> AppState {
2770 let tmp = tempfile::tempdir().expect("tempdir");
2771 let root = tmp.path().to_path_buf();
2772 std::mem::forget(tmp);
2773 AppState::new(root)
2774 }
2775
2776 #[test]
2777 fn drawer_preview_collapses_whitespace_and_truncates() {
2778 assert_eq!(drawer_content_preview("hello world"), "hello world");
2780
2781 assert_eq!(
2783 drawer_content_preview("first line\n\nsecond\tline third"),
2784 "first line second line third"
2785 );
2786
2787 assert_eq!(drawer_content_preview(" padded "), "padded");
2789
2790 assert_eq!(drawer_content_preview(""), "");
2792
2793 let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
2795 let preview = drawer_content_preview(&long);
2796 assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
2797 assert!(preview.ends_with('…'));
2798
2799 let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
2801 assert_eq!(drawer_content_preview(&exact), exact);
2802 }
2803
2804 #[tokio::test]
2805 async fn health_endpoint_returns_ok() {
2806 let state = test_state();
2807 let app = router().with_state(state);
2808 let resp = app
2809 .oneshot(
2810 Request::builder()
2811 .uri("/health")
2812 .body(Body::empty())
2813 .unwrap(),
2814 )
2815 .await
2816 .unwrap();
2817 assert_eq!(resp.status(), StatusCode::OK);
2818 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2819 let v: Value = serde_json::from_slice(&bytes).unwrap();
2820 assert_eq!(v["status"], "ok");
2821 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
2822 }
2823
2824 #[tokio::test]
2834 async fn health_endpoint_includes_resource_fields() {
2835 let state = test_state();
2836 let app = router().with_state(state);
2837 let resp = app
2838 .oneshot(
2839 Request::builder()
2840 .uri("/health")
2841 .body(Body::empty())
2842 .unwrap(),
2843 )
2844 .await
2845 .unwrap();
2846 assert_eq!(resp.status(), StatusCode::OK);
2847 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2848 let v: Value = serde_json::from_slice(&bytes).unwrap();
2849 let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
2851 assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
2852 let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
2854 assert!(cpu >= 0.0, "cpu_pct must be non-negative");
2855 assert_eq!(v["disk_bytes"].as_u64(), Some(0));
2857 assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
2859 }
2860
2861 #[tokio::test]
2872 async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
2873 let state = test_state();
2874 let app = router().with_state(state);
2875 let resp = app
2876 .oneshot(
2877 Request::builder()
2878 .uri("/health")
2879 .body(Body::empty())
2880 .unwrap(),
2881 )
2882 .await
2883 .unwrap();
2884 assert_eq!(resp.status(), StatusCode::OK);
2885 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2886 let v: Value = serde_json::from_slice(&bytes).unwrap();
2887 assert_eq!(v["status"], "ok");
2888 assert!(
2889 v.get("detail").is_none() || v["detail"].is_null(),
2890 "fresh-install health must not carry a degraded detail (got {v:?})"
2891 );
2892 }
2893
2894 #[tokio::test]
2910 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2911 async fn health_endpoint_round_trip_with_palace_is_ok() {
2912 let state = test_state();
2913 let palace = trusty_common::memory_core::Palace {
2914 id: PalaceId::new("health-probe-palace"),
2915 name: "health-probe-palace".to_string(),
2916 description: None,
2917 created_at: chrono::Utc::now(),
2918 data_dir: state.data_root.join("health-probe-palace"),
2919 };
2920 state
2921 .registry
2922 .create_palace(&state.data_root, palace)
2923 .expect("create_palace");
2924
2925 let app = router().with_state(state);
2926 let resp = app
2927 .oneshot(
2928 Request::builder()
2929 .uri("/health")
2930 .body(Body::empty())
2931 .unwrap(),
2932 )
2933 .await
2934 .unwrap();
2935 assert_eq!(resp.status(), StatusCode::OK);
2936 let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
2937 let v: Value = serde_json::from_slice(&bytes).unwrap();
2938 assert_eq!(
2939 v["status"], "ok",
2940 "round-trip should succeed against a fresh palace; got {v:?}"
2941 );
2942 assert!(
2943 v.get("detail").is_none() || v["detail"].is_null(),
2944 "successful round-trip must not carry a detail field (got {v:?})"
2945 );
2946 }
2947
2948 #[test]
2961 fn recall_entry_json_hoists_drawer_fields() {
2962 use trusty_common::memory_core::Drawer;
2963
2964 let room = Uuid::new_v4();
2965 let mut drawer = Drawer::new(room, "the answer is 42");
2966 drawer.tags = vec!["source:kuzu".to_string()];
2967 drawer.importance = 0.7;
2968
2969 let entry = recall_entry_json(RecallResult {
2970 drawer,
2971 score: 0.699,
2972 layer: 1,
2973 });
2974
2975 assert_eq!(
2977 entry.get("content").and_then(|v| v.as_str()),
2978 Some("the answer is 42"),
2979 "content must be at the top level, got {entry:?}"
2980 );
2981 assert!(
2982 entry.get("drawer").is_none(),
2983 "the legacy `drawer` wrapper must not be present, got {entry:?}"
2984 );
2985 assert_eq!(
2987 entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2988 Some(7.0)
2989 );
2990 assert_eq!(
2991 entry["tags"][0].as_str(),
2992 Some("source:kuzu"),
2993 "tags must be hoisted, got {entry:?}"
2994 );
2995 assert_eq!(entry["layer"].as_u64(), Some(1));
2997 assert!(
2998 entry["score"]
2999 .as_f64()
3000 .is_some_and(|s| (s - 0.699).abs() < 1e-6),
3001 "score must be preserved, got {entry:?}"
3002 );
3003 }
3004
3005 #[tokio::test]
3014 async fn logs_tail_returns_recent_lines() {
3015 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
3016 buffer.push("line one".to_string());
3017 buffer.push("line two".to_string());
3018 buffer.push("line three".to_string());
3019 let state = test_state().with_log_buffer(buffer);
3020 let app = router().with_state(state);
3021 let resp = app
3022 .oneshot(
3023 Request::builder()
3024 .uri("/api/v1/logs/tail?n=2")
3025 .body(Body::empty())
3026 .unwrap(),
3027 )
3028 .await
3029 .unwrap();
3030 assert_eq!(resp.status(), StatusCode::OK);
3031 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3032 let v: Value = serde_json::from_slice(&bytes).unwrap();
3033 let lines = v["lines"].as_array().expect("lines array");
3034 assert_eq!(lines.len(), 2, "n=2 must return two lines");
3035 assert_eq!(lines[0].as_str(), Some("line two"));
3036 assert_eq!(lines[1].as_str(), Some("line three"));
3037 assert_eq!(v["total"].as_u64(), Some(3));
3038 }
3039
3040 #[tokio::test]
3049 async fn logs_tail_clamps_n() {
3050 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
3051 for i in 0..5 {
3052 buffer.push(format!("l{i}"));
3053 }
3054 let state = test_state().with_log_buffer(buffer);
3055 let app = router().with_state(state);
3056
3057 let resp = app
3059 .clone()
3060 .oneshot(
3061 Request::builder()
3062 .uri("/api/v1/logs/tail?n=0")
3063 .body(Body::empty())
3064 .unwrap(),
3065 )
3066 .await
3067 .unwrap();
3068 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3069 let v: Value = serde_json::from_slice(&bytes).unwrap();
3070 assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
3071
3072 let resp = app
3074 .oneshot(
3075 Request::builder()
3076 .uri("/api/v1/logs/tail?n=999999")
3077 .body(Body::empty())
3078 .unwrap(),
3079 )
3080 .await
3081 .unwrap();
3082 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3083 let v: Value = serde_json::from_slice(&bytes).unwrap();
3084 assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
3085 }
3086
3087 #[tokio::test]
3098 async fn admin_stop_returns_ok() {
3099 let state = test_state();
3100 let Json(body) = admin_stop(State(state)).await;
3101 assert_eq!(body["ok"], Value::Bool(true));
3102 assert_eq!(body["message"].as_str(), Some("shutting down"));
3103 }
3104
3105 #[tokio::test]
3106 async fn status_endpoint_returns_payload() {
3107 let state = test_state();
3108 let app = router().with_state(state);
3109 let resp = app
3110 .oneshot(
3111 Request::builder()
3112 .uri("/api/v1/status")
3113 .body(Body::empty())
3114 .unwrap(),
3115 )
3116 .await
3117 .unwrap();
3118 assert_eq!(resp.status(), StatusCode::OK);
3119 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3120 let v: Value = serde_json::from_slice(&bytes).unwrap();
3121 assert!(v["version"].is_string());
3122 assert_eq!(v["palace_count"], 0);
3123 }
3124
3125 #[tokio::test]
3126 async fn unknown_api_returns_404() {
3127 let state = test_state();
3128 let app = router().with_state(state);
3129 let resp = app
3130 .oneshot(
3131 Request::builder()
3132 .uri("/api/v1/does-not-exist")
3133 .body(Body::empty())
3134 .unwrap(),
3135 )
3136 .await
3137 .unwrap();
3138 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3139 }
3140
3141 #[tokio::test]
3152 async fn memories_alias_routes_to_drawers() {
3153 let state = test_state();
3154 let palace = Palace {
3155 id: PalaceId::new("alias-test"),
3156 name: "alias-test".to_string(),
3157 description: None,
3158 created_at: chrono::Utc::now(),
3159 data_dir: state.data_root.join("alias-test"),
3160 };
3161 state
3162 .registry
3163 .create_palace(&state.data_root, palace)
3164 .expect("create_palace");
3165
3166 let app = router().with_state(state);
3167 let resp = app
3168 .oneshot(
3169 Request::builder()
3170 .uri("/api/v1/palaces/alias-test/memories")
3171 .body(Body::empty())
3172 .unwrap(),
3173 )
3174 .await
3175 .unwrap();
3176 assert_eq!(
3177 resp.status(),
3178 StatusCode::OK,
3179 "the /memories alias must resolve to list_drawers, not 404"
3180 );
3181 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3182 let v: Value = serde_json::from_slice(&bytes).unwrap();
3183 assert!(
3184 v.is_array(),
3185 "the alias must return the list-drawers array shape, got {v:?}"
3186 );
3187 }
3188
3189 #[tokio::test]
3190 async fn create_then_list_palace() {
3191 let state = test_state();
3192 let app = router().with_state(state.clone());
3193 let body = json!({"name": "web-test", "description": "from test"}).to_string();
3194 let resp = app
3195 .clone()
3196 .oneshot(
3197 Request::builder()
3198 .method("POST")
3199 .uri("/api/v1/palaces")
3200 .header("content-type", "application/json")
3201 .body(Body::from(body))
3202 .unwrap(),
3203 )
3204 .await
3205 .unwrap();
3206 assert_eq!(resp.status(), StatusCode::OK);
3207
3208 let resp = app
3209 .oneshot(
3210 Request::builder()
3211 .uri("/api/v1/palaces")
3212 .body(Body::empty())
3213 .unwrap(),
3214 )
3215 .await
3216 .unwrap();
3217 assert_eq!(resp.status(), StatusCode::OK);
3218 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3219 let v: Value = serde_json::from_slice(&bytes).unwrap();
3220 let arr = v.as_array().expect("array");
3221 assert!(arr.iter().any(|p| p["id"] == "web-test"));
3222 }
3223
3224 #[tokio::test]
3233 async fn palace_list_includes_graph_counts() {
3234 let state = test_state();
3235 let app = router().with_state(state.clone());
3236 let body = json!({"name": "graph-counts", "description": null}).to_string();
3237 let resp = app
3238 .clone()
3239 .oneshot(
3240 Request::builder()
3241 .method("POST")
3242 .uri("/api/v1/palaces")
3243 .header("content-type", "application/json")
3244 .body(Body::from(body))
3245 .unwrap(),
3246 )
3247 .await
3248 .unwrap();
3249 assert_eq!(resp.status(), StatusCode::OK);
3250
3251 let resp = app
3252 .oneshot(
3253 Request::builder()
3254 .uri("/api/v1/palaces")
3255 .body(Body::empty())
3256 .unwrap(),
3257 )
3258 .await
3259 .unwrap();
3260 assert_eq!(resp.status(), StatusCode::OK);
3261 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3262 let v: Value = serde_json::from_slice(&bytes).unwrap();
3263 let arr = v.as_array().expect("array");
3264 let row = arr
3265 .iter()
3266 .find(|p| p["id"] == "graph-counts")
3267 .expect("created palace must appear in list");
3268 assert_eq!(row["node_count"].as_u64(), Some(0));
3269 assert_eq!(row["edge_count"].as_u64(), Some(0));
3270 assert_eq!(row["community_count"].as_u64(), Some(0));
3271 assert_eq!(row["is_compacting"].as_bool(), Some(false));
3272 }
3273
3274 #[tokio::test]
3281 async fn status_includes_total_counters() {
3282 let state = test_state();
3283 let app = router().with_state(state);
3284 let resp = app
3285 .oneshot(
3286 Request::builder()
3287 .uri("/api/v1/status")
3288 .body(Body::empty())
3289 .unwrap(),
3290 )
3291 .await
3292 .unwrap();
3293 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3294 let v: Value = serde_json::from_slice(&bytes).unwrap();
3295 assert_eq!(v["total_drawers"], 0);
3296 assert_eq!(v["total_vectors"], 0);
3297 assert_eq!(v["total_kg_triples"], 0);
3298 }
3299
3300 #[tokio::test]
3307 async fn dream_status_empty_returns_nulls() {
3308 let state = test_state();
3309 let app = router().with_state(state);
3310 let resp = app
3311 .oneshot(
3312 Request::builder()
3313 .uri("/api/v1/dream/status")
3314 .body(Body::empty())
3315 .unwrap(),
3316 )
3317 .await
3318 .unwrap();
3319 assert_eq!(resp.status(), StatusCode::OK);
3320 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3321 let v: Value = serde_json::from_slice(&bytes).unwrap();
3322 assert!(v["last_run_at"].is_null());
3323 assert_eq!(v["merged"], 0);
3324 assert_eq!(v["pruned"], 0);
3325 }
3326
3327 #[tokio::test]
3334 async fn providers_endpoint_returns_payload() {
3335 let state = test_state();
3336 let app = router().with_state(state);
3337 let resp = app
3338 .oneshot(
3339 Request::builder()
3340 .uri("/api/v1/chat/providers")
3341 .body(Body::empty())
3342 .unwrap(),
3343 )
3344 .await
3345 .unwrap();
3346 assert_eq!(resp.status(), StatusCode::OK);
3347 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3348 let v: Value = serde_json::from_slice(&bytes).unwrap();
3349 let arr = v["providers"].as_array().expect("providers array");
3350 assert_eq!(arr.len(), 2);
3351 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
3352 assert!(names.contains(&"ollama"));
3353 assert!(names.contains(&"openrouter"));
3354 assert!(v.get("active").is_some());
3356 }
3357
3358 #[tokio::test]
3365 async fn chat_session_crud_round_trip() {
3366 let state = test_state();
3367 let palace = trusty_common::memory_core::Palace {
3369 id: PalaceId::new("sess-test"),
3370 name: "sess-test".to_string(),
3371 description: None,
3372 created_at: chrono::Utc::now(),
3373 data_dir: state.data_root.join("sess-test"),
3374 };
3375 state
3376 .registry
3377 .create_palace(&state.data_root, palace)
3378 .expect("create_palace");
3379 let app = router().with_state(state);
3380
3381 let resp = app
3383 .clone()
3384 .oneshot(
3385 Request::builder()
3386 .method("POST")
3387 .uri("/api/v1/palaces/sess-test/chat/sessions")
3388 .header("content-type", "application/json")
3389 .body(Body::from(json!({"title":"first chat"}).to_string()))
3390 .unwrap(),
3391 )
3392 .await
3393 .unwrap();
3394 assert_eq!(resp.status(), StatusCode::OK);
3395 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3396 let v: Value = serde_json::from_slice(&bytes).unwrap();
3397 let sid = v["id"].as_str().expect("session id").to_string();
3398
3399 let resp = app
3401 .clone()
3402 .oneshot(
3403 Request::builder()
3404 .uri("/api/v1/palaces/sess-test/chat/sessions")
3405 .body(Body::empty())
3406 .unwrap(),
3407 )
3408 .await
3409 .unwrap();
3410 assert_eq!(resp.status(), StatusCode::OK);
3411 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3412 let v: Value = serde_json::from_slice(&bytes).unwrap();
3413 let arr = v.as_array().expect("array");
3414 assert!(arr.iter().any(|s| s["id"] == sid));
3415
3416 let resp = app
3418 .clone()
3419 .oneshot(
3420 Request::builder()
3421 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3422 .body(Body::empty())
3423 .unwrap(),
3424 )
3425 .await
3426 .unwrap();
3427 assert_eq!(resp.status(), StatusCode::OK);
3428
3429 let resp = app
3431 .clone()
3432 .oneshot(
3433 Request::builder()
3434 .method("DELETE")
3435 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3436 .body(Body::empty())
3437 .unwrap(),
3438 )
3439 .await
3440 .unwrap();
3441 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3442
3443 let resp = app
3445 .oneshot(
3446 Request::builder()
3447 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3448 .body(Body::empty())
3449 .unwrap(),
3450 )
3451 .await
3452 .unwrap();
3453 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3454 }
3455
3456 #[test]
3463 fn all_tools_returns_expected_set() {
3464 let tools = all_tools();
3465 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3466 assert_eq!(
3467 names,
3468 vec![
3469 "list_palaces",
3470 "get_palace",
3471 "recall_memories",
3472 "list_drawers",
3473 "kg_query",
3474 "get_config",
3475 "get_status",
3476 "get_dream_status",
3477 "get_palace_dream_status",
3478 "create_memory",
3479 "kg_assert",
3480 "memory_recall_all",
3481 ]
3482 );
3483 for t in &tools {
3486 assert_eq!(
3487 t.parameters["type"], "object",
3488 "tool {} schema type",
3489 t.name
3490 );
3491 assert!(
3492 t.parameters["required"].is_array(),
3493 "tool {} required not array",
3494 t.name
3495 );
3496 }
3497 }
3498
3499 #[tokio::test]
3506 async fn execute_tool_dispatches_known_tools() {
3507 let state = test_state();
3508 let result = execute_tool("list_palaces", "{}", &state).await;
3509 assert!(
3510 result.is_array(),
3511 "list_palaces should be array, got {result}"
3512 );
3513 assert_eq!(result.as_array().unwrap().len(), 0);
3514
3515 let unknown = execute_tool("not_a_tool", "{}", &state).await;
3516 assert!(
3517 unknown["error"]
3518 .as_str()
3519 .unwrap_or("")
3520 .contains("unknown tool"),
3521 "expected unknown-tool error, got {unknown}"
3522 );
3523
3524 let missing = execute_tool("get_palace", "{}", &state).await;
3525 assert!(
3526 missing["error"]
3527 .as_str()
3528 .unwrap_or("")
3529 .contains("palace_id"),
3530 "expected missing-arg error, got {missing}"
3531 );
3532 }
3533
3534 #[tokio::test]
3543 async fn sse_broadcast_emits_palace_created() {
3544 let state = test_state();
3545 let mut rx = state.events.subscribe();
3546 let app = router().with_state(state.clone());
3547 let body = json!({"name": "sse-test"}).to_string();
3548 let resp = app
3549 .oneshot(
3550 Request::builder()
3551 .method("POST")
3552 .uri("/api/v1/palaces")
3553 .header("content-type", "application/json")
3554 .body(Body::from(body))
3555 .unwrap(),
3556 )
3557 .await
3558 .unwrap();
3559 assert_eq!(resp.status(), StatusCode::OK);
3560 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3562 .await
3563 .expect("event received within timeout")
3564 .expect("event channel still open");
3565 match event {
3566 DaemonEvent::PalaceCreated { id, name } => {
3567 assert_eq!(id, "sse-test");
3568 assert_eq!(name, "sse-test");
3569 }
3570 other => panic!("expected PalaceCreated, got {other:?}"),
3571 }
3572 }
3573
3574 #[tokio::test]
3581 async fn sse_endpoint_emits_connected_frame() {
3582 use axum::routing::get;
3583 let state = test_state();
3584 let app = router()
3585 .route("/sse", get(crate::sse_handler))
3586 .with_state(state);
3587 let resp = app
3588 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3589 .await
3590 .unwrap();
3591 assert_eq!(resp.status(), StatusCode::OK);
3592 assert_eq!(
3593 resp.headers()
3594 .get(header::CONTENT_TYPE)
3595 .and_then(|v| v.to_str().ok()),
3596 Some("text/event-stream")
3597 );
3598 let body = resp.into_body();
3601 let bytes =
3602 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3603 .await
3604 .ok()
3605 .and_then(|r| r.ok())
3606 .unwrap_or_default();
3607 let text = String::from_utf8_lossy(&bytes);
3608 assert!(
3609 text.contains("\"type\":\"connected\""),
3610 "expected connected frame, got: {text}"
3611 );
3612 }
3613
3614 #[tokio::test]
3624 async fn dream_status_aggregates_across_palaces() {
3625 use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3626
3627 let state = test_state();
3628 for (id, stats, ts) in [
3632 (
3633 "palace-a",
3634 DreamStats {
3635 merged: 1,
3636 pruned: 2,
3637 compacted: 3,
3638 closets_updated: 4,
3639 duration_ms: 100,
3640 },
3641 chrono::Utc::now() - chrono::Duration::seconds(60),
3642 ),
3643 (
3644 "palace-b",
3645 DreamStats {
3646 merged: 10,
3647 pruned: 20,
3648 compacted: 30,
3649 closets_updated: 40,
3650 duration_ms: 200,
3651 },
3652 chrono::Utc::now(),
3653 ),
3654 ] {
3655 let palace = trusty_common::memory_core::Palace {
3656 id: PalaceId::new(id),
3657 name: id.to_string(),
3658 description: None,
3659 created_at: chrono::Utc::now(),
3660 data_dir: state.data_root.join(id),
3661 };
3662 state
3663 .registry
3664 .create_palace(&state.data_root, palace)
3665 .expect("create palace");
3666 let persisted = PersistedDreamStats {
3667 last_run_at: ts,
3668 stats,
3669 };
3670 persisted
3671 .save(&state.data_root.join(id))
3672 .expect("save dream stats");
3673 }
3674
3675 let later = chrono::Utc::now();
3676 let app = router().with_state(state);
3677 let resp = app
3678 .oneshot(
3679 Request::builder()
3680 .uri("/api/v1/dream/status")
3681 .body(Body::empty())
3682 .unwrap(),
3683 )
3684 .await
3685 .unwrap();
3686 assert_eq!(resp.status(), StatusCode::OK);
3687 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3688 let v: Value = serde_json::from_slice(&bytes).unwrap();
3689
3690 assert_eq!(v["merged"], 11);
3692 assert_eq!(v["pruned"], 22);
3693 assert_eq!(v["compacted"], 33);
3694 assert_eq!(v["closets_updated"], 44);
3695 assert_eq!(v["duration_ms"], 300);
3696
3697 let last = v["last_run_at"].as_str().expect("last_run_at is string");
3699 let parsed: chrono::DateTime<chrono::Utc> = last
3700 .parse()
3701 .expect("last_run_at parses as RFC3339 timestamp");
3702 assert!(
3703 parsed <= later,
3704 "last_run_at ({parsed}) should not exceed wall clock ({later})"
3705 );
3706 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3708 assert!(
3709 parsed >= cutoff,
3710 "expected the newer (palace-b) timestamp; got {parsed}"
3711 );
3712 }
3713
3714 #[tokio::test]
3726 async fn dream_run_aggregates_stats() {
3727 let state = test_state();
3728 let palace = trusty_common::memory_core::Palace {
3729 id: PalaceId::new("dream-run-test"),
3730 name: "dream-run-test".to_string(),
3731 description: None,
3732 created_at: chrono::Utc::now(),
3733 data_dir: state.data_root.join("dream-run-test"),
3734 };
3735 state
3736 .registry
3737 .create_palace(&state.data_root, palace)
3738 .expect("create palace");
3739
3740 let app = router().with_state(state);
3741 let resp = app
3742 .oneshot(
3743 Request::builder()
3744 .method("POST")
3745 .uri("/api/v1/dream/run")
3746 .body(Body::empty())
3747 .unwrap(),
3748 )
3749 .await
3750 .unwrap();
3751 assert_eq!(resp.status(), StatusCode::OK);
3752 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3753 let v: Value = serde_json::from_slice(&bytes).unwrap();
3754
3755 for key in [
3758 "merged",
3759 "pruned",
3760 "compacted",
3761 "closets_updated",
3762 "duration_ms",
3763 ] {
3764 assert!(
3765 v.get(key).is_some(),
3766 "missing key {key} in dream_run payload: {v}"
3767 );
3768 assert!(
3769 v[key].is_u64() || v[key].is_i64(),
3770 "{key} should be integer, got {}",
3771 v[key]
3772 );
3773 }
3774 assert!(
3775 v["last_run_at"].is_string(),
3776 "last_run_at must be set by dream_run; got {v}"
3777 );
3778 }
3779
3780 #[tokio::test]
3787 async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3788 let state = test_state();
3789 let palace = trusty_common::memory_core::Palace {
3790 id: PalaceId::new("gaps-empty"),
3791 name: "gaps-empty".to_string(),
3792 description: None,
3793 created_at: chrono::Utc::now(),
3794 data_dir: state.data_root.join("gaps-empty"),
3795 };
3796 state
3797 .registry
3798 .create_palace(&state.data_root, palace)
3799 .expect("create palace");
3800
3801 let app = router().with_state(state);
3802 let resp = app
3803 .oneshot(
3804 Request::builder()
3805 .uri("/api/v1/kg/gaps?palace=gaps-empty")
3806 .body(Body::empty())
3807 .unwrap(),
3808 )
3809 .await
3810 .unwrap();
3811 assert_eq!(resp.status(), StatusCode::OK);
3812 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3813 let v: Value = serde_json::from_slice(&bytes).unwrap();
3814 assert_eq!(v.as_array().expect("array").len(), 0);
3815 }
3816
3817 #[tokio::test]
3824 async fn kg_gaps_endpoint_returns_cached_gaps() {
3825 use trusty_common::memory_core::community::KnowledgeGap;
3826
3827 let state = test_state();
3828 let palace = trusty_common::memory_core::Palace {
3829 id: PalaceId::new("gaps-seed"),
3830 name: "gaps-seed".to_string(),
3831 description: None,
3832 created_at: chrono::Utc::now(),
3833 data_dir: state.data_root.join("gaps-seed"),
3834 };
3835 state
3836 .registry
3837 .create_palace(&state.data_root, palace)
3838 .expect("create palace");
3839
3840 state.registry.set_gaps(
3841 PalaceId::new("gaps-seed"),
3842 vec![KnowledgeGap {
3843 entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3844 internal_density: 0.15,
3845 external_bridges: 2,
3846 suggested_exploration: "Explore connections between foo and related concepts"
3847 .to_string(),
3848 }],
3849 );
3850
3851 let app = router().with_state(state);
3852 let resp = app
3853 .oneshot(
3854 Request::builder()
3855 .uri("/api/v1/kg/gaps?palace=gaps-seed")
3856 .body(Body::empty())
3857 .unwrap(),
3858 )
3859 .await
3860 .unwrap();
3861 assert_eq!(resp.status(), StatusCode::OK);
3862 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3863 let v: Value = serde_json::from_slice(&bytes).unwrap();
3864 let arr = v.as_array().expect("array");
3865 assert_eq!(arr.len(), 1);
3866 assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3867 assert_eq!(arr[0]["external_bridges"], 2);
3868 assert!(arr[0]["suggested_exploration"]
3869 .as_str()
3870 .unwrap()
3871 .contains("foo"));
3872 }
3873
3874 #[tokio::test]
3881 async fn kg_list_subjects_returns_distinct() {
3882 let state = test_state();
3883 let app = router().with_state(state.clone());
3884
3885 let resp = app
3887 .clone()
3888 .oneshot(
3889 Request::builder()
3890 .method("POST")
3891 .uri("/api/v1/palaces")
3892 .header("content-type", "application/json")
3893 .body(Body::from(json!({"name": "kg-list"}).to_string()))
3894 .unwrap(),
3895 )
3896 .await
3897 .unwrap();
3898 assert_eq!(resp.status(), StatusCode::OK);
3899
3900 for subj in ["alpha", "beta"] {
3902 let body = json!({
3903 "subject": subj,
3904 "predicate": "is",
3905 "object": "thing",
3906 })
3907 .to_string();
3908 let r = app
3909 .clone()
3910 .oneshot(
3911 Request::builder()
3912 .method("POST")
3913 .uri("/api/v1/palaces/kg-list/kg")
3914 .header("content-type", "application/json")
3915 .body(Body::from(body))
3916 .unwrap(),
3917 )
3918 .await
3919 .unwrap();
3920 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3921 }
3922
3923 let resp = app
3924 .oneshot(
3925 Request::builder()
3926 .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3927 .body(Body::empty())
3928 .unwrap(),
3929 )
3930 .await
3931 .unwrap();
3932 assert_eq!(resp.status(), StatusCode::OK);
3933 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3934 let v: Value = serde_json::from_slice(&bytes).unwrap();
3935 let arr = v.as_array().expect("subjects must be array");
3936 let subjects: Vec<String> = arr
3937 .iter()
3938 .filter_map(|x| x.as_str().map(String::from))
3939 .collect();
3940 assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3941 }
3942
3943 #[tokio::test]
3950 async fn kg_list_all_returns_paginated_triples() {
3951 let state = test_state();
3952 let app = router().with_state(state.clone());
3953
3954 let resp = app
3955 .clone()
3956 .oneshot(
3957 Request::builder()
3958 .method("POST")
3959 .uri("/api/v1/palaces")
3960 .header("content-type", "application/json")
3961 .body(Body::from(json!({"name": "kg-all"}).to_string()))
3962 .unwrap(),
3963 )
3964 .await
3965 .unwrap();
3966 assert_eq!(resp.status(), StatusCode::OK);
3967
3968 let body = json!({
3969 "subject": "alpha",
3970 "predicate": "is",
3971 "object": "thing",
3972 })
3973 .to_string();
3974 let r = app
3975 .clone()
3976 .oneshot(
3977 Request::builder()
3978 .method("POST")
3979 .uri("/api/v1/palaces/kg-all/kg")
3980 .header("content-type", "application/json")
3981 .body(Body::from(body))
3982 .unwrap(),
3983 )
3984 .await
3985 .unwrap();
3986 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3987
3988 let resp = app
3989 .oneshot(
3990 Request::builder()
3991 .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3992 .body(Body::empty())
3993 .unwrap(),
3994 )
3995 .await
3996 .unwrap();
3997 assert_eq!(resp.status(), StatusCode::OK);
3998 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3999 let v: Value = serde_json::from_slice(&bytes).unwrap();
4000 let arr = v.as_array().expect("triples must be array");
4001 assert_eq!(arr.len(), 1);
4002 assert_eq!(arr[0]["subject"], "alpha");
4003 assert_eq!(arr[0]["predicate"], "is");
4004 assert_eq!(arr[0]["object"], "thing");
4005 }
4006
4007 #[tokio::test]
4011 async fn prompt_context_endpoint_returns_formatted_block() {
4012 let state = test_state();
4013
4014 let app = router().with_state(state.clone());
4016 let resp = app
4017 .oneshot(
4018 Request::builder()
4019 .uri("/api/v1/kg/prompt-context")
4020 .body(Body::empty())
4021 .unwrap(),
4022 )
4023 .await
4024 .unwrap();
4025 assert_eq!(resp.status(), StatusCode::OK);
4026 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4027 let text = String::from_utf8(bytes.to_vec()).unwrap();
4028 assert_eq!(text, "No prompt facts stored yet.");
4029
4030 {
4032 let mut guard = state.prompt_context_cache.write().expect("write lock");
4033 let triples = vec![(
4034 "tga".to_string(),
4035 "is_alias_for".to_string(),
4036 "trusty-git-analytics".to_string(),
4037 )];
4038 let formatted = crate::prompt_facts::build_prompt_context(&triples);
4039 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
4040 }
4041 let app = router().with_state(state);
4042 let resp = app
4043 .oneshot(
4044 Request::builder()
4045 .uri("/api/v1/kg/prompt-context")
4046 .body(Body::empty())
4047 .unwrap(),
4048 )
4049 .await
4050 .unwrap();
4051 assert_eq!(resp.status(), StatusCode::OK);
4052 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4053 let text = String::from_utf8(bytes.to_vec()).unwrap();
4054 assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
4055 }
4056
4057 #[tokio::test]
4061 async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
4062 let tmp = tempfile::tempdir().expect("tempdir");
4063 let root = tmp.path().to_path_buf();
4064 std::mem::forget(tmp);
4065 let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
4066 let palace = trusty_common::memory_core::Palace {
4067 id: PalaceId::new("aliases"),
4068 name: "aliases".to_string(),
4069 description: None,
4070 created_at: chrono::Utc::now(),
4071 data_dir: state.data_root.join("aliases"),
4072 };
4073 state
4074 .registry
4075 .create_palace(&state.data_root, palace)
4076 .expect("create palace");
4077
4078 let body = json!({"short": "tm", "full": "trusty-memory"});
4079 let app = router().with_state(state.clone());
4080 let resp = app
4081 .oneshot(
4082 Request::builder()
4083 .method("POST")
4084 .uri("/api/v1/kg/aliases")
4085 .header("content-type", "application/json")
4086 .body(Body::from(serde_json::to_vec(&body).unwrap()))
4087 .unwrap(),
4088 )
4089 .await
4090 .unwrap();
4091 assert_eq!(resp.status(), StatusCode::OK);
4092 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4093 let v: Value = serde_json::from_slice(&bytes).unwrap();
4094 assert_eq!(v["subject"], "tm");
4095 assert_eq!(v["object"], "trusty-memory");
4096
4097 let guard = state.prompt_context_cache.read().expect("read lock");
4099 assert!(
4100 guard.formatted.contains("tm → trusty-memory"),
4101 "cache missing alias; got: {}",
4102 guard.formatted
4103 );
4104 }
4105
4106 #[tokio::test]
4110 async fn list_prompt_facts_endpoint_returns_hot_triples() {
4111 let tmp = tempfile::tempdir().expect("tempdir");
4112 let root = tmp.path().to_path_buf();
4113 std::mem::forget(tmp);
4114 let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4115 let palace = trusty_common::memory_core::Palace {
4116 id: PalaceId::new("listfacts"),
4117 name: "listfacts".to_string(),
4118 description: None,
4119 created_at: chrono::Utc::now(),
4120 data_dir: state.data_root.join("listfacts"),
4121 };
4122 let handle = state
4123 .registry
4124 .create_palace(&state.data_root, palace)
4125 .expect("create palace");
4126
4127 handle
4130 .kg
4131 .assert(Triple {
4132 subject: "ts".to_string(),
4133 predicate: "is_alias_for".to_string(),
4134 object: "trusty-search".to_string(),
4135 valid_from: chrono::Utc::now(),
4136 valid_to: None,
4137 confidence: 1.0,
4138 provenance: None,
4139 })
4140 .await
4141 .expect("assert alias");
4142 handle
4143 .kg
4144 .assert(Triple {
4145 subject: "alice".to_string(),
4146 predicate: "works_at".to_string(),
4147 object: "Acme".to_string(),
4148 valid_from: chrono::Utc::now(),
4149 valid_to: None,
4150 confidence: 1.0,
4151 provenance: None,
4152 })
4153 .await
4154 .expect("assert works_at");
4155
4156 let app = router().with_state(state);
4157 let resp = app
4158 .oneshot(
4159 Request::builder()
4160 .uri("/api/v1/kg/prompt-facts")
4161 .body(Body::empty())
4162 .unwrap(),
4163 )
4164 .await
4165 .unwrap();
4166 assert_eq!(resp.status(), StatusCode::OK);
4167 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4168 let v: Value = serde_json::from_slice(&bytes).unwrap();
4169 let arr = v.as_array().expect("array");
4170 assert!(
4171 arr.iter().any(|r| r["subject"] == "ts"
4172 && r["predicate"] == "is_alias_for"
4173 && r["object"] == "trusty-search"),
4174 "missing ts alias; got {arr:?}"
4175 );
4176 assert!(
4178 !arr.iter().any(|r| r["predicate"] == "works_at"),
4179 "non-hot triple leaked into prompt facts: {arr:?}"
4180 );
4181 }
4182
4183 #[tokio::test]
4186 async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4187 let tmp = tempfile::tempdir().expect("tempdir");
4188 let root = tmp.path().to_path_buf();
4189 std::mem::forget(tmp);
4190 let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4191 let palace = trusty_common::memory_core::Palace {
4192 id: PalaceId::new("rmfacts"),
4193 name: "rmfacts".to_string(),
4194 description: None,
4195 created_at: chrono::Utc::now(),
4196 data_dir: state.data_root.join("rmfacts"),
4197 };
4198 let handle = state
4199 .registry
4200 .create_palace(&state.data_root, palace)
4201 .expect("create palace");
4202
4203 handle
4204 .kg
4205 .assert(Triple {
4206 subject: "ta".to_string(),
4207 predicate: "is_alias_for".to_string(),
4208 object: "trusty-analyze".to_string(),
4209 valid_from: chrono::Utc::now(),
4210 valid_to: None,
4211 confidence: 1.0,
4212 provenance: None,
4213 })
4214 .await
4215 .expect("assert alias");
4216 crate::prompt_facts::rebuild_prompt_cache(&state)
4218 .await
4219 .expect("rebuild prompt cache");
4220
4221 let app = router().with_state(state.clone());
4222 let resp = app
4223 .oneshot(
4224 Request::builder()
4225 .method("DELETE")
4226 .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4227 .body(Body::empty())
4228 .unwrap(),
4229 )
4230 .await
4231 .unwrap();
4232 assert_eq!(resp.status(), StatusCode::OK);
4233 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4234 let v: Value = serde_json::from_slice(&bytes).unwrap();
4235 assert_eq!(v["removed"], true);
4236 assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4237
4238 {
4240 let guard = state.prompt_context_cache.read().expect("read lock");
4241 assert!(
4242 !guard.formatted.contains("ta → trusty-analyze"),
4243 "alias still in cache after delete: {}",
4244 guard.formatted
4245 );
4246 }
4247
4248 let app = router().with_state(state);
4250 let resp = app
4251 .oneshot(
4252 Request::builder()
4253 .method("DELETE")
4254 .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4255 .body(Body::empty())
4256 .unwrap(),
4257 )
4258 .await
4259 .unwrap();
4260 assert_eq!(resp.status(), StatusCode::OK);
4261 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4262 let v: Value = serde_json::from_slice(&bytes).unwrap();
4263 assert_eq!(v["removed"], false);
4264 }
4265
4266 #[tokio::test]
4267 async fn serves_index_html_fallback() {
4268 let state = test_state();
4269 let app = router().with_state(state);
4270 let resp = app
4271 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4272 .await
4273 .unwrap();
4274 assert!(
4276 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4277 "got {}",
4278 resp.status()
4279 );
4280 }
4281}