Skip to main content

trusty_memory/
web.rs

1//! HTTP API + embedded SPA shell for the trusty-memory admin UI.
2//!
3//! Why: The web admin panel is the primary GUI for non-MCP clients. Bundling
4//! the Svelte build via `rust-embed` keeps deployment to "drop the binary on
5//! a host"; the JSON API surface mirrors the MCP tool set so anything
6//! trusty-memory can do via Claude Code can also be done via curl or browser.
7//! What: All `/api/v1/*` handlers (status, palaces, drawers, recall, KG,
8//! config, chat) plus an embedded-asset fallback that serves `ui/dist/`.
9//! Test: `cargo test -p trusty-memory-mcp web::tests` covers the asset
10//! fallback and JSON shape of every read endpoint against an in-memory
11//! palace built on a `tempdir`.
12
13use 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/// Embedded UI assets produced by `pnpm build` in `ui/`.
39///
40/// Why: Single-binary deploys with no separate static-file dance. `build.rs`
41/// runs the Vite build before compilation so this folder is always populated.
42/// What: All files under `ui/dist/` are included in the binary.
43/// Test: `serves_index_html` confirms the SPA shell loads.
44#[derive(RustEmbed)]
45// Monorepo migration: upstream trusty-memory put the Svelte UI at the repo
46// root (`ui/dist/`), so the original path was `$CARGO_MANIFEST_DIR/../../ui/dist/`.
47// In the trusty-tools monorepo we keep the UI inside the crate to avoid
48// polluting the workspace root with per-crate asset directories.
49#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
50struct WebAssets;
51
52/// Build the public router with API routes + SPA asset fallback.
53///
54/// Why: `run_http` calls this so the same router shape is used in tests.
55/// What: All API routes under `/api/v1`, fallback to the SPA shell.
56/// Test: `serves_index_html` and `status_endpoint_returns_payload`.
57pub fn router() -> Router<AppState> {
58    // axum 0.8 path syntax uses `{param}` instead of `:param`. The shared
59    // `trusty_common::server::with_standard_middleware` layer brings in CORS,
60    // tracing, and gzip (with SSE excluded) so we don't drift from sibling
61    // trusty-* daemons.
62    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// ---------------------------------------------------------------------------
101// Health check
102// ---------------------------------------------------------------------------
103
104/// Liveness/version payload for `GET /health`.
105///
106/// Why: `daemon_probe` requires an HTTP 200 from `/health` to confirm that the
107/// port is owned by this daemon (and not a stale or foreign process).
108/// What: Carries a fixed `status` string plus the compile-time crate version.
109/// Test: Asserted by `health_endpoint_returns_ok` in this module's tests.
110#[derive(serde::Serialize)]
111struct HealthResponse {
112    status: &'static str,
113    version: &'static str,
114}
115
116/// `GET /health` — unauthenticated liveness probe.
117///
118/// Why: Gives `daemon_probe` and external monitors a cheap way to confirm port
119/// ownership without touching palace state.
120/// What: Returns HTTP 200 with `{"status":"ok","version":"<crate version>"}`.
121/// Test: `health_endpoint_returns_ok` drives this through the router.
122async fn health() -> Json<HealthResponse> {
123    Json(HealthResponse {
124        status: "ok",
125        version: env!("CARGO_PKG_VERSION"),
126    })
127}
128
129// ---------------------------------------------------------------------------
130// Static asset serving
131// ---------------------------------------------------------------------------
132
133/// Serve any embedded asset; fall back to `index.html` for SPA routes.
134///
135/// Why: Hash-based routing lives client-side, but `/assets/foo.js` etc. must
136/// resolve to the embedded file directly.
137/// What: Looks up the request path under `WebAssets`; if absent, returns
138/// `index.html`. Unknown paths under `/api/` return 404.
139/// Test: `serves_index_html`, `serves_static_asset`, `unknown_api_404`.
140async 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        // SPA fallback.
149        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// ---------------------------------------------------------------------------
169// /api/v1/status, /api/v1/config
170// ---------------------------------------------------------------------------
171
172#[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/// Minimal mirror of the user-config schema (the real type lives in the bin
222/// crate; replicating just the fields we need here avoids a cyclic dep).
223#[derive(Deserialize, Default, Clone)]
224struct UserConfigMin {
225    #[serde(default)]
226    openrouter: OpenRouterMin,
227    #[serde(default)]
228    local_model: LocalModelMin,
229    // Carry forward unknown sections by ignoring them on parse.
230}
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// ---------------------------------------------------------------------------
312// /api/v1/palaces
313// ---------------------------------------------------------------------------
314
315#[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
327/// Build a `PalaceInfo` from a `Palace` row plus an optional opened handle.
328///
329/// Why: Both `list_palaces` and `get_palace_handler` need the same enriched
330/// shape; centralizing the field-pulling avoids drift.
331/// What: Reads drawer count, vector index size, active KG triple count, and
332/// derives wing_count from the number of distinct `room_id`s in the drawer
333/// table (until a dedicated wings/rooms table exists, distinct rooms-by-drawer
334/// is the closest proxy).
335/// Test: `palace_list_includes_richer_counts`.
336fn 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// ---------------------------------------------------------------------------
424// Drawers
425// ---------------------------------------------------------------------------
426
427#[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
504/// Compute the current aggregate `StatusChanged` event by walking all palaces.
505///
506/// Why: Several mutating handlers (drawer add/delete, dream run) need to push
507/// a refreshed status snapshot so dashboard stat cards stay in sync without
508/// the SPA having to issue an extra `/api/v1/status` request.
509/// What: Mirrors the math in the `status` handler — sums drawer count,
510/// vector index size, and active KG triples across every persisted palace.
511/// Test: Indirectly via the SSE integration tests that observe the event.
512fn 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// ---------------------------------------------------------------------------
530// Recall
531// ---------------------------------------------------------------------------
532
533#[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
569/// `GET /api/v1/recall?q=<query>&top_k=<n>&deep=<bool>` — cross-palace semantic
570/// search.
571///
572/// Why: Agents and dashboard widgets often need the most relevant memories
573/// regardless of palace boundary; forcing the caller to issue one request per
574/// palace and merge client-side is both slower (no fan-out) and wrong (no
575/// dedup/rerank). Serving the merged top-k from the daemon collapses the
576/// round-trip and reuses the shared embedder singleton.
577/// What: Lists all palaces, opens each (skipping any that fail to open with a
578/// warning), and delegates to `execute_recall_all`. Returns a JSON array of
579/// `{ palace_id, drawer, score, layer }` entries sorted by score descending.
580/// Test: Exercised via `execute_recall_all` directly and through the MCP
581/// `memory_recall_all` tool dispatch.
582async 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// ---------------------------------------------------------------------------
596// Knowledge Graph
597// ---------------------------------------------------------------------------
598
599#[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// ---------------------------------------------------------------------------
653// Dream cycle status + on-demand run
654// ---------------------------------------------------------------------------
655
656/// Wire payload for dream status endpoints — `last_run_at` may be null when no
657/// cycle has run yet on this palace (or the aggregate has nothing to report).
658#[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
681/// GET /api/v1/dream/status — aggregate latest dream stats across all palaces.
682///
683/// Why: The dashboard wants a single "last dream cycle" panel rather than
684/// per-palace details; we sum the per-palace counters and surface the most
685/// recent `last_run_at` so operators can spot a stalled background loop.
686/// What: Walks every palace, loads its `dream_stats.json` if present, sums
687/// counts, and returns the max `last_run_at` (or null if no palace has run).
688/// Test: `dream_status_aggregates_across_palaces` covers the read path.
689async 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
715/// GET /api/v1/palaces/:id/dream/status — per-palace dream stats snapshot.
716async 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
732/// POST /api/v1/dream/run — run a dream cycle across all palaces on demand.
733///
734/// Why: The dashboard exposes a "Run now" button so operators can force a
735/// cycle without waiting for the idle clock; useful after a bulk ingest or
736/// when diagnosing the dream loop itself.
737/// What: Opens every persisted palace, runs `Dreamer::dream_cycle` with the
738/// default config, and returns the aggregated stats plus the run timestamp.
739/// Errors on individual palaces are logged but don't abort the sweep.
740/// Test: `dream_run_aggregates_stats` covers the round-trip.
741async 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// ---------------------------------------------------------------------------
779// Chat (OpenRouter, SSE-streaming)
780// ---------------------------------------------------------------------------
781
782#[derive(Deserialize)]
783struct ChatBody {
784    #[serde(default)]
785    palace_id: Option<String>,
786    message: String,
787    #[serde(default)]
788    history: Vec<ChatMessage>,
789    /// Optional existing chat-session id; when provided we load+append+save.
790    #[serde(default)]
791    session_id: Option<String>,
792}
793
794/// Hard cap on the number of `tool -> assistant` round trips per chat turn.
795///
796/// Why: Without a bound, a malicious or confused model could request tools
797/// indefinitely; 10 is generous enough for any realistic plan-and-act loop
798/// while still terminating quickly when the model gets stuck.
799const MAX_TOOL_ROUNDS: usize = 10;
800
801/// Build the complete set of tool definitions the chat assistant can call.
802///
803/// Why: Centralizing the tool surface keeps the wire schema, the dispatcher in
804/// `execute_tool`, and the system prompt in lock-step — adding a new tool means
805/// editing this one function plus a match arm.
806/// What: Returns the 11 read/write tools spanning palace introspection,
807/// memory recall/create, KG read/write, and daemon status.
808/// Test: `all_tools_returns_expected_set` asserts names and required-arg shape.
809fn 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
929/// Execute a tool call against the live `AppState`.
930///
931/// Why: We want the model's tool invocations to call the same Rust paths the
932/// HTTP handlers use — no extra HTTP round-trip, no JSON re-parsing, and the
933/// results always reflect this daemon's view of the world.
934/// What: Parses `arguments` as JSON, dispatches by tool name, returns a JSON
935/// value that becomes the `role: "tool"` message content. Errors are caught
936/// and returned as `{"error": "..."}` JSON so the model can react.
937/// Test: `execute_tool_dispatches_known_tools` covers the dispatch path and
938/// the unknown-tool error case.
939async 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
1089/// Execute a cross-palace recall and return JSON results tagged with palace id.
1090///
1091/// Why: Both the MCP `memory_recall_all` tool and the `GET /api/v1/recall`
1092/// HTTP route share the same wiring — list palaces, open handles, fan out via
1093/// `recall_across_palaces_with_default_embedder`, and serialize.
1094/// What: Lists every palace on disk, opens each (skipping any that fail with
1095/// a `tracing::warn!`), and delegates to the core fan-out. On success returns
1096/// a JSON array; on listing failure returns `{ "error": "..." }`.
1097/// Test: Indirectly via `recall_across_palaces_merges_results` (core merge
1098/// logic) and the HTTP/MCP integration paths.
1099async 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    // Select the active provider (Ollama auto-detect, else OpenRouter).
1289    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    // Resolve palace id (explicit > default).
1298    let palace_id = body
1299        .palace_id
1300        .clone()
1301        .or_else(|| state.default_palace.clone())
1302        .unwrap_or_default();
1303
1304    // Resolve / create chat session when a palace is bound.
1305    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    // Full palace roster for the identity block — names + ids, not just count,
1353    // so the model can pick the right one when the user names a palace.
1354    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    // Config + global dream snapshot — give the model an honest view of what's
1363    // available so it doesn't invent tools or providers that aren't there.
1364    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    // Look up the selected palace's metadata (name/description) and open its
1373    // handle for live counts + recall context.
1374    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            // Live counts from the opened handle.
1390            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            // Prefer the on-disk palace.json name/description; fall back to id.
1395            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    // Build the grounded system prompt with identity, palace, RAG, config,
1430    // dream-snapshot, and behavior blocks so the LLM never confuses
1431    // trusty-memory palaces with real-world architectural palaces.
1432    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    // Config block — what providers/models are wired up right now.
1448    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    // Dream snapshot — give the model a sense of how stale memory state is.
1462    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    // Append the new user message to the in-memory history we'll persist.
1492    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    // Capture session-persistence inputs.
1513    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    // Drive the tool-execution loop in a background task so the response can
1521    // start streaming immediately.
1522    let loop_state = state.clone();
1523    tokio::spawn(async move {
1524        // Emit a leading session_id frame so the SPA can correlate this stream
1525        // with a persisted session row.
1526        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            // Drain the spawned stream task; surface any error.
1588            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                // Model produced a plain answer — we're done.
1602                break;
1603            }
1604
1605            // Build the assistant message that requested these tool calls.
1606            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            // Execute each tool and append its result as a `role: "tool"`
1624            // message. The next loop iteration feeds these back to the model.
1625            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            // Safety net: log when we walk off the round limit.
1646            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        // Persist the completed conversation regardless of streaming error
1655        // (partial assistant reply still better than nothing).
1656        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
1699// ---------------------------------------------------------------------------
1700// Providers + sessions
1701// ---------------------------------------------------------------------------
1702
1703/// GET /api/v1/chat/providers — report provider availability + active choice.
1704///
1705/// Why: The UI's chat panel surfaces whether the user has a local model
1706/// running or is hitting OpenRouter. Probing both upstreams here keeps that
1707/// logic on the server so the SPA stays dumb.
1708/// What: Calls `auto_detect_local_provider` (1s timeout) for Ollama and checks
1709/// for a non-empty OpenRouter key. Returns shape `{providers:[...], active}`.
1710/// Test: `providers_endpoint_returns_payload`.
1711async 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
1800// ---------------------------------------------------------------------------
1801// Helpers
1802// ---------------------------------------------------------------------------
1803
1804fn 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
1814/// Lightweight error type for HTTP handlers.
1815struct 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    /// Why: The enriched status payload backs the dashboard's top-row stats;
1953    /// it must always include the new total_* counters, even on an empty data
1954    /// root, so the UI can render zeros without special-casing missing fields.
1955    /// What: Hit `/api/v1/status` on a fresh state and assert the new fields
1956    /// are present and set to 0.
1957    /// Test: This test itself.
1958    #[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    /// Why: `/api/v1/dream/status` must return a well-shaped payload even
1979    /// when no palace has ever run a dream cycle (so the dashboard's first
1980    /// load doesn't error).
1981    /// What: Hit the endpoint on a fresh state and assert `last_run_at` is
1982    /// null and the counters are zero.
1983    /// Test: This test itself.
1984    #[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    /// Why: `/api/v1/chat/providers` must return a well-shaped payload even
2006    /// when no provider is available, so the SPA can render disabled states
2007    /// without special-casing missing fields.
2008    /// What: Hit the endpoint on a fresh state; assert it returns `providers`
2009    /// (an array of length 2) and an `active` field (possibly null).
2010    /// Test: This test itself.
2011    #[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        // `active` may be null when no provider is configured/reachable.
2033        assert!(v.get("active").is_some());
2034    }
2035
2036    /// Why: Chat-session CRUD must round-trip end-to-end through the HTTP
2037    /// surface — create returns an id, list shows it, get returns the
2038    /// (empty) history, delete removes it.
2039    /// What: Create a palace, then exercise the four session endpoints
2040    /// sequentially, asserting JSON shapes at each step.
2041    /// Test: This test itself.
2042    #[tokio::test]
2043    async fn chat_session_crud_round_trip() {
2044        let state = test_state();
2045        // Pre-create a palace dir so session store has a place to live.
2046        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        // Create
2060        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        // List
2078        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        // Get
2095        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        // Delete
2108        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        // Get after delete -> 404
2122        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    /// Why: The chat assistant's tool surface is part of the public API — any
2135    /// drift in tool names or required-argument lists is a breaking change for
2136    /// the UI and any external automation. Pin the shape here so a refactor
2137    /// has to acknowledge it.
2138    /// What: Snapshots the names + every tool's `required` array.
2139    /// Test: This test itself.
2140    #[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        // Every tool's `parameters` must be a JSON Schema object with a
2162        // `required` array (possibly empty).
2163        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    /// Why: `execute_tool` is the bridge between the model's tool_call
2178    /// arguments and the live Rust core. We exercise the happy path
2179    /// (`list_palaces` on an empty registry returns `[]`) and the unknown-
2180    /// tool path (returns `{"error": "..."}`) to lock down both branches.
2181    /// What: Calls execute_tool against a fresh `AppState`.
2182    /// Test: This test itself.
2183    #[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    /// Why: The SSE event bus is the dashboard's live-update transport;
2213    /// regressing it would silently break the UI. Subscribing before the
2214    /// emit guarantees the broadcast channel has a receiver when the
2215    /// handler fires, so we can deterministically observe the event.
2216    /// What: Subscribes to `state.events`, calls the `create_palace`
2217    /// handler through the router, then asserts a `PalaceCreated` event
2218    /// (and a follow-up status event from drawer mutation) flow through.
2219    /// Test: `cargo test -p trusty-memory-mcp sse_broadcast_emits_palace_created`.
2220    #[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        // The handler should have emitted PalaceCreated before returning.
2239        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    /// Why: Confirm the `/sse` endpoint speaks `text/event-stream` and emits
2253    /// the initial `connected` frame so dashboard clients can rely on a
2254    /// known greeting.
2255    /// What: Issues a GET against `/sse`, reads the response body chunk,
2256    /// asserts the content-type header and the first SSE frame shape.
2257    /// Test: `cargo test -p trusty-memory-mcp sse_endpoint_emits_connected_frame`.
2258    #[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        // Read just the first chunk (the connected frame) — the stream stays
2277        // open otherwise, so we use a small read budget plus timeout.
2278        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    /// Why: `/api/v1/dream/status` must sum per-palace `dream_stats.json`
2293    /// counters and surface the most recent `last_run_at`. A regression that
2294    /// returned only the first palace's stats would silently break the
2295    /// "global dream activity" dashboard panel.
2296    /// What: Pre-seeds two palace dirs under the AppState root, writes a
2297    /// distinct `PersistedDreamStats` JSON file into each, hits the endpoint,
2298    /// and asserts the integer fields are summed and `last_run_at` equals the
2299    /// newer of the two timestamps.
2300    /// Test: This test itself.
2301    #[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        // Two palace directories — each must contain a `palace.json` so
2307        // `PalaceRegistry::list_palaces` sees them, plus a `dream_stats.json`
2308        // with distinct counter values.
2309        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        // Aggregated counters.
2369        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        // `last_run_at` is the more-recent of the two timestamps.
2376        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        // Must have picked palace-b's newer stamp, not palace-a's older one.
2385        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    /// Why: `POST /api/v1/dream/run` triggers a dream cycle across every
2393    /// palace and must return the aggregated stats. Even when no palace
2394    /// has work to do (empty registry) the endpoint must round-trip 200
2395    /// with the well-formed payload shape so the dashboard's "Run now"
2396    /// button never fails the UI.
2397    /// What: Pre-creates one palace via the registry, posts to the endpoint,
2398    /// and asserts the response is 200 with all expected fields present.
2399    /// Deeper assertions (specific merged/pruned counts) are skipped here
2400    /// because running a full dream cycle requires the ONNX embedder load
2401    /// path and we want this test to stay fast and embedder-free.
2402    /// Test: This test itself.
2403    #[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        // Shape: every aggregated counter must be present (even if zero) and
2434        // `last_run_at` is set by the handler to "now".
2435        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        // Either OK with embedded HTML, or NOT_FOUND if assets not built.
2467        assert!(
2468            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
2469            "got {}",
2470            resp.status()
2471        );
2472    }
2473}