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::dream::{DreamConfig, Dreamer, PersistedDreamStats};
28use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
29use trusty_common::memory_core::retrieval::{
30 recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
31 recall_with_default_embedder,
32};
33use trusty_common::memory_core::store::kg::Triple;
34use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
35use trusty_common::{ChatEvent, ChatMessage, ToolDef};
36use uuid::Uuid;
37
38#[derive(RustEmbed)]
45#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
50struct WebAssets;
51
52pub fn router() -> Router<AppState> {
58 let router = Router::new()
63 .route("/api/v1/status", get(status))
64 .route("/api/v1/config", get(config))
65 .route("/api/v1/palaces", get(list_palaces).post(create_palace))
66 .route("/api/v1/palaces/{id}", get(get_palace_handler))
67 .route(
68 "/api/v1/palaces/{id}/drawers",
69 get(list_drawers).post(create_drawer),
70 )
71 .route(
72 "/api/v1/palaces/{id}/drawers/{drawer_id}",
73 delete(delete_drawer),
74 )
75 .route("/api/v1/palaces/{id}/recall", get(recall_handler))
76 .route("/api/v1/recall", get(recall_all_handler))
77 .route("/api/v1/palaces/{id}/kg", get(kg_query).post(kg_assert))
78 .route(
79 "/api/v1/palaces/{id}/dream/status",
80 get(palace_dream_status),
81 )
82 .route("/api/v1/dream/status", get(dream_status))
83 .route("/api/v1/dream/run", post(dream_run))
84 .route("/api/v1/chat", post(chat_handler))
85 .route("/api/v1/chat/providers", get(list_providers))
86 .route(
87 "/api/v1/palaces/{id}/chat/sessions",
88 get(list_chat_sessions).post(create_chat_session),
89 )
90 .route(
91 "/api/v1/palaces/{id}/chat/sessions/{session_id}",
92 get(get_chat_session).delete(delete_chat_session),
93 )
94 .route("/health", get(health))
95 .route("/api/v1/logs/tail", get(logs_tail))
96 .route("/api/v1/admin/stop", post(admin_stop))
97 .fallback(static_handler);
98
99 trusty_common::server::with_standard_middleware(router)
100}
101
102#[derive(serde::Serialize)]
118struct HealthResponse {
119 status: &'static str,
120 version: &'static str,
121 rss_mb: u64,
124 disk_bytes: u64,
128 cpu_pct: f32,
132 uptime_secs: u64,
134}
135
136async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
148 let (rss_mb, cpu_pct) = {
149 let mut metrics = state.sys_metrics.lock().await;
150 metrics.sample()
151 };
152 let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
153 let uptime_secs = state.started_at.elapsed().as_secs();
154 Json(HealthResponse {
155 status: "ok",
156 version: env!("CARGO_PKG_VERSION"),
157 rss_mb,
158 disk_bytes,
159 cpu_pct,
160 uptime_secs,
161 })
162}
163
164const DEFAULT_LOGS_TAIL_N: usize = 100;
171
172const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
175
176fn default_logs_tail_n() -> usize {
177 DEFAULT_LOGS_TAIL_N
178}
179
180#[derive(serde::Deserialize)]
189struct LogsTailParams {
190 #[serde(default = "default_logs_tail_n")]
191 n: usize,
192}
193
194async fn logs_tail(
206 State(state): State<AppState>,
207 Query(params): Query<LogsTailParams>,
208) -> Json<Value> {
209 let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
210 let lines = state.log_buffer.tail(n);
211 Json(serde_json::json!({
212 "lines": lines,
213 "total": state.log_buffer.len(),
214 }))
215}
216
217async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
228 tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
229 tokio::spawn(async {
230 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
231 std::process::exit(0);
232 });
233 Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
234}
235
236async fn static_handler(req: Request<Body>) -> Response {
248 let path = req.uri().path().trim_start_matches('/').to_string();
249
250 if path.starts_with("api/") {
251 return (StatusCode::NOT_FOUND, "not found").into_response();
252 }
253
254 serve_embedded(&path).unwrap_or_else(|| {
255 serve_embedded("index.html")
257 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
258 })
259}
260
261fn serve_embedded(path: &str) -> Option<Response> {
262 let path = if path.is_empty() { "index.html" } else { path };
263 let asset = WebAssets::get(path)?;
264 let mime = mime_guess::from_path(path).first_or_octet_stream();
265 let body = Body::from(asset.data.into_owned());
266 let mut resp = Response::new(body);
267 resp.headers_mut().insert(
268 header::CONTENT_TYPE,
269 HeaderValue::from_str(mime.as_ref())
270 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
271 );
272 Some(resp)
273}
274
275#[derive(Serialize)]
280struct StatusPayload {
281 version: String,
282 palace_count: usize,
283 default_palace: Option<String>,
284 data_root: String,
285 total_drawers: usize,
286 total_vectors: usize,
287 total_kg_triples: usize,
288}
289
290async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
291 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
292 let palace_count = palaces.len();
293 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
294 for p in &palaces {
295 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
296 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
297 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
298 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
299 }
300 }
301 Json(StatusPayload {
302 version: state.version.clone(),
303 palace_count,
304 default_palace: state.default_palace.clone(),
305 data_root: state.data_root.display().to_string(),
306 total_drawers,
307 total_vectors,
308 total_kg_triples,
309 })
310}
311
312#[derive(Serialize)]
313struct ConfigPayload {
314 openrouter_configured: bool,
315 model: String,
316 data_root: String,
317}
318
319async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
320 let cfg = load_user_config().unwrap_or_default();
321 Json(ConfigPayload {
322 openrouter_configured: !cfg.openrouter_api_key.is_empty(),
323 model: cfg.openrouter_model,
324 data_root: state.data_root.display().to_string(),
325 })
326}
327
328#[derive(Deserialize, Default, Clone)]
331struct UserConfigMin {
332 #[serde(default)]
333 openrouter: OpenRouterMin,
334 #[serde(default)]
335 local_model: LocalModelMin,
336 }
338
339#[derive(Deserialize, Default, Clone)]
340struct OpenRouterMin {
341 #[serde(default)]
342 api_key: String,
343 #[serde(default)]
344 model: String,
345}
346
347#[derive(Deserialize, Clone)]
348struct LocalModelMin {
349 #[serde(default = "default_local_enabled")]
350 enabled: bool,
351 #[serde(default = "default_local_base_url")]
352 base_url: String,
353 #[serde(default = "default_local_model")]
354 model: String,
355}
356
357fn default_local_enabled() -> bool {
358 true
359}
360fn default_local_base_url() -> String {
361 "http://localhost:11434".to_string()
362}
363fn default_local_model() -> String {
364 "llama3.2".to_string()
365}
366
367impl Default for LocalModelMin {
368 fn default() -> Self {
369 Self {
370 enabled: default_local_enabled(),
371 base_url: default_local_base_url(),
372 model: default_local_model(),
373 }
374 }
375}
376
377#[derive(Clone)]
378pub(crate) struct LoadedUserConfig {
379 pub(crate) openrouter_api_key: String,
380 pub(crate) openrouter_model: String,
381 pub(crate) local_model: trusty_common::LocalModelConfig,
382}
383
384impl Default for LoadedUserConfig {
385 fn default() -> Self {
386 Self {
387 openrouter_api_key: String::new(),
388 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
389 local_model: trusty_common::LocalModelConfig::default(),
390 }
391 }
392}
393
394pub(crate) fn load_user_config() -> Option<LoadedUserConfig> {
395 let home = dirs::home_dir()?;
396 let path = home.join(".trusty-memory").join("config.toml");
397 if !path.exists() {
398 return Some(LoadedUserConfig::default());
399 }
400 let raw = std::fs::read_to_string(&path).ok()?;
401 let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
402 let model = if parsed.openrouter.model.is_empty() {
403 "anthropic/claude-3-5-sonnet".to_string()
404 } else {
405 parsed.openrouter.model
406 };
407 Some(LoadedUserConfig {
408 openrouter_api_key: parsed.openrouter.api_key,
409 openrouter_model: model,
410 local_model: trusty_common::LocalModelConfig {
411 enabled: parsed.local_model.enabled,
412 base_url: parsed.local_model.base_url,
413 model: parsed.local_model.model,
414 },
415 })
416}
417
418#[derive(Serialize)]
423struct PalaceInfo {
424 id: String,
425 name: String,
426 description: Option<String>,
427 drawer_count: usize,
428 vector_count: usize,
429 kg_triple_count: usize,
430 wing_count: usize,
431 created_at: chrono::DateTime<chrono::Utc>,
432}
433
434fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
444 let (drawer_count, vector_count, kg_triple_count, wing_count) = if let Some(h) = handle {
445 let drawers = h.drawers.read();
446 let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
447 (
448 drawers.len(),
449 h.vector_store.index_size(),
450 h.kg.count_active_triples(),
451 distinct_rooms.len(),
452 )
453 } else {
454 (0, 0, 0, 0)
455 };
456 PalaceInfo {
457 id: palace.id.0.clone(),
458 name: palace.name.clone(),
459 description: palace.description.clone(),
460 drawer_count,
461 vector_count,
462 kg_triple_count,
463 wing_count,
464 created_at: palace.created_at,
465 }
466}
467
468async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
469 let palaces = PalaceRegistry::list_palaces(&state.data_root)
470 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
471 let mut out = Vec::with_capacity(palaces.len());
472 for p in palaces {
473 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
474 out.push(palace_info_from(&p, handle.as_ref()));
475 }
476 Ok(Json(out))
477}
478
479#[derive(Deserialize)]
480struct CreatePalaceBody {
481 name: String,
482 #[serde(default)]
483 description: Option<String>,
484}
485
486async fn create_palace(
487 State(state): State<AppState>,
488 Json(body): Json<CreatePalaceBody>,
489) -> Result<Json<Value>, ApiError> {
490 let name = body.name.trim().to_string();
491 if name.is_empty() {
492 return Err(ApiError::bad_request("name is required"));
493 }
494 let id = PalaceId::new(&name);
495 let palace = Palace {
496 id: id.clone(),
497 name: name.clone(),
498 description: body.description.filter(|s| !s.is_empty()),
499 created_at: chrono::Utc::now(),
500 data_dir: state.data_root.join(&name),
501 };
502 state
503 .registry
504 .create_palace(&state.data_root, palace)
505 .map_err(|e| ApiError::internal(format!("create palace: {e:#}")))?;
506 state.emit(DaemonEvent::PalaceCreated {
507 id: name.clone(),
508 name: name.clone(),
509 });
510 Ok(Json(json!({ "id": name })))
511}
512
513async fn get_palace_handler(
514 State(state): State<AppState>,
515 AxumPath(id): AxumPath<String>,
516) -> Result<Json<PalaceInfo>, ApiError> {
517 let palaces = PalaceRegistry::list_palaces(&state.data_root)
518 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
519 let palace = palaces
520 .into_iter()
521 .find(|p| p.id.0 == id)
522 .ok_or_else(|| ApiError::not_found(format!("palace not found: {id}")))?;
523 let handle = state
524 .registry
525 .open_palace(&state.data_root, &palace.id)
526 .ok();
527 Ok(Json(palace_info_from(&palace, handle.as_ref())))
528}
529
530#[derive(Deserialize)]
535struct ListDrawersQuery {
536 #[serde(default)]
537 room: Option<String>,
538 #[serde(default)]
539 tag: Option<String>,
540 #[serde(default)]
541 limit: Option<usize>,
542}
543
544async fn list_drawers(
545 State(state): State<AppState>,
546 AxumPath(id): AxumPath<String>,
547 Query(q): Query<ListDrawersQuery>,
548) -> Result<Json<Value>, ApiError> {
549 let handle = open_handle(&state, &id)?;
550 let room = q.room.as_deref().map(RoomType::parse);
551 let drawers = handle.list_drawers(room, q.tag.clone(), q.limit.unwrap_or(50));
552 Ok(Json(serde_json::to_value(drawers).unwrap_or(json!([]))))
553}
554
555#[derive(Deserialize)]
556struct CreateDrawerBody {
557 content: String,
558 #[serde(default)]
559 room: Option<String>,
560 #[serde(default)]
561 tags: Vec<String>,
562 #[serde(default)]
563 importance: Option<f32>,
564}
565
566async fn create_drawer(
567 State(state): State<AppState>,
568 AxumPath(id): AxumPath<String>,
569 Json(body): Json<CreateDrawerBody>,
570) -> Result<Json<Value>, ApiError> {
571 let handle = open_handle(&state, &id)?;
572 let room = body
573 .room
574 .as_deref()
575 .map(RoomType::parse)
576 .unwrap_or(RoomType::General);
577 let importance = body.importance.unwrap_or(0.5);
578 let drawer_id = handle
579 .remember(body.content, room, body.tags, importance)
580 .await
581 .map_err(|e| ApiError::internal(format!("remember: {e:#}")))?;
582 let drawer_count = handle.drawers.read().len();
583 state.emit(DaemonEvent::DrawerAdded {
584 palace_id: id.clone(),
585 drawer_count,
586 });
587 state.emit(aggregate_status_event(&state));
588 Ok(Json(json!({ "id": drawer_id })))
589}
590
591async fn delete_drawer(
592 State(state): State<AppState>,
593 AxumPath((id, drawer_id)): AxumPath<(String, String)>,
594) -> Result<StatusCode, ApiError> {
595 let handle = open_handle(&state, &id)?;
596 let uuid = Uuid::parse_str(&drawer_id)
597 .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
598 handle
599 .forget(uuid)
600 .await
601 .map_err(|e| ApiError::internal(format!("forget: {e:#}")))?;
602 let drawer_count = handle.drawers.read().len();
603 state.emit(DaemonEvent::DrawerDeleted {
604 palace_id: id.clone(),
605 drawer_count,
606 });
607 state.emit(aggregate_status_event(&state));
608 Ok(StatusCode::NO_CONTENT)
609}
610
611fn aggregate_status_event(state: &AppState) -> DaemonEvent {
620 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
621 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
622 for p in &palaces {
623 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
624 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
625 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
626 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
627 }
628 }
629 DaemonEvent::StatusChanged {
630 total_drawers,
631 total_vectors,
632 total_kg_triples,
633 }
634}
635
636#[derive(Deserialize)]
641struct RecallQuery {
642 q: String,
643 #[serde(default)]
644 top_k: Option<usize>,
645 #[serde(default)]
646 deep: Option<bool>,
647}
648
649async fn recall_handler(
650 State(state): State<AppState>,
651 AxumPath(id): AxumPath<String>,
652 Query(q): Query<RecallQuery>,
653) -> Result<Json<Value>, ApiError> {
654 let handle = open_handle(&state, &id)?;
655 let top_k = q.top_k.unwrap_or(10);
656 let results = if q.deep.unwrap_or(false) {
657 recall_deep_with_default_embedder(&handle, &q.q, top_k).await
658 } else {
659 recall_with_default_embedder(&handle, &q.q, top_k).await
660 }
661 .map_err(|e| ApiError::internal(format!("recall: {e:#}")))?;
662
663 let payload: Vec<Value> = results
664 .into_iter()
665 .map(|r| {
666 json!({
667 "drawer": r.drawer,
668 "score": r.score,
669 "layer": r.layer,
670 })
671 })
672 .collect();
673 Ok(Json(json!(payload)))
674}
675
676async fn recall_all_handler(
690 State(state): State<AppState>,
691 Query(q): Query<RecallQuery>,
692) -> Result<Json<Value>, ApiError> {
693 let top_k = q.top_k.unwrap_or(10);
694 let deep = q.deep.unwrap_or(false);
695 let value = execute_recall_all(&state, &q.q, top_k, deep).await;
696 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
697 return Err(ApiError::internal(err.to_string()));
698 }
699 Ok(Json(value))
700}
701
702#[derive(Deserialize)]
707struct KgQueryParams {
708 subject: String,
709}
710
711async fn kg_query(
712 State(state): State<AppState>,
713 AxumPath(id): AxumPath<String>,
714 Query(q): Query<KgQueryParams>,
715) -> Result<Json<Vec<Triple>>, ApiError> {
716 let handle = open_handle(&state, &id)?;
717 let triples = handle
718 .kg
719 .query_active(&q.subject)
720 .await
721 .map_err(|e| ApiError::internal(format!("kg query: {e:#}")))?;
722 Ok(Json(triples))
723}
724
725#[derive(Deserialize)]
726struct KgAssertBody {
727 subject: String,
728 predicate: String,
729 object: String,
730 #[serde(default)]
731 confidence: Option<f32>,
732 #[serde(default)]
733 provenance: Option<String>,
734}
735
736async fn kg_assert(
737 State(state): State<AppState>,
738 AxumPath(id): AxumPath<String>,
739 Json(body): Json<KgAssertBody>,
740) -> Result<StatusCode, ApiError> {
741 let handle = open_handle(&state, &id)?;
742 let triple = Triple {
743 subject: body.subject,
744 predicate: body.predicate,
745 object: body.object,
746 valid_from: chrono::Utc::now(),
747 valid_to: None,
748 confidence: body.confidence.unwrap_or(1.0),
749 provenance: body.provenance,
750 };
751 handle
752 .kg
753 .assert(triple)
754 .await
755 .map_err(|e| ApiError::internal(format!("kg assert: {e:#}")))?;
756 Ok(StatusCode::NO_CONTENT)
757}
758
759#[derive(Serialize, Default)]
766struct DreamStatusPayload {
767 last_run_at: Option<chrono::DateTime<chrono::Utc>>,
768 merged: usize,
769 pruned: usize,
770 compacted: usize,
771 closets_updated: usize,
772 duration_ms: u64,
773}
774
775impl From<PersistedDreamStats> for DreamStatusPayload {
776 fn from(p: PersistedDreamStats) -> Self {
777 Self {
778 last_run_at: Some(p.last_run_at),
779 merged: p.stats.merged,
780 pruned: p.stats.pruned,
781 compacted: p.stats.compacted,
782 closets_updated: p.stats.closets_updated,
783 duration_ms: p.stats.duration_ms,
784 }
785 }
786}
787
788async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
797 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
798 let mut out = DreamStatusPayload::default();
799 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
800 for p in palaces {
801 let data_dir = state.data_root.join(p.id.as_str());
802 let snap = match PersistedDreamStats::load(&data_dir) {
803 Ok(Some(s)) => s,
804 _ => continue,
805 };
806 out.merged = out.merged.saturating_add(snap.stats.merged);
807 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
808 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
809 out.closets_updated = out
810 .closets_updated
811 .saturating_add(snap.stats.closets_updated);
812 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
813 latest = match latest {
814 Some(t) if t >= snap.last_run_at => Some(t),
815 _ => Some(snap.last_run_at),
816 };
817 }
818 out.last_run_at = latest;
819 Json(out)
820}
821
822async fn palace_dream_status(
824 State(state): State<AppState>,
825 AxumPath(id): AxumPath<String>,
826) -> Result<Json<DreamStatusPayload>, ApiError> {
827 let data_dir = state.data_root.join(&id);
828 if !data_dir.exists() {
829 return Err(ApiError::not_found(format!("palace not found: {id}")));
830 }
831 let payload = match PersistedDreamStats::load(&data_dir) {
832 Ok(Some(s)) => s.into(),
833 Ok(None) => DreamStatusPayload::default(),
834 Err(e) => return Err(ApiError::internal(format!("read dream stats: {e:#}"))),
835 };
836 Ok(Json(payload))
837}
838
839async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
849 let palaces = PalaceRegistry::list_palaces(&state.data_root)
850 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
851 let dreamer = Dreamer::new(DreamConfig::default());
852 let mut out = DreamStatusPayload::default();
853 for p in palaces {
854 let handle = match state.registry.open_palace(&state.data_root, &p.id) {
855 Ok(h) => h,
856 Err(e) => {
857 tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
858 continue;
859 }
860 };
861 match dreamer.dream_cycle(&handle).await {
862 Ok(stats) => {
863 out.merged = out.merged.saturating_add(stats.merged);
864 out.pruned = out.pruned.saturating_add(stats.pruned);
865 out.compacted = out.compacted.saturating_add(stats.compacted);
866 out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
867 out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
868 }
869 Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
870 }
871 }
872 out.last_run_at = Some(chrono::Utc::now());
873 state.emit(DaemonEvent::DreamCompleted {
874 palace_id: None,
875 merged: out.merged,
876 pruned: out.pruned,
877 compacted: out.compacted,
878 closets_updated: out.closets_updated,
879 duration_ms: out.duration_ms,
880 });
881 state.emit(aggregate_status_event(&state));
882 Ok(Json(out))
883}
884
885#[derive(Deserialize)]
890struct ChatBody {
891 #[serde(default)]
892 palace_id: Option<String>,
893 message: String,
894 #[serde(default)]
895 history: Vec<ChatMessage>,
896 #[serde(default)]
898 session_id: Option<String>,
899}
900
901const MAX_TOOL_ROUNDS: usize = 10;
907
908fn all_tools() -> Vec<ToolDef> {
917 vec![
918 ToolDef {
919 name: "list_palaces".into(),
920 description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
921 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
922 },
923 ToolDef {
924 name: "get_palace".into(),
925 description: "Get details for a specific palace by id.".into(),
926 parameters: json!({
927 "type": "object",
928 "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
929 "required": ["palace_id"],
930 }),
931 },
932 ToolDef {
933 name: "recall_memories".into(),
934 description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
935 parameters: json!({
936 "type": "object",
937 "properties": {
938 "palace_id": { "type": "string" },
939 "query": { "type": "string", "description": "Free-text query" },
940 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
941 },
942 "required": ["palace_id", "query"],
943 }),
944 },
945 ToolDef {
946 name: "list_drawers".into(),
947 description: "List all drawers (memories) in a palace, most recent first.".into(),
948 parameters: json!({
949 "type": "object",
950 "properties": { "palace_id": { "type": "string" } },
951 "required": ["palace_id"],
952 }),
953 },
954 ToolDef {
955 name: "kg_query".into(),
956 description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
957 parameters: json!({
958 "type": "object",
959 "properties": {
960 "palace_id": { "type": "string" },
961 "subject": { "type": "string" }
962 },
963 "required": ["palace_id", "subject"],
964 }),
965 },
966 ToolDef {
967 name: "get_config".into(),
968 description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
969 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
970 },
971 ToolDef {
972 name: "get_status".into(),
973 description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
974 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
975 },
976 ToolDef {
977 name: "get_dream_status".into(),
978 description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
979 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
980 },
981 ToolDef {
982 name: "get_palace_dream_status".into(),
983 description: "Get dreamer activity stats for a specific palace.".into(),
984 parameters: json!({
985 "type": "object",
986 "properties": { "palace_id": { "type": "string" } },
987 "required": ["palace_id"],
988 }),
989 },
990 ToolDef {
991 name: "create_memory".into(),
992 description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
993 parameters: json!({
994 "type": "object",
995 "properties": {
996 "palace_id": { "type": "string" },
997 "content": { "type": "string", "description": "Verbatim memory text" },
998 "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
999 "tags": { "type": "array", "items": { "type": "string" } },
1000 "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
1001 },
1002 "required": ["palace_id", "content"],
1003 }),
1004 },
1005 ToolDef {
1006 name: "kg_assert".into(),
1007 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(),
1008 parameters: json!({
1009 "type": "object",
1010 "properties": {
1011 "palace_id": { "type": "string" },
1012 "subject": { "type": "string" },
1013 "predicate": { "type": "string" },
1014 "object": { "type": "string" },
1015 "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
1016 },
1017 "required": ["palace_id", "subject", "predicate", "object"],
1018 }),
1019 },
1020 ToolDef {
1021 name: "memory_recall_all".into(),
1022 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(),
1023 parameters: json!({
1024 "type": "object",
1025 "properties": {
1026 "q": { "type": "string", "description": "Free-text query" },
1027 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
1028 "deep": { "type": "boolean", "default": false }
1029 },
1030 "required": ["q"],
1031 }),
1032 },
1033 ]
1034}
1035
1036async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
1047 let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
1048 match name {
1049 "list_palaces" => execute_list_palaces(state).await,
1050 "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1051 Some(id) => execute_get_palace(state, id).await,
1052 None => json!({ "error": "missing required argument: palace_id" }),
1053 },
1054 "recall_memories" => {
1055 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1056 let q = parsed.get("query").and_then(|v| v.as_str());
1057 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1058 match (pid, q) {
1059 (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
1060 _ => json!({ "error": "missing required argument(s): palace_id, query" }),
1061 }
1062 }
1063 "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1064 Some(id) => execute_list_drawers(state, id).await,
1065 None => json!({ "error": "missing required argument: palace_id" }),
1066 },
1067 "kg_query" => {
1068 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1069 let subj = parsed.get("subject").and_then(|v| v.as_str());
1070 match (pid, subj) {
1071 (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
1072 _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
1073 }
1074 }
1075 "get_config" => execute_get_config(state),
1076 "get_status" => execute_get_status(state).await,
1077 "get_dream_status" => execute_get_dream_status(state).await,
1078 "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1079 Some(id) => execute_get_palace_dream_status(state, id).await,
1080 None => json!({ "error": "missing required argument: palace_id" }),
1081 },
1082 "create_memory" => {
1083 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1084 let content = parsed.get("content").and_then(|v| v.as_str());
1085 let room = parsed.get("room").and_then(|v| v.as_str());
1086 let tags: Vec<String> = parsed
1087 .get("tags")
1088 .and_then(|v| v.as_array())
1089 .map(|arr| {
1090 arr.iter()
1091 .filter_map(|t| t.as_str().map(|s| s.to_string()))
1092 .collect()
1093 })
1094 .unwrap_or_default();
1095 let importance = parsed
1096 .get("importance")
1097 .and_then(|v| v.as_f64())
1098 .map(|f| f as f32)
1099 .unwrap_or(0.5);
1100 match (pid, content) {
1101 (Some(p), Some(c)) => {
1102 execute_create_memory(state, p, c, room, tags, importance).await
1103 }
1104 _ => json!({ "error": "missing required argument(s): palace_id, content" }),
1105 }
1106 }
1107 "kg_assert" => {
1108 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1109 let subj = parsed.get("subject").and_then(|v| v.as_str());
1110 let pred = parsed.get("predicate").and_then(|v| v.as_str());
1111 let obj = parsed.get("object").and_then(|v| v.as_str());
1112 let conf = parsed
1113 .get("confidence")
1114 .and_then(|v| v.as_f64())
1115 .map(|f| f as f32)
1116 .unwrap_or(1.0);
1117 match (pid, subj, pred, obj) {
1118 (Some(p), Some(s), Some(pr), Some(o)) => {
1119 execute_kg_assert(state, p, s, pr, o, conf).await
1120 }
1121 _ => json!({
1122 "error": "missing required argument(s): palace_id, subject, predicate, object"
1123 }),
1124 }
1125 }
1126 "memory_recall_all" => {
1127 let q = parsed.get("q").and_then(|v| v.as_str());
1128 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1129 let deep = parsed
1130 .get("deep")
1131 .and_then(|v| v.as_bool())
1132 .unwrap_or(false);
1133 match q {
1134 Some(q) => execute_recall_all(state, q, top_k, deep).await,
1135 None => json!({ "error": "missing required argument: q" }),
1136 }
1137 }
1138 _ => json!({ "error": format!("unknown tool: {name}") }),
1139 }
1140}
1141
1142async fn execute_list_palaces(state: &AppState) -> Value {
1143 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1144 Ok(v) => v,
1145 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1146 };
1147 let out: Vec<Value> = palaces
1148 .into_iter()
1149 .map(|p| {
1150 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1151 let info = palace_info_from(&p, handle.as_ref());
1152 serde_json::to_value(info).unwrap_or(json!({}))
1153 })
1154 .collect();
1155 json!(out)
1156}
1157
1158async fn execute_get_palace(state: &AppState, id: &str) -> Value {
1159 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1160 Ok(v) => v,
1161 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1162 };
1163 match palaces.into_iter().find(|p| p.id.0 == id) {
1164 Some(p) => {
1165 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1166 serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
1167 }
1168 None => json!({ "error": format!("palace not found: {id}") }),
1169 }
1170}
1171
1172async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
1173 let handle = match state
1174 .registry
1175 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1176 {
1177 Ok(h) => h,
1178 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1179 };
1180 match recall_with_default_embedder(&handle, query, top_k).await {
1181 Ok(hits) => json!(hits
1182 .into_iter()
1183 .map(|r| json!({
1184 "drawer_id": r.drawer.id.to_string(),
1185 "content": r.drawer.content,
1186 "importance": r.drawer.importance,
1187 "tags": r.drawer.tags,
1188 "score": r.score,
1189 "layer": r.layer,
1190 }))
1191 .collect::<Vec<_>>()),
1192 Err(e) => json!({ "error": format!("recall: {e:#}") }),
1193 }
1194}
1195
1196async fn execute_recall_all(state: &AppState, query: &str, top_k: usize, deep: bool) -> Value {
1207 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1208 Ok(v) => v,
1209 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1210 };
1211 let mut handles = Vec::with_capacity(palaces.len());
1212 for p in &palaces {
1213 match state.registry.open_palace(&state.data_root, &p.id) {
1214 Ok(h) => handles.push(h),
1215 Err(e) => {
1216 tracing::warn!(palace = %p.id, "execute_recall_all: open failed: {e:#}");
1217 }
1218 }
1219 }
1220 if handles.is_empty() {
1221 return json!([]);
1222 }
1223 match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
1224 Ok(results) => json!(results
1225 .into_iter()
1226 .map(|r| json!({
1227 "palace_id": r.palace_id,
1228 "drawer_id": r.result.drawer.id.to_string(),
1229 "content": r.result.drawer.content,
1230 "importance": r.result.drawer.importance,
1231 "tags": r.result.drawer.tags,
1232 "score": r.result.score,
1233 "layer": r.result.layer,
1234 }))
1235 .collect::<Vec<_>>()),
1236 Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
1237 }
1238}
1239
1240async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
1241 let handle = match state
1242 .registry
1243 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1244 {
1245 Ok(h) => h,
1246 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1247 };
1248 let drawers = handle.list_drawers(None, None, 200);
1249 serde_json::to_value(drawers).unwrap_or(json!([]))
1250}
1251
1252async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
1253 let handle = match state
1254 .registry
1255 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1256 {
1257 Ok(h) => h,
1258 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1259 };
1260 match handle.kg.query_active(subject).await {
1261 Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
1262 Err(e) => json!({ "error": format!("kg query: {e:#}") }),
1263 }
1264}
1265
1266fn execute_get_config(state: &AppState) -> Value {
1267 let cfg = load_user_config().unwrap_or_default();
1268 json!({
1269 "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
1270 "openrouter_model": cfg.openrouter_model,
1271 "local_model": {
1272 "enabled": cfg.local_model.enabled,
1273 "base_url": cfg.local_model.base_url,
1274 "model": cfg.local_model.model,
1275 },
1276 "data_root": state.data_root.display().to_string(),
1277 })
1278}
1279
1280async fn execute_get_status(state: &AppState) -> Value {
1281 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1282 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
1283 for p in &palaces {
1284 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
1285 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
1286 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
1287 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
1288 }
1289 }
1290 json!({
1291 "version": state.version,
1292 "palace_count": palaces.len(),
1293 "default_palace": state.default_palace,
1294 "data_root": state.data_root.display().to_string(),
1295 "total_drawers": total_drawers,
1296 "total_vectors": total_vectors,
1297 "total_kg_triples": total_kg_triples,
1298 })
1299}
1300
1301async fn execute_get_dream_status(state: &AppState) -> Value {
1302 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1303 let mut out = DreamStatusPayload::default();
1304 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1305 for p in palaces {
1306 let data_dir = state.data_root.join(p.id.as_str());
1307 let snap = match PersistedDreamStats::load(&data_dir) {
1308 Ok(Some(s)) => s,
1309 _ => continue,
1310 };
1311 out.merged = out.merged.saturating_add(snap.stats.merged);
1312 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1313 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1314 out.closets_updated = out
1315 .closets_updated
1316 .saturating_add(snap.stats.closets_updated);
1317 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1318 latest = match latest {
1319 Some(t) if t >= snap.last_run_at => Some(t),
1320 _ => Some(snap.last_run_at),
1321 };
1322 }
1323 out.last_run_at = latest;
1324 serde_json::to_value(out).unwrap_or(json!({}))
1325}
1326
1327async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
1328 let data_dir = state.data_root.join(palace_id);
1329 if !data_dir.exists() {
1330 return json!({ "error": format!("palace not found: {palace_id}") });
1331 }
1332 match PersistedDreamStats::load(&data_dir) {
1333 Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
1334 Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
1335 Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
1336 }
1337}
1338
1339async fn execute_create_memory(
1340 state: &AppState,
1341 palace_id: &str,
1342 content: &str,
1343 room: Option<&str>,
1344 tags: Vec<String>,
1345 importance: f32,
1346) -> Value {
1347 let handle = match state
1348 .registry
1349 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1350 {
1351 Ok(h) => h,
1352 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1353 };
1354 let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
1355 match handle
1356 .remember(content.to_string(), room, tags, importance)
1357 .await
1358 {
1359 Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
1360 Err(e) => json!({ "error": format!("remember: {e:#}") }),
1361 }
1362}
1363
1364async fn execute_kg_assert(
1365 state: &AppState,
1366 palace_id: &str,
1367 subject: &str,
1368 predicate: &str,
1369 object: &str,
1370 confidence: f32,
1371) -> Value {
1372 let handle = match state
1373 .registry
1374 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1375 {
1376 Ok(h) => h,
1377 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1378 };
1379 let triple = Triple {
1380 subject: subject.to_string(),
1381 predicate: predicate.to_string(),
1382 object: object.to_string(),
1383 valid_from: chrono::Utc::now(),
1384 valid_to: None,
1385 confidence,
1386 provenance: Some("chat:assistant".to_string()),
1387 };
1388 match handle.kg.assert(triple).await {
1389 Ok(()) => json!({ "status": "asserted" }),
1390 Err(e) => json!({ "error": format!("kg assert: {e:#}") }),
1391 }
1392}
1393
1394async fn chat_handler(State(state): State<AppState>, Json(body): Json<ChatBody>) -> Response {
1395 let Some(provider) = state.chat_provider().await else {
1397 return (
1398 StatusCode::PRECONDITION_FAILED,
1399 "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
1400 )
1401 .into_response();
1402 };
1403
1404 let palace_id = body
1406 .palace_id
1407 .clone()
1408 .or_else(|| state.default_palace.clone())
1409 .unwrap_or_default();
1410
1411 let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
1413 let store = match state.session_store(&palace_id) {
1414 Ok(s) => s,
1415 Err(e) => {
1416 tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
1417 return (
1418 StatusCode::INTERNAL_SERVER_ERROR,
1419 format!("session store: {e:#}"),
1420 )
1421 .into_response();
1422 }
1423 };
1424 match body.session_id.clone() {
1425 Some(sid) => match store.get_session(&sid) {
1426 Ok(Some(s)) => (
1427 Some(sid),
1428 s.history
1429 .into_iter()
1430 .map(|m| ChatMessage {
1431 role: m.role,
1432 content: m.content,
1433 tool_call_id: None,
1434 tool_calls: None,
1435 })
1436 .collect(),
1437 ),
1438 _ => (Some(sid), body.history.clone()),
1439 },
1440 None => {
1441 let new_id = store.create_session(None).unwrap_or_else(|e| {
1442 tracing::warn!("create_session failed: {e:#}");
1443 String::new()
1444 });
1445 (
1446 if new_id.is_empty() {
1447 None
1448 } else {
1449 Some(new_id)
1450 },
1451 body.history.clone(),
1452 )
1453 }
1454 }
1455 } else {
1456 (None, body.history.clone())
1457 };
1458
1459 let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1462 let palace_count = all_palaces.len();
1463 let palace_roster: String = all_palaces
1464 .iter()
1465 .map(|p| format!("- {} (id: {})", p.name, p.id.0))
1466 .collect::<Vec<_>>()
1467 .join("\n");
1468
1469 let cfg = load_user_config().unwrap_or_default();
1472 let active_provider_name = state
1473 .chat_provider()
1474 .await
1475 .map(|p| p.name().to_string())
1476 .unwrap_or_else(|| "none".to_string());
1477 let dream_snapshot = execute_get_dream_status(&state).await;
1478
1479 let selected_palace_meta = if palace_id.is_empty() {
1482 None
1483 } else {
1484 all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
1485 };
1486
1487 let mut palace_block = String::new();
1488 let mut context = String::new();
1489 let mut palace_display_name = palace_id.clone();
1490
1491 if !palace_id.is_empty() {
1492 if let Ok(handle) = state
1493 .registry
1494 .open_palace(&state.data_root, &PalaceId::new(&palace_id))
1495 {
1496 let drawer_count = handle.drawers.read().len();
1498 let vector_count = handle.vector_store.index_size();
1499 let kg_triple_count = handle.kg.count_active_triples();
1500
1501 let (name, description) = match &selected_palace_meta {
1503 Some(p) => (p.name.clone(), p.description.clone()),
1504 None => (palace_id.clone(), None),
1505 };
1506 palace_display_name = name.clone();
1507
1508 palace_block.push_str(&format!(
1509 "Currently selected palace:\n\
1510 - id: {id}\n\
1511 - name: {name}\n",
1512 id = palace_id,
1513 name = name,
1514 ));
1515 if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
1516 palace_block.push_str(&format!("- description: {desc}\n"));
1517 }
1518 palace_block.push_str(&format!(
1519 "- drawers: {drawer_count}\n\
1520 - vectors: {vector_count}\n\
1521 - kg_triples: {kg_triple_count}\n",
1522 ));
1523 let identity_trimmed = handle.identity.trim();
1524 if !identity_trimmed.is_empty() {
1525 palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
1526 }
1527
1528 if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
1529 for r in hits.iter().take(5) {
1530 context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content));
1531 }
1532 }
1533 }
1534 }
1535
1536 let mut system = String::new();
1540 system.push_str(&format!(
1541 "You are the assistant for trusty-memory, a machine-wide AI memory \
1542 service running locally on this user's machine. trusty-memory stores \
1543 knowledge in named \"palaces\" — isolated memory namespaces, each with \
1544 its own vector index (usearch HNSW) and temporal knowledge graph \
1545 (SQLite). Memories are organized as Palace -> Wing -> Room -> Closet \
1546 -> Drawer, where a Drawer is an atomic memory unit.\n\
1547 There are currently {palace_count} palace(s) on this machine.\n",
1548 ));
1549 if !palace_roster.is_empty() {
1550 system.push_str(&format!("Palaces:\n{palace_roster}\n"));
1551 }
1552 system.push('\n');
1553
1554 system.push_str(&format!(
1556 "System configuration:\n\
1557 - active chat provider: {active_provider_name}\n\
1558 - openrouter model: {or_model}\n\
1559 - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
1560 - data root: {data_root}\n\n",
1561 or_model = cfg.openrouter_model,
1562 local_model = cfg.local_model.model,
1563 local_url = cfg.local_model.base_url,
1564 local_enabled = cfg.local_model.enabled,
1565 data_root = state.data_root.display(),
1566 ));
1567
1568 system.push_str(&format!(
1570 "Global dream status (background memory maintenance):\n{}\n\n",
1571 dream_snapshot,
1572 ));
1573
1574 if !palace_block.is_empty() {
1575 system.push_str(&palace_block);
1576 system.push('\n');
1577 }
1578
1579 if !context.is_empty() {
1580 system.push_str(&format!(
1581 "Relevant memories from the '{palace_display_name}' palace \
1582 (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
1583 {context}\n",
1584 ));
1585 }
1586
1587 system.push_str(
1588 "You have a set of tools to introspect and modify this trusty-memory \
1589 daemon. Prefer calling a tool over guessing — e.g. call \
1590 `list_palaces` rather than relying on the roster above if you need \
1591 live counts, and call `recall_memories` to search for facts you \
1592 don't have in context. When the user asks about \"palaces\", they \
1593 mean trusty-memory palaces (memory namespaces on this machine), not \
1594 architectural palaces like Versailles. If a tool returns an error, \
1595 report it honestly and don't fabricate results.",
1596 );
1597
1598 history.push(ChatMessage {
1600 role: "user".to_string(),
1601 content: body.message.clone(),
1602 tool_call_id: None,
1603 tool_calls: None,
1604 });
1605
1606 let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
1607 messages.push(ChatMessage {
1608 role: "system".to_string(),
1609 content: system,
1610 tool_call_id: None,
1611 tool_calls: None,
1612 });
1613 messages.extend(history.iter().cloned());
1614
1615 let tools = all_tools();
1616 let (sse_tx, sse_rx) =
1617 tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
1618
1619 let session_store = if !palace_id.is_empty() && session_id.is_some() {
1621 state.session_store(&palace_id).ok()
1622 } else {
1623 None
1624 };
1625 let persist_session_id = session_id.clone();
1626
1627 let loop_state = state.clone();
1630 tokio::spawn(async move {
1631 if let Some(sid) = persist_session_id.as_deref() {
1634 let frame = format!("data: {}\n\n", json!({ "session_id": sid }));
1635 if sse_tx
1636 .send(Ok(axum::body::Bytes::from(frame)))
1637 .await
1638 .is_err()
1639 {
1640 return;
1641 }
1642 }
1643
1644 let mut final_assistant_text = String::new();
1645 let mut stream_err: Option<String> = None;
1646
1647 for round in 0..MAX_TOOL_ROUNDS {
1648 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
1649 let messages_clone = messages.clone();
1650 let tools_clone = tools.clone();
1651 let provider_clone = provider.clone();
1652 let stream_handle = tokio::spawn(async move {
1653 provider_clone
1654 .chat_stream(messages_clone, tools_clone, event_tx)
1655 .await
1656 });
1657
1658 let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
1659 let mut round_assistant_text = String::new();
1660
1661 while let Some(event) = event_rx.recv().await {
1662 match event {
1663 ChatEvent::Delta(text) => {
1664 round_assistant_text.push_str(&text);
1665 let frame = format!("data: {}\n\n", json!({ "delta": text }));
1666 if sse_tx
1667 .send(Ok(axum::body::Bytes::from(frame)))
1668 .await
1669 .is_err()
1670 {
1671 return;
1672 }
1673 }
1674 ChatEvent::ToolCall(tc) => {
1675 let frame = format!(
1676 "data: {}\n\n",
1677 json!({ "tool_call": {
1678 "id": tc.id,
1679 "name": tc.name,
1680 "arguments": tc.arguments,
1681 }})
1682 );
1683 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
1684 tool_calls_this_round.push(tc);
1685 }
1686 ChatEvent::Done => break,
1687 ChatEvent::Error(e) => {
1688 stream_err = Some(e);
1689 break;
1690 }
1691 }
1692 }
1693
1694 match stream_handle.await {
1696 Ok(Ok(())) => {}
1697 Ok(Err(e)) => stream_err = Some(e.to_string()),
1698 Err(e) => stream_err = Some(format!("join: {e}")),
1699 }
1700
1701 if stream_err.is_some() {
1702 break;
1703 }
1704
1705 final_assistant_text.push_str(&round_assistant_text);
1706
1707 if tool_calls_this_round.is_empty() {
1708 break;
1710 }
1711
1712 let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
1714 .iter()
1715 .map(|tc| {
1716 json!({
1717 "id": tc.id,
1718 "type": "function",
1719 "function": { "name": tc.name, "arguments": tc.arguments },
1720 })
1721 })
1722 .collect();
1723 messages.push(ChatMessage {
1724 role: "assistant".to_string(),
1725 content: round_assistant_text,
1726 tool_call_id: None,
1727 tool_calls: Some(assistant_tool_calls_json),
1728 });
1729
1730 for tc in &tool_calls_this_round {
1733 let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
1734 let result_str = result.to_string();
1735 let frame = format!(
1736 "data: {}\n\n",
1737 json!({ "tool_result": {
1738 "id": tc.id,
1739 "name": tc.name,
1740 "content": &result_str,
1741 }})
1742 );
1743 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
1744 messages.push(ChatMessage {
1745 role: "tool".to_string(),
1746 content: result_str,
1747 tool_call_id: Some(tc.id.clone()),
1748 tool_calls: None,
1749 });
1750 }
1751
1752 if round + 1 == MAX_TOOL_ROUNDS {
1754 tracing::warn!(
1755 "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
1756 MAX_TOOL_ROUNDS
1757 );
1758 }
1759 }
1760
1761 if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
1764 if !final_assistant_text.is_empty() {
1765 history.push(ChatMessage {
1766 role: "assistant".into(),
1767 content: final_assistant_text,
1768 tool_call_id: None,
1769 tool_calls: None,
1770 });
1771 }
1772 let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
1773 history
1774 .iter()
1775 .map(
1776 |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
1777 role: m.role.clone(),
1778 content: m.content.clone(),
1779 },
1780 )
1781 .collect();
1782 if let Err(e) = store.upsert_session(sid, &core_history) {
1783 tracing::warn!("upsert_session failed: {e:#}");
1784 }
1785 }
1786
1787 match stream_err {
1788 None => {
1789 let _ = sse_tx
1790 .send(Ok(axum::body::Bytes::from("data: [DONE]\n\n")))
1791 .await;
1792 }
1793 Some(e) => {
1794 let out = format!("data: {}\n\n", json!({ "error": e }));
1795 let _ = sse_tx.send(Ok(axum::body::Bytes::from(out))).await;
1796 }
1797 }
1798 });
1799
1800 let stream = tokio_stream::wrappers::ReceiverStream::new(sse_rx);
1801
1802 Response::builder()
1803 .header("Content-Type", "text/event-stream")
1804 .header("Cache-Control", "no-cache")
1805 .body(Body::from_stream(stream))
1806 .expect("static SSE response builds")
1807}
1808
1809async fn list_providers(State(state): State<AppState>) -> Json<Value> {
1822 let cfg = load_user_config().unwrap_or_default();
1823 let ollama_available = if cfg.local_model.enabled {
1824 trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
1825 .await
1826 .is_some()
1827 } else {
1828 false
1829 };
1830 let openrouter_available = !cfg.openrouter_api_key.is_empty();
1831 let active = state.chat_provider().await.map(|p| p.name().to_string());
1832 Json(json!({
1833 "providers": [
1834 {
1835 "name": "ollama",
1836 "model": cfg.local_model.model,
1837 "available": ollama_available,
1838 },
1839 {
1840 "name": "openrouter",
1841 "model": cfg.openrouter_model,
1842 "available": openrouter_available,
1843 }
1844 ],
1845 "active": active,
1846 }))
1847}
1848
1849#[derive(Deserialize, Default)]
1850struct CreateSessionBody {
1851 #[serde(default)]
1852 title: Option<String>,
1853}
1854
1855async fn create_chat_session(
1856 State(state): State<AppState>,
1857 AxumPath(id): AxumPath<String>,
1858 body: Option<Json<CreateSessionBody>>,
1859) -> Result<Json<Value>, ApiError> {
1860 let store = state
1861 .session_store(&id)
1862 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1863 let title = body.and_then(|b| b.0.title);
1864 let sid = store
1865 .create_session(title)
1866 .map_err(|e| ApiError::internal(format!("create session: {e:#}")))?;
1867 Ok(Json(json!({ "id": sid })))
1868}
1869
1870async fn list_chat_sessions(
1871 State(state): State<AppState>,
1872 AxumPath(id): AxumPath<String>,
1873) -> Result<Json<Value>, ApiError> {
1874 let store = state
1875 .session_store(&id)
1876 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1877 let metas = store
1878 .list_sessions()
1879 .map_err(|e| ApiError::internal(format!("list sessions: {e:#}")))?;
1880 Ok(Json(serde_json::to_value(metas).unwrap_or(json!([]))))
1881}
1882
1883async fn get_chat_session(
1884 State(state): State<AppState>,
1885 AxumPath((id, session_id)): AxumPath<(String, String)>,
1886) -> Result<Json<Value>, ApiError> {
1887 let store = state
1888 .session_store(&id)
1889 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1890 let s = store
1891 .get_session(&session_id)
1892 .map_err(|e| ApiError::internal(format!("get session: {e:#}")))?
1893 .ok_or_else(|| ApiError::not_found(format!("session not found: {session_id}")))?;
1894 Ok(Json(serde_json::to_value(s).unwrap_or(json!({}))))
1895}
1896
1897async fn delete_chat_session(
1898 State(state): State<AppState>,
1899 AxumPath((id, session_id)): AxumPath<(String, String)>,
1900) -> Result<StatusCode, ApiError> {
1901 let store = state
1902 .session_store(&id)
1903 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1904 store
1905 .delete_session(&session_id)
1906 .map_err(|e| ApiError::internal(format!("delete session: {e:#}")))?;
1907 Ok(StatusCode::NO_CONTENT)
1908}
1909
1910fn open_handle(
1915 state: &AppState,
1916 id: &str,
1917) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
1918 state
1919 .registry
1920 .open_palace(&state.data_root, &PalaceId::new(id))
1921 .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
1922}
1923
1924struct ApiError {
1926 status: StatusCode,
1927 message: String,
1928}
1929
1930impl ApiError {
1931 fn bad_request(msg: impl Into<String>) -> Self {
1932 Self {
1933 status: StatusCode::BAD_REQUEST,
1934 message: msg.into(),
1935 }
1936 }
1937 fn not_found(msg: impl Into<String>) -> Self {
1938 Self {
1939 status: StatusCode::NOT_FOUND,
1940 message: msg.into(),
1941 }
1942 }
1943 fn internal(msg: impl Into<String>) -> Self {
1944 Self {
1945 status: StatusCode::INTERNAL_SERVER_ERROR,
1946 message: msg.into(),
1947 }
1948 }
1949}
1950
1951impl IntoResponse for ApiError {
1952 fn into_response(self) -> Response {
1953 (self.status, Json(json!({ "error": self.message }))).into_response()
1954 }
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959 use super::*;
1960 use axum::body::to_bytes;
1961 use axum::http::Request;
1962 use tower::util::ServiceExt;
1963
1964 fn test_state() -> AppState {
1965 let tmp = tempfile::tempdir().expect("tempdir");
1966 let root = tmp.path().to_path_buf();
1967 std::mem::forget(tmp);
1968 AppState::new(root)
1969 }
1970
1971 #[tokio::test]
1972 async fn health_endpoint_returns_ok() {
1973 let state = test_state();
1974 let app = router().with_state(state);
1975 let resp = app
1976 .oneshot(
1977 Request::builder()
1978 .uri("/health")
1979 .body(Body::empty())
1980 .unwrap(),
1981 )
1982 .await
1983 .unwrap();
1984 assert_eq!(resp.status(), StatusCode::OK);
1985 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1986 let v: Value = serde_json::from_slice(&bytes).unwrap();
1987 assert_eq!(v["status"], "ok");
1988 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1989 }
1990
1991 #[tokio::test]
2001 async fn health_endpoint_includes_resource_fields() {
2002 let state = test_state();
2003 let app = router().with_state(state);
2004 let resp = app
2005 .oneshot(
2006 Request::builder()
2007 .uri("/health")
2008 .body(Body::empty())
2009 .unwrap(),
2010 )
2011 .await
2012 .unwrap();
2013 assert_eq!(resp.status(), StatusCode::OK);
2014 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2015 let v: Value = serde_json::from_slice(&bytes).unwrap();
2016 let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
2018 assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
2019 let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
2021 assert!(cpu >= 0.0, "cpu_pct must be non-negative");
2022 assert_eq!(v["disk_bytes"].as_u64(), Some(0));
2024 assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
2026 }
2027
2028 #[tokio::test]
2037 async fn logs_tail_returns_recent_lines() {
2038 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2039 buffer.push("line one".to_string());
2040 buffer.push("line two".to_string());
2041 buffer.push("line three".to_string());
2042 let state = test_state().with_log_buffer(buffer);
2043 let app = router().with_state(state);
2044 let resp = app
2045 .oneshot(
2046 Request::builder()
2047 .uri("/api/v1/logs/tail?n=2")
2048 .body(Body::empty())
2049 .unwrap(),
2050 )
2051 .await
2052 .unwrap();
2053 assert_eq!(resp.status(), StatusCode::OK);
2054 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2055 let v: Value = serde_json::from_slice(&bytes).unwrap();
2056 let lines = v["lines"].as_array().expect("lines array");
2057 assert_eq!(lines.len(), 2, "n=2 must return two lines");
2058 assert_eq!(lines[0].as_str(), Some("line two"));
2059 assert_eq!(lines[1].as_str(), Some("line three"));
2060 assert_eq!(v["total"].as_u64(), Some(3));
2061 }
2062
2063 #[tokio::test]
2072 async fn logs_tail_clamps_n() {
2073 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2074 for i in 0..5 {
2075 buffer.push(format!("l{i}"));
2076 }
2077 let state = test_state().with_log_buffer(buffer);
2078 let app = router().with_state(state);
2079
2080 let resp = app
2082 .clone()
2083 .oneshot(
2084 Request::builder()
2085 .uri("/api/v1/logs/tail?n=0")
2086 .body(Body::empty())
2087 .unwrap(),
2088 )
2089 .await
2090 .unwrap();
2091 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2092 let v: Value = serde_json::from_slice(&bytes).unwrap();
2093 assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2094
2095 let resp = app
2097 .oneshot(
2098 Request::builder()
2099 .uri("/api/v1/logs/tail?n=999999")
2100 .body(Body::empty())
2101 .unwrap(),
2102 )
2103 .await
2104 .unwrap();
2105 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2106 let v: Value = serde_json::from_slice(&bytes).unwrap();
2107 assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2108 }
2109
2110 #[tokio::test]
2121 async fn admin_stop_returns_ok() {
2122 let state = test_state();
2123 let Json(body) = admin_stop(State(state)).await;
2124 assert_eq!(body["ok"], Value::Bool(true));
2125 assert_eq!(body["message"].as_str(), Some("shutting down"));
2126 }
2127
2128 #[tokio::test]
2129 async fn status_endpoint_returns_payload() {
2130 let state = test_state();
2131 let app = router().with_state(state);
2132 let resp = app
2133 .oneshot(
2134 Request::builder()
2135 .uri("/api/v1/status")
2136 .body(Body::empty())
2137 .unwrap(),
2138 )
2139 .await
2140 .unwrap();
2141 assert_eq!(resp.status(), StatusCode::OK);
2142 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2143 let v: Value = serde_json::from_slice(&bytes).unwrap();
2144 assert!(v["version"].is_string());
2145 assert_eq!(v["palace_count"], 0);
2146 }
2147
2148 #[tokio::test]
2149 async fn unknown_api_returns_404() {
2150 let state = test_state();
2151 let app = router().with_state(state);
2152 let resp = app
2153 .oneshot(
2154 Request::builder()
2155 .uri("/api/v1/does-not-exist")
2156 .body(Body::empty())
2157 .unwrap(),
2158 )
2159 .await
2160 .unwrap();
2161 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2162 }
2163
2164 #[tokio::test]
2165 async fn create_then_list_palace() {
2166 let state = test_state();
2167 let app = router().with_state(state.clone());
2168 let body = json!({"name": "web-test", "description": "from test"}).to_string();
2169 let resp = app
2170 .clone()
2171 .oneshot(
2172 Request::builder()
2173 .method("POST")
2174 .uri("/api/v1/palaces")
2175 .header("content-type", "application/json")
2176 .body(Body::from(body))
2177 .unwrap(),
2178 )
2179 .await
2180 .unwrap();
2181 assert_eq!(resp.status(), StatusCode::OK);
2182
2183 let resp = app
2184 .oneshot(
2185 Request::builder()
2186 .uri("/api/v1/palaces")
2187 .body(Body::empty())
2188 .unwrap(),
2189 )
2190 .await
2191 .unwrap();
2192 assert_eq!(resp.status(), StatusCode::OK);
2193 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2194 let v: Value = serde_json::from_slice(&bytes).unwrap();
2195 let arr = v.as_array().expect("array");
2196 assert!(arr.iter().any(|p| p["id"] == "web-test"));
2197 }
2198
2199 #[tokio::test]
2206 async fn status_includes_total_counters() {
2207 let state = test_state();
2208 let app = router().with_state(state);
2209 let resp = app
2210 .oneshot(
2211 Request::builder()
2212 .uri("/api/v1/status")
2213 .body(Body::empty())
2214 .unwrap(),
2215 )
2216 .await
2217 .unwrap();
2218 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2219 let v: Value = serde_json::from_slice(&bytes).unwrap();
2220 assert_eq!(v["total_drawers"], 0);
2221 assert_eq!(v["total_vectors"], 0);
2222 assert_eq!(v["total_kg_triples"], 0);
2223 }
2224
2225 #[tokio::test]
2232 async fn dream_status_empty_returns_nulls() {
2233 let state = test_state();
2234 let app = router().with_state(state);
2235 let resp = app
2236 .oneshot(
2237 Request::builder()
2238 .uri("/api/v1/dream/status")
2239 .body(Body::empty())
2240 .unwrap(),
2241 )
2242 .await
2243 .unwrap();
2244 assert_eq!(resp.status(), StatusCode::OK);
2245 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2246 let v: Value = serde_json::from_slice(&bytes).unwrap();
2247 assert!(v["last_run_at"].is_null());
2248 assert_eq!(v["merged"], 0);
2249 assert_eq!(v["pruned"], 0);
2250 }
2251
2252 #[tokio::test]
2259 async fn providers_endpoint_returns_payload() {
2260 let state = test_state();
2261 let app = router().with_state(state);
2262 let resp = app
2263 .oneshot(
2264 Request::builder()
2265 .uri("/api/v1/chat/providers")
2266 .body(Body::empty())
2267 .unwrap(),
2268 )
2269 .await
2270 .unwrap();
2271 assert_eq!(resp.status(), StatusCode::OK);
2272 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2273 let v: Value = serde_json::from_slice(&bytes).unwrap();
2274 let arr = v["providers"].as_array().expect("providers array");
2275 assert_eq!(arr.len(), 2);
2276 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2277 assert!(names.contains(&"ollama"));
2278 assert!(names.contains(&"openrouter"));
2279 assert!(v.get("active").is_some());
2281 }
2282
2283 #[tokio::test]
2290 async fn chat_session_crud_round_trip() {
2291 let state = test_state();
2292 let palace = trusty_common::memory_core::Palace {
2294 id: PalaceId::new("sess-test"),
2295 name: "sess-test".to_string(),
2296 description: None,
2297 created_at: chrono::Utc::now(),
2298 data_dir: state.data_root.join("sess-test"),
2299 };
2300 state
2301 .registry
2302 .create_palace(&state.data_root, palace)
2303 .expect("create_palace");
2304 let app = router().with_state(state);
2305
2306 let resp = app
2308 .clone()
2309 .oneshot(
2310 Request::builder()
2311 .method("POST")
2312 .uri("/api/v1/palaces/sess-test/chat/sessions")
2313 .header("content-type", "application/json")
2314 .body(Body::from(json!({"title":"first chat"}).to_string()))
2315 .unwrap(),
2316 )
2317 .await
2318 .unwrap();
2319 assert_eq!(resp.status(), StatusCode::OK);
2320 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2321 let v: Value = serde_json::from_slice(&bytes).unwrap();
2322 let sid = v["id"].as_str().expect("session id").to_string();
2323
2324 let resp = app
2326 .clone()
2327 .oneshot(
2328 Request::builder()
2329 .uri("/api/v1/palaces/sess-test/chat/sessions")
2330 .body(Body::empty())
2331 .unwrap(),
2332 )
2333 .await
2334 .unwrap();
2335 assert_eq!(resp.status(), StatusCode::OK);
2336 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2337 let v: Value = serde_json::from_slice(&bytes).unwrap();
2338 let arr = v.as_array().expect("array");
2339 assert!(arr.iter().any(|s| s["id"] == sid));
2340
2341 let resp = app
2343 .clone()
2344 .oneshot(
2345 Request::builder()
2346 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2347 .body(Body::empty())
2348 .unwrap(),
2349 )
2350 .await
2351 .unwrap();
2352 assert_eq!(resp.status(), StatusCode::OK);
2353
2354 let resp = app
2356 .clone()
2357 .oneshot(
2358 Request::builder()
2359 .method("DELETE")
2360 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2361 .body(Body::empty())
2362 .unwrap(),
2363 )
2364 .await
2365 .unwrap();
2366 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2367
2368 let resp = app
2370 .oneshot(
2371 Request::builder()
2372 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2373 .body(Body::empty())
2374 .unwrap(),
2375 )
2376 .await
2377 .unwrap();
2378 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2379 }
2380
2381 #[test]
2388 fn all_tools_returns_expected_set() {
2389 let tools = all_tools();
2390 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
2391 assert_eq!(
2392 names,
2393 vec![
2394 "list_palaces",
2395 "get_palace",
2396 "recall_memories",
2397 "list_drawers",
2398 "kg_query",
2399 "get_config",
2400 "get_status",
2401 "get_dream_status",
2402 "get_palace_dream_status",
2403 "create_memory",
2404 "kg_assert",
2405 "memory_recall_all",
2406 ]
2407 );
2408 for t in &tools {
2411 assert_eq!(
2412 t.parameters["type"], "object",
2413 "tool {} schema type",
2414 t.name
2415 );
2416 assert!(
2417 t.parameters["required"].is_array(),
2418 "tool {} required not array",
2419 t.name
2420 );
2421 }
2422 }
2423
2424 #[tokio::test]
2431 async fn execute_tool_dispatches_known_tools() {
2432 let state = test_state();
2433 let result = execute_tool("list_palaces", "{}", &state).await;
2434 assert!(
2435 result.is_array(),
2436 "list_palaces should be array, got {result}"
2437 );
2438 assert_eq!(result.as_array().unwrap().len(), 0);
2439
2440 let unknown = execute_tool("not_a_tool", "{}", &state).await;
2441 assert!(
2442 unknown["error"]
2443 .as_str()
2444 .unwrap_or("")
2445 .contains("unknown tool"),
2446 "expected unknown-tool error, got {unknown}"
2447 );
2448
2449 let missing = execute_tool("get_palace", "{}", &state).await;
2450 assert!(
2451 missing["error"]
2452 .as_str()
2453 .unwrap_or("")
2454 .contains("palace_id"),
2455 "expected missing-arg error, got {missing}"
2456 );
2457 }
2458
2459 #[tokio::test]
2468 async fn sse_broadcast_emits_palace_created() {
2469 let state = test_state();
2470 let mut rx = state.events.subscribe();
2471 let app = router().with_state(state.clone());
2472 let body = json!({"name": "sse-test"}).to_string();
2473 let resp = app
2474 .oneshot(
2475 Request::builder()
2476 .method("POST")
2477 .uri("/api/v1/palaces")
2478 .header("content-type", "application/json")
2479 .body(Body::from(body))
2480 .unwrap(),
2481 )
2482 .await
2483 .unwrap();
2484 assert_eq!(resp.status(), StatusCode::OK);
2485 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
2487 .await
2488 .expect("event received within timeout")
2489 .expect("event channel still open");
2490 match event {
2491 DaemonEvent::PalaceCreated { id, name } => {
2492 assert_eq!(id, "sse-test");
2493 assert_eq!(name, "sse-test");
2494 }
2495 other => panic!("expected PalaceCreated, got {other:?}"),
2496 }
2497 }
2498
2499 #[tokio::test]
2506 async fn sse_endpoint_emits_connected_frame() {
2507 use axum::routing::get;
2508 let state = test_state();
2509 let app = router()
2510 .route("/sse", get(crate::sse_handler))
2511 .with_state(state);
2512 let resp = app
2513 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
2514 .await
2515 .unwrap();
2516 assert_eq!(resp.status(), StatusCode::OK);
2517 assert_eq!(
2518 resp.headers()
2519 .get(header::CONTENT_TYPE)
2520 .and_then(|v| v.to_str().ok()),
2521 Some("text/event-stream")
2522 );
2523 let body = resp.into_body();
2526 let bytes =
2527 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
2528 .await
2529 .ok()
2530 .and_then(|r| r.ok())
2531 .unwrap_or_default();
2532 let text = String::from_utf8_lossy(&bytes);
2533 assert!(
2534 text.contains("\"type\":\"connected\""),
2535 "expected connected frame, got: {text}"
2536 );
2537 }
2538
2539 #[tokio::test]
2549 async fn dream_status_aggregates_across_palaces() {
2550 use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
2551
2552 let state = test_state();
2553 for (id, stats, ts) in [
2557 (
2558 "palace-a",
2559 DreamStats {
2560 merged: 1,
2561 pruned: 2,
2562 compacted: 3,
2563 closets_updated: 4,
2564 duration_ms: 100,
2565 },
2566 chrono::Utc::now() - chrono::Duration::seconds(60),
2567 ),
2568 (
2569 "palace-b",
2570 DreamStats {
2571 merged: 10,
2572 pruned: 20,
2573 compacted: 30,
2574 closets_updated: 40,
2575 duration_ms: 200,
2576 },
2577 chrono::Utc::now(),
2578 ),
2579 ] {
2580 let palace = trusty_common::memory_core::Palace {
2581 id: PalaceId::new(id),
2582 name: id.to_string(),
2583 description: None,
2584 created_at: chrono::Utc::now(),
2585 data_dir: state.data_root.join(id),
2586 };
2587 state
2588 .registry
2589 .create_palace(&state.data_root, palace)
2590 .expect("create palace");
2591 let persisted = PersistedDreamStats {
2592 last_run_at: ts,
2593 stats,
2594 };
2595 persisted
2596 .save(&state.data_root.join(id))
2597 .expect("save dream stats");
2598 }
2599
2600 let later = chrono::Utc::now();
2601 let app = router().with_state(state);
2602 let resp = app
2603 .oneshot(
2604 Request::builder()
2605 .uri("/api/v1/dream/status")
2606 .body(Body::empty())
2607 .unwrap(),
2608 )
2609 .await
2610 .unwrap();
2611 assert_eq!(resp.status(), StatusCode::OK);
2612 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2613 let v: Value = serde_json::from_slice(&bytes).unwrap();
2614
2615 assert_eq!(v["merged"], 11);
2617 assert_eq!(v["pruned"], 22);
2618 assert_eq!(v["compacted"], 33);
2619 assert_eq!(v["closets_updated"], 44);
2620 assert_eq!(v["duration_ms"], 300);
2621
2622 let last = v["last_run_at"].as_str().expect("last_run_at is string");
2624 let parsed: chrono::DateTime<chrono::Utc> = last
2625 .parse()
2626 .expect("last_run_at parses as RFC3339 timestamp");
2627 assert!(
2628 parsed <= later,
2629 "last_run_at ({parsed}) should not exceed wall clock ({later})"
2630 );
2631 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
2633 assert!(
2634 parsed >= cutoff,
2635 "expected the newer (palace-b) timestamp; got {parsed}"
2636 );
2637 }
2638
2639 #[tokio::test]
2651 async fn dream_run_aggregates_stats() {
2652 let state = test_state();
2653 let palace = trusty_common::memory_core::Palace {
2654 id: PalaceId::new("dream-run-test"),
2655 name: "dream-run-test".to_string(),
2656 description: None,
2657 created_at: chrono::Utc::now(),
2658 data_dir: state.data_root.join("dream-run-test"),
2659 };
2660 state
2661 .registry
2662 .create_palace(&state.data_root, palace)
2663 .expect("create palace");
2664
2665 let app = router().with_state(state);
2666 let resp = app
2667 .oneshot(
2668 Request::builder()
2669 .method("POST")
2670 .uri("/api/v1/dream/run")
2671 .body(Body::empty())
2672 .unwrap(),
2673 )
2674 .await
2675 .unwrap();
2676 assert_eq!(resp.status(), StatusCode::OK);
2677 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2678 let v: Value = serde_json::from_slice(&bytes).unwrap();
2679
2680 for key in [
2683 "merged",
2684 "pruned",
2685 "compacted",
2686 "closets_updated",
2687 "duration_ms",
2688 ] {
2689 assert!(
2690 v.get(key).is_some(),
2691 "missing key {key} in dream_run payload: {v}"
2692 );
2693 assert!(
2694 v[key].is_u64() || v[key].is_i64(),
2695 "{key} should be integer, got {}",
2696 v[key]
2697 );
2698 }
2699 assert!(
2700 v["last_run_at"].is_string(),
2701 "last_run_at must be set by dream_run; got {v}"
2702 );
2703 }
2704
2705 #[tokio::test]
2706 async fn serves_index_html_fallback() {
2707 let state = test_state();
2708 let app = router().with_state(state);
2709 let resp = app
2710 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2711 .await
2712 .unwrap();
2713 assert!(
2715 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
2716 "got {}",
2717 resp.status()
2718 );
2719 }
2720}