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,
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("/api/v1/palaces/{id}/recall", get(recall_handler))
77 .route("/api/v1/recall", get(recall_all_handler))
78 .route("/api/v1/palaces/{id}/kg", get(kg_query).post(kg_assert))
79 .route("/api/v1/palaces/{id}/kg/subjects", get(kg_list_subjects))
80 .route(
81 "/api/v1/palaces/{id}/kg/subjects_with_counts",
82 get(kg_list_subjects_with_counts),
83 )
84 .route("/api/v1/palaces/{id}/kg/all", get(kg_list_all))
85 .route("/api/v1/palaces/{id}/kg/count", get(kg_count))
86 .route(
87 "/api/v1/palaces/{id}/dream/status",
88 get(palace_dream_status),
89 )
90 .route("/api/v1/dream/status", get(dream_status))
91 .route("/api/v1/dream/run", post(dream_run))
92 .route("/api/v1/kg/gaps", get(kg_gaps_handler))
93 .route("/api/v1/kg/prompt-context", get(prompt_context_handler))
94 .route("/api/v1/kg/aliases", post(add_alias_handler))
95 .route(
96 "/api/v1/kg/prompt-facts",
97 get(list_prompt_facts_handler).delete(remove_prompt_fact_handler),
98 )
99 .route("/api/v1/chat", post(chat_handler))
100 .route("/api/v1/chat/providers", get(list_providers))
101 .route(
102 "/api/v1/palaces/{id}/chat/sessions",
103 get(list_chat_sessions).post(create_chat_session),
104 )
105 .route(
106 "/api/v1/palaces/{id}/chat/sessions/{session_id}",
107 get(get_chat_session).delete(delete_chat_session),
108 )
109 .route("/health", get(health))
110 .route("/api/v1/logs/tail", get(logs_tail))
111 .route("/api/v1/admin/stop", post(admin_stop))
112 .fallback(static_handler);
113
114 trusty_common::server::with_standard_middleware(router)
115}
116
117#[derive(serde::Serialize)]
133struct HealthResponse {
134 status: &'static str,
135 version: &'static str,
136 rss_mb: u64,
139 disk_bytes: u64,
143 cpu_pct: f32,
147 uptime_secs: u64,
149 #[serde(skip_serializing_if = "Option::is_none")]
155 addr: Option<String>,
156}
157
158async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
170 let (rss_mb, cpu_pct) = {
171 let mut metrics = state.sys_metrics.lock().await;
172 metrics.sample()
173 };
174 let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
175 let uptime_secs = state.started_at.elapsed().as_secs();
176 let addr = state.bound_addr.get().map(|a| a.to_string());
177 Json(HealthResponse {
178 status: "ok",
179 version: env!("CARGO_PKG_VERSION"),
180 rss_mb,
181 disk_bytes,
182 cpu_pct,
183 uptime_secs,
184 addr,
185 })
186}
187
188const DEFAULT_LOGS_TAIL_N: usize = 100;
195
196const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
199
200fn default_logs_tail_n() -> usize {
201 DEFAULT_LOGS_TAIL_N
202}
203
204#[derive(serde::Deserialize)]
213struct LogsTailParams {
214 #[serde(default = "default_logs_tail_n")]
215 n: usize,
216}
217
218async fn logs_tail(
230 State(state): State<AppState>,
231 Query(params): Query<LogsTailParams>,
232) -> Json<Value> {
233 let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
234 let lines = state.log_buffer.tail(n);
235 Json(serde_json::json!({
236 "lines": lines,
237 "total": state.log_buffer.len(),
238 }))
239}
240
241async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
252 tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
253 tokio::spawn(async {
254 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
255 std::process::exit(0);
256 });
257 Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
258}
259
260async fn static_handler(req: Request<Body>) -> Response {
272 let path = req.uri().path().trim_start_matches('/').to_string();
273
274 if path.starts_with("api/") {
275 return (StatusCode::NOT_FOUND, "not found").into_response();
276 }
277
278 serve_embedded(&path).unwrap_or_else(|| {
279 serve_embedded("index.html")
281 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
282 })
283}
284
285fn serve_embedded(path: &str) -> Option<Response> {
286 let path = if path.is_empty() { "index.html" } else { path };
287 let asset = WebAssets::get(path)?;
288 let mime = mime_guess::from_path(path).first_or_octet_stream();
289 let body = Body::from(asset.data.into_owned());
290 let mut resp = Response::new(body);
291 resp.headers_mut().insert(
292 header::CONTENT_TYPE,
293 HeaderValue::from_str(mime.as_ref())
294 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
295 );
296 Some(resp)
297}
298
299#[derive(Serialize)]
304struct StatusPayload {
305 version: String,
306 palace_count: usize,
307 default_palace: Option<String>,
308 data_root: String,
309 total_drawers: usize,
310 total_vectors: usize,
311 total_kg_triples: usize,
312}
313
314async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
315 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
316 let palace_count = palaces.len();
317 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
318 for p in &palaces {
319 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
320 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
321 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
322 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
323 }
324 }
325 Json(StatusPayload {
326 version: state.version.clone(),
327 palace_count,
328 default_palace: state.default_palace.clone(),
329 data_root: state.data_root.display().to_string(),
330 total_drawers,
331 total_vectors,
332 total_kg_triples,
333 })
334}
335
336#[derive(Serialize)]
337struct ConfigPayload {
338 openrouter_configured: bool,
339 model: String,
340 data_root: String,
341}
342
343async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
344 let cfg = load_user_config().unwrap_or_default();
345 Json(ConfigPayload {
346 openrouter_configured: !cfg.openrouter_api_key.is_empty(),
347 model: cfg.openrouter_model,
348 data_root: state.data_root.display().to_string(),
349 })
350}
351
352#[derive(Deserialize, Default, Clone)]
355struct UserConfigMin {
356 #[serde(default)]
357 openrouter: OpenRouterMin,
358 #[serde(default)]
359 local_model: LocalModelMin,
360 }
362
363#[derive(Deserialize, Default, Clone)]
364struct OpenRouterMin {
365 #[serde(default)]
366 api_key: String,
367 #[serde(default)]
368 model: String,
369}
370
371#[derive(Deserialize, Clone)]
372struct LocalModelMin {
373 #[serde(default = "default_local_enabled")]
374 enabled: bool,
375 #[serde(default = "default_local_base_url")]
376 base_url: String,
377 #[serde(default = "default_local_model")]
378 model: String,
379}
380
381fn default_local_enabled() -> bool {
382 true
383}
384fn default_local_base_url() -> String {
385 "http://localhost:11434".to_string()
386}
387fn default_local_model() -> String {
388 "llama3.2".to_string()
389}
390
391impl Default for LocalModelMin {
392 fn default() -> Self {
393 Self {
394 enabled: default_local_enabled(),
395 base_url: default_local_base_url(),
396 model: default_local_model(),
397 }
398 }
399}
400
401#[derive(Clone)]
402pub(crate) struct LoadedUserConfig {
403 pub(crate) openrouter_api_key: String,
404 pub(crate) openrouter_model: String,
405 pub(crate) local_model: trusty_common::LocalModelConfig,
406}
407
408impl Default for LoadedUserConfig {
409 fn default() -> Self {
410 Self {
411 openrouter_api_key: String::new(),
412 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
413 local_model: trusty_common::LocalModelConfig::default(),
414 }
415 }
416}
417
418pub(crate) fn load_user_config() -> Option<LoadedUserConfig> {
419 let home = dirs::home_dir()?;
420 let path = home.join(".trusty-memory").join("config.toml");
421 if !path.exists() {
422 return Some(LoadedUserConfig::default());
423 }
424 let raw = std::fs::read_to_string(&path).ok()?;
425 let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
426 let model = if parsed.openrouter.model.is_empty() {
427 "anthropic/claude-3-5-sonnet".to_string()
428 } else {
429 parsed.openrouter.model
430 };
431 Some(LoadedUserConfig {
432 openrouter_api_key: parsed.openrouter.api_key,
433 openrouter_model: model,
434 local_model: trusty_common::LocalModelConfig {
435 enabled: parsed.local_model.enabled,
436 base_url: parsed.local_model.base_url,
437 model: parsed.local_model.model,
438 },
439 })
440}
441
442#[derive(Serialize)]
447struct PalaceInfo {
448 id: String,
449 name: String,
450 description: Option<String>,
451 drawer_count: usize,
452 vector_count: usize,
453 kg_triple_count: usize,
454 wing_count: usize,
455 created_at: chrono::DateTime<chrono::Utc>,
456 last_write_at: Option<chrono::DateTime<chrono::Utc>>,
466 #[serde(default)]
474 node_count: u64,
475 #[serde(default)]
481 edge_count: u64,
482 #[serde(default)]
490 community_count: u64,
491 #[serde(default)]
500 is_compacting: bool,
501}
502
503fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
513 let (
514 drawer_count,
515 vector_count,
516 kg_triple_count,
517 wing_count,
518 last_write_at,
519 node_count,
520 edge_count,
521 community_count,
522 is_compacting,
523 ) = if let Some(h) = handle {
524 let drawers = h.drawers.read();
525 let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
526 let last_write = drawers.iter().map(|d| d.created_at).max();
527 (
528 drawers.len(),
529 h.vector_store.index_size(),
530 h.kg.count_active_triples(),
531 distinct_rooms.len(),
532 last_write,
533 h.kg.node_count() as u64,
534 h.kg.edge_count() as u64,
535 h.kg.community_count() as u64,
536 h.is_compacting(),
537 )
538 } else {
539 (0, 0, 0, 0, None, 0, 0, 0, false)
540 };
541 PalaceInfo {
542 id: palace.id.0.clone(),
543 name: palace.name.clone(),
544 description: palace.description.clone(),
545 drawer_count,
546 vector_count,
547 kg_triple_count,
548 wing_count,
549 created_at: palace.created_at,
550 last_write_at,
551 node_count,
552 edge_count,
553 community_count,
554 is_compacting,
555 }
556}
557
558async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
559 let palaces = PalaceRegistry::list_palaces(&state.data_root)
560 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
561 let mut out = Vec::with_capacity(palaces.len());
562 for p in palaces {
563 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
564 out.push(palace_info_from(&p, handle.as_ref()));
565 }
566 Ok(Json(out))
567}
568
569#[derive(Deserialize)]
570struct CreatePalaceBody {
571 name: String,
572 #[serde(default)]
573 description: Option<String>,
574}
575
576async fn create_palace(
577 State(state): State<AppState>,
578 Json(body): Json<CreatePalaceBody>,
579) -> Result<Json<Value>, ApiError> {
580 let name = body.name.trim().to_string();
581 if name.is_empty() {
582 return Err(ApiError::bad_request("name is required"));
583 }
584 let id = PalaceId::new(&name);
585 let palace = Palace {
586 id: id.clone(),
587 name: name.clone(),
588 description: body.description.filter(|s| !s.is_empty()),
589 created_at: chrono::Utc::now(),
590 data_dir: state.data_root.join(&name),
591 };
592 state
593 .registry
594 .create_palace(&state.data_root, palace)
595 .map_err(|e| ApiError::internal(format!("create palace: {e:#}")))?;
596 state.emit(DaemonEvent::PalaceCreated {
597 id: name.clone(),
598 name: name.clone(),
599 });
600 Ok(Json(json!({ "id": name })))
601}
602
603async fn get_palace_handler(
604 State(state): State<AppState>,
605 AxumPath(id): AxumPath<String>,
606) -> Result<Json<PalaceInfo>, ApiError> {
607 let palaces = PalaceRegistry::list_palaces(&state.data_root)
608 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
609 let palace = palaces
610 .into_iter()
611 .find(|p| p.id.0 == id)
612 .ok_or_else(|| ApiError::not_found(format!("palace not found: {id}")))?;
613 let handle = state
614 .registry
615 .open_palace(&state.data_root, &palace.id)
616 .ok();
617 Ok(Json(palace_info_from(&palace, handle.as_ref())))
618}
619
620#[derive(Deserialize)]
625struct ListDrawersQuery {
626 #[serde(default)]
627 room: Option<String>,
628 #[serde(default)]
629 tag: Option<String>,
630 #[serde(default)]
631 limit: Option<usize>,
632}
633
634async fn list_drawers(
635 State(state): State<AppState>,
636 AxumPath(id): AxumPath<String>,
637 Query(q): Query<ListDrawersQuery>,
638) -> Result<Json<Value>, ApiError> {
639 let handle = open_handle(&state, &id)?;
640 let room = q.room.as_deref().map(RoomType::parse);
641 let drawers = handle.list_drawers(room, q.tag.clone(), q.limit.unwrap_or(50));
642 Ok(Json(serde_json::to_value(drawers).unwrap_or(json!([]))))
643}
644
645#[derive(Deserialize)]
646struct CreateDrawerBody {
647 content: String,
648 #[serde(default)]
649 room: Option<String>,
650 #[serde(default)]
651 tags: Vec<String>,
652 #[serde(default)]
653 importance: Option<f32>,
654}
655
656async fn create_drawer(
657 State(state): State<AppState>,
658 AxumPath(id): AxumPath<String>,
659 Json(body): Json<CreateDrawerBody>,
660) -> Result<Json<Value>, ApiError> {
661 let handle = open_handle(&state, &id)?;
662 let room = body
663 .room
664 .as_deref()
665 .map(RoomType::parse)
666 .unwrap_or(RoomType::General);
667 let importance = body.importance.unwrap_or(0.5);
668 let drawer_id = handle
669 .remember(body.content, room, body.tags, importance)
670 .await
671 .map_err(|e| ApiError::internal(format!("remember: {e:#}")))?;
672 let drawer_count = handle.drawers.read().len();
673 let palace_name = PalaceRegistry::list_palaces(&state.data_root)
674 .ok()
675 .and_then(|ps| ps.into_iter().find(|p| p.id.0 == id).map(|p| p.name))
676 .unwrap_or_else(|| id.clone());
677 state.emit(DaemonEvent::DrawerAdded {
678 palace_id: id.clone(),
679 palace_name,
680 drawer_count,
681 timestamp: chrono::Utc::now(),
682 });
683 state.emit(aggregate_status_event(&state));
684 Ok(Json(json!({ "id": drawer_id })))
685}
686
687async fn delete_drawer(
688 State(state): State<AppState>,
689 AxumPath((id, drawer_id)): AxumPath<(String, String)>,
690) -> Result<StatusCode, ApiError> {
691 let handle = open_handle(&state, &id)?;
692 let uuid = Uuid::parse_str(&drawer_id)
693 .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
694 handle
695 .forget(uuid)
696 .await
697 .map_err(|e| ApiError::internal(format!("forget: {e:#}")))?;
698 let drawer_count = handle.drawers.read().len();
699 state.emit(DaemonEvent::DrawerDeleted {
700 palace_id: id.clone(),
701 drawer_count,
702 });
703 state.emit(aggregate_status_event(&state));
704 Ok(StatusCode::NO_CONTENT)
705}
706
707fn aggregate_status_event(state: &AppState) -> DaemonEvent {
716 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
717 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
718 for p in &palaces {
719 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
720 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
721 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
722 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
723 }
724 }
725 DaemonEvent::StatusChanged {
726 total_drawers,
727 total_vectors,
728 total_kg_triples,
729 }
730}
731
732#[derive(Deserialize)]
737struct RecallQuery {
738 q: String,
739 #[serde(default)]
740 top_k: Option<usize>,
741 #[serde(default)]
742 deep: Option<bool>,
743}
744
745async fn recall_handler(
746 State(state): State<AppState>,
747 AxumPath(id): AxumPath<String>,
748 Query(q): Query<RecallQuery>,
749) -> Result<Json<Value>, ApiError> {
750 let handle = open_handle(&state, &id)?;
751 let top_k = q.top_k.unwrap_or(10);
752 let results = if q.deep.unwrap_or(false) {
753 recall_deep_with_default_embedder(&handle, &q.q, top_k).await
754 } else {
755 recall_with_default_embedder(&handle, &q.q, top_k).await
756 }
757 .map_err(|e| ApiError::internal(format!("recall: {e:#}")))?;
758
759 let payload: Vec<Value> = results
760 .into_iter()
761 .map(|r| {
762 json!({
763 "drawer": r.drawer,
764 "score": r.score,
765 "layer": r.layer,
766 })
767 })
768 .collect();
769 Ok(Json(json!(payload)))
770}
771
772async fn recall_all_handler(
786 State(state): State<AppState>,
787 Query(q): Query<RecallQuery>,
788) -> Result<Json<Value>, ApiError> {
789 let top_k = q.top_k.unwrap_or(10);
790 let deep = q.deep.unwrap_or(false);
791 let value = execute_recall_all(&state, &q.q, top_k, deep).await;
792 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
793 return Err(ApiError::internal(err.to_string()));
794 }
795 Ok(Json(value))
796}
797
798#[derive(Deserialize)]
803struct KgQueryParams {
804 subject: String,
805}
806
807async fn kg_query(
808 State(state): State<AppState>,
809 AxumPath(id): AxumPath<String>,
810 Query(q): Query<KgQueryParams>,
811) -> Result<Json<Vec<Triple>>, ApiError> {
812 let handle = open_handle(&state, &id)?;
813 let triples = handle
814 .kg
815 .query_active(&q.subject)
816 .await
817 .map_err(|e| ApiError::internal(format!("kg query: {e:#}")))?;
818 Ok(Json(triples))
819}
820
821#[derive(Deserialize)]
822struct KgAssertBody {
823 subject: String,
824 predicate: String,
825 object: String,
826 #[serde(default)]
827 confidence: Option<f32>,
828 #[serde(default)]
829 provenance: Option<String>,
830}
831
832async fn kg_assert(
833 State(state): State<AppState>,
834 AxumPath(id): AxumPath<String>,
835 Json(body): Json<KgAssertBody>,
836) -> Result<StatusCode, ApiError> {
837 let handle = open_handle(&state, &id)?;
838 let triple = Triple {
839 subject: body.subject,
840 predicate: body.predicate,
841 object: body.object,
842 valid_from: chrono::Utc::now(),
843 valid_to: None,
844 confidence: body.confidence.unwrap_or(1.0),
845 provenance: body.provenance,
846 };
847 handle
848 .kg
849 .assert(triple)
850 .await
851 .map_err(|e| ApiError::internal(format!("kg assert: {e:#}")))?;
852 Ok(StatusCode::NO_CONTENT)
853}
854
855const DEFAULT_KG_LIST_LIMIT: usize = 50;
860
861const MAX_KG_LIST_LIMIT: usize = 200;
866
867fn default_kg_list_limit() -> usize {
868 DEFAULT_KG_LIST_LIMIT
869}
870
871#[derive(Deserialize)]
880struct KgListSubjectsParams {
881 #[serde(default = "default_kg_list_limit")]
882 limit: usize,
883}
884
885async fn kg_list_subjects(
895 State(state): State<AppState>,
896 AxumPath(id): AxumPath<String>,
897 Query(q): Query<KgListSubjectsParams>,
898) -> Result<Json<Vec<String>>, ApiError> {
899 let handle = open_handle(&state, &id)?;
900 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
901 let subjects = handle
902 .kg
903 .list_subjects(limit)
904 .map_err(|e| ApiError::internal(format!("kg list_subjects: {e:#}")))?;
905 Ok(Json(subjects))
906}
907
908async fn kg_list_subjects_with_counts(
920 State(state): State<AppState>,
921 AxumPath(id): AxumPath<String>,
922 Query(q): Query<KgListSubjectsParams>,
923) -> Result<Json<Vec<Value>>, ApiError> {
924 let handle = open_handle(&state, &id)?;
925 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
926 let rows = handle
927 .kg
928 .list_subjects_with_counts(limit)
929 .map_err(|e| ApiError::internal(format!("kg list_subjects_with_counts: {e:#}")))?;
930 let out: Vec<Value> = rows
931 .into_iter()
932 .map(|(subject, count)| json!({ "subject": subject, "count": count }))
933 .collect();
934 Ok(Json(out))
935}
936
937#[derive(Deserialize)]
944struct KgListAllParams {
945 #[serde(default = "default_kg_list_limit")]
946 limit: usize,
947 #[serde(default)]
948 offset: usize,
949}
950
951async fn kg_list_all(
960 State(state): State<AppState>,
961 AxumPath(id): AxumPath<String>,
962 Query(q): Query<KgListAllParams>,
963) -> Result<Json<Vec<Triple>>, ApiError> {
964 let handle = open_handle(&state, &id)?;
965 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
966 let triples = handle
967 .kg
968 .list_active(limit, q.offset)
969 .await
970 .map_err(|e| ApiError::internal(format!("kg list_active: {e:#}")))?;
971 Ok(Json(triples))
972}
973
974async fn kg_count(
982 State(state): State<AppState>,
983 AxumPath(id): AxumPath<String>,
984) -> Result<Json<Value>, ApiError> {
985 let handle = open_handle(&state, &id)?;
986 let active = handle.kg.count_active_triples();
987 Ok(Json(json!({ "active": active })))
988}
989
990#[derive(Serialize, Default)]
997struct DreamStatusPayload {
998 last_run_at: Option<chrono::DateTime<chrono::Utc>>,
999 merged: usize,
1000 pruned: usize,
1001 compacted: usize,
1002 closets_updated: usize,
1003 duration_ms: u64,
1004}
1005
1006impl From<PersistedDreamStats> for DreamStatusPayload {
1007 fn from(p: PersistedDreamStats) -> Self {
1008 Self {
1009 last_run_at: Some(p.last_run_at),
1010 merged: p.stats.merged,
1011 pruned: p.stats.pruned,
1012 compacted: p.stats.compacted,
1013 closets_updated: p.stats.closets_updated,
1014 duration_ms: p.stats.duration_ms,
1015 }
1016 }
1017}
1018
1019async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
1028 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1029 let mut out = DreamStatusPayload::default();
1030 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1031 for p in palaces {
1032 let data_dir = state.data_root.join(p.id.as_str());
1033 let snap = match PersistedDreamStats::load(&data_dir) {
1034 Ok(Some(s)) => s,
1035 _ => continue,
1036 };
1037 out.merged = out.merged.saturating_add(snap.stats.merged);
1038 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1039 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1040 out.closets_updated = out
1041 .closets_updated
1042 .saturating_add(snap.stats.closets_updated);
1043 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1044 latest = match latest {
1045 Some(t) if t >= snap.last_run_at => Some(t),
1046 _ => Some(snap.last_run_at),
1047 };
1048 }
1049 out.last_run_at = latest;
1050 Json(out)
1051}
1052
1053async fn palace_dream_status(
1055 State(state): State<AppState>,
1056 AxumPath(id): AxumPath<String>,
1057) -> Result<Json<DreamStatusPayload>, ApiError> {
1058 let data_dir = state.data_root.join(&id);
1059 if !data_dir.exists() {
1060 return Err(ApiError::not_found(format!("palace not found: {id}")));
1061 }
1062 let payload = match PersistedDreamStats::load(&data_dir) {
1063 Ok(Some(s)) => s.into(),
1064 Ok(None) => DreamStatusPayload::default(),
1065 Err(e) => return Err(ApiError::internal(format!("read dream stats: {e:#}"))),
1066 };
1067 Ok(Json(payload))
1068}
1069
1070async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
1080 let palaces = PalaceRegistry::list_palaces(&state.data_root)
1081 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
1082 let dreamer = Dreamer::new(DreamConfig::default());
1083 let mut out = DreamStatusPayload::default();
1084 for p in palaces {
1085 let handle = match state.registry.open_palace(&state.data_root, &p.id) {
1086 Ok(h) => h,
1087 Err(e) => {
1088 tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
1089 continue;
1090 }
1091 };
1092 match dreamer.dream_cycle(&handle).await {
1093 Ok(stats) => {
1094 out.merged = out.merged.saturating_add(stats.merged);
1095 out.pruned = out.pruned.saturating_add(stats.pruned);
1096 out.compacted = out.compacted.saturating_add(stats.compacted);
1097 out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
1098 out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
1099 }
1100 Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
1101 }
1102 refresh_gaps_cache(&state, &handle).await;
1107 }
1108 out.last_run_at = Some(chrono::Utc::now());
1109 state.emit(DaemonEvent::DreamCompleted {
1110 palace_id: None,
1111 merged: out.merged,
1112 pruned: out.pruned,
1113 compacted: out.compacted,
1114 closets_updated: out.closets_updated,
1115 duration_ms: out.duration_ms,
1116 });
1117 state.emit(aggregate_status_event(&state));
1118 Ok(Json(out))
1119}
1120
1121#[derive(Serialize, Debug, Clone)]
1135pub struct KnowledgeGapResponse {
1136 pub entities: Vec<String>,
1137 pub internal_density: f32,
1138 pub external_bridges: usize,
1139 pub suggested_exploration: String,
1140}
1141
1142impl From<KnowledgeGap> for KnowledgeGapResponse {
1143 fn from(g: KnowledgeGap) -> Self {
1144 Self {
1145 entities: g.entities,
1146 internal_density: g.internal_density,
1147 external_bridges: g.external_bridges,
1148 suggested_exploration: g.suggested_exploration,
1149 }
1150 }
1151}
1152
1153#[derive(Deserialize)]
1154struct KgGapsQuery {
1155 #[serde(default)]
1156 palace: Option<String>,
1157}
1158
1159async fn kg_gaps_handler(
1174 State(state): State<AppState>,
1175 Query(q): Query<KgGapsQuery>,
1176) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
1177 let palace_name = q
1178 .palace
1179 .clone()
1180 .or_else(|| state.default_palace.clone())
1181 .ok_or_else(|| {
1182 ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
1183 })?;
1184
1185 let _handle = open_handle(&state, &palace_name)?;
1189
1190 let pid = PalaceId::new(&palace_name);
1191 let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
1192 let body: Vec<KnowledgeGapResponse> =
1193 gaps.into_iter().map(KnowledgeGapResponse::from).collect();
1194 Ok(Json(body))
1195}
1196
1197#[derive(Deserialize)]
1211struct PromptFactsQuery {
1212 #[serde(default)]
1217 #[allow(dead_code)]
1218 palace: Option<String>,
1219}
1220
1221#[derive(Deserialize)]
1230struct AddAliasRequest {
1231 short: String,
1232 full: String,
1233 #[serde(default)]
1234 palace: Option<String>,
1235}
1236
1237#[derive(Serialize)]
1245struct PromptFactRow {
1246 subject: String,
1247 predicate: String,
1248 object: String,
1249}
1250
1251#[derive(Deserialize)]
1262struct RemovePromptFactQuery {
1263 subject: String,
1264 predicate: String,
1265 #[serde(default)]
1266 #[allow(dead_code)]
1267 object: Option<String>,
1268 #[serde(default)]
1269 #[allow(dead_code)]
1270 palace: Option<String>,
1271}
1272
1273async fn prompt_context_handler(
1284 State(state): State<AppState>,
1285 Query(_q): Query<PromptFactsQuery>,
1286) -> Result<Response, ApiError> {
1287 let cache_snapshot = {
1288 let guard = state
1289 .prompt_context_cache
1290 .read()
1291 .map_err(|e| ApiError::internal(format!("prompt cache lock poisoned: {e}")))?;
1292 guard.clone()
1293 };
1294 let body = if cache_snapshot.formatted.is_empty() {
1295 "No prompt facts stored yet.".to_string()
1296 } else {
1297 cache_snapshot.formatted
1298 };
1299 let mut resp = body.into_response();
1300 resp.headers_mut().insert(
1301 header::CONTENT_TYPE,
1302 HeaderValue::from_static("text/plain; charset=utf-8"),
1303 );
1304 Ok(resp)
1305}
1306
1307async fn add_alias_handler(
1317 State(state): State<AppState>,
1318 Json(req): Json<AddAliasRequest>,
1319) -> Result<Json<Value>, ApiError> {
1320 if req.short.is_empty() || req.full.is_empty() {
1321 return Err(ApiError::bad_request("short and full are required"));
1322 }
1323 let palace_name = req
1324 .palace
1325 .clone()
1326 .or_else(|| state.default_palace.clone())
1327 .ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
1328 let handle = open_handle(&state, &palace_name)?;
1329 let triple = Triple {
1330 subject: req.short.clone(),
1331 predicate: "is_alias_for".to_string(),
1332 object: req.full.clone(),
1333 valid_from: chrono::Utc::now(),
1334 valid_to: None,
1335 confidence: 1.0,
1336 provenance: Some("add_alias_http".to_string()),
1337 };
1338 handle
1339 .kg
1340 .assert(triple)
1341 .await
1342 .map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
1343 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1344 tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
1345 }
1346 Ok(Json(json!({
1347 "subject": req.short,
1348 "predicate": "is_alias_for",
1349 "object": req.full,
1350 "palace": palace_name,
1351 })))
1352}
1353
1354async fn list_prompt_facts_handler(
1363 State(state): State<AppState>,
1364 Query(_q): Query<PromptFactsQuery>,
1365) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
1366 let triples = crate::prompt_facts::gather_hot_triples(&state)
1367 .await
1368 .map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
1369 let rows: Vec<PromptFactRow> = triples
1370 .into_iter()
1371 .map(|(subject, predicate, object)| PromptFactRow {
1372 subject,
1373 predicate,
1374 object,
1375 })
1376 .collect();
1377 Ok(Json(rows))
1378}
1379
1380async fn remove_prompt_fact_handler(
1391 State(state): State<AppState>,
1392 Query(q): Query<RemovePromptFactQuery>,
1393) -> Result<Json<Value>, ApiError> {
1394 if q.subject.is_empty() || q.predicate.is_empty() {
1395 return Err(ApiError::bad_request("subject and predicate are required"));
1396 }
1397 let mut closed_total: usize = 0;
1398 for palace_id in state.registry.list() {
1399 if let Some(handle) = state.registry.get(&palace_id) {
1400 match handle.kg.retract(&q.subject, &q.predicate).await {
1401 Ok(n) => closed_total += n,
1402 Err(e) => tracing::warn!(
1403 palace = %palace_id.as_str(),
1404 "HTTP retract failed: {e:#}",
1405 ),
1406 }
1407 }
1408 }
1409 if closed_total > 0 {
1410 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1411 tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
1412 }
1413 Ok(Json(json!({"removed": true, "closed": closed_total})))
1414 } else {
1415 Ok(Json(json!({"removed": false, "reason": "not found"})))
1416 }
1417}
1418
1419async fn refresh_gaps_cache(state: &AppState, handle: &Arc<PalaceHandle>) {
1432 let mut gaps = handle.kg.knowledge_gaps();
1433 if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") {
1438 if !api_key.is_empty() {
1439 for gap in gaps.iter_mut() {
1440 if let Some(enriched) = enrich_gap_exploration(&api_key, gap).await {
1441 gap.suggested_exploration = enriched;
1442 }
1443 }
1444 }
1445 }
1446 let gap_count = gaps.len();
1447 state.registry.set_gaps(handle.id.clone(), gaps);
1448 tracing::debug!(palace = %handle.id, gaps = gap_count, "community gaps updated");
1449}
1450
1451async fn enrich_gap_exploration(api_key: &str, gap: &KnowledgeGap) -> Option<String> {
1466 let preview: Vec<&str> = gap.entities.iter().take(5).map(String::as_str).collect();
1469 if preview.is_empty() {
1470 return None;
1471 }
1472 let entities = preview.join(", ");
1473 let user = format!(
1474 "Given these related entities from a knowledge graph: {entities}. \
1475 Suggest one specific research question (single sentence, under 25 words) \
1476 that would help fill gaps in this knowledge cluster. Return only the question."
1477 );
1478 let messages = vec![trusty_common::ChatMessage {
1479 role: "user".to_string(),
1480 content: user,
1481 tool_call_id: None,
1482 tool_calls: None,
1483 }];
1484 #[allow(deprecated)]
1488 let res = trusty_common::openrouter_chat(api_key, "openai/gpt-4o-mini", messages).await;
1489 match res {
1490 Ok(text) => {
1491 let trimmed = text.trim().to_string();
1492 if trimmed.is_empty() {
1493 None
1494 } else {
1495 Some(trimmed)
1496 }
1497 }
1498 Err(e) => {
1499 tracing::debug!("openrouter gap enrichment failed (using template): {e:#}");
1500 None
1501 }
1502 }
1503}
1504
1505#[derive(Deserialize)]
1510struct ChatBody {
1511 #[serde(default)]
1512 palace_id: Option<String>,
1513 message: String,
1514 #[serde(default)]
1515 history: Vec<ChatMessage>,
1516 #[serde(default)]
1518 session_id: Option<String>,
1519}
1520
1521const MAX_TOOL_ROUNDS: usize = 10;
1527
1528fn all_tools() -> Vec<ToolDef> {
1537 vec![
1538 ToolDef {
1539 name: "list_palaces".into(),
1540 description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
1541 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1542 },
1543 ToolDef {
1544 name: "get_palace".into(),
1545 description: "Get details for a specific palace by id.".into(),
1546 parameters: json!({
1547 "type": "object",
1548 "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
1549 "required": ["palace_id"],
1550 }),
1551 },
1552 ToolDef {
1553 name: "recall_memories".into(),
1554 description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
1555 parameters: json!({
1556 "type": "object",
1557 "properties": {
1558 "palace_id": { "type": "string" },
1559 "query": { "type": "string", "description": "Free-text query" },
1560 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
1561 },
1562 "required": ["palace_id", "query"],
1563 }),
1564 },
1565 ToolDef {
1566 name: "list_drawers".into(),
1567 description: "List all drawers (memories) in a palace, most recent first.".into(),
1568 parameters: json!({
1569 "type": "object",
1570 "properties": { "palace_id": { "type": "string" } },
1571 "required": ["palace_id"],
1572 }),
1573 },
1574 ToolDef {
1575 name: "kg_query".into(),
1576 description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
1577 parameters: json!({
1578 "type": "object",
1579 "properties": {
1580 "palace_id": { "type": "string" },
1581 "subject": { "type": "string" }
1582 },
1583 "required": ["palace_id", "subject"],
1584 }),
1585 },
1586 ToolDef {
1587 name: "get_config".into(),
1588 description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
1589 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1590 },
1591 ToolDef {
1592 name: "get_status".into(),
1593 description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
1594 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1595 },
1596 ToolDef {
1597 name: "get_dream_status".into(),
1598 description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
1599 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1600 },
1601 ToolDef {
1602 name: "get_palace_dream_status".into(),
1603 description: "Get dreamer activity stats for a specific palace.".into(),
1604 parameters: json!({
1605 "type": "object",
1606 "properties": { "palace_id": { "type": "string" } },
1607 "required": ["palace_id"],
1608 }),
1609 },
1610 ToolDef {
1611 name: "create_memory".into(),
1612 description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
1613 parameters: json!({
1614 "type": "object",
1615 "properties": {
1616 "palace_id": { "type": "string" },
1617 "content": { "type": "string", "description": "Verbatim memory text" },
1618 "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
1619 "tags": { "type": "array", "items": { "type": "string" } },
1620 "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
1621 },
1622 "required": ["palace_id", "content"],
1623 }),
1624 },
1625 ToolDef {
1626 name: "kg_assert".into(),
1627 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(),
1628 parameters: json!({
1629 "type": "object",
1630 "properties": {
1631 "palace_id": { "type": "string" },
1632 "subject": { "type": "string" },
1633 "predicate": { "type": "string" },
1634 "object": { "type": "string" },
1635 "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
1636 },
1637 "required": ["palace_id", "subject", "predicate", "object"],
1638 }),
1639 },
1640 ToolDef {
1641 name: "memory_recall_all".into(),
1642 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(),
1643 parameters: json!({
1644 "type": "object",
1645 "properties": {
1646 "q": { "type": "string", "description": "Free-text query" },
1647 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
1648 "deep": { "type": "boolean", "default": false }
1649 },
1650 "required": ["q"],
1651 }),
1652 },
1653 ]
1654}
1655
1656async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
1667 let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
1668 match name {
1669 "list_palaces" => execute_list_palaces(state).await,
1670 "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1671 Some(id) => execute_get_palace(state, id).await,
1672 None => json!({ "error": "missing required argument: palace_id" }),
1673 },
1674 "recall_memories" => {
1675 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1676 let q = parsed.get("query").and_then(|v| v.as_str());
1677 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1678 match (pid, q) {
1679 (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
1680 _ => json!({ "error": "missing required argument(s): palace_id, query" }),
1681 }
1682 }
1683 "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1684 Some(id) => execute_list_drawers(state, id).await,
1685 None => json!({ "error": "missing required argument: palace_id" }),
1686 },
1687 "kg_query" => {
1688 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1689 let subj = parsed.get("subject").and_then(|v| v.as_str());
1690 match (pid, subj) {
1691 (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
1692 _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
1693 }
1694 }
1695 "get_config" => execute_get_config(state),
1696 "get_status" => execute_get_status(state).await,
1697 "get_dream_status" => execute_get_dream_status(state).await,
1698 "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1699 Some(id) => execute_get_palace_dream_status(state, id).await,
1700 None => json!({ "error": "missing required argument: palace_id" }),
1701 },
1702 "create_memory" => {
1703 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1704 let content = parsed.get("content").and_then(|v| v.as_str());
1705 let room = parsed.get("room").and_then(|v| v.as_str());
1706 let tags: Vec<String> = parsed
1707 .get("tags")
1708 .and_then(|v| v.as_array())
1709 .map(|arr| {
1710 arr.iter()
1711 .filter_map(|t| t.as_str().map(|s| s.to_string()))
1712 .collect()
1713 })
1714 .unwrap_or_default();
1715 let importance = parsed
1716 .get("importance")
1717 .and_then(|v| v.as_f64())
1718 .map(|f| f as f32)
1719 .unwrap_or(0.5);
1720 match (pid, content) {
1721 (Some(p), Some(c)) => {
1722 execute_create_memory(state, p, c, room, tags, importance).await
1723 }
1724 _ => json!({ "error": "missing required argument(s): palace_id, content" }),
1725 }
1726 }
1727 "kg_assert" => {
1728 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1729 let subj = parsed.get("subject").and_then(|v| v.as_str());
1730 let pred = parsed.get("predicate").and_then(|v| v.as_str());
1731 let obj = parsed.get("object").and_then(|v| v.as_str());
1732 let conf = parsed
1733 .get("confidence")
1734 .and_then(|v| v.as_f64())
1735 .map(|f| f as f32)
1736 .unwrap_or(1.0);
1737 match (pid, subj, pred, obj) {
1738 (Some(p), Some(s), Some(pr), Some(o)) => {
1739 execute_kg_assert(state, p, s, pr, o, conf).await
1740 }
1741 _ => json!({
1742 "error": "missing required argument(s): palace_id, subject, predicate, object"
1743 }),
1744 }
1745 }
1746 "memory_recall_all" => {
1747 let q = parsed.get("q").and_then(|v| v.as_str());
1748 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1749 let deep = parsed
1750 .get("deep")
1751 .and_then(|v| v.as_bool())
1752 .unwrap_or(false);
1753 match q {
1754 Some(q) => execute_recall_all(state, q, top_k, deep).await,
1755 None => json!({ "error": "missing required argument: q" }),
1756 }
1757 }
1758 _ => json!({ "error": format!("unknown tool: {name}") }),
1759 }
1760}
1761
1762async fn execute_list_palaces(state: &AppState) -> Value {
1763 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1764 Ok(v) => v,
1765 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1766 };
1767 let out: Vec<Value> = palaces
1768 .into_iter()
1769 .map(|p| {
1770 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1771 let info = palace_info_from(&p, handle.as_ref());
1772 serde_json::to_value(info).unwrap_or(json!({}))
1773 })
1774 .collect();
1775 json!(out)
1776}
1777
1778async fn execute_get_palace(state: &AppState, id: &str) -> Value {
1779 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1780 Ok(v) => v,
1781 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1782 };
1783 match palaces.into_iter().find(|p| p.id.0 == id) {
1784 Some(p) => {
1785 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1786 serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
1787 }
1788 None => json!({ "error": format!("palace not found: {id}") }),
1789 }
1790}
1791
1792async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
1793 let handle = match state
1794 .registry
1795 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1796 {
1797 Ok(h) => h,
1798 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1799 };
1800 match recall_with_default_embedder(&handle, query, top_k).await {
1801 Ok(hits) => json!(hits
1802 .into_iter()
1803 .map(|r| json!({
1804 "drawer_id": r.drawer.id.to_string(),
1805 "content": r.drawer.content,
1806 "importance": r.drawer.importance,
1807 "tags": r.drawer.tags,
1808 "score": r.score,
1809 "layer": r.layer,
1810 }))
1811 .collect::<Vec<_>>()),
1812 Err(e) => json!({ "error": format!("recall: {e:#}") }),
1813 }
1814}
1815
1816async fn execute_recall_all(state: &AppState, query: &str, top_k: usize, deep: bool) -> Value {
1827 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1828 Ok(v) => v,
1829 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1830 };
1831 let mut handles = Vec::with_capacity(palaces.len());
1832 for p in &palaces {
1833 match state.registry.open_palace(&state.data_root, &p.id) {
1834 Ok(h) => handles.push(h),
1835 Err(e) => {
1836 tracing::warn!(palace = %p.id, "execute_recall_all: open failed: {e:#}");
1837 }
1838 }
1839 }
1840 if handles.is_empty() {
1841 return json!([]);
1842 }
1843 match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
1844 Ok(results) => json!(results
1845 .into_iter()
1846 .map(|r| json!({
1847 "palace_id": r.palace_id,
1848 "drawer_id": r.result.drawer.id.to_string(),
1849 "content": r.result.drawer.content,
1850 "importance": r.result.drawer.importance,
1851 "tags": r.result.drawer.tags,
1852 "score": r.result.score,
1853 "layer": r.result.layer,
1854 }))
1855 .collect::<Vec<_>>()),
1856 Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
1857 }
1858}
1859
1860async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
1861 let handle = match state
1862 .registry
1863 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1864 {
1865 Ok(h) => h,
1866 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1867 };
1868 let drawers = handle.list_drawers(None, None, 200);
1869 serde_json::to_value(drawers).unwrap_or(json!([]))
1870}
1871
1872async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
1873 let handle = match state
1874 .registry
1875 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1876 {
1877 Ok(h) => h,
1878 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1879 };
1880 match handle.kg.query_active(subject).await {
1881 Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
1882 Err(e) => json!({ "error": format!("kg query: {e:#}") }),
1883 }
1884}
1885
1886fn execute_get_config(state: &AppState) -> Value {
1887 let cfg = load_user_config().unwrap_or_default();
1888 json!({
1889 "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
1890 "openrouter_model": cfg.openrouter_model,
1891 "local_model": {
1892 "enabled": cfg.local_model.enabled,
1893 "base_url": cfg.local_model.base_url,
1894 "model": cfg.local_model.model,
1895 },
1896 "data_root": state.data_root.display().to_string(),
1897 })
1898}
1899
1900async fn execute_get_status(state: &AppState) -> Value {
1901 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1902 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
1903 for p in &palaces {
1904 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
1905 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
1906 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
1907 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
1908 }
1909 }
1910 json!({
1911 "version": state.version,
1912 "palace_count": palaces.len(),
1913 "default_palace": state.default_palace,
1914 "data_root": state.data_root.display().to_string(),
1915 "total_drawers": total_drawers,
1916 "total_vectors": total_vectors,
1917 "total_kg_triples": total_kg_triples,
1918 })
1919}
1920
1921async fn execute_get_dream_status(state: &AppState) -> Value {
1922 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1923 let mut out = DreamStatusPayload::default();
1924 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1925 for p in palaces {
1926 let data_dir = state.data_root.join(p.id.as_str());
1927 let snap = match PersistedDreamStats::load(&data_dir) {
1928 Ok(Some(s)) => s,
1929 _ => continue,
1930 };
1931 out.merged = out.merged.saturating_add(snap.stats.merged);
1932 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1933 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1934 out.closets_updated = out
1935 .closets_updated
1936 .saturating_add(snap.stats.closets_updated);
1937 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1938 latest = match latest {
1939 Some(t) if t >= snap.last_run_at => Some(t),
1940 _ => Some(snap.last_run_at),
1941 };
1942 }
1943 out.last_run_at = latest;
1944 serde_json::to_value(out).unwrap_or(json!({}))
1945}
1946
1947async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
1948 let data_dir = state.data_root.join(palace_id);
1949 if !data_dir.exists() {
1950 return json!({ "error": format!("palace not found: {palace_id}") });
1951 }
1952 match PersistedDreamStats::load(&data_dir) {
1953 Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
1954 Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
1955 Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
1956 }
1957}
1958
1959async fn execute_create_memory(
1960 state: &AppState,
1961 palace_id: &str,
1962 content: &str,
1963 room: Option<&str>,
1964 tags: Vec<String>,
1965 importance: f32,
1966) -> Value {
1967 let handle = match state
1968 .registry
1969 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1970 {
1971 Ok(h) => h,
1972 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1973 };
1974 let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
1975 match handle
1976 .remember(content.to_string(), room, tags, importance)
1977 .await
1978 {
1979 Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
1980 Err(e) => json!({ "error": format!("remember: {e:#}") }),
1981 }
1982}
1983
1984async fn execute_kg_assert(
1985 state: &AppState,
1986 palace_id: &str,
1987 subject: &str,
1988 predicate: &str,
1989 object: &str,
1990 confidence: f32,
1991) -> Value {
1992 let handle = match state
1993 .registry
1994 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1995 {
1996 Ok(h) => h,
1997 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1998 };
1999 let triple = Triple {
2000 subject: subject.to_string(),
2001 predicate: predicate.to_string(),
2002 object: object.to_string(),
2003 valid_from: chrono::Utc::now(),
2004 valid_to: None,
2005 confidence,
2006 provenance: Some("chat:assistant".to_string()),
2007 };
2008 match handle.kg.assert(triple).await {
2009 Ok(()) => json!({ "status": "asserted" }),
2010 Err(e) => json!({ "error": format!("kg assert: {e:#}") }),
2011 }
2012}
2013
2014async fn chat_handler(State(state): State<AppState>, Json(body): Json<ChatBody>) -> Response {
2015 let Some(provider) = state.chat_provider().await else {
2017 return (
2018 StatusCode::PRECONDITION_FAILED,
2019 "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
2020 )
2021 .into_response();
2022 };
2023
2024 let palace_id = body
2026 .palace_id
2027 .clone()
2028 .or_else(|| state.default_palace.clone())
2029 .unwrap_or_default();
2030
2031 let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
2033 let store = match state.session_store(&palace_id) {
2034 Ok(s) => s,
2035 Err(e) => {
2036 tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
2037 return (
2038 StatusCode::INTERNAL_SERVER_ERROR,
2039 format!("session store: {e:#}"),
2040 )
2041 .into_response();
2042 }
2043 };
2044 match body.session_id.clone() {
2045 Some(sid) => match store.get_session(&sid) {
2046 Ok(Some(s)) => (
2047 Some(sid),
2048 s.history
2049 .into_iter()
2050 .map(|m| ChatMessage {
2051 role: m.role,
2052 content: m.content,
2053 tool_call_id: None,
2054 tool_calls: None,
2055 })
2056 .collect(),
2057 ),
2058 _ => (Some(sid), body.history.clone()),
2059 },
2060 None => {
2061 let new_id = store.create_session(None).unwrap_or_else(|e| {
2062 tracing::warn!("create_session failed: {e:#}");
2063 String::new()
2064 });
2065 (
2066 if new_id.is_empty() {
2067 None
2068 } else {
2069 Some(new_id)
2070 },
2071 body.history.clone(),
2072 )
2073 }
2074 }
2075 } else {
2076 (None, body.history.clone())
2077 };
2078
2079 let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2082 let palace_count = all_palaces.len();
2083 let palace_roster: String = all_palaces
2084 .iter()
2085 .map(|p| format!("- {} (id: {})", p.name, p.id.0))
2086 .collect::<Vec<_>>()
2087 .join("\n");
2088
2089 let cfg = load_user_config().unwrap_or_default();
2092 let active_provider_name = state
2093 .chat_provider()
2094 .await
2095 .map(|p| p.name().to_string())
2096 .unwrap_or_else(|| "none".to_string());
2097 let dream_snapshot = execute_get_dream_status(&state).await;
2098
2099 let selected_palace_meta = if palace_id.is_empty() {
2102 None
2103 } else {
2104 all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
2105 };
2106
2107 let mut palace_block = String::new();
2108 let mut context = String::new();
2109 let mut palace_display_name = palace_id.clone();
2110
2111 if !palace_id.is_empty() {
2112 if let Ok(handle) = state
2113 .registry
2114 .open_palace(&state.data_root, &PalaceId::new(&palace_id))
2115 {
2116 let drawer_count = handle.drawers.read().len();
2118 let vector_count = handle.vector_store.index_size();
2119 let kg_triple_count = handle.kg.count_active_triples();
2120
2121 let (name, description) = match &selected_palace_meta {
2123 Some(p) => (p.name.clone(), p.description.clone()),
2124 None => (palace_id.clone(), None),
2125 };
2126 palace_display_name = name.clone();
2127
2128 palace_block.push_str(&format!(
2129 "Currently selected palace:\n\
2130 - id: {id}\n\
2131 - name: {name}\n",
2132 id = palace_id,
2133 name = name,
2134 ));
2135 if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
2136 palace_block.push_str(&format!("- description: {desc}\n"));
2137 }
2138 palace_block.push_str(&format!(
2139 "- drawers: {drawer_count}\n\
2140 - vectors: {vector_count}\n\
2141 - kg_triples: {kg_triple_count}\n",
2142 ));
2143 let identity_trimmed = handle.identity.trim();
2144 if !identity_trimmed.is_empty() {
2145 palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
2146 }
2147
2148 if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
2149 for r in hits.iter().take(5) {
2150 context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content));
2151 }
2152 }
2153 }
2154 }
2155
2156 let mut system = String::new();
2160 system.push_str(&format!(
2161 "You are the assistant for trusty-memory, a machine-wide AI memory \
2162 service running locally on this user's machine. trusty-memory stores \
2163 knowledge in named \"palaces\" — isolated memory namespaces, each with \
2164 its own vector index (usearch HNSW) and temporal knowledge graph \
2165 (SQLite). Memories are organized as Palace -> Wing -> Room -> Closet \
2166 -> Drawer, where a Drawer is an atomic memory unit.\n\
2167 There are currently {palace_count} palace(s) on this machine.\n",
2168 ));
2169 if !palace_roster.is_empty() {
2170 system.push_str(&format!("Palaces:\n{palace_roster}\n"));
2171 }
2172 system.push('\n');
2173
2174 system.push_str(&format!(
2176 "System configuration:\n\
2177 - active chat provider: {active_provider_name}\n\
2178 - openrouter model: {or_model}\n\
2179 - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
2180 - data root: {data_root}\n\n",
2181 or_model = cfg.openrouter_model,
2182 local_model = cfg.local_model.model,
2183 local_url = cfg.local_model.base_url,
2184 local_enabled = cfg.local_model.enabled,
2185 data_root = state.data_root.display(),
2186 ));
2187
2188 system.push_str(&format!(
2190 "Global dream status (background memory maintenance):\n{}\n\n",
2191 dream_snapshot,
2192 ));
2193
2194 if !palace_block.is_empty() {
2195 system.push_str(&palace_block);
2196 system.push('\n');
2197 }
2198
2199 if !context.is_empty() {
2200 system.push_str(&format!(
2201 "Relevant memories from the '{palace_display_name}' palace \
2202 (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
2203 {context}\n",
2204 ));
2205 }
2206
2207 system.push_str(
2208 "You have a set of tools to introspect and modify this trusty-memory \
2209 daemon. Prefer calling a tool over guessing — e.g. call \
2210 `list_palaces` rather than relying on the roster above if you need \
2211 live counts, and call `recall_memories` to search for facts you \
2212 don't have in context. When the user asks about \"palaces\", they \
2213 mean trusty-memory palaces (memory namespaces on this machine), not \
2214 architectural palaces like Versailles. If a tool returns an error, \
2215 report it honestly and don't fabricate results.",
2216 );
2217
2218 history.push(ChatMessage {
2220 role: "user".to_string(),
2221 content: body.message.clone(),
2222 tool_call_id: None,
2223 tool_calls: None,
2224 });
2225
2226 let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
2227 messages.push(ChatMessage {
2228 role: "system".to_string(),
2229 content: system,
2230 tool_call_id: None,
2231 tool_calls: None,
2232 });
2233 messages.extend(history.iter().cloned());
2234
2235 let tools = all_tools();
2236 let (sse_tx, sse_rx) =
2237 tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
2238
2239 let session_store = if !palace_id.is_empty() && session_id.is_some() {
2241 state.session_store(&palace_id).ok()
2242 } else {
2243 None
2244 };
2245 let persist_session_id = session_id.clone();
2246
2247 let loop_state = state.clone();
2250 tokio::spawn(async move {
2251 if let Some(sid) = persist_session_id.as_deref() {
2254 let frame = format!("data: {}\n\n", json!({ "session_id": sid }));
2255 if sse_tx
2256 .send(Ok(axum::body::Bytes::from(frame)))
2257 .await
2258 .is_err()
2259 {
2260 return;
2261 }
2262 }
2263
2264 let mut final_assistant_text = String::new();
2265 let mut stream_err: Option<String> = None;
2266
2267 for round in 0..MAX_TOOL_ROUNDS {
2268 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
2269 let messages_clone = messages.clone();
2270 let tools_clone = tools.clone();
2271 let provider_clone = provider.clone();
2272 let stream_handle = tokio::spawn(async move {
2273 provider_clone
2274 .chat_stream(messages_clone, tools_clone, event_tx)
2275 .await
2276 });
2277
2278 let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
2279 let mut round_assistant_text = String::new();
2280
2281 while let Some(event) = event_rx.recv().await {
2282 match event {
2283 ChatEvent::Delta(text) => {
2284 round_assistant_text.push_str(&text);
2285 let frame = format!("data: {}\n\n", json!({ "delta": text }));
2286 if sse_tx
2287 .send(Ok(axum::body::Bytes::from(frame)))
2288 .await
2289 .is_err()
2290 {
2291 return;
2292 }
2293 }
2294 ChatEvent::ToolCall(tc) => {
2295 let frame = format!(
2296 "data: {}\n\n",
2297 json!({ "tool_call": {
2298 "id": tc.id,
2299 "name": tc.name,
2300 "arguments": tc.arguments,
2301 }})
2302 );
2303 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
2304 tool_calls_this_round.push(tc);
2305 }
2306 ChatEvent::Done => break,
2307 ChatEvent::Error(e) => {
2308 stream_err = Some(e);
2309 break;
2310 }
2311 }
2312 }
2313
2314 match stream_handle.await {
2316 Ok(Ok(())) => {}
2317 Ok(Err(e)) => stream_err = Some(e.to_string()),
2318 Err(e) => stream_err = Some(format!("join: {e}")),
2319 }
2320
2321 if stream_err.is_some() {
2322 break;
2323 }
2324
2325 final_assistant_text.push_str(&round_assistant_text);
2326
2327 if tool_calls_this_round.is_empty() {
2328 break;
2330 }
2331
2332 let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
2334 .iter()
2335 .map(|tc| {
2336 json!({
2337 "id": tc.id,
2338 "type": "function",
2339 "function": { "name": tc.name, "arguments": tc.arguments },
2340 })
2341 })
2342 .collect();
2343 messages.push(ChatMessage {
2344 role: "assistant".to_string(),
2345 content: round_assistant_text,
2346 tool_call_id: None,
2347 tool_calls: Some(assistant_tool_calls_json),
2348 });
2349
2350 for tc in &tool_calls_this_round {
2353 let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
2354 let result_str = result.to_string();
2355 let frame = format!(
2356 "data: {}\n\n",
2357 json!({ "tool_result": {
2358 "id": tc.id,
2359 "name": tc.name,
2360 "content": &result_str,
2361 }})
2362 );
2363 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
2364 messages.push(ChatMessage {
2365 role: "tool".to_string(),
2366 content: result_str,
2367 tool_call_id: Some(tc.id.clone()),
2368 tool_calls: None,
2369 });
2370 }
2371
2372 if round + 1 == MAX_TOOL_ROUNDS {
2374 tracing::warn!(
2375 "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
2376 MAX_TOOL_ROUNDS
2377 );
2378 }
2379 }
2380
2381 if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
2384 if !final_assistant_text.is_empty() {
2385 history.push(ChatMessage {
2386 role: "assistant".into(),
2387 content: final_assistant_text,
2388 tool_call_id: None,
2389 tool_calls: None,
2390 });
2391 }
2392 let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
2393 history
2394 .iter()
2395 .map(
2396 |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
2397 role: m.role.clone(),
2398 content: m.content.clone(),
2399 },
2400 )
2401 .collect();
2402 if let Err(e) = store.upsert_session(sid, &core_history) {
2403 tracing::warn!("upsert_session failed: {e:#}");
2404 }
2405 }
2406
2407 match stream_err {
2408 None => {
2409 let _ = sse_tx
2410 .send(Ok(axum::body::Bytes::from("data: [DONE]\n\n")))
2411 .await;
2412 }
2413 Some(e) => {
2414 let out = format!("data: {}\n\n", json!({ "error": e }));
2415 let _ = sse_tx.send(Ok(axum::body::Bytes::from(out))).await;
2416 }
2417 }
2418 });
2419
2420 let stream = tokio_stream::wrappers::ReceiverStream::new(sse_rx);
2421
2422 Response::builder()
2423 .header("Content-Type", "text/event-stream")
2424 .header("Cache-Control", "no-cache")
2425 .body(Body::from_stream(stream))
2426 .expect("static SSE response builds")
2427}
2428
2429async fn list_providers(State(state): State<AppState>) -> Json<Value> {
2442 let cfg = load_user_config().unwrap_or_default();
2443 let ollama_available = if cfg.local_model.enabled {
2444 trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
2445 .await
2446 .is_some()
2447 } else {
2448 false
2449 };
2450 let openrouter_available = !cfg.openrouter_api_key.is_empty();
2451 let active = state.chat_provider().await.map(|p| p.name().to_string());
2452 Json(json!({
2453 "providers": [
2454 {
2455 "name": "ollama",
2456 "model": cfg.local_model.model,
2457 "available": ollama_available,
2458 },
2459 {
2460 "name": "openrouter",
2461 "model": cfg.openrouter_model,
2462 "available": openrouter_available,
2463 }
2464 ],
2465 "active": active,
2466 }))
2467}
2468
2469#[derive(Deserialize, Default)]
2470struct CreateSessionBody {
2471 #[serde(default)]
2472 title: Option<String>,
2473}
2474
2475async fn create_chat_session(
2476 State(state): State<AppState>,
2477 AxumPath(id): AxumPath<String>,
2478 body: Option<Json<CreateSessionBody>>,
2479) -> Result<Json<Value>, ApiError> {
2480 let store = state
2481 .session_store(&id)
2482 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2483 let title = body.and_then(|b| b.0.title);
2484 let sid = store
2485 .create_session(title)
2486 .map_err(|e| ApiError::internal(format!("create session: {e:#}")))?;
2487 Ok(Json(json!({ "id": sid })))
2488}
2489
2490async fn list_chat_sessions(
2491 State(state): State<AppState>,
2492 AxumPath(id): AxumPath<String>,
2493) -> Result<Json<Value>, ApiError> {
2494 let store = state
2495 .session_store(&id)
2496 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2497 let metas = store
2498 .list_sessions()
2499 .map_err(|e| ApiError::internal(format!("list sessions: {e:#}")))?;
2500 Ok(Json(serde_json::to_value(metas).unwrap_or(json!([]))))
2501}
2502
2503async fn get_chat_session(
2504 State(state): State<AppState>,
2505 AxumPath((id, session_id)): AxumPath<(String, String)>,
2506) -> Result<Json<Value>, ApiError> {
2507 let store = state
2508 .session_store(&id)
2509 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2510 let s = store
2511 .get_session(&session_id)
2512 .map_err(|e| ApiError::internal(format!("get session: {e:#}")))?
2513 .ok_or_else(|| ApiError::not_found(format!("session not found: {session_id}")))?;
2514 Ok(Json(serde_json::to_value(s).unwrap_or(json!({}))))
2515}
2516
2517async fn delete_chat_session(
2518 State(state): State<AppState>,
2519 AxumPath((id, session_id)): AxumPath<(String, String)>,
2520) -> Result<StatusCode, ApiError> {
2521 let store = state
2522 .session_store(&id)
2523 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2524 store
2525 .delete_session(&session_id)
2526 .map_err(|e| ApiError::internal(format!("delete session: {e:#}")))?;
2527 Ok(StatusCode::NO_CONTENT)
2528}
2529
2530fn open_handle(
2535 state: &AppState,
2536 id: &str,
2537) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
2538 state
2539 .registry
2540 .open_palace(&state.data_root, &PalaceId::new(id))
2541 .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
2542}
2543
2544struct ApiError {
2546 status: StatusCode,
2547 message: String,
2548}
2549
2550impl ApiError {
2551 fn bad_request(msg: impl Into<String>) -> Self {
2552 Self {
2553 status: StatusCode::BAD_REQUEST,
2554 message: msg.into(),
2555 }
2556 }
2557 fn not_found(msg: impl Into<String>) -> Self {
2558 Self {
2559 status: StatusCode::NOT_FOUND,
2560 message: msg.into(),
2561 }
2562 }
2563 fn internal(msg: impl Into<String>) -> Self {
2564 Self {
2565 status: StatusCode::INTERNAL_SERVER_ERROR,
2566 message: msg.into(),
2567 }
2568 }
2569}
2570
2571impl IntoResponse for ApiError {
2572 fn into_response(self) -> Response {
2573 (self.status, Json(json!({ "error": self.message }))).into_response()
2574 }
2575}
2576
2577#[cfg(test)]
2578mod tests {
2579 use super::*;
2580 use axum::body::to_bytes;
2581 use axum::http::Request;
2582 use tower::util::ServiceExt;
2583
2584 fn test_state() -> AppState {
2585 let tmp = tempfile::tempdir().expect("tempdir");
2586 let root = tmp.path().to_path_buf();
2587 std::mem::forget(tmp);
2588 AppState::new(root)
2589 }
2590
2591 #[tokio::test]
2592 async fn health_endpoint_returns_ok() {
2593 let state = test_state();
2594 let app = router().with_state(state);
2595 let resp = app
2596 .oneshot(
2597 Request::builder()
2598 .uri("/health")
2599 .body(Body::empty())
2600 .unwrap(),
2601 )
2602 .await
2603 .unwrap();
2604 assert_eq!(resp.status(), StatusCode::OK);
2605 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2606 let v: Value = serde_json::from_slice(&bytes).unwrap();
2607 assert_eq!(v["status"], "ok");
2608 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
2609 }
2610
2611 #[tokio::test]
2621 async fn health_endpoint_includes_resource_fields() {
2622 let state = test_state();
2623 let app = router().with_state(state);
2624 let resp = app
2625 .oneshot(
2626 Request::builder()
2627 .uri("/health")
2628 .body(Body::empty())
2629 .unwrap(),
2630 )
2631 .await
2632 .unwrap();
2633 assert_eq!(resp.status(), StatusCode::OK);
2634 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2635 let v: Value = serde_json::from_slice(&bytes).unwrap();
2636 let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
2638 assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
2639 let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
2641 assert!(cpu >= 0.0, "cpu_pct must be non-negative");
2642 assert_eq!(v["disk_bytes"].as_u64(), Some(0));
2644 assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
2646 }
2647
2648 #[tokio::test]
2657 async fn logs_tail_returns_recent_lines() {
2658 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2659 buffer.push("line one".to_string());
2660 buffer.push("line two".to_string());
2661 buffer.push("line three".to_string());
2662 let state = test_state().with_log_buffer(buffer);
2663 let app = router().with_state(state);
2664 let resp = app
2665 .oneshot(
2666 Request::builder()
2667 .uri("/api/v1/logs/tail?n=2")
2668 .body(Body::empty())
2669 .unwrap(),
2670 )
2671 .await
2672 .unwrap();
2673 assert_eq!(resp.status(), StatusCode::OK);
2674 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2675 let v: Value = serde_json::from_slice(&bytes).unwrap();
2676 let lines = v["lines"].as_array().expect("lines array");
2677 assert_eq!(lines.len(), 2, "n=2 must return two lines");
2678 assert_eq!(lines[0].as_str(), Some("line two"));
2679 assert_eq!(lines[1].as_str(), Some("line three"));
2680 assert_eq!(v["total"].as_u64(), Some(3));
2681 }
2682
2683 #[tokio::test]
2692 async fn logs_tail_clamps_n() {
2693 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2694 for i in 0..5 {
2695 buffer.push(format!("l{i}"));
2696 }
2697 let state = test_state().with_log_buffer(buffer);
2698 let app = router().with_state(state);
2699
2700 let resp = app
2702 .clone()
2703 .oneshot(
2704 Request::builder()
2705 .uri("/api/v1/logs/tail?n=0")
2706 .body(Body::empty())
2707 .unwrap(),
2708 )
2709 .await
2710 .unwrap();
2711 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2712 let v: Value = serde_json::from_slice(&bytes).unwrap();
2713 assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2714
2715 let resp = app
2717 .oneshot(
2718 Request::builder()
2719 .uri("/api/v1/logs/tail?n=999999")
2720 .body(Body::empty())
2721 .unwrap(),
2722 )
2723 .await
2724 .unwrap();
2725 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2726 let v: Value = serde_json::from_slice(&bytes).unwrap();
2727 assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2728 }
2729
2730 #[tokio::test]
2741 async fn admin_stop_returns_ok() {
2742 let state = test_state();
2743 let Json(body) = admin_stop(State(state)).await;
2744 assert_eq!(body["ok"], Value::Bool(true));
2745 assert_eq!(body["message"].as_str(), Some("shutting down"));
2746 }
2747
2748 #[tokio::test]
2749 async fn status_endpoint_returns_payload() {
2750 let state = test_state();
2751 let app = router().with_state(state);
2752 let resp = app
2753 .oneshot(
2754 Request::builder()
2755 .uri("/api/v1/status")
2756 .body(Body::empty())
2757 .unwrap(),
2758 )
2759 .await
2760 .unwrap();
2761 assert_eq!(resp.status(), StatusCode::OK);
2762 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2763 let v: Value = serde_json::from_slice(&bytes).unwrap();
2764 assert!(v["version"].is_string());
2765 assert_eq!(v["palace_count"], 0);
2766 }
2767
2768 #[tokio::test]
2769 async fn unknown_api_returns_404() {
2770 let state = test_state();
2771 let app = router().with_state(state);
2772 let resp = app
2773 .oneshot(
2774 Request::builder()
2775 .uri("/api/v1/does-not-exist")
2776 .body(Body::empty())
2777 .unwrap(),
2778 )
2779 .await
2780 .unwrap();
2781 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2782 }
2783
2784 #[tokio::test]
2785 async fn create_then_list_palace() {
2786 let state = test_state();
2787 let app = router().with_state(state.clone());
2788 let body = json!({"name": "web-test", "description": "from test"}).to_string();
2789 let resp = app
2790 .clone()
2791 .oneshot(
2792 Request::builder()
2793 .method("POST")
2794 .uri("/api/v1/palaces")
2795 .header("content-type", "application/json")
2796 .body(Body::from(body))
2797 .unwrap(),
2798 )
2799 .await
2800 .unwrap();
2801 assert_eq!(resp.status(), StatusCode::OK);
2802
2803 let resp = app
2804 .oneshot(
2805 Request::builder()
2806 .uri("/api/v1/palaces")
2807 .body(Body::empty())
2808 .unwrap(),
2809 )
2810 .await
2811 .unwrap();
2812 assert_eq!(resp.status(), StatusCode::OK);
2813 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2814 let v: Value = serde_json::from_slice(&bytes).unwrap();
2815 let arr = v.as_array().expect("array");
2816 assert!(arr.iter().any(|p| p["id"] == "web-test"));
2817 }
2818
2819 #[tokio::test]
2828 async fn palace_list_includes_graph_counts() {
2829 let state = test_state();
2830 let app = router().with_state(state.clone());
2831 let body = json!({"name": "graph-counts", "description": null}).to_string();
2832 let resp = app
2833 .clone()
2834 .oneshot(
2835 Request::builder()
2836 .method("POST")
2837 .uri("/api/v1/palaces")
2838 .header("content-type", "application/json")
2839 .body(Body::from(body))
2840 .unwrap(),
2841 )
2842 .await
2843 .unwrap();
2844 assert_eq!(resp.status(), StatusCode::OK);
2845
2846 let resp = app
2847 .oneshot(
2848 Request::builder()
2849 .uri("/api/v1/palaces")
2850 .body(Body::empty())
2851 .unwrap(),
2852 )
2853 .await
2854 .unwrap();
2855 assert_eq!(resp.status(), StatusCode::OK);
2856 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2857 let v: Value = serde_json::from_slice(&bytes).unwrap();
2858 let arr = v.as_array().expect("array");
2859 let row = arr
2860 .iter()
2861 .find(|p| p["id"] == "graph-counts")
2862 .expect("created palace must appear in list");
2863 assert_eq!(row["node_count"].as_u64(), Some(0));
2864 assert_eq!(row["edge_count"].as_u64(), Some(0));
2865 assert_eq!(row["community_count"].as_u64(), Some(0));
2866 assert_eq!(row["is_compacting"].as_bool(), Some(false));
2867 }
2868
2869 #[tokio::test]
2876 async fn status_includes_total_counters() {
2877 let state = test_state();
2878 let app = router().with_state(state);
2879 let resp = app
2880 .oneshot(
2881 Request::builder()
2882 .uri("/api/v1/status")
2883 .body(Body::empty())
2884 .unwrap(),
2885 )
2886 .await
2887 .unwrap();
2888 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2889 let v: Value = serde_json::from_slice(&bytes).unwrap();
2890 assert_eq!(v["total_drawers"], 0);
2891 assert_eq!(v["total_vectors"], 0);
2892 assert_eq!(v["total_kg_triples"], 0);
2893 }
2894
2895 #[tokio::test]
2902 async fn dream_status_empty_returns_nulls() {
2903 let state = test_state();
2904 let app = router().with_state(state);
2905 let resp = app
2906 .oneshot(
2907 Request::builder()
2908 .uri("/api/v1/dream/status")
2909 .body(Body::empty())
2910 .unwrap(),
2911 )
2912 .await
2913 .unwrap();
2914 assert_eq!(resp.status(), StatusCode::OK);
2915 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2916 let v: Value = serde_json::from_slice(&bytes).unwrap();
2917 assert!(v["last_run_at"].is_null());
2918 assert_eq!(v["merged"], 0);
2919 assert_eq!(v["pruned"], 0);
2920 }
2921
2922 #[tokio::test]
2929 async fn providers_endpoint_returns_payload() {
2930 let state = test_state();
2931 let app = router().with_state(state);
2932 let resp = app
2933 .oneshot(
2934 Request::builder()
2935 .uri("/api/v1/chat/providers")
2936 .body(Body::empty())
2937 .unwrap(),
2938 )
2939 .await
2940 .unwrap();
2941 assert_eq!(resp.status(), StatusCode::OK);
2942 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2943 let v: Value = serde_json::from_slice(&bytes).unwrap();
2944 let arr = v["providers"].as_array().expect("providers array");
2945 assert_eq!(arr.len(), 2);
2946 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2947 assert!(names.contains(&"ollama"));
2948 assert!(names.contains(&"openrouter"));
2949 assert!(v.get("active").is_some());
2951 }
2952
2953 #[tokio::test]
2960 async fn chat_session_crud_round_trip() {
2961 let state = test_state();
2962 let palace = trusty_common::memory_core::Palace {
2964 id: PalaceId::new("sess-test"),
2965 name: "sess-test".to_string(),
2966 description: None,
2967 created_at: chrono::Utc::now(),
2968 data_dir: state.data_root.join("sess-test"),
2969 };
2970 state
2971 .registry
2972 .create_palace(&state.data_root, palace)
2973 .expect("create_palace");
2974 let app = router().with_state(state);
2975
2976 let resp = app
2978 .clone()
2979 .oneshot(
2980 Request::builder()
2981 .method("POST")
2982 .uri("/api/v1/palaces/sess-test/chat/sessions")
2983 .header("content-type", "application/json")
2984 .body(Body::from(json!({"title":"first chat"}).to_string()))
2985 .unwrap(),
2986 )
2987 .await
2988 .unwrap();
2989 assert_eq!(resp.status(), StatusCode::OK);
2990 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2991 let v: Value = serde_json::from_slice(&bytes).unwrap();
2992 let sid = v["id"].as_str().expect("session id").to_string();
2993
2994 let resp = app
2996 .clone()
2997 .oneshot(
2998 Request::builder()
2999 .uri("/api/v1/palaces/sess-test/chat/sessions")
3000 .body(Body::empty())
3001 .unwrap(),
3002 )
3003 .await
3004 .unwrap();
3005 assert_eq!(resp.status(), StatusCode::OK);
3006 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3007 let v: Value = serde_json::from_slice(&bytes).unwrap();
3008 let arr = v.as_array().expect("array");
3009 assert!(arr.iter().any(|s| s["id"] == sid));
3010
3011 let resp = app
3013 .clone()
3014 .oneshot(
3015 Request::builder()
3016 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3017 .body(Body::empty())
3018 .unwrap(),
3019 )
3020 .await
3021 .unwrap();
3022 assert_eq!(resp.status(), StatusCode::OK);
3023
3024 let resp = app
3026 .clone()
3027 .oneshot(
3028 Request::builder()
3029 .method("DELETE")
3030 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3031 .body(Body::empty())
3032 .unwrap(),
3033 )
3034 .await
3035 .unwrap();
3036 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3037
3038 let resp = app
3040 .oneshot(
3041 Request::builder()
3042 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3043 .body(Body::empty())
3044 .unwrap(),
3045 )
3046 .await
3047 .unwrap();
3048 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3049 }
3050
3051 #[test]
3058 fn all_tools_returns_expected_set() {
3059 let tools = all_tools();
3060 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3061 assert_eq!(
3062 names,
3063 vec![
3064 "list_palaces",
3065 "get_palace",
3066 "recall_memories",
3067 "list_drawers",
3068 "kg_query",
3069 "get_config",
3070 "get_status",
3071 "get_dream_status",
3072 "get_palace_dream_status",
3073 "create_memory",
3074 "kg_assert",
3075 "memory_recall_all",
3076 ]
3077 );
3078 for t in &tools {
3081 assert_eq!(
3082 t.parameters["type"], "object",
3083 "tool {} schema type",
3084 t.name
3085 );
3086 assert!(
3087 t.parameters["required"].is_array(),
3088 "tool {} required not array",
3089 t.name
3090 );
3091 }
3092 }
3093
3094 #[tokio::test]
3101 async fn execute_tool_dispatches_known_tools() {
3102 let state = test_state();
3103 let result = execute_tool("list_palaces", "{}", &state).await;
3104 assert!(
3105 result.is_array(),
3106 "list_palaces should be array, got {result}"
3107 );
3108 assert_eq!(result.as_array().unwrap().len(), 0);
3109
3110 let unknown = execute_tool("not_a_tool", "{}", &state).await;
3111 assert!(
3112 unknown["error"]
3113 .as_str()
3114 .unwrap_or("")
3115 .contains("unknown tool"),
3116 "expected unknown-tool error, got {unknown}"
3117 );
3118
3119 let missing = execute_tool("get_palace", "{}", &state).await;
3120 assert!(
3121 missing["error"]
3122 .as_str()
3123 .unwrap_or("")
3124 .contains("palace_id"),
3125 "expected missing-arg error, got {missing}"
3126 );
3127 }
3128
3129 #[tokio::test]
3138 async fn sse_broadcast_emits_palace_created() {
3139 let state = test_state();
3140 let mut rx = state.events.subscribe();
3141 let app = router().with_state(state.clone());
3142 let body = json!({"name": "sse-test"}).to_string();
3143 let resp = app
3144 .oneshot(
3145 Request::builder()
3146 .method("POST")
3147 .uri("/api/v1/palaces")
3148 .header("content-type", "application/json")
3149 .body(Body::from(body))
3150 .unwrap(),
3151 )
3152 .await
3153 .unwrap();
3154 assert_eq!(resp.status(), StatusCode::OK);
3155 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3157 .await
3158 .expect("event received within timeout")
3159 .expect("event channel still open");
3160 match event {
3161 DaemonEvent::PalaceCreated { id, name } => {
3162 assert_eq!(id, "sse-test");
3163 assert_eq!(name, "sse-test");
3164 }
3165 other => panic!("expected PalaceCreated, got {other:?}"),
3166 }
3167 }
3168
3169 #[tokio::test]
3176 async fn sse_endpoint_emits_connected_frame() {
3177 use axum::routing::get;
3178 let state = test_state();
3179 let app = router()
3180 .route("/sse", get(crate::sse_handler))
3181 .with_state(state);
3182 let resp = app
3183 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3184 .await
3185 .unwrap();
3186 assert_eq!(resp.status(), StatusCode::OK);
3187 assert_eq!(
3188 resp.headers()
3189 .get(header::CONTENT_TYPE)
3190 .and_then(|v| v.to_str().ok()),
3191 Some("text/event-stream")
3192 );
3193 let body = resp.into_body();
3196 let bytes =
3197 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3198 .await
3199 .ok()
3200 .and_then(|r| r.ok())
3201 .unwrap_or_default();
3202 let text = String::from_utf8_lossy(&bytes);
3203 assert!(
3204 text.contains("\"type\":\"connected\""),
3205 "expected connected frame, got: {text}"
3206 );
3207 }
3208
3209 #[tokio::test]
3219 async fn dream_status_aggregates_across_palaces() {
3220 use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3221
3222 let state = test_state();
3223 for (id, stats, ts) in [
3227 (
3228 "palace-a",
3229 DreamStats {
3230 merged: 1,
3231 pruned: 2,
3232 compacted: 3,
3233 closets_updated: 4,
3234 duration_ms: 100,
3235 },
3236 chrono::Utc::now() - chrono::Duration::seconds(60),
3237 ),
3238 (
3239 "palace-b",
3240 DreamStats {
3241 merged: 10,
3242 pruned: 20,
3243 compacted: 30,
3244 closets_updated: 40,
3245 duration_ms: 200,
3246 },
3247 chrono::Utc::now(),
3248 ),
3249 ] {
3250 let palace = trusty_common::memory_core::Palace {
3251 id: PalaceId::new(id),
3252 name: id.to_string(),
3253 description: None,
3254 created_at: chrono::Utc::now(),
3255 data_dir: state.data_root.join(id),
3256 };
3257 state
3258 .registry
3259 .create_palace(&state.data_root, palace)
3260 .expect("create palace");
3261 let persisted = PersistedDreamStats {
3262 last_run_at: ts,
3263 stats,
3264 };
3265 persisted
3266 .save(&state.data_root.join(id))
3267 .expect("save dream stats");
3268 }
3269
3270 let later = chrono::Utc::now();
3271 let app = router().with_state(state);
3272 let resp = app
3273 .oneshot(
3274 Request::builder()
3275 .uri("/api/v1/dream/status")
3276 .body(Body::empty())
3277 .unwrap(),
3278 )
3279 .await
3280 .unwrap();
3281 assert_eq!(resp.status(), StatusCode::OK);
3282 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3283 let v: Value = serde_json::from_slice(&bytes).unwrap();
3284
3285 assert_eq!(v["merged"], 11);
3287 assert_eq!(v["pruned"], 22);
3288 assert_eq!(v["compacted"], 33);
3289 assert_eq!(v["closets_updated"], 44);
3290 assert_eq!(v["duration_ms"], 300);
3291
3292 let last = v["last_run_at"].as_str().expect("last_run_at is string");
3294 let parsed: chrono::DateTime<chrono::Utc> = last
3295 .parse()
3296 .expect("last_run_at parses as RFC3339 timestamp");
3297 assert!(
3298 parsed <= later,
3299 "last_run_at ({parsed}) should not exceed wall clock ({later})"
3300 );
3301 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3303 assert!(
3304 parsed >= cutoff,
3305 "expected the newer (palace-b) timestamp; got {parsed}"
3306 );
3307 }
3308
3309 #[tokio::test]
3321 async fn dream_run_aggregates_stats() {
3322 let state = test_state();
3323 let palace = trusty_common::memory_core::Palace {
3324 id: PalaceId::new("dream-run-test"),
3325 name: "dream-run-test".to_string(),
3326 description: None,
3327 created_at: chrono::Utc::now(),
3328 data_dir: state.data_root.join("dream-run-test"),
3329 };
3330 state
3331 .registry
3332 .create_palace(&state.data_root, palace)
3333 .expect("create palace");
3334
3335 let app = router().with_state(state);
3336 let resp = app
3337 .oneshot(
3338 Request::builder()
3339 .method("POST")
3340 .uri("/api/v1/dream/run")
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
3350 for key in [
3353 "merged",
3354 "pruned",
3355 "compacted",
3356 "closets_updated",
3357 "duration_ms",
3358 ] {
3359 assert!(
3360 v.get(key).is_some(),
3361 "missing key {key} in dream_run payload: {v}"
3362 );
3363 assert!(
3364 v[key].is_u64() || v[key].is_i64(),
3365 "{key} should be integer, got {}",
3366 v[key]
3367 );
3368 }
3369 assert!(
3370 v["last_run_at"].is_string(),
3371 "last_run_at must be set by dream_run; got {v}"
3372 );
3373 }
3374
3375 #[tokio::test]
3382 async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3383 let state = test_state();
3384 let palace = trusty_common::memory_core::Palace {
3385 id: PalaceId::new("gaps-empty"),
3386 name: "gaps-empty".to_string(),
3387 description: None,
3388 created_at: chrono::Utc::now(),
3389 data_dir: state.data_root.join("gaps-empty"),
3390 };
3391 state
3392 .registry
3393 .create_palace(&state.data_root, palace)
3394 .expect("create palace");
3395
3396 let app = router().with_state(state);
3397 let resp = app
3398 .oneshot(
3399 Request::builder()
3400 .uri("/api/v1/kg/gaps?palace=gaps-empty")
3401 .body(Body::empty())
3402 .unwrap(),
3403 )
3404 .await
3405 .unwrap();
3406 assert_eq!(resp.status(), StatusCode::OK);
3407 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3408 let v: Value = serde_json::from_slice(&bytes).unwrap();
3409 assert_eq!(v.as_array().expect("array").len(), 0);
3410 }
3411
3412 #[tokio::test]
3419 async fn kg_gaps_endpoint_returns_cached_gaps() {
3420 use trusty_common::memory_core::community::KnowledgeGap;
3421
3422 let state = test_state();
3423 let palace = trusty_common::memory_core::Palace {
3424 id: PalaceId::new("gaps-seed"),
3425 name: "gaps-seed".to_string(),
3426 description: None,
3427 created_at: chrono::Utc::now(),
3428 data_dir: state.data_root.join("gaps-seed"),
3429 };
3430 state
3431 .registry
3432 .create_palace(&state.data_root, palace)
3433 .expect("create palace");
3434
3435 state.registry.set_gaps(
3436 PalaceId::new("gaps-seed"),
3437 vec![KnowledgeGap {
3438 entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3439 internal_density: 0.15,
3440 external_bridges: 2,
3441 suggested_exploration: "Explore connections between foo and related concepts"
3442 .to_string(),
3443 }],
3444 );
3445
3446 let app = router().with_state(state);
3447 let resp = app
3448 .oneshot(
3449 Request::builder()
3450 .uri("/api/v1/kg/gaps?palace=gaps-seed")
3451 .body(Body::empty())
3452 .unwrap(),
3453 )
3454 .await
3455 .unwrap();
3456 assert_eq!(resp.status(), StatusCode::OK);
3457 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3458 let v: Value = serde_json::from_slice(&bytes).unwrap();
3459 let arr = v.as_array().expect("array");
3460 assert_eq!(arr.len(), 1);
3461 assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3462 assert_eq!(arr[0]["external_bridges"], 2);
3463 assert!(arr[0]["suggested_exploration"]
3464 .as_str()
3465 .unwrap()
3466 .contains("foo"));
3467 }
3468
3469 #[tokio::test]
3476 async fn kg_list_subjects_returns_distinct() {
3477 let state = test_state();
3478 let app = router().with_state(state.clone());
3479
3480 let resp = app
3482 .clone()
3483 .oneshot(
3484 Request::builder()
3485 .method("POST")
3486 .uri("/api/v1/palaces")
3487 .header("content-type", "application/json")
3488 .body(Body::from(json!({"name": "kg-list"}).to_string()))
3489 .unwrap(),
3490 )
3491 .await
3492 .unwrap();
3493 assert_eq!(resp.status(), StatusCode::OK);
3494
3495 for subj in ["alpha", "beta"] {
3497 let body = json!({
3498 "subject": subj,
3499 "predicate": "is",
3500 "object": "thing",
3501 })
3502 .to_string();
3503 let r = app
3504 .clone()
3505 .oneshot(
3506 Request::builder()
3507 .method("POST")
3508 .uri("/api/v1/palaces/kg-list/kg")
3509 .header("content-type", "application/json")
3510 .body(Body::from(body))
3511 .unwrap(),
3512 )
3513 .await
3514 .unwrap();
3515 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3516 }
3517
3518 let resp = app
3519 .oneshot(
3520 Request::builder()
3521 .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3522 .body(Body::empty())
3523 .unwrap(),
3524 )
3525 .await
3526 .unwrap();
3527 assert_eq!(resp.status(), StatusCode::OK);
3528 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3529 let v: Value = serde_json::from_slice(&bytes).unwrap();
3530 let arr = v.as_array().expect("subjects must be array");
3531 let subjects: Vec<String> = arr
3532 .iter()
3533 .filter_map(|x| x.as_str().map(String::from))
3534 .collect();
3535 assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3536 }
3537
3538 #[tokio::test]
3545 async fn kg_list_all_returns_paginated_triples() {
3546 let state = test_state();
3547 let app = router().with_state(state.clone());
3548
3549 let resp = app
3550 .clone()
3551 .oneshot(
3552 Request::builder()
3553 .method("POST")
3554 .uri("/api/v1/palaces")
3555 .header("content-type", "application/json")
3556 .body(Body::from(json!({"name": "kg-all"}).to_string()))
3557 .unwrap(),
3558 )
3559 .await
3560 .unwrap();
3561 assert_eq!(resp.status(), StatusCode::OK);
3562
3563 let body = json!({
3564 "subject": "alpha",
3565 "predicate": "is",
3566 "object": "thing",
3567 })
3568 .to_string();
3569 let r = app
3570 .clone()
3571 .oneshot(
3572 Request::builder()
3573 .method("POST")
3574 .uri("/api/v1/palaces/kg-all/kg")
3575 .header("content-type", "application/json")
3576 .body(Body::from(body))
3577 .unwrap(),
3578 )
3579 .await
3580 .unwrap();
3581 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3582
3583 let resp = app
3584 .oneshot(
3585 Request::builder()
3586 .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3587 .body(Body::empty())
3588 .unwrap(),
3589 )
3590 .await
3591 .unwrap();
3592 assert_eq!(resp.status(), StatusCode::OK);
3593 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3594 let v: Value = serde_json::from_slice(&bytes).unwrap();
3595 let arr = v.as_array().expect("triples must be array");
3596 assert_eq!(arr.len(), 1);
3597 assert_eq!(arr[0]["subject"], "alpha");
3598 assert_eq!(arr[0]["predicate"], "is");
3599 assert_eq!(arr[0]["object"], "thing");
3600 }
3601
3602 #[tokio::test]
3606 async fn prompt_context_endpoint_returns_formatted_block() {
3607 let state = test_state();
3608
3609 let app = router().with_state(state.clone());
3611 let resp = app
3612 .oneshot(
3613 Request::builder()
3614 .uri("/api/v1/kg/prompt-context")
3615 .body(Body::empty())
3616 .unwrap(),
3617 )
3618 .await
3619 .unwrap();
3620 assert_eq!(resp.status(), StatusCode::OK);
3621 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3622 let text = String::from_utf8(bytes.to_vec()).unwrap();
3623 assert_eq!(text, "No prompt facts stored yet.");
3624
3625 {
3627 let mut guard = state.prompt_context_cache.write().expect("write lock");
3628 let triples = vec![(
3629 "tga".to_string(),
3630 "is_alias_for".to_string(),
3631 "trusty-git-analytics".to_string(),
3632 )];
3633 let formatted = crate::prompt_facts::build_prompt_context(&triples);
3634 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
3635 }
3636 let app = router().with_state(state);
3637 let resp = app
3638 .oneshot(
3639 Request::builder()
3640 .uri("/api/v1/kg/prompt-context")
3641 .body(Body::empty())
3642 .unwrap(),
3643 )
3644 .await
3645 .unwrap();
3646 assert_eq!(resp.status(), StatusCode::OK);
3647 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3648 let text = String::from_utf8(bytes.to_vec()).unwrap();
3649 assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
3650 }
3651
3652 #[tokio::test]
3656 async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
3657 let tmp = tempfile::tempdir().expect("tempdir");
3658 let root = tmp.path().to_path_buf();
3659 std::mem::forget(tmp);
3660 let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
3661 let palace = trusty_common::memory_core::Palace {
3662 id: PalaceId::new("aliases"),
3663 name: "aliases".to_string(),
3664 description: None,
3665 created_at: chrono::Utc::now(),
3666 data_dir: state.data_root.join("aliases"),
3667 };
3668 state
3669 .registry
3670 .create_palace(&state.data_root, palace)
3671 .expect("create palace");
3672
3673 let body = json!({"short": "tm", "full": "trusty-memory"});
3674 let app = router().with_state(state.clone());
3675 let resp = app
3676 .oneshot(
3677 Request::builder()
3678 .method("POST")
3679 .uri("/api/v1/kg/aliases")
3680 .header("content-type", "application/json")
3681 .body(Body::from(serde_json::to_vec(&body).unwrap()))
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 assert_eq!(v["subject"], "tm");
3690 assert_eq!(v["object"], "trusty-memory");
3691
3692 let guard = state.prompt_context_cache.read().expect("read lock");
3694 assert!(
3695 guard.formatted.contains("tm → trusty-memory"),
3696 "cache missing alias; got: {}",
3697 guard.formatted
3698 );
3699 }
3700
3701 #[tokio::test]
3705 async fn list_prompt_facts_endpoint_returns_hot_triples() {
3706 let tmp = tempfile::tempdir().expect("tempdir");
3707 let root = tmp.path().to_path_buf();
3708 std::mem::forget(tmp);
3709 let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
3710 let palace = trusty_common::memory_core::Palace {
3711 id: PalaceId::new("listfacts"),
3712 name: "listfacts".to_string(),
3713 description: None,
3714 created_at: chrono::Utc::now(),
3715 data_dir: state.data_root.join("listfacts"),
3716 };
3717 let handle = state
3718 .registry
3719 .create_palace(&state.data_root, palace)
3720 .expect("create palace");
3721
3722 handle
3725 .kg
3726 .assert(Triple {
3727 subject: "ts".to_string(),
3728 predicate: "is_alias_for".to_string(),
3729 object: "trusty-search".to_string(),
3730 valid_from: chrono::Utc::now(),
3731 valid_to: None,
3732 confidence: 1.0,
3733 provenance: None,
3734 })
3735 .await
3736 .expect("assert alias");
3737 handle
3738 .kg
3739 .assert(Triple {
3740 subject: "alice".to_string(),
3741 predicate: "works_at".to_string(),
3742 object: "Acme".to_string(),
3743 valid_from: chrono::Utc::now(),
3744 valid_to: None,
3745 confidence: 1.0,
3746 provenance: None,
3747 })
3748 .await
3749 .expect("assert works_at");
3750
3751 let app = router().with_state(state);
3752 let resp = app
3753 .oneshot(
3754 Request::builder()
3755 .uri("/api/v1/kg/prompt-facts")
3756 .body(Body::empty())
3757 .unwrap(),
3758 )
3759 .await
3760 .unwrap();
3761 assert_eq!(resp.status(), StatusCode::OK);
3762 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3763 let v: Value = serde_json::from_slice(&bytes).unwrap();
3764 let arr = v.as_array().expect("array");
3765 assert!(
3766 arr.iter().any(|r| r["subject"] == "ts"
3767 && r["predicate"] == "is_alias_for"
3768 && r["object"] == "trusty-search"),
3769 "missing ts alias; got {arr:?}"
3770 );
3771 assert!(
3773 !arr.iter().any(|r| r["predicate"] == "works_at"),
3774 "non-hot triple leaked into prompt facts: {arr:?}"
3775 );
3776 }
3777
3778 #[tokio::test]
3781 async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
3782 let tmp = tempfile::tempdir().expect("tempdir");
3783 let root = tmp.path().to_path_buf();
3784 std::mem::forget(tmp);
3785 let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
3786 let palace = trusty_common::memory_core::Palace {
3787 id: PalaceId::new("rmfacts"),
3788 name: "rmfacts".to_string(),
3789 description: None,
3790 created_at: chrono::Utc::now(),
3791 data_dir: state.data_root.join("rmfacts"),
3792 };
3793 let handle = state
3794 .registry
3795 .create_palace(&state.data_root, palace)
3796 .expect("create palace");
3797
3798 handle
3799 .kg
3800 .assert(Triple {
3801 subject: "ta".to_string(),
3802 predicate: "is_alias_for".to_string(),
3803 object: "trusty-analyze".to_string(),
3804 valid_from: chrono::Utc::now(),
3805 valid_to: None,
3806 confidence: 1.0,
3807 provenance: None,
3808 })
3809 .await
3810 .expect("assert alias");
3811 crate::prompt_facts::rebuild_prompt_cache(&state)
3813 .await
3814 .expect("rebuild prompt cache");
3815
3816 let app = router().with_state(state.clone());
3817 let resp = app
3818 .oneshot(
3819 Request::builder()
3820 .method("DELETE")
3821 .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
3822 .body(Body::empty())
3823 .unwrap(),
3824 )
3825 .await
3826 .unwrap();
3827 assert_eq!(resp.status(), StatusCode::OK);
3828 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3829 let v: Value = serde_json::from_slice(&bytes).unwrap();
3830 assert_eq!(v["removed"], true);
3831 assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
3832
3833 {
3835 let guard = state.prompt_context_cache.read().expect("read lock");
3836 assert!(
3837 !guard.formatted.contains("ta → trusty-analyze"),
3838 "alias still in cache after delete: {}",
3839 guard.formatted
3840 );
3841 }
3842
3843 let app = router().with_state(state);
3845 let resp = app
3846 .oneshot(
3847 Request::builder()
3848 .method("DELETE")
3849 .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
3850 .body(Body::empty())
3851 .unwrap(),
3852 )
3853 .await
3854 .unwrap();
3855 assert_eq!(resp.status(), StatusCode::OK);
3856 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3857 let v: Value = serde_json::from_slice(&bytes).unwrap();
3858 assert_eq!(v["removed"], false);
3859 }
3860
3861 #[tokio::test]
3862 async fn serves_index_html_fallback() {
3863 let state = test_state();
3864 let app = router().with_state(state);
3865 let resp = app
3866 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
3867 .await
3868 .unwrap();
3869 assert!(
3871 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
3872 "got {}",
3873 resp.status()
3874 );
3875 }
3876}