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::{ChatEvent, ChatMessage, ToolDef};
28use trusty_memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
29use trusty_memory_core::palace::{Palace, PalaceId, RoomType};
30use trusty_memory_core::retrieval::{
31 recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
32 recall_with_default_embedder,
33};
34use trusty_memory_core::store::kg::Triple;
35use trusty_memory_core::{PalaceHandle, PalaceRegistry};
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 .fallback(static_handler);
96
97 trusty_common::server::with_standard_middleware(router)
98}
99
100#[derive(serde::Serialize)]
111struct HealthResponse {
112 status: &'static str,
113 version: &'static str,
114}
115
116async fn health() -> Json<HealthResponse> {
123 Json(HealthResponse {
124 status: "ok",
125 version: env!("CARGO_PKG_VERSION"),
126 })
127}
128
129async fn static_handler(req: Request<Body>) -> Response {
141 let path = req.uri().path().trim_start_matches('/').to_string();
142
143 if path.starts_with("api/") {
144 return (StatusCode::NOT_FOUND, "not found").into_response();
145 }
146
147 serve_embedded(&path).unwrap_or_else(|| {
148 serve_embedded("index.html")
150 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
151 })
152}
153
154fn serve_embedded(path: &str) -> Option<Response> {
155 let path = if path.is_empty() { "index.html" } else { path };
156 let asset = WebAssets::get(path)?;
157 let mime = mime_guess::from_path(path).first_or_octet_stream();
158 let body = Body::from(asset.data.into_owned());
159 let mut resp = Response::new(body);
160 resp.headers_mut().insert(
161 header::CONTENT_TYPE,
162 HeaderValue::from_str(mime.as_ref())
163 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
164 );
165 Some(resp)
166}
167
168#[derive(Serialize)]
173struct StatusPayload {
174 version: String,
175 palace_count: usize,
176 default_palace: Option<String>,
177 data_root: String,
178 total_drawers: usize,
179 total_vectors: usize,
180 total_kg_triples: usize,
181}
182
183async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
184 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
185 let palace_count = palaces.len();
186 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
187 for p in &palaces {
188 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
189 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
190 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
191 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
192 }
193 }
194 Json(StatusPayload {
195 version: state.version.clone(),
196 palace_count,
197 default_palace: state.default_palace.clone(),
198 data_root: state.data_root.display().to_string(),
199 total_drawers,
200 total_vectors,
201 total_kg_triples,
202 })
203}
204
205#[derive(Serialize)]
206struct ConfigPayload {
207 openrouter_configured: bool,
208 model: String,
209 data_root: String,
210}
211
212async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
213 let cfg = load_user_config().unwrap_or_default();
214 Json(ConfigPayload {
215 openrouter_configured: !cfg.openrouter_api_key.is_empty(),
216 model: cfg.openrouter_model,
217 data_root: state.data_root.display().to_string(),
218 })
219}
220
221#[derive(Deserialize, Default, Clone)]
224struct UserConfigMin {
225 #[serde(default)]
226 openrouter: OpenRouterMin,
227 #[serde(default)]
228 local_model: LocalModelMin,
229 }
231
232#[derive(Deserialize, Default, Clone)]
233struct OpenRouterMin {
234 #[serde(default)]
235 api_key: String,
236 #[serde(default)]
237 model: String,
238}
239
240#[derive(Deserialize, Clone)]
241struct LocalModelMin {
242 #[serde(default = "default_local_enabled")]
243 enabled: bool,
244 #[serde(default = "default_local_base_url")]
245 base_url: String,
246 #[serde(default = "default_local_model")]
247 model: String,
248}
249
250fn default_local_enabled() -> bool {
251 true
252}
253fn default_local_base_url() -> String {
254 "http://localhost:11434".to_string()
255}
256fn default_local_model() -> String {
257 "llama3.2".to_string()
258}
259
260impl Default for LocalModelMin {
261 fn default() -> Self {
262 Self {
263 enabled: default_local_enabled(),
264 base_url: default_local_base_url(),
265 model: default_local_model(),
266 }
267 }
268}
269
270#[derive(Clone)]
271pub(crate) struct LoadedUserConfig {
272 pub(crate) openrouter_api_key: String,
273 pub(crate) openrouter_model: String,
274 pub(crate) local_model: trusty_common::LocalModelConfig,
275}
276
277impl Default for LoadedUserConfig {
278 fn default() -> Self {
279 Self {
280 openrouter_api_key: String::new(),
281 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
282 local_model: trusty_common::LocalModelConfig::default(),
283 }
284 }
285}
286
287pub(crate) fn load_user_config() -> Option<LoadedUserConfig> {
288 let home = dirs::home_dir()?;
289 let path = home.join(".trusty-memory").join("config.toml");
290 if !path.exists() {
291 return Some(LoadedUserConfig::default());
292 }
293 let raw = std::fs::read_to_string(&path).ok()?;
294 let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
295 let model = if parsed.openrouter.model.is_empty() {
296 "anthropic/claude-3-5-sonnet".to_string()
297 } else {
298 parsed.openrouter.model
299 };
300 Some(LoadedUserConfig {
301 openrouter_api_key: parsed.openrouter.api_key,
302 openrouter_model: model,
303 local_model: trusty_common::LocalModelConfig {
304 enabled: parsed.local_model.enabled,
305 base_url: parsed.local_model.base_url,
306 model: parsed.local_model.model,
307 },
308 })
309}
310
311#[derive(Serialize)]
316struct PalaceInfo {
317 id: String,
318 name: String,
319 description: Option<String>,
320 drawer_count: usize,
321 vector_count: usize,
322 kg_triple_count: usize,
323 wing_count: usize,
324 created_at: chrono::DateTime<chrono::Utc>,
325}
326
327fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
337 let (drawer_count, vector_count, kg_triple_count, wing_count) = if let Some(h) = handle {
338 let drawers = h.drawers.read();
339 let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
340 (
341 drawers.len(),
342 h.vector_store.index_size(),
343 h.kg.count_active_triples(),
344 distinct_rooms.len(),
345 )
346 } else {
347 (0, 0, 0, 0)
348 };
349 PalaceInfo {
350 id: palace.id.0.clone(),
351 name: palace.name.clone(),
352 description: palace.description.clone(),
353 drawer_count,
354 vector_count,
355 kg_triple_count,
356 wing_count,
357 created_at: palace.created_at,
358 }
359}
360
361async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
362 let palaces = PalaceRegistry::list_palaces(&state.data_root)
363 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
364 let mut out = Vec::with_capacity(palaces.len());
365 for p in palaces {
366 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
367 out.push(palace_info_from(&p, handle.as_ref()));
368 }
369 Ok(Json(out))
370}
371
372#[derive(Deserialize)]
373struct CreatePalaceBody {
374 name: String,
375 #[serde(default)]
376 description: Option<String>,
377}
378
379async fn create_palace(
380 State(state): State<AppState>,
381 Json(body): Json<CreatePalaceBody>,
382) -> Result<Json<Value>, ApiError> {
383 let name = body.name.trim().to_string();
384 if name.is_empty() {
385 return Err(ApiError::bad_request("name is required"));
386 }
387 let id = PalaceId::new(&name);
388 let palace = Palace {
389 id: id.clone(),
390 name: name.clone(),
391 description: body.description.filter(|s| !s.is_empty()),
392 created_at: chrono::Utc::now(),
393 data_dir: state.data_root.join(&name),
394 };
395 state
396 .registry
397 .create_palace(&state.data_root, palace)
398 .map_err(|e| ApiError::internal(format!("create palace: {e:#}")))?;
399 state.emit(DaemonEvent::PalaceCreated {
400 id: name.clone(),
401 name: name.clone(),
402 });
403 Ok(Json(json!({ "id": name })))
404}
405
406async fn get_palace_handler(
407 State(state): State<AppState>,
408 AxumPath(id): AxumPath<String>,
409) -> Result<Json<PalaceInfo>, ApiError> {
410 let palaces = PalaceRegistry::list_palaces(&state.data_root)
411 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
412 let palace = palaces
413 .into_iter()
414 .find(|p| p.id.0 == id)
415 .ok_or_else(|| ApiError::not_found(format!("palace not found: {id}")))?;
416 let handle = state
417 .registry
418 .open_palace(&state.data_root, &palace.id)
419 .ok();
420 Ok(Json(palace_info_from(&palace, handle.as_ref())))
421}
422
423#[derive(Deserialize)]
428struct ListDrawersQuery {
429 #[serde(default)]
430 room: Option<String>,
431 #[serde(default)]
432 tag: Option<String>,
433 #[serde(default)]
434 limit: Option<usize>,
435}
436
437async fn list_drawers(
438 State(state): State<AppState>,
439 AxumPath(id): AxumPath<String>,
440 Query(q): Query<ListDrawersQuery>,
441) -> Result<Json<Value>, ApiError> {
442 let handle = open_handle(&state, &id)?;
443 let room = q.room.as_deref().map(RoomType::parse);
444 let drawers = handle.list_drawers(room, q.tag.clone(), q.limit.unwrap_or(50));
445 Ok(Json(serde_json::to_value(drawers).unwrap_or(json!([]))))
446}
447
448#[derive(Deserialize)]
449struct CreateDrawerBody {
450 content: String,
451 #[serde(default)]
452 room: Option<String>,
453 #[serde(default)]
454 tags: Vec<String>,
455 #[serde(default)]
456 importance: Option<f32>,
457}
458
459async fn create_drawer(
460 State(state): State<AppState>,
461 AxumPath(id): AxumPath<String>,
462 Json(body): Json<CreateDrawerBody>,
463) -> Result<Json<Value>, ApiError> {
464 let handle = open_handle(&state, &id)?;
465 let room = body
466 .room
467 .as_deref()
468 .map(RoomType::parse)
469 .unwrap_or(RoomType::General);
470 let importance = body.importance.unwrap_or(0.5);
471 let drawer_id = handle
472 .remember(body.content, room, body.tags, importance)
473 .await
474 .map_err(|e| ApiError::internal(format!("remember: {e:#}")))?;
475 let drawer_count = handle.drawers.read().len();
476 state.emit(DaemonEvent::DrawerAdded {
477 palace_id: id.clone(),
478 drawer_count,
479 });
480 state.emit(aggregate_status_event(&state));
481 Ok(Json(json!({ "id": drawer_id })))
482}
483
484async fn delete_drawer(
485 State(state): State<AppState>,
486 AxumPath((id, drawer_id)): AxumPath<(String, String)>,
487) -> Result<StatusCode, ApiError> {
488 let handle = open_handle(&state, &id)?;
489 let uuid = Uuid::parse_str(&drawer_id)
490 .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
491 handle
492 .forget(uuid)
493 .await
494 .map_err(|e| ApiError::internal(format!("forget: {e:#}")))?;
495 let drawer_count = handle.drawers.read().len();
496 state.emit(DaemonEvent::DrawerDeleted {
497 palace_id: id.clone(),
498 drawer_count,
499 });
500 state.emit(aggregate_status_event(&state));
501 Ok(StatusCode::NO_CONTENT)
502}
503
504fn aggregate_status_event(state: &AppState) -> DaemonEvent {
513 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
514 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
515 for p in &palaces {
516 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
517 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
518 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
519 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
520 }
521 }
522 DaemonEvent::StatusChanged {
523 total_drawers,
524 total_vectors,
525 total_kg_triples,
526 }
527}
528
529#[derive(Deserialize)]
534struct RecallQuery {
535 q: String,
536 #[serde(default)]
537 top_k: Option<usize>,
538 #[serde(default)]
539 deep: Option<bool>,
540}
541
542async fn recall_handler(
543 State(state): State<AppState>,
544 AxumPath(id): AxumPath<String>,
545 Query(q): Query<RecallQuery>,
546) -> Result<Json<Value>, ApiError> {
547 let handle = open_handle(&state, &id)?;
548 let top_k = q.top_k.unwrap_or(10);
549 let results = if q.deep.unwrap_or(false) {
550 recall_deep_with_default_embedder(&handle, &q.q, top_k).await
551 } else {
552 recall_with_default_embedder(&handle, &q.q, top_k).await
553 }
554 .map_err(|e| ApiError::internal(format!("recall: {e:#}")))?;
555
556 let payload: Vec<Value> = results
557 .into_iter()
558 .map(|r| {
559 json!({
560 "drawer": r.drawer,
561 "score": r.score,
562 "layer": r.layer,
563 })
564 })
565 .collect();
566 Ok(Json(json!(payload)))
567}
568
569async fn recall_all_handler(
583 State(state): State<AppState>,
584 Query(q): Query<RecallQuery>,
585) -> Result<Json<Value>, ApiError> {
586 let top_k = q.top_k.unwrap_or(10);
587 let deep = q.deep.unwrap_or(false);
588 let value = execute_recall_all(&state, &q.q, top_k, deep).await;
589 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
590 return Err(ApiError::internal(err.to_string()));
591 }
592 Ok(Json(value))
593}
594
595#[derive(Deserialize)]
600struct KgQueryParams {
601 subject: String,
602}
603
604async fn kg_query(
605 State(state): State<AppState>,
606 AxumPath(id): AxumPath<String>,
607 Query(q): Query<KgQueryParams>,
608) -> Result<Json<Vec<Triple>>, ApiError> {
609 let handle = open_handle(&state, &id)?;
610 let triples = handle
611 .kg
612 .query_active(&q.subject)
613 .await
614 .map_err(|e| ApiError::internal(format!("kg query: {e:#}")))?;
615 Ok(Json(triples))
616}
617
618#[derive(Deserialize)]
619struct KgAssertBody {
620 subject: String,
621 predicate: String,
622 object: String,
623 #[serde(default)]
624 confidence: Option<f32>,
625 #[serde(default)]
626 provenance: Option<String>,
627}
628
629async fn kg_assert(
630 State(state): State<AppState>,
631 AxumPath(id): AxumPath<String>,
632 Json(body): Json<KgAssertBody>,
633) -> Result<StatusCode, ApiError> {
634 let handle = open_handle(&state, &id)?;
635 let triple = Triple {
636 subject: body.subject,
637 predicate: body.predicate,
638 object: body.object,
639 valid_from: chrono::Utc::now(),
640 valid_to: None,
641 confidence: body.confidence.unwrap_or(1.0),
642 provenance: body.provenance,
643 };
644 handle
645 .kg
646 .assert(triple)
647 .await
648 .map_err(|e| ApiError::internal(format!("kg assert: {e:#}")))?;
649 Ok(StatusCode::NO_CONTENT)
650}
651
652#[derive(Serialize, Default)]
659struct DreamStatusPayload {
660 last_run_at: Option<chrono::DateTime<chrono::Utc>>,
661 merged: usize,
662 pruned: usize,
663 compacted: usize,
664 closets_updated: usize,
665 duration_ms: u64,
666}
667
668impl From<PersistedDreamStats> for DreamStatusPayload {
669 fn from(p: PersistedDreamStats) -> Self {
670 Self {
671 last_run_at: Some(p.last_run_at),
672 merged: p.stats.merged,
673 pruned: p.stats.pruned,
674 compacted: p.stats.compacted,
675 closets_updated: p.stats.closets_updated,
676 duration_ms: p.stats.duration_ms,
677 }
678 }
679}
680
681async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
690 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
691 let mut out = DreamStatusPayload::default();
692 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
693 for p in palaces {
694 let data_dir = state.data_root.join(p.id.as_str());
695 let snap = match PersistedDreamStats::load(&data_dir) {
696 Ok(Some(s)) => s,
697 _ => continue,
698 };
699 out.merged = out.merged.saturating_add(snap.stats.merged);
700 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
701 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
702 out.closets_updated = out
703 .closets_updated
704 .saturating_add(snap.stats.closets_updated);
705 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
706 latest = match latest {
707 Some(t) if t >= snap.last_run_at => Some(t),
708 _ => Some(snap.last_run_at),
709 };
710 }
711 out.last_run_at = latest;
712 Json(out)
713}
714
715async fn palace_dream_status(
717 State(state): State<AppState>,
718 AxumPath(id): AxumPath<String>,
719) -> Result<Json<DreamStatusPayload>, ApiError> {
720 let data_dir = state.data_root.join(&id);
721 if !data_dir.exists() {
722 return Err(ApiError::not_found(format!("palace not found: {id}")));
723 }
724 let payload = match PersistedDreamStats::load(&data_dir) {
725 Ok(Some(s)) => s.into(),
726 Ok(None) => DreamStatusPayload::default(),
727 Err(e) => return Err(ApiError::internal(format!("read dream stats: {e:#}"))),
728 };
729 Ok(Json(payload))
730}
731
732async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
742 let palaces = PalaceRegistry::list_palaces(&state.data_root)
743 .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
744 let dreamer = Dreamer::new(DreamConfig::default());
745 let mut out = DreamStatusPayload::default();
746 for p in palaces {
747 let handle = match state.registry.open_palace(&state.data_root, &p.id) {
748 Ok(h) => h,
749 Err(e) => {
750 tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
751 continue;
752 }
753 };
754 match dreamer.dream_cycle(&handle).await {
755 Ok(stats) => {
756 out.merged = out.merged.saturating_add(stats.merged);
757 out.pruned = out.pruned.saturating_add(stats.pruned);
758 out.compacted = out.compacted.saturating_add(stats.compacted);
759 out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
760 out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
761 }
762 Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
763 }
764 }
765 out.last_run_at = Some(chrono::Utc::now());
766 state.emit(DaemonEvent::DreamCompleted {
767 palace_id: None,
768 merged: out.merged,
769 pruned: out.pruned,
770 compacted: out.compacted,
771 closets_updated: out.closets_updated,
772 duration_ms: out.duration_ms,
773 });
774 state.emit(aggregate_status_event(&state));
775 Ok(Json(out))
776}
777
778#[derive(Deserialize)]
783struct ChatBody {
784 #[serde(default)]
785 palace_id: Option<String>,
786 message: String,
787 #[serde(default)]
788 history: Vec<ChatMessage>,
789 #[serde(default)]
791 session_id: Option<String>,
792}
793
794const MAX_TOOL_ROUNDS: usize = 10;
800
801fn all_tools() -> Vec<ToolDef> {
810 vec![
811 ToolDef {
812 name: "list_palaces".into(),
813 description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
814 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
815 },
816 ToolDef {
817 name: "get_palace".into(),
818 description: "Get details for a specific palace by id.".into(),
819 parameters: json!({
820 "type": "object",
821 "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
822 "required": ["palace_id"],
823 }),
824 },
825 ToolDef {
826 name: "recall_memories".into(),
827 description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
828 parameters: json!({
829 "type": "object",
830 "properties": {
831 "palace_id": { "type": "string" },
832 "query": { "type": "string", "description": "Free-text query" },
833 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
834 },
835 "required": ["palace_id", "query"],
836 }),
837 },
838 ToolDef {
839 name: "list_drawers".into(),
840 description: "List all drawers (memories) in a palace, most recent first.".into(),
841 parameters: json!({
842 "type": "object",
843 "properties": { "palace_id": { "type": "string" } },
844 "required": ["palace_id"],
845 }),
846 },
847 ToolDef {
848 name: "kg_query".into(),
849 description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
850 parameters: json!({
851 "type": "object",
852 "properties": {
853 "palace_id": { "type": "string" },
854 "subject": { "type": "string" }
855 },
856 "required": ["palace_id", "subject"],
857 }),
858 },
859 ToolDef {
860 name: "get_config".into(),
861 description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
862 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
863 },
864 ToolDef {
865 name: "get_status".into(),
866 description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
867 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
868 },
869 ToolDef {
870 name: "get_dream_status".into(),
871 description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
872 parameters: json!({ "type": "object", "properties": {}, "required": [] }),
873 },
874 ToolDef {
875 name: "get_palace_dream_status".into(),
876 description: "Get dreamer activity stats for a specific palace.".into(),
877 parameters: json!({
878 "type": "object",
879 "properties": { "palace_id": { "type": "string" } },
880 "required": ["palace_id"],
881 }),
882 },
883 ToolDef {
884 name: "create_memory".into(),
885 description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
886 parameters: json!({
887 "type": "object",
888 "properties": {
889 "palace_id": { "type": "string" },
890 "content": { "type": "string", "description": "Verbatim memory text" },
891 "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
892 "tags": { "type": "array", "items": { "type": "string" } },
893 "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
894 },
895 "required": ["palace_id", "content"],
896 }),
897 },
898 ToolDef {
899 name: "kg_assert".into(),
900 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(),
901 parameters: json!({
902 "type": "object",
903 "properties": {
904 "palace_id": { "type": "string" },
905 "subject": { "type": "string" },
906 "predicate": { "type": "string" },
907 "object": { "type": "string" },
908 "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
909 },
910 "required": ["palace_id", "subject", "predicate", "object"],
911 }),
912 },
913 ToolDef {
914 name: "memory_recall_all".into(),
915 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(),
916 parameters: json!({
917 "type": "object",
918 "properties": {
919 "q": { "type": "string", "description": "Free-text query" },
920 "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
921 "deep": { "type": "boolean", "default": false }
922 },
923 "required": ["q"],
924 }),
925 },
926 ]
927}
928
929async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
940 let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
941 match name {
942 "list_palaces" => execute_list_palaces(state).await,
943 "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
944 Some(id) => execute_get_palace(state, id).await,
945 None => json!({ "error": "missing required argument: palace_id" }),
946 },
947 "recall_memories" => {
948 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
949 let q = parsed.get("query").and_then(|v| v.as_str());
950 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
951 match (pid, q) {
952 (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
953 _ => json!({ "error": "missing required argument(s): palace_id, query" }),
954 }
955 }
956 "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
957 Some(id) => execute_list_drawers(state, id).await,
958 None => json!({ "error": "missing required argument: palace_id" }),
959 },
960 "kg_query" => {
961 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
962 let subj = parsed.get("subject").and_then(|v| v.as_str());
963 match (pid, subj) {
964 (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
965 _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
966 }
967 }
968 "get_config" => execute_get_config(state),
969 "get_status" => execute_get_status(state).await,
970 "get_dream_status" => execute_get_dream_status(state).await,
971 "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
972 Some(id) => execute_get_palace_dream_status(state, id).await,
973 None => json!({ "error": "missing required argument: palace_id" }),
974 },
975 "create_memory" => {
976 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
977 let content = parsed.get("content").and_then(|v| v.as_str());
978 let room = parsed.get("room").and_then(|v| v.as_str());
979 let tags: Vec<String> = parsed
980 .get("tags")
981 .and_then(|v| v.as_array())
982 .map(|arr| {
983 arr.iter()
984 .filter_map(|t| t.as_str().map(|s| s.to_string()))
985 .collect()
986 })
987 .unwrap_or_default();
988 let importance = parsed
989 .get("importance")
990 .and_then(|v| v.as_f64())
991 .map(|f| f as f32)
992 .unwrap_or(0.5);
993 match (pid, content) {
994 (Some(p), Some(c)) => {
995 execute_create_memory(state, p, c, room, tags, importance).await
996 }
997 _ => json!({ "error": "missing required argument(s): palace_id, content" }),
998 }
999 }
1000 "kg_assert" => {
1001 let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1002 let subj = parsed.get("subject").and_then(|v| v.as_str());
1003 let pred = parsed.get("predicate").and_then(|v| v.as_str());
1004 let obj = parsed.get("object").and_then(|v| v.as_str());
1005 let conf = parsed
1006 .get("confidence")
1007 .and_then(|v| v.as_f64())
1008 .map(|f| f as f32)
1009 .unwrap_or(1.0);
1010 match (pid, subj, pred, obj) {
1011 (Some(p), Some(s), Some(pr), Some(o)) => {
1012 execute_kg_assert(state, p, s, pr, o, conf).await
1013 }
1014 _ => json!({
1015 "error": "missing required argument(s): palace_id, subject, predicate, object"
1016 }),
1017 }
1018 }
1019 "memory_recall_all" => {
1020 let q = parsed.get("q").and_then(|v| v.as_str());
1021 let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1022 let deep = parsed
1023 .get("deep")
1024 .and_then(|v| v.as_bool())
1025 .unwrap_or(false);
1026 match q {
1027 Some(q) => execute_recall_all(state, q, top_k, deep).await,
1028 None => json!({ "error": "missing required argument: q" }),
1029 }
1030 }
1031 _ => json!({ "error": format!("unknown tool: {name}") }),
1032 }
1033}
1034
1035async fn execute_list_palaces(state: &AppState) -> Value {
1036 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1037 Ok(v) => v,
1038 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1039 };
1040 let out: Vec<Value> = palaces
1041 .into_iter()
1042 .map(|p| {
1043 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1044 let info = palace_info_from(&p, handle.as_ref());
1045 serde_json::to_value(info).unwrap_or(json!({}))
1046 })
1047 .collect();
1048 json!(out)
1049}
1050
1051async fn execute_get_palace(state: &AppState, id: &str) -> Value {
1052 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1053 Ok(v) => v,
1054 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1055 };
1056 match palaces.into_iter().find(|p| p.id.0 == id) {
1057 Some(p) => {
1058 let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1059 serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
1060 }
1061 None => json!({ "error": format!("palace not found: {id}") }),
1062 }
1063}
1064
1065async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
1066 let handle = match state
1067 .registry
1068 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1069 {
1070 Ok(h) => h,
1071 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1072 };
1073 match recall_with_default_embedder(&handle, query, top_k).await {
1074 Ok(hits) => json!(hits
1075 .into_iter()
1076 .map(|r| json!({
1077 "drawer_id": r.drawer.id.to_string(),
1078 "content": r.drawer.content,
1079 "importance": r.drawer.importance,
1080 "tags": r.drawer.tags,
1081 "score": r.score,
1082 "layer": r.layer,
1083 }))
1084 .collect::<Vec<_>>()),
1085 Err(e) => json!({ "error": format!("recall: {e:#}") }),
1086 }
1087}
1088
1089async fn execute_recall_all(state: &AppState, query: &str, top_k: usize, deep: bool) -> Value {
1100 let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1101 Ok(v) => v,
1102 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1103 };
1104 let mut handles = Vec::with_capacity(palaces.len());
1105 for p in &palaces {
1106 match state.registry.open_palace(&state.data_root, &p.id) {
1107 Ok(h) => handles.push(h),
1108 Err(e) => {
1109 tracing::warn!(palace = %p.id, "execute_recall_all: open failed: {e:#}");
1110 }
1111 }
1112 }
1113 if handles.is_empty() {
1114 return json!([]);
1115 }
1116 match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
1117 Ok(results) => json!(results
1118 .into_iter()
1119 .map(|r| json!({
1120 "palace_id": r.palace_id,
1121 "drawer_id": r.result.drawer.id.to_string(),
1122 "content": r.result.drawer.content,
1123 "importance": r.result.drawer.importance,
1124 "tags": r.result.drawer.tags,
1125 "score": r.result.score,
1126 "layer": r.result.layer,
1127 }))
1128 .collect::<Vec<_>>()),
1129 Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
1130 }
1131}
1132
1133async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
1134 let handle = match state
1135 .registry
1136 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1137 {
1138 Ok(h) => h,
1139 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1140 };
1141 let drawers = handle.list_drawers(None, None, 200);
1142 serde_json::to_value(drawers).unwrap_or(json!([]))
1143}
1144
1145async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
1146 let handle = match state
1147 .registry
1148 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1149 {
1150 Ok(h) => h,
1151 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1152 };
1153 match handle.kg.query_active(subject).await {
1154 Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
1155 Err(e) => json!({ "error": format!("kg query: {e:#}") }),
1156 }
1157}
1158
1159fn execute_get_config(state: &AppState) -> Value {
1160 let cfg = load_user_config().unwrap_or_default();
1161 json!({
1162 "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
1163 "openrouter_model": cfg.openrouter_model,
1164 "local_model": {
1165 "enabled": cfg.local_model.enabled,
1166 "base_url": cfg.local_model.base_url,
1167 "model": cfg.local_model.model,
1168 },
1169 "data_root": state.data_root.display().to_string(),
1170 })
1171}
1172
1173async fn execute_get_status(state: &AppState) -> Value {
1174 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1175 let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
1176 for p in &palaces {
1177 if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
1178 total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
1179 total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
1180 total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
1181 }
1182 }
1183 json!({
1184 "version": state.version,
1185 "palace_count": palaces.len(),
1186 "default_palace": state.default_palace,
1187 "data_root": state.data_root.display().to_string(),
1188 "total_drawers": total_drawers,
1189 "total_vectors": total_vectors,
1190 "total_kg_triples": total_kg_triples,
1191 })
1192}
1193
1194async fn execute_get_dream_status(state: &AppState) -> Value {
1195 let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1196 let mut out = DreamStatusPayload::default();
1197 let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1198 for p in palaces {
1199 let data_dir = state.data_root.join(p.id.as_str());
1200 let snap = match PersistedDreamStats::load(&data_dir) {
1201 Ok(Some(s)) => s,
1202 _ => continue,
1203 };
1204 out.merged = out.merged.saturating_add(snap.stats.merged);
1205 out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1206 out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1207 out.closets_updated = out
1208 .closets_updated
1209 .saturating_add(snap.stats.closets_updated);
1210 out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1211 latest = match latest {
1212 Some(t) if t >= snap.last_run_at => Some(t),
1213 _ => Some(snap.last_run_at),
1214 };
1215 }
1216 out.last_run_at = latest;
1217 serde_json::to_value(out).unwrap_or(json!({}))
1218}
1219
1220async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
1221 let data_dir = state.data_root.join(palace_id);
1222 if !data_dir.exists() {
1223 return json!({ "error": format!("palace not found: {palace_id}") });
1224 }
1225 match PersistedDreamStats::load(&data_dir) {
1226 Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
1227 Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
1228 Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
1229 }
1230}
1231
1232async fn execute_create_memory(
1233 state: &AppState,
1234 palace_id: &str,
1235 content: &str,
1236 room: Option<&str>,
1237 tags: Vec<String>,
1238 importance: f32,
1239) -> Value {
1240 let handle = match state
1241 .registry
1242 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1243 {
1244 Ok(h) => h,
1245 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1246 };
1247 let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
1248 match handle
1249 .remember(content.to_string(), room, tags, importance)
1250 .await
1251 {
1252 Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
1253 Err(e) => json!({ "error": format!("remember: {e:#}") }),
1254 }
1255}
1256
1257async fn execute_kg_assert(
1258 state: &AppState,
1259 palace_id: &str,
1260 subject: &str,
1261 predicate: &str,
1262 object: &str,
1263 confidence: f32,
1264) -> Value {
1265 let handle = match state
1266 .registry
1267 .open_palace(&state.data_root, &PalaceId::new(palace_id))
1268 {
1269 Ok(h) => h,
1270 Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1271 };
1272 let triple = Triple {
1273 subject: subject.to_string(),
1274 predicate: predicate.to_string(),
1275 object: object.to_string(),
1276 valid_from: chrono::Utc::now(),
1277 valid_to: None,
1278 confidence,
1279 provenance: Some("chat:assistant".to_string()),
1280 };
1281 match handle.kg.assert(triple).await {
1282 Ok(()) => json!({ "status": "asserted" }),
1283 Err(e) => json!({ "error": format!("kg assert: {e:#}") }),
1284 }
1285}
1286
1287async fn chat_handler(State(state): State<AppState>, Json(body): Json<ChatBody>) -> Response {
1288 let Some(provider) = state.chat_provider().await else {
1290 return (
1291 StatusCode::PRECONDITION_FAILED,
1292 "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
1293 )
1294 .into_response();
1295 };
1296
1297 let palace_id = body
1299 .palace_id
1300 .clone()
1301 .or_else(|| state.default_palace.clone())
1302 .unwrap_or_default();
1303
1304 let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
1306 let store = match state.session_store(&palace_id) {
1307 Ok(s) => s,
1308 Err(e) => {
1309 tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
1310 return (
1311 StatusCode::INTERNAL_SERVER_ERROR,
1312 format!("session store: {e:#}"),
1313 )
1314 .into_response();
1315 }
1316 };
1317 match body.session_id.clone() {
1318 Some(sid) => match store.get_session(&sid) {
1319 Ok(Some(s)) => (
1320 Some(sid),
1321 s.history
1322 .into_iter()
1323 .map(|m| ChatMessage {
1324 role: m.role,
1325 content: m.content,
1326 tool_call_id: None,
1327 tool_calls: None,
1328 })
1329 .collect(),
1330 ),
1331 _ => (Some(sid), body.history.clone()),
1332 },
1333 None => {
1334 let new_id = store.create_session(None).unwrap_or_else(|e| {
1335 tracing::warn!("create_session failed: {e:#}");
1336 String::new()
1337 });
1338 (
1339 if new_id.is_empty() {
1340 None
1341 } else {
1342 Some(new_id)
1343 },
1344 body.history.clone(),
1345 )
1346 }
1347 }
1348 } else {
1349 (None, body.history.clone())
1350 };
1351
1352 let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1355 let palace_count = all_palaces.len();
1356 let palace_roster: String = all_palaces
1357 .iter()
1358 .map(|p| format!("- {} (id: {})", p.name, p.id.0))
1359 .collect::<Vec<_>>()
1360 .join("\n");
1361
1362 let cfg = load_user_config().unwrap_or_default();
1365 let active_provider_name = state
1366 .chat_provider()
1367 .await
1368 .map(|p| p.name().to_string())
1369 .unwrap_or_else(|| "none".to_string());
1370 let dream_snapshot = execute_get_dream_status(&state).await;
1371
1372 let selected_palace_meta = if palace_id.is_empty() {
1375 None
1376 } else {
1377 all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
1378 };
1379
1380 let mut palace_block = String::new();
1381 let mut context = String::new();
1382 let mut palace_display_name = palace_id.clone();
1383
1384 if !palace_id.is_empty() {
1385 if let Ok(handle) = state
1386 .registry
1387 .open_palace(&state.data_root, &PalaceId::new(&palace_id))
1388 {
1389 let drawer_count = handle.drawers.read().len();
1391 let vector_count = handle.vector_store.index_size();
1392 let kg_triple_count = handle.kg.count_active_triples();
1393
1394 let (name, description) = match &selected_palace_meta {
1396 Some(p) => (p.name.clone(), p.description.clone()),
1397 None => (palace_id.clone(), None),
1398 };
1399 palace_display_name = name.clone();
1400
1401 palace_block.push_str(&format!(
1402 "Currently selected palace:\n\
1403 - id: {id}\n\
1404 - name: {name}\n",
1405 id = palace_id,
1406 name = name,
1407 ));
1408 if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
1409 palace_block.push_str(&format!("- description: {desc}\n"));
1410 }
1411 palace_block.push_str(&format!(
1412 "- drawers: {drawer_count}\n\
1413 - vectors: {vector_count}\n\
1414 - kg_triples: {kg_triple_count}\n",
1415 ));
1416 let identity_trimmed = handle.identity.trim();
1417 if !identity_trimmed.is_empty() {
1418 palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
1419 }
1420
1421 if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
1422 for r in hits.iter().take(5) {
1423 context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content));
1424 }
1425 }
1426 }
1427 }
1428
1429 let mut system = String::new();
1433 system.push_str(&format!(
1434 "You are the assistant for trusty-memory, a machine-wide AI memory \
1435 service running locally on this user's machine. trusty-memory stores \
1436 knowledge in named \"palaces\" — isolated memory namespaces, each with \
1437 its own vector index (usearch HNSW) and temporal knowledge graph \
1438 (SQLite). Memories are organized as Palace -> Wing -> Room -> Closet \
1439 -> Drawer, where a Drawer is an atomic memory unit.\n\
1440 There are currently {palace_count} palace(s) on this machine.\n",
1441 ));
1442 if !palace_roster.is_empty() {
1443 system.push_str(&format!("Palaces:\n{palace_roster}\n"));
1444 }
1445 system.push('\n');
1446
1447 system.push_str(&format!(
1449 "System configuration:\n\
1450 - active chat provider: {active_provider_name}\n\
1451 - openrouter model: {or_model}\n\
1452 - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
1453 - data root: {data_root}\n\n",
1454 or_model = cfg.openrouter_model,
1455 local_model = cfg.local_model.model,
1456 local_url = cfg.local_model.base_url,
1457 local_enabled = cfg.local_model.enabled,
1458 data_root = state.data_root.display(),
1459 ));
1460
1461 system.push_str(&format!(
1463 "Global dream status (background memory maintenance):\n{}\n\n",
1464 dream_snapshot,
1465 ));
1466
1467 if !palace_block.is_empty() {
1468 system.push_str(&palace_block);
1469 system.push('\n');
1470 }
1471
1472 if !context.is_empty() {
1473 system.push_str(&format!(
1474 "Relevant memories from the '{palace_display_name}' palace \
1475 (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
1476 {context}\n",
1477 ));
1478 }
1479
1480 system.push_str(
1481 "You have a set of tools to introspect and modify this trusty-memory \
1482 daemon. Prefer calling a tool over guessing — e.g. call \
1483 `list_palaces` rather than relying on the roster above if you need \
1484 live counts, and call `recall_memories` to search for facts you \
1485 don't have in context. When the user asks about \"palaces\", they \
1486 mean trusty-memory palaces (memory namespaces on this machine), not \
1487 architectural palaces like Versailles. If a tool returns an error, \
1488 report it honestly and don't fabricate results.",
1489 );
1490
1491 history.push(ChatMessage {
1493 role: "user".to_string(),
1494 content: body.message.clone(),
1495 tool_call_id: None,
1496 tool_calls: None,
1497 });
1498
1499 let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
1500 messages.push(ChatMessage {
1501 role: "system".to_string(),
1502 content: system,
1503 tool_call_id: None,
1504 tool_calls: None,
1505 });
1506 messages.extend(history.iter().cloned());
1507
1508 let tools = all_tools();
1509 let (sse_tx, sse_rx) =
1510 tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
1511
1512 let session_store = if !palace_id.is_empty() && session_id.is_some() {
1514 state.session_store(&palace_id).ok()
1515 } else {
1516 None
1517 };
1518 let persist_session_id = session_id.clone();
1519
1520 let loop_state = state.clone();
1523 tokio::spawn(async move {
1524 if let Some(sid) = persist_session_id.as_deref() {
1527 let frame = format!("data: {}\n\n", json!({ "session_id": sid }));
1528 if sse_tx
1529 .send(Ok(axum::body::Bytes::from(frame)))
1530 .await
1531 .is_err()
1532 {
1533 return;
1534 }
1535 }
1536
1537 let mut final_assistant_text = String::new();
1538 let mut stream_err: Option<String> = None;
1539
1540 for round in 0..MAX_TOOL_ROUNDS {
1541 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
1542 let messages_clone = messages.clone();
1543 let tools_clone = tools.clone();
1544 let provider_clone = provider.clone();
1545 let stream_handle = tokio::spawn(async move {
1546 provider_clone
1547 .chat_stream(messages_clone, tools_clone, event_tx)
1548 .await
1549 });
1550
1551 let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
1552 let mut round_assistant_text = String::new();
1553
1554 while let Some(event) = event_rx.recv().await {
1555 match event {
1556 ChatEvent::Delta(text) => {
1557 round_assistant_text.push_str(&text);
1558 let frame = format!("data: {}\n\n", json!({ "delta": text }));
1559 if sse_tx
1560 .send(Ok(axum::body::Bytes::from(frame)))
1561 .await
1562 .is_err()
1563 {
1564 return;
1565 }
1566 }
1567 ChatEvent::ToolCall(tc) => {
1568 let frame = format!(
1569 "data: {}\n\n",
1570 json!({ "tool_call": {
1571 "id": tc.id,
1572 "name": tc.name,
1573 "arguments": tc.arguments,
1574 }})
1575 );
1576 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
1577 tool_calls_this_round.push(tc);
1578 }
1579 ChatEvent::Done => break,
1580 ChatEvent::Error(e) => {
1581 stream_err = Some(e);
1582 break;
1583 }
1584 }
1585 }
1586
1587 match stream_handle.await {
1589 Ok(Ok(())) => {}
1590 Ok(Err(e)) => stream_err = Some(e.to_string()),
1591 Err(e) => stream_err = Some(format!("join: {e}")),
1592 }
1593
1594 if stream_err.is_some() {
1595 break;
1596 }
1597
1598 final_assistant_text.push_str(&round_assistant_text);
1599
1600 if tool_calls_this_round.is_empty() {
1601 break;
1603 }
1604
1605 let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
1607 .iter()
1608 .map(|tc| {
1609 json!({
1610 "id": tc.id,
1611 "type": "function",
1612 "function": { "name": tc.name, "arguments": tc.arguments },
1613 })
1614 })
1615 .collect();
1616 messages.push(ChatMessage {
1617 role: "assistant".to_string(),
1618 content: round_assistant_text,
1619 tool_call_id: None,
1620 tool_calls: Some(assistant_tool_calls_json),
1621 });
1622
1623 for tc in &tool_calls_this_round {
1626 let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
1627 let result_str = result.to_string();
1628 let frame = format!(
1629 "data: {}\n\n",
1630 json!({ "tool_result": {
1631 "id": tc.id,
1632 "name": tc.name,
1633 "content": &result_str,
1634 }})
1635 );
1636 let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
1637 messages.push(ChatMessage {
1638 role: "tool".to_string(),
1639 content: result_str,
1640 tool_call_id: Some(tc.id.clone()),
1641 tool_calls: None,
1642 });
1643 }
1644
1645 if round + 1 == MAX_TOOL_ROUNDS {
1647 tracing::warn!(
1648 "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
1649 MAX_TOOL_ROUNDS
1650 );
1651 }
1652 }
1653
1654 if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
1657 if !final_assistant_text.is_empty() {
1658 history.push(ChatMessage {
1659 role: "assistant".into(),
1660 content: final_assistant_text,
1661 tool_call_id: None,
1662 tool_calls: None,
1663 });
1664 }
1665 let core_history: Vec<trusty_memory_core::store::chat_sessions::ChatMessage> = history
1666 .iter()
1667 .map(|m| trusty_memory_core::store::chat_sessions::ChatMessage {
1668 role: m.role.clone(),
1669 content: m.content.clone(),
1670 })
1671 .collect();
1672 if let Err(e) = store.upsert_session(sid, &core_history) {
1673 tracing::warn!("upsert_session failed: {e:#}");
1674 }
1675 }
1676
1677 match stream_err {
1678 None => {
1679 let _ = sse_tx
1680 .send(Ok(axum::body::Bytes::from("data: [DONE]\n\n")))
1681 .await;
1682 }
1683 Some(e) => {
1684 let out = format!("data: {}\n\n", json!({ "error": e }));
1685 let _ = sse_tx.send(Ok(axum::body::Bytes::from(out))).await;
1686 }
1687 }
1688 });
1689
1690 let stream = tokio_stream::wrappers::ReceiverStream::new(sse_rx);
1691
1692 Response::builder()
1693 .header("Content-Type", "text/event-stream")
1694 .header("Cache-Control", "no-cache")
1695 .body(Body::from_stream(stream))
1696 .expect("static SSE response builds")
1697}
1698
1699async fn list_providers(State(state): State<AppState>) -> Json<Value> {
1712 let cfg = load_user_config().unwrap_or_default();
1713 let ollama_available = if cfg.local_model.enabled {
1714 trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
1715 .await
1716 .is_some()
1717 } else {
1718 false
1719 };
1720 let openrouter_available = !cfg.openrouter_api_key.is_empty();
1721 let active = state.chat_provider().await.map(|p| p.name().to_string());
1722 Json(json!({
1723 "providers": [
1724 {
1725 "name": "ollama",
1726 "model": cfg.local_model.model,
1727 "available": ollama_available,
1728 },
1729 {
1730 "name": "openrouter",
1731 "model": cfg.openrouter_model,
1732 "available": openrouter_available,
1733 }
1734 ],
1735 "active": active,
1736 }))
1737}
1738
1739#[derive(Deserialize, Default)]
1740struct CreateSessionBody {
1741 #[serde(default)]
1742 title: Option<String>,
1743}
1744
1745async fn create_chat_session(
1746 State(state): State<AppState>,
1747 AxumPath(id): AxumPath<String>,
1748 body: Option<Json<CreateSessionBody>>,
1749) -> Result<Json<Value>, ApiError> {
1750 let store = state
1751 .session_store(&id)
1752 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1753 let title = body.and_then(|b| b.0.title);
1754 let sid = store
1755 .create_session(title)
1756 .map_err(|e| ApiError::internal(format!("create session: {e:#}")))?;
1757 Ok(Json(json!({ "id": sid })))
1758}
1759
1760async fn list_chat_sessions(
1761 State(state): State<AppState>,
1762 AxumPath(id): AxumPath<String>,
1763) -> Result<Json<Value>, ApiError> {
1764 let store = state
1765 .session_store(&id)
1766 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1767 let metas = store
1768 .list_sessions()
1769 .map_err(|e| ApiError::internal(format!("list sessions: {e:#}")))?;
1770 Ok(Json(serde_json::to_value(metas).unwrap_or(json!([]))))
1771}
1772
1773async fn get_chat_session(
1774 State(state): State<AppState>,
1775 AxumPath((id, session_id)): AxumPath<(String, String)>,
1776) -> Result<Json<Value>, ApiError> {
1777 let store = state
1778 .session_store(&id)
1779 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1780 let s = store
1781 .get_session(&session_id)
1782 .map_err(|e| ApiError::internal(format!("get session: {e:#}")))?
1783 .ok_or_else(|| ApiError::not_found(format!("session not found: {session_id}")))?;
1784 Ok(Json(serde_json::to_value(s).unwrap_or(json!({}))))
1785}
1786
1787async fn delete_chat_session(
1788 State(state): State<AppState>,
1789 AxumPath((id, session_id)): AxumPath<(String, String)>,
1790) -> Result<StatusCode, ApiError> {
1791 let store = state
1792 .session_store(&id)
1793 .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1794 store
1795 .delete_session(&session_id)
1796 .map_err(|e| ApiError::internal(format!("delete session: {e:#}")))?;
1797 Ok(StatusCode::NO_CONTENT)
1798}
1799
1800fn open_handle(
1805 state: &AppState,
1806 id: &str,
1807) -> Result<std::sync::Arc<trusty_memory_core::PalaceHandle>, ApiError> {
1808 state
1809 .registry
1810 .open_palace(&state.data_root, &PalaceId::new(id))
1811 .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
1812}
1813
1814struct ApiError {
1816 status: StatusCode,
1817 message: String,
1818}
1819
1820impl ApiError {
1821 fn bad_request(msg: impl Into<String>) -> Self {
1822 Self {
1823 status: StatusCode::BAD_REQUEST,
1824 message: msg.into(),
1825 }
1826 }
1827 fn not_found(msg: impl Into<String>) -> Self {
1828 Self {
1829 status: StatusCode::NOT_FOUND,
1830 message: msg.into(),
1831 }
1832 }
1833 fn internal(msg: impl Into<String>) -> Self {
1834 Self {
1835 status: StatusCode::INTERNAL_SERVER_ERROR,
1836 message: msg.into(),
1837 }
1838 }
1839}
1840
1841impl IntoResponse for ApiError {
1842 fn into_response(self) -> Response {
1843 (self.status, Json(json!({ "error": self.message }))).into_response()
1844 }
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849 use super::*;
1850 use axum::body::to_bytes;
1851 use axum::http::Request;
1852 use tower::util::ServiceExt;
1853
1854 fn test_state() -> AppState {
1855 let tmp = tempfile::tempdir().expect("tempdir");
1856 let root = tmp.path().to_path_buf();
1857 std::mem::forget(tmp);
1858 AppState::new(root)
1859 }
1860
1861 #[tokio::test]
1862 async fn health_endpoint_returns_ok() {
1863 let state = test_state();
1864 let app = router().with_state(state);
1865 let resp = app
1866 .oneshot(
1867 Request::builder()
1868 .uri("/health")
1869 .body(Body::empty())
1870 .unwrap(),
1871 )
1872 .await
1873 .unwrap();
1874 assert_eq!(resp.status(), StatusCode::OK);
1875 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1876 let v: Value = serde_json::from_slice(&bytes).unwrap();
1877 assert_eq!(v["status"], "ok");
1878 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1879 }
1880
1881 #[tokio::test]
1882 async fn status_endpoint_returns_payload() {
1883 let state = test_state();
1884 let app = router().with_state(state);
1885 let resp = app
1886 .oneshot(
1887 Request::builder()
1888 .uri("/api/v1/status")
1889 .body(Body::empty())
1890 .unwrap(),
1891 )
1892 .await
1893 .unwrap();
1894 assert_eq!(resp.status(), StatusCode::OK);
1895 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1896 let v: Value = serde_json::from_slice(&bytes).unwrap();
1897 assert!(v["version"].is_string());
1898 assert_eq!(v["palace_count"], 0);
1899 }
1900
1901 #[tokio::test]
1902 async fn unknown_api_returns_404() {
1903 let state = test_state();
1904 let app = router().with_state(state);
1905 let resp = app
1906 .oneshot(
1907 Request::builder()
1908 .uri("/api/v1/does-not-exist")
1909 .body(Body::empty())
1910 .unwrap(),
1911 )
1912 .await
1913 .unwrap();
1914 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1915 }
1916
1917 #[tokio::test]
1918 async fn create_then_list_palace() {
1919 let state = test_state();
1920 let app = router().with_state(state.clone());
1921 let body = json!({"name": "web-test", "description": "from test"}).to_string();
1922 let resp = app
1923 .clone()
1924 .oneshot(
1925 Request::builder()
1926 .method("POST")
1927 .uri("/api/v1/palaces")
1928 .header("content-type", "application/json")
1929 .body(Body::from(body))
1930 .unwrap(),
1931 )
1932 .await
1933 .unwrap();
1934 assert_eq!(resp.status(), StatusCode::OK);
1935
1936 let resp = app
1937 .oneshot(
1938 Request::builder()
1939 .uri("/api/v1/palaces")
1940 .body(Body::empty())
1941 .unwrap(),
1942 )
1943 .await
1944 .unwrap();
1945 assert_eq!(resp.status(), StatusCode::OK);
1946 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
1947 let v: Value = serde_json::from_slice(&bytes).unwrap();
1948 let arr = v.as_array().expect("array");
1949 assert!(arr.iter().any(|p| p["id"] == "web-test"));
1950 }
1951
1952 #[tokio::test]
1959 async fn status_includes_total_counters() {
1960 let state = test_state();
1961 let app = router().with_state(state);
1962 let resp = app
1963 .oneshot(
1964 Request::builder()
1965 .uri("/api/v1/status")
1966 .body(Body::empty())
1967 .unwrap(),
1968 )
1969 .await
1970 .unwrap();
1971 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
1972 let v: Value = serde_json::from_slice(&bytes).unwrap();
1973 assert_eq!(v["total_drawers"], 0);
1974 assert_eq!(v["total_vectors"], 0);
1975 assert_eq!(v["total_kg_triples"], 0);
1976 }
1977
1978 #[tokio::test]
1985 async fn dream_status_empty_returns_nulls() {
1986 let state = test_state();
1987 let app = router().with_state(state);
1988 let resp = app
1989 .oneshot(
1990 Request::builder()
1991 .uri("/api/v1/dream/status")
1992 .body(Body::empty())
1993 .unwrap(),
1994 )
1995 .await
1996 .unwrap();
1997 assert_eq!(resp.status(), StatusCode::OK);
1998 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
1999 let v: Value = serde_json::from_slice(&bytes).unwrap();
2000 assert!(v["last_run_at"].is_null());
2001 assert_eq!(v["merged"], 0);
2002 assert_eq!(v["pruned"], 0);
2003 }
2004
2005 #[tokio::test]
2012 async fn providers_endpoint_returns_payload() {
2013 let state = test_state();
2014 let app = router().with_state(state);
2015 let resp = app
2016 .oneshot(
2017 Request::builder()
2018 .uri("/api/v1/chat/providers")
2019 .body(Body::empty())
2020 .unwrap(),
2021 )
2022 .await
2023 .unwrap();
2024 assert_eq!(resp.status(), StatusCode::OK);
2025 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2026 let v: Value = serde_json::from_slice(&bytes).unwrap();
2027 let arr = v["providers"].as_array().expect("providers array");
2028 assert_eq!(arr.len(), 2);
2029 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2030 assert!(names.contains(&"ollama"));
2031 assert!(names.contains(&"openrouter"));
2032 assert!(v.get("active").is_some());
2034 }
2035
2036 #[tokio::test]
2043 async fn chat_session_crud_round_trip() {
2044 let state = test_state();
2045 let palace = trusty_memory_core::Palace {
2047 id: PalaceId::new("sess-test"),
2048 name: "sess-test".to_string(),
2049 description: None,
2050 created_at: chrono::Utc::now(),
2051 data_dir: state.data_root.join("sess-test"),
2052 };
2053 state
2054 .registry
2055 .create_palace(&state.data_root, palace)
2056 .expect("create_palace");
2057 let app = router().with_state(state);
2058
2059 let resp = app
2061 .clone()
2062 .oneshot(
2063 Request::builder()
2064 .method("POST")
2065 .uri("/api/v1/palaces/sess-test/chat/sessions")
2066 .header("content-type", "application/json")
2067 .body(Body::from(json!({"title":"first chat"}).to_string()))
2068 .unwrap(),
2069 )
2070 .await
2071 .unwrap();
2072 assert_eq!(resp.status(), StatusCode::OK);
2073 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2074 let v: Value = serde_json::from_slice(&bytes).unwrap();
2075 let sid = v["id"].as_str().expect("session id").to_string();
2076
2077 let resp = app
2079 .clone()
2080 .oneshot(
2081 Request::builder()
2082 .uri("/api/v1/palaces/sess-test/chat/sessions")
2083 .body(Body::empty())
2084 .unwrap(),
2085 )
2086 .await
2087 .unwrap();
2088 assert_eq!(resp.status(), StatusCode::OK);
2089 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2090 let v: Value = serde_json::from_slice(&bytes).unwrap();
2091 let arr = v.as_array().expect("array");
2092 assert!(arr.iter().any(|s| s["id"] == sid));
2093
2094 let resp = app
2096 .clone()
2097 .oneshot(
2098 Request::builder()
2099 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2100 .body(Body::empty())
2101 .unwrap(),
2102 )
2103 .await
2104 .unwrap();
2105 assert_eq!(resp.status(), StatusCode::OK);
2106
2107 let resp = app
2109 .clone()
2110 .oneshot(
2111 Request::builder()
2112 .method("DELETE")
2113 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2114 .body(Body::empty())
2115 .unwrap(),
2116 )
2117 .await
2118 .unwrap();
2119 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2120
2121 let resp = app
2123 .oneshot(
2124 Request::builder()
2125 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2126 .body(Body::empty())
2127 .unwrap(),
2128 )
2129 .await
2130 .unwrap();
2131 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2132 }
2133
2134 #[test]
2141 fn all_tools_returns_expected_set() {
2142 let tools = all_tools();
2143 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
2144 assert_eq!(
2145 names,
2146 vec![
2147 "list_palaces",
2148 "get_palace",
2149 "recall_memories",
2150 "list_drawers",
2151 "kg_query",
2152 "get_config",
2153 "get_status",
2154 "get_dream_status",
2155 "get_palace_dream_status",
2156 "create_memory",
2157 "kg_assert",
2158 "memory_recall_all",
2159 ]
2160 );
2161 for t in &tools {
2164 assert_eq!(
2165 t.parameters["type"], "object",
2166 "tool {} schema type",
2167 t.name
2168 );
2169 assert!(
2170 t.parameters["required"].is_array(),
2171 "tool {} required not array",
2172 t.name
2173 );
2174 }
2175 }
2176
2177 #[tokio::test]
2184 async fn execute_tool_dispatches_known_tools() {
2185 let state = test_state();
2186 let result = execute_tool("list_palaces", "{}", &state).await;
2187 assert!(
2188 result.is_array(),
2189 "list_palaces should be array, got {result}"
2190 );
2191 assert_eq!(result.as_array().unwrap().len(), 0);
2192
2193 let unknown = execute_tool("not_a_tool", "{}", &state).await;
2194 assert!(
2195 unknown["error"]
2196 .as_str()
2197 .unwrap_or("")
2198 .contains("unknown tool"),
2199 "expected unknown-tool error, got {unknown}"
2200 );
2201
2202 let missing = execute_tool("get_palace", "{}", &state).await;
2203 assert!(
2204 missing["error"]
2205 .as_str()
2206 .unwrap_or("")
2207 .contains("palace_id"),
2208 "expected missing-arg error, got {missing}"
2209 );
2210 }
2211
2212 #[tokio::test]
2221 async fn sse_broadcast_emits_palace_created() {
2222 let state = test_state();
2223 let mut rx = state.events.subscribe();
2224 let app = router().with_state(state.clone());
2225 let body = json!({"name": "sse-test"}).to_string();
2226 let resp = app
2227 .oneshot(
2228 Request::builder()
2229 .method("POST")
2230 .uri("/api/v1/palaces")
2231 .header("content-type", "application/json")
2232 .body(Body::from(body))
2233 .unwrap(),
2234 )
2235 .await
2236 .unwrap();
2237 assert_eq!(resp.status(), StatusCode::OK);
2238 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
2240 .await
2241 .expect("event received within timeout")
2242 .expect("event channel still open");
2243 match event {
2244 DaemonEvent::PalaceCreated { id, name } => {
2245 assert_eq!(id, "sse-test");
2246 assert_eq!(name, "sse-test");
2247 }
2248 other => panic!("expected PalaceCreated, got {other:?}"),
2249 }
2250 }
2251
2252 #[tokio::test]
2259 async fn sse_endpoint_emits_connected_frame() {
2260 use axum::routing::get;
2261 let state = test_state();
2262 let app = router()
2263 .route("/sse", get(crate::sse_handler))
2264 .with_state(state);
2265 let resp = app
2266 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
2267 .await
2268 .unwrap();
2269 assert_eq!(resp.status(), StatusCode::OK);
2270 assert_eq!(
2271 resp.headers()
2272 .get(header::CONTENT_TYPE)
2273 .and_then(|v| v.to_str().ok()),
2274 Some("text/event-stream")
2275 );
2276 let body = resp.into_body();
2279 let bytes =
2280 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
2281 .await
2282 .ok()
2283 .and_then(|r| r.ok())
2284 .unwrap_or_default();
2285 let text = String::from_utf8_lossy(&bytes);
2286 assert!(
2287 text.contains("\"type\":\"connected\""),
2288 "expected connected frame, got: {text}"
2289 );
2290 }
2291
2292 #[tokio::test]
2302 async fn dream_status_aggregates_across_palaces() {
2303 use trusty_memory_core::dream::{DreamStats, PersistedDreamStats};
2304
2305 let state = test_state();
2306 for (id, stats, ts) in [
2310 (
2311 "palace-a",
2312 DreamStats {
2313 merged: 1,
2314 pruned: 2,
2315 compacted: 3,
2316 closets_updated: 4,
2317 duration_ms: 100,
2318 },
2319 chrono::Utc::now() - chrono::Duration::seconds(60),
2320 ),
2321 (
2322 "palace-b",
2323 DreamStats {
2324 merged: 10,
2325 pruned: 20,
2326 compacted: 30,
2327 closets_updated: 40,
2328 duration_ms: 200,
2329 },
2330 chrono::Utc::now(),
2331 ),
2332 ] {
2333 let palace = trusty_memory_core::Palace {
2334 id: PalaceId::new(id),
2335 name: id.to_string(),
2336 description: None,
2337 created_at: chrono::Utc::now(),
2338 data_dir: state.data_root.join(id),
2339 };
2340 state
2341 .registry
2342 .create_palace(&state.data_root, palace)
2343 .expect("create palace");
2344 let persisted = PersistedDreamStats {
2345 last_run_at: ts,
2346 stats,
2347 };
2348 persisted
2349 .save(&state.data_root.join(id))
2350 .expect("save dream stats");
2351 }
2352
2353 let later = chrono::Utc::now();
2354 let app = router().with_state(state);
2355 let resp = app
2356 .oneshot(
2357 Request::builder()
2358 .uri("/api/v1/dream/status")
2359 .body(Body::empty())
2360 .unwrap(),
2361 )
2362 .await
2363 .unwrap();
2364 assert_eq!(resp.status(), StatusCode::OK);
2365 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2366 let v: Value = serde_json::from_slice(&bytes).unwrap();
2367
2368 assert_eq!(v["merged"], 11);
2370 assert_eq!(v["pruned"], 22);
2371 assert_eq!(v["compacted"], 33);
2372 assert_eq!(v["closets_updated"], 44);
2373 assert_eq!(v["duration_ms"], 300);
2374
2375 let last = v["last_run_at"].as_str().expect("last_run_at is string");
2377 let parsed: chrono::DateTime<chrono::Utc> = last
2378 .parse()
2379 .expect("last_run_at parses as RFC3339 timestamp");
2380 assert!(
2381 parsed <= later,
2382 "last_run_at ({parsed}) should not exceed wall clock ({later})"
2383 );
2384 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
2386 assert!(
2387 parsed >= cutoff,
2388 "expected the newer (palace-b) timestamp; got {parsed}"
2389 );
2390 }
2391
2392 #[tokio::test]
2404 async fn dream_run_aggregates_stats() {
2405 let state = test_state();
2406 let palace = trusty_memory_core::Palace {
2407 id: PalaceId::new("dream-run-test"),
2408 name: "dream-run-test".to_string(),
2409 description: None,
2410 created_at: chrono::Utc::now(),
2411 data_dir: state.data_root.join("dream-run-test"),
2412 };
2413 state
2414 .registry
2415 .create_palace(&state.data_root, palace)
2416 .expect("create palace");
2417
2418 let app = router().with_state(state);
2419 let resp = app
2420 .oneshot(
2421 Request::builder()
2422 .method("POST")
2423 .uri("/api/v1/dream/run")
2424 .body(Body::empty())
2425 .unwrap(),
2426 )
2427 .await
2428 .unwrap();
2429 assert_eq!(resp.status(), StatusCode::OK);
2430 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2431 let v: Value = serde_json::from_slice(&bytes).unwrap();
2432
2433 for key in [
2436 "merged",
2437 "pruned",
2438 "compacted",
2439 "closets_updated",
2440 "duration_ms",
2441 ] {
2442 assert!(
2443 v.get(key).is_some(),
2444 "missing key {key} in dream_run payload: {v}"
2445 );
2446 assert!(
2447 v[key].is_u64() || v[key].is_i64(),
2448 "{key} should be integer, got {}",
2449 v[key]
2450 );
2451 }
2452 assert!(
2453 v["last_run_at"].is_string(),
2454 "last_run_at must be set by dream_run; got {v}"
2455 );
2456 }
2457
2458 #[tokio::test]
2459 async fn serves_index_html_fallback() {
2460 let state = test_state();
2461 let app = router().with_state(state);
2462 let resp = app
2463 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2464 .await
2465 .unwrap();
2466 assert!(
2468 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
2469 "got {}",
2470 resp.status()
2471 );
2472 }
2473}