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::memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
28use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
29use trusty_common::memory_core::retrieval::{
30    recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
31    recall_with_default_embedder,
32};
33use trusty_common::memory_core::store::kg::Triple;
34use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
35use trusty_common::{ChatEvent, ChatMessage, ToolDef};
36use uuid::Uuid;
37
38/// 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        .route("/api/v1/logs/tail", get(logs_tail))
96        .route("/api/v1/admin/stop", post(admin_stop))
97        .fallback(static_handler);
98
99    trusty_common::server::with_standard_middleware(router)
100}
101
102// ---------------------------------------------------------------------------
103// Health check
104// ---------------------------------------------------------------------------
105
106/// Liveness/version payload for `GET /health`.
107///
108/// Why: `daemon_probe` requires an HTTP 200 from `/health` to confirm that the
109/// port is owned by this daemon (and not a stale or foreign process). Issue
110/// #35 enriches it with process resource metrics so operators (and the admin
111/// UI) can see RSS, disk footprint, CPU, and uptime in one cheap call.
112/// What: Carries a fixed `status` string, the compile-time crate version, and
113/// the issue-#35 resource block (`rss_mb`, `disk_bytes`, `cpu_pct`,
114/// `uptime_secs`).
115/// Test: Asserted by `health_endpoint_returns_ok` and
116/// `health_endpoint_includes_resource_fields` in this module's tests.
117#[derive(serde::Serialize)]
118struct HealthResponse {
119    status: &'static str,
120    version: &'static str,
121    /// Current process Resident Set Size in megabytes (issue #35). Sampled
122    /// via the shared `SysMetrics` on each health request.
123    rss_mb: u64,
124    /// On-disk footprint of the daemon's `data_root` in bytes (issue #35):
125    /// the sum of every palace file. Refreshed by a background task every
126    /// 10 s; `0` until the first walk completes.
127    disk_bytes: u64,
128    /// Current process CPU usage as a percentage (issue #35), where `100.0`
129    /// means one fully-saturated core. The first reading after daemon start
130    /// may be `0.0` until a delta window exists.
131    cpu_pct: f32,
132    /// Seconds elapsed since the daemon started (issue #35).
133    uptime_secs: u64,
134}
135
136/// `GET /health` — unauthenticated liveness probe.
137///
138/// Why: Gives `daemon_probe` and external monitors a cheap way to confirm port
139/// ownership without touching palace state. Issue #35 additionally reports
140/// process RSS, CPU, the `data_root` disk footprint, and uptime.
141/// What: Returns HTTP 200 with `{status, version, rss_mb, disk_bytes,
142/// cpu_pct, uptime_secs}`. RSS + CPU are sampled live via the shared
143/// `SysMetrics`; `disk_bytes` is read from the background-ticker atomic;
144/// `uptime_secs` is the elapsed time since `state.started_at`.
145/// Test: `health_endpoint_returns_ok` and
146/// `health_endpoint_includes_resource_fields`.
147async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
148    let (rss_mb, cpu_pct) = {
149        let mut metrics = state.sys_metrics.lock().await;
150        metrics.sample()
151    };
152    let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
153    let uptime_secs = state.started_at.elapsed().as_secs();
154    Json(HealthResponse {
155        status: "ok",
156        version: env!("CARGO_PKG_VERSION"),
157        rss_mb,
158        disk_bytes,
159        cpu_pct,
160        uptime_secs,
161    })
162}
163
164// ---------------------------------------------------------------------------
165// Logs tail + admin stop (issue #35)
166// ---------------------------------------------------------------------------
167
168/// Default number of log lines returned by `GET /api/v1/logs/tail` when `n`
169/// is absent. 100 lines is enough context for a glance without a huge payload.
170const DEFAULT_LOGS_TAIL_N: usize = 100;
171
172/// Hard ceiling on `GET /api/v1/logs/tail?n=` — equal to the ring-buffer
173/// capacity, so a request can never ask for more lines than the buffer holds.
174const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
175
176fn default_logs_tail_n() -> usize {
177    DEFAULT_LOGS_TAIL_N
178}
179
180/// Query parameters for `GET /api/v1/logs/tail`.
181///
182/// Why (issue #35): callers ask for a bounded number of recent log lines;
183/// `n` defaults to a useful page size and is clamped server-side so a
184/// misconfigured client cannot request more lines than the buffer holds.
185/// What: `n` is optional; absent → [`DEFAULT_LOGS_TAIL_N`]. Clamped to
186/// `[1, MAX_LOGS_TAIL_N]` in the handler.
187/// Test: `logs_tail_clamps_n` exercises the clamp.
188#[derive(serde::Deserialize)]
189struct LogsTailParams {
190    #[serde(default = "default_logs_tail_n")]
191    n: usize,
192}
193
194/// `GET /api/v1/logs/tail?n=200` — return the most recent N tracing log lines.
195///
196/// Why (issue #35): operators debugging a running daemon want recent logs
197/// over HTTP without SSHing to the box or restarting with a different
198/// `RUST_LOG`. The in-memory ring buffer (fed by the `LogBufferLayer` wired
199/// into the subscriber at startup) makes this near-free.
200/// What: clamps `n` to `[1, MAX_LOGS_TAIL_N]`, drains the tail of
201/// `state.log_buffer`, and returns `{ "lines": [...], "total": <buffered> }`
202/// where `total` is the number of lines currently buffered (so callers can
203/// tell whether the ring has wrapped).
204/// Test: `logs_tail_returns_recent_lines` and `logs_tail_clamps_n`.
205async fn logs_tail(
206    State(state): State<AppState>,
207    Query(params): Query<LogsTailParams>,
208) -> Json<Value> {
209    let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
210    let lines = state.log_buffer.tail(n);
211    Json(serde_json::json!({
212        "lines": lines,
213        "total": state.log_buffer.len(),
214    }))
215}
216
217/// `POST /api/v1/admin/stop` — request a graceful shutdown of the daemon.
218///
219/// Why (issue #35): the admin UI and operators want a one-call way to stop
220/// the daemon without resolving its PID and sending a signal. The daemon is
221/// localhost-only and trusts every caller, so no auth is required.
222/// What: spawns a detached task that sleeps 200 ms (giving this HTTP response
223/// time to flush to the client) and then calls `std::process::exit(0)`.
224/// Returns `{ "ok": true, "message": "shutting down" }` immediately.
225/// Test: `admin_stop_returns_ok` asserts the response shape (it does not
226/// drive the real exit — that would terminate the test process).
227async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
228    tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
229    tokio::spawn(async {
230        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
231        std::process::exit(0);
232    });
233    Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
234}
235
236// ---------------------------------------------------------------------------
237// Static asset serving
238// ---------------------------------------------------------------------------
239
240/// Serve any embedded asset; fall back to `index.html` for SPA routes.
241///
242/// Why: Hash-based routing lives client-side, but `/assets/foo.js` etc. must
243/// resolve to the embedded file directly.
244/// What: Looks up the request path under `WebAssets`; if absent, returns
245/// `index.html`. Unknown paths under `/api/` return 404.
246/// Test: `serves_index_html`, `serves_static_asset`, `unknown_api_404`.
247async fn static_handler(req: Request<Body>) -> Response {
248    let path = req.uri().path().trim_start_matches('/').to_string();
249
250    if path.starts_with("api/") {
251        return (StatusCode::NOT_FOUND, "not found").into_response();
252    }
253
254    serve_embedded(&path).unwrap_or_else(|| {
255        // SPA fallback.
256        serve_embedded("index.html")
257            .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
258    })
259}
260
261fn serve_embedded(path: &str) -> Option<Response> {
262    let path = if path.is_empty() { "index.html" } else { path };
263    let asset = WebAssets::get(path)?;
264    let mime = mime_guess::from_path(path).first_or_octet_stream();
265    let body = Body::from(asset.data.into_owned());
266    let mut resp = Response::new(body);
267    resp.headers_mut().insert(
268        header::CONTENT_TYPE,
269        HeaderValue::from_str(mime.as_ref())
270            .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
271    );
272    Some(resp)
273}
274
275// ---------------------------------------------------------------------------
276// /api/v1/status, /api/v1/config
277// ---------------------------------------------------------------------------
278
279#[derive(Serialize)]
280struct StatusPayload {
281    version: String,
282    palace_count: usize,
283    default_palace: Option<String>,
284    data_root: String,
285    total_drawers: usize,
286    total_vectors: usize,
287    total_kg_triples: usize,
288}
289
290async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
291    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
292    let palace_count = palaces.len();
293    let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
294    for p in &palaces {
295        if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
296            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
297            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
298            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
299        }
300    }
301    Json(StatusPayload {
302        version: state.version.clone(),
303        palace_count,
304        default_palace: state.default_palace.clone(),
305        data_root: state.data_root.display().to_string(),
306        total_drawers,
307        total_vectors,
308        total_kg_triples,
309    })
310}
311
312#[derive(Serialize)]
313struct ConfigPayload {
314    openrouter_configured: bool,
315    model: String,
316    data_root: String,
317}
318
319async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
320    let cfg = load_user_config().unwrap_or_default();
321    Json(ConfigPayload {
322        openrouter_configured: !cfg.openrouter_api_key.is_empty(),
323        model: cfg.openrouter_model,
324        data_root: state.data_root.display().to_string(),
325    })
326}
327
328/// Minimal mirror of the user-config schema (the real type lives in the bin
329/// crate; replicating just the fields we need here avoids a cyclic dep).
330#[derive(Deserialize, Default, Clone)]
331struct UserConfigMin {
332    #[serde(default)]
333    openrouter: OpenRouterMin,
334    #[serde(default)]
335    local_model: LocalModelMin,
336    // Carry forward unknown sections by ignoring them on parse.
337}
338
339#[derive(Deserialize, Default, Clone)]
340struct OpenRouterMin {
341    #[serde(default)]
342    api_key: String,
343    #[serde(default)]
344    model: String,
345}
346
347#[derive(Deserialize, Clone)]
348struct LocalModelMin {
349    #[serde(default = "default_local_enabled")]
350    enabled: bool,
351    #[serde(default = "default_local_base_url")]
352    base_url: String,
353    #[serde(default = "default_local_model")]
354    model: String,
355}
356
357fn default_local_enabled() -> bool {
358    true
359}
360fn default_local_base_url() -> String {
361    "http://localhost:11434".to_string()
362}
363fn default_local_model() -> String {
364    "llama3.2".to_string()
365}
366
367impl Default for LocalModelMin {
368    fn default() -> Self {
369        Self {
370            enabled: default_local_enabled(),
371            base_url: default_local_base_url(),
372            model: default_local_model(),
373        }
374    }
375}
376
377#[derive(Clone)]
378pub(crate) struct LoadedUserConfig {
379    pub(crate) openrouter_api_key: String,
380    pub(crate) openrouter_model: String,
381    pub(crate) local_model: trusty_common::LocalModelConfig,
382}
383
384impl Default for LoadedUserConfig {
385    fn default() -> Self {
386        Self {
387            openrouter_api_key: String::new(),
388            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
389            local_model: trusty_common::LocalModelConfig::default(),
390        }
391    }
392}
393
394pub(crate) fn load_user_config() -> Option<LoadedUserConfig> {
395    let home = dirs::home_dir()?;
396    let path = home.join(".trusty-memory").join("config.toml");
397    if !path.exists() {
398        return Some(LoadedUserConfig::default());
399    }
400    let raw = std::fs::read_to_string(&path).ok()?;
401    let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
402    let model = if parsed.openrouter.model.is_empty() {
403        "anthropic/claude-3-5-sonnet".to_string()
404    } else {
405        parsed.openrouter.model
406    };
407    Some(LoadedUserConfig {
408        openrouter_api_key: parsed.openrouter.api_key,
409        openrouter_model: model,
410        local_model: trusty_common::LocalModelConfig {
411            enabled: parsed.local_model.enabled,
412            base_url: parsed.local_model.base_url,
413            model: parsed.local_model.model,
414        },
415    })
416}
417
418// ---------------------------------------------------------------------------
419// /api/v1/palaces
420// ---------------------------------------------------------------------------
421
422#[derive(Serialize)]
423struct PalaceInfo {
424    id: String,
425    name: String,
426    description: Option<String>,
427    drawer_count: usize,
428    vector_count: usize,
429    kg_triple_count: usize,
430    wing_count: usize,
431    created_at: chrono::DateTime<chrono::Utc>,
432}
433
434/// Build a `PalaceInfo` from a `Palace` row plus an optional opened handle.
435///
436/// Why: Both `list_palaces` and `get_palace_handler` need the same enriched
437/// shape; centralizing the field-pulling avoids drift.
438/// What: Reads drawer count, vector index size, active KG triple count, and
439/// derives wing_count from the number of distinct `room_id`s in the drawer
440/// table (until a dedicated wings/rooms table exists, distinct rooms-by-drawer
441/// is the closest proxy).
442/// Test: `palace_list_includes_richer_counts`.
443fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
444    let (drawer_count, vector_count, kg_triple_count, wing_count) = if let Some(h) = handle {
445        let drawers = h.drawers.read();
446        let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
447        (
448            drawers.len(),
449            h.vector_store.index_size(),
450            h.kg.count_active_triples(),
451            distinct_rooms.len(),
452        )
453    } else {
454        (0, 0, 0, 0)
455    };
456    PalaceInfo {
457        id: palace.id.0.clone(),
458        name: palace.name.clone(),
459        description: palace.description.clone(),
460        drawer_count,
461        vector_count,
462        kg_triple_count,
463        wing_count,
464        created_at: palace.created_at,
465    }
466}
467
468async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
469    let palaces = PalaceRegistry::list_palaces(&state.data_root)
470        .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
471    let mut out = Vec::with_capacity(palaces.len());
472    for p in palaces {
473        let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
474        out.push(palace_info_from(&p, handle.as_ref()));
475    }
476    Ok(Json(out))
477}
478
479#[derive(Deserialize)]
480struct CreatePalaceBody {
481    name: String,
482    #[serde(default)]
483    description: Option<String>,
484}
485
486async fn create_palace(
487    State(state): State<AppState>,
488    Json(body): Json<CreatePalaceBody>,
489) -> Result<Json<Value>, ApiError> {
490    let name = body.name.trim().to_string();
491    if name.is_empty() {
492        return Err(ApiError::bad_request("name is required"));
493    }
494    let id = PalaceId::new(&name);
495    let palace = Palace {
496        id: id.clone(),
497        name: name.clone(),
498        description: body.description.filter(|s| !s.is_empty()),
499        created_at: chrono::Utc::now(),
500        data_dir: state.data_root.join(&name),
501    };
502    state
503        .registry
504        .create_palace(&state.data_root, palace)
505        .map_err(|e| ApiError::internal(format!("create palace: {e:#}")))?;
506    state.emit(DaemonEvent::PalaceCreated {
507        id: name.clone(),
508        name: name.clone(),
509    });
510    Ok(Json(json!({ "id": name })))
511}
512
513async fn get_palace_handler(
514    State(state): State<AppState>,
515    AxumPath(id): AxumPath<String>,
516) -> Result<Json<PalaceInfo>, ApiError> {
517    let palaces = PalaceRegistry::list_palaces(&state.data_root)
518        .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
519    let palace = palaces
520        .into_iter()
521        .find(|p| p.id.0 == id)
522        .ok_or_else(|| ApiError::not_found(format!("palace not found: {id}")))?;
523    let handle = state
524        .registry
525        .open_palace(&state.data_root, &palace.id)
526        .ok();
527    Ok(Json(palace_info_from(&palace, handle.as_ref())))
528}
529
530// ---------------------------------------------------------------------------
531// Drawers
532// ---------------------------------------------------------------------------
533
534#[derive(Deserialize)]
535struct ListDrawersQuery {
536    #[serde(default)]
537    room: Option<String>,
538    #[serde(default)]
539    tag: Option<String>,
540    #[serde(default)]
541    limit: Option<usize>,
542}
543
544async fn list_drawers(
545    State(state): State<AppState>,
546    AxumPath(id): AxumPath<String>,
547    Query(q): Query<ListDrawersQuery>,
548) -> Result<Json<Value>, ApiError> {
549    let handle = open_handle(&state, &id)?;
550    let room = q.room.as_deref().map(RoomType::parse);
551    let drawers = handle.list_drawers(room, q.tag.clone(), q.limit.unwrap_or(50));
552    Ok(Json(serde_json::to_value(drawers).unwrap_or(json!([]))))
553}
554
555#[derive(Deserialize)]
556struct CreateDrawerBody {
557    content: String,
558    #[serde(default)]
559    room: Option<String>,
560    #[serde(default)]
561    tags: Vec<String>,
562    #[serde(default)]
563    importance: Option<f32>,
564}
565
566async fn create_drawer(
567    State(state): State<AppState>,
568    AxumPath(id): AxumPath<String>,
569    Json(body): Json<CreateDrawerBody>,
570) -> Result<Json<Value>, ApiError> {
571    let handle = open_handle(&state, &id)?;
572    let room = body
573        .room
574        .as_deref()
575        .map(RoomType::parse)
576        .unwrap_or(RoomType::General);
577    let importance = body.importance.unwrap_or(0.5);
578    let drawer_id = handle
579        .remember(body.content, room, body.tags, importance)
580        .await
581        .map_err(|e| ApiError::internal(format!("remember: {e:#}")))?;
582    let drawer_count = handle.drawers.read().len();
583    state.emit(DaemonEvent::DrawerAdded {
584        palace_id: id.clone(),
585        drawer_count,
586    });
587    state.emit(aggregate_status_event(&state));
588    Ok(Json(json!({ "id": drawer_id })))
589}
590
591async fn delete_drawer(
592    State(state): State<AppState>,
593    AxumPath((id, drawer_id)): AxumPath<(String, String)>,
594) -> Result<StatusCode, ApiError> {
595    let handle = open_handle(&state, &id)?;
596    let uuid = Uuid::parse_str(&drawer_id)
597        .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
598    handle
599        .forget(uuid)
600        .await
601        .map_err(|e| ApiError::internal(format!("forget: {e:#}")))?;
602    let drawer_count = handle.drawers.read().len();
603    state.emit(DaemonEvent::DrawerDeleted {
604        palace_id: id.clone(),
605        drawer_count,
606    });
607    state.emit(aggregate_status_event(&state));
608    Ok(StatusCode::NO_CONTENT)
609}
610
611/// Compute the current aggregate `StatusChanged` event by walking all palaces.
612///
613/// Why: Several mutating handlers (drawer add/delete, dream run) need to push
614/// a refreshed status snapshot so dashboard stat cards stay in sync without
615/// the SPA having to issue an extra `/api/v1/status` request.
616/// What: Mirrors the math in the `status` handler — sums drawer count,
617/// vector index size, and active KG triples across every persisted palace.
618/// Test: Indirectly via the SSE integration tests that observe the event.
619fn aggregate_status_event(state: &AppState) -> DaemonEvent {
620    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
621    let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
622    for p in &palaces {
623        if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
624            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
625            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
626            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
627        }
628    }
629    DaemonEvent::StatusChanged {
630        total_drawers,
631        total_vectors,
632        total_kg_triples,
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Recall
638// ---------------------------------------------------------------------------
639
640#[derive(Deserialize)]
641struct RecallQuery {
642    q: String,
643    #[serde(default)]
644    top_k: Option<usize>,
645    #[serde(default)]
646    deep: Option<bool>,
647}
648
649async fn recall_handler(
650    State(state): State<AppState>,
651    AxumPath(id): AxumPath<String>,
652    Query(q): Query<RecallQuery>,
653) -> Result<Json<Value>, ApiError> {
654    let handle = open_handle(&state, &id)?;
655    let top_k = q.top_k.unwrap_or(10);
656    let results = if q.deep.unwrap_or(false) {
657        recall_deep_with_default_embedder(&handle, &q.q, top_k).await
658    } else {
659        recall_with_default_embedder(&handle, &q.q, top_k).await
660    }
661    .map_err(|e| ApiError::internal(format!("recall: {e:#}")))?;
662
663    let payload: Vec<Value> = results
664        .into_iter()
665        .map(|r| {
666            json!({
667                "drawer": r.drawer,
668                "score": r.score,
669                "layer": r.layer,
670            })
671        })
672        .collect();
673    Ok(Json(json!(payload)))
674}
675
676/// `GET /api/v1/recall?q=<query>&top_k=<n>&deep=<bool>` — cross-palace semantic
677/// search.
678///
679/// Why: Agents and dashboard widgets often need the most relevant memories
680/// regardless of palace boundary; forcing the caller to issue one request per
681/// palace and merge client-side is both slower (no fan-out) and wrong (no
682/// dedup/rerank). Serving the merged top-k from the daemon collapses the
683/// round-trip and reuses the shared embedder singleton.
684/// What: Lists all palaces, opens each (skipping any that fail to open with a
685/// warning), and delegates to `execute_recall_all`. Returns a JSON array of
686/// `{ palace_id, drawer, score, layer }` entries sorted by score descending.
687/// Test: Exercised via `execute_recall_all` directly and through the MCP
688/// `memory_recall_all` tool dispatch.
689async fn recall_all_handler(
690    State(state): State<AppState>,
691    Query(q): Query<RecallQuery>,
692) -> Result<Json<Value>, ApiError> {
693    let top_k = q.top_k.unwrap_or(10);
694    let deep = q.deep.unwrap_or(false);
695    let value = execute_recall_all(&state, &q.q, top_k, deep).await;
696    if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
697        return Err(ApiError::internal(err.to_string()));
698    }
699    Ok(Json(value))
700}
701
702// ---------------------------------------------------------------------------
703// Knowledge Graph
704// ---------------------------------------------------------------------------
705
706#[derive(Deserialize)]
707struct KgQueryParams {
708    subject: String,
709}
710
711async fn kg_query(
712    State(state): State<AppState>,
713    AxumPath(id): AxumPath<String>,
714    Query(q): Query<KgQueryParams>,
715) -> Result<Json<Vec<Triple>>, ApiError> {
716    let handle = open_handle(&state, &id)?;
717    let triples = handle
718        .kg
719        .query_active(&q.subject)
720        .await
721        .map_err(|e| ApiError::internal(format!("kg query: {e:#}")))?;
722    Ok(Json(triples))
723}
724
725#[derive(Deserialize)]
726struct KgAssertBody {
727    subject: String,
728    predicate: String,
729    object: String,
730    #[serde(default)]
731    confidence: Option<f32>,
732    #[serde(default)]
733    provenance: Option<String>,
734}
735
736async fn kg_assert(
737    State(state): State<AppState>,
738    AxumPath(id): AxumPath<String>,
739    Json(body): Json<KgAssertBody>,
740) -> Result<StatusCode, ApiError> {
741    let handle = open_handle(&state, &id)?;
742    let triple = Triple {
743        subject: body.subject,
744        predicate: body.predicate,
745        object: body.object,
746        valid_from: chrono::Utc::now(),
747        valid_to: None,
748        confidence: body.confidence.unwrap_or(1.0),
749        provenance: body.provenance,
750    };
751    handle
752        .kg
753        .assert(triple)
754        .await
755        .map_err(|e| ApiError::internal(format!("kg assert: {e:#}")))?;
756    Ok(StatusCode::NO_CONTENT)
757}
758
759// ---------------------------------------------------------------------------
760// Dream cycle status + on-demand run
761// ---------------------------------------------------------------------------
762
763/// Wire payload for dream status endpoints — `last_run_at` may be null when no
764/// cycle has run yet on this palace (or the aggregate has nothing to report).
765#[derive(Serialize, Default)]
766struct DreamStatusPayload {
767    last_run_at: Option<chrono::DateTime<chrono::Utc>>,
768    merged: usize,
769    pruned: usize,
770    compacted: usize,
771    closets_updated: usize,
772    duration_ms: u64,
773}
774
775impl From<PersistedDreamStats> for DreamStatusPayload {
776    fn from(p: PersistedDreamStats) -> Self {
777        Self {
778            last_run_at: Some(p.last_run_at),
779            merged: p.stats.merged,
780            pruned: p.stats.pruned,
781            compacted: p.stats.compacted,
782            closets_updated: p.stats.closets_updated,
783            duration_ms: p.stats.duration_ms,
784        }
785    }
786}
787
788/// GET /api/v1/dream/status — aggregate latest dream stats across all palaces.
789///
790/// Why: The dashboard wants a single "last dream cycle" panel rather than
791/// per-palace details; we sum the per-palace counters and surface the most
792/// recent `last_run_at` so operators can spot a stalled background loop.
793/// What: Walks every palace, loads its `dream_stats.json` if present, sums
794/// counts, and returns the max `last_run_at` (or null if no palace has run).
795/// Test: `dream_status_aggregates_across_palaces` covers the read path.
796async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
797    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
798    let mut out = DreamStatusPayload::default();
799    let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
800    for p in palaces {
801        let data_dir = state.data_root.join(p.id.as_str());
802        let snap = match PersistedDreamStats::load(&data_dir) {
803            Ok(Some(s)) => s,
804            _ => continue,
805        };
806        out.merged = out.merged.saturating_add(snap.stats.merged);
807        out.pruned = out.pruned.saturating_add(snap.stats.pruned);
808        out.compacted = out.compacted.saturating_add(snap.stats.compacted);
809        out.closets_updated = out
810            .closets_updated
811            .saturating_add(snap.stats.closets_updated);
812        out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
813        latest = match latest {
814            Some(t) if t >= snap.last_run_at => Some(t),
815            _ => Some(snap.last_run_at),
816        };
817    }
818    out.last_run_at = latest;
819    Json(out)
820}
821
822/// GET /api/v1/palaces/:id/dream/status — per-palace dream stats snapshot.
823async fn palace_dream_status(
824    State(state): State<AppState>,
825    AxumPath(id): AxumPath<String>,
826) -> Result<Json<DreamStatusPayload>, ApiError> {
827    let data_dir = state.data_root.join(&id);
828    if !data_dir.exists() {
829        return Err(ApiError::not_found(format!("palace not found: {id}")));
830    }
831    let payload = match PersistedDreamStats::load(&data_dir) {
832        Ok(Some(s)) => s.into(),
833        Ok(None) => DreamStatusPayload::default(),
834        Err(e) => return Err(ApiError::internal(format!("read dream stats: {e:#}"))),
835    };
836    Ok(Json(payload))
837}
838
839/// POST /api/v1/dream/run — run a dream cycle across all palaces on demand.
840///
841/// Why: The dashboard exposes a "Run now" button so operators can force a
842/// cycle without waiting for the idle clock; useful after a bulk ingest or
843/// when diagnosing the dream loop itself.
844/// What: Opens every persisted palace, runs `Dreamer::dream_cycle` with the
845/// default config, and returns the aggregated stats plus the run timestamp.
846/// Errors on individual palaces are logged but don't abort the sweep.
847/// Test: `dream_run_aggregates_stats` covers the round-trip.
848async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
849    let palaces = PalaceRegistry::list_palaces(&state.data_root)
850        .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
851    let dreamer = Dreamer::new(DreamConfig::default());
852    let mut out = DreamStatusPayload::default();
853    for p in palaces {
854        let handle = match state.registry.open_palace(&state.data_root, &p.id) {
855            Ok(h) => h,
856            Err(e) => {
857                tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
858                continue;
859            }
860        };
861        match dreamer.dream_cycle(&handle).await {
862            Ok(stats) => {
863                out.merged = out.merged.saturating_add(stats.merged);
864                out.pruned = out.pruned.saturating_add(stats.pruned);
865                out.compacted = out.compacted.saturating_add(stats.compacted);
866                out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
867                out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
868            }
869            Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
870        }
871    }
872    out.last_run_at = Some(chrono::Utc::now());
873    state.emit(DaemonEvent::DreamCompleted {
874        palace_id: None,
875        merged: out.merged,
876        pruned: out.pruned,
877        compacted: out.compacted,
878        closets_updated: out.closets_updated,
879        duration_ms: out.duration_ms,
880    });
881    state.emit(aggregate_status_event(&state));
882    Ok(Json(out))
883}
884
885// ---------------------------------------------------------------------------
886// Chat (OpenRouter, SSE-streaming)
887// ---------------------------------------------------------------------------
888
889#[derive(Deserialize)]
890struct ChatBody {
891    #[serde(default)]
892    palace_id: Option<String>,
893    message: String,
894    #[serde(default)]
895    history: Vec<ChatMessage>,
896    /// Optional existing chat-session id; when provided we load+append+save.
897    #[serde(default)]
898    session_id: Option<String>,
899}
900
901/// Hard cap on the number of `tool -> assistant` round trips per chat turn.
902///
903/// Why: Without a bound, a malicious or confused model could request tools
904/// indefinitely; 10 is generous enough for any realistic plan-and-act loop
905/// while still terminating quickly when the model gets stuck.
906const MAX_TOOL_ROUNDS: usize = 10;
907
908/// Build the complete set of tool definitions the chat assistant can call.
909///
910/// Why: Centralizing the tool surface keeps the wire schema, the dispatcher in
911/// `execute_tool`, and the system prompt in lock-step — adding a new tool means
912/// editing this one function plus a match arm.
913/// What: Returns the 11 read/write tools spanning palace introspection,
914/// memory recall/create, KG read/write, and daemon status.
915/// Test: `all_tools_returns_expected_set` asserts names and required-arg shape.
916fn all_tools() -> Vec<ToolDef> {
917    vec![
918        ToolDef {
919            name: "list_palaces".into(),
920            description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
921            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
922        },
923        ToolDef {
924            name: "get_palace".into(),
925            description: "Get details for a specific palace by id.".into(),
926            parameters: json!({
927                "type": "object",
928                "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
929                "required": ["palace_id"],
930            }),
931        },
932        ToolDef {
933            name: "recall_memories".into(),
934            description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
935            parameters: json!({
936                "type": "object",
937                "properties": {
938                    "palace_id": { "type": "string" },
939                    "query": { "type": "string", "description": "Free-text query" },
940                    "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
941                },
942                "required": ["palace_id", "query"],
943            }),
944        },
945        ToolDef {
946            name: "list_drawers".into(),
947            description: "List all drawers (memories) in a palace, most recent first.".into(),
948            parameters: json!({
949                "type": "object",
950                "properties": { "palace_id": { "type": "string" } },
951                "required": ["palace_id"],
952            }),
953        },
954        ToolDef {
955            name: "kg_query".into(),
956            description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
957            parameters: json!({
958                "type": "object",
959                "properties": {
960                    "palace_id": { "type": "string" },
961                    "subject": { "type": "string" }
962                },
963                "required": ["palace_id", "subject"],
964            }),
965        },
966        ToolDef {
967            name: "get_config".into(),
968            description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
969            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
970        },
971        ToolDef {
972            name: "get_status".into(),
973            description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
974            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
975        },
976        ToolDef {
977            name: "get_dream_status".into(),
978            description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
979            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
980        },
981        ToolDef {
982            name: "get_palace_dream_status".into(),
983            description: "Get dreamer activity stats for a specific palace.".into(),
984            parameters: json!({
985                "type": "object",
986                "properties": { "palace_id": { "type": "string" } },
987                "required": ["palace_id"],
988            }),
989        },
990        ToolDef {
991            name: "create_memory".into(),
992            description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
993            parameters: json!({
994                "type": "object",
995                "properties": {
996                    "palace_id": { "type": "string" },
997                    "content": { "type": "string", "description": "Verbatim memory text" },
998                    "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
999                    "tags": { "type": "array", "items": { "type": "string" } },
1000                    "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
1001                },
1002                "required": ["palace_id", "content"],
1003            }),
1004        },
1005        ToolDef {
1006            name: "kg_assert".into(),
1007            description: "Assert a knowledge-graph triple. Any prior active triple with the same (subject, predicate) is closed out (valid_to set to now) before the new one is inserted.".into(),
1008            parameters: json!({
1009                "type": "object",
1010                "properties": {
1011                    "palace_id": { "type": "string" },
1012                    "subject": { "type": "string" },
1013                    "predicate": { "type": "string" },
1014                    "object": { "type": "string" },
1015                    "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
1016                },
1017                "required": ["palace_id", "subject", "predicate", "object"],
1018            }),
1019        },
1020        ToolDef {
1021            name: "memory_recall_all".into(),
1022            description: "Semantic search across ALL palaces simultaneously. Returns the top-k most relevant drawers ranked by similarity, regardless of which palace they belong to. Each result includes a `palace_id` field identifying its source.".into(),
1023            parameters: json!({
1024                "type": "object",
1025                "properties": {
1026                    "q": { "type": "string", "description": "Free-text query" },
1027                    "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
1028                    "deep": { "type": "boolean", "default": false }
1029                },
1030                "required": ["q"],
1031            }),
1032        },
1033    ]
1034}
1035
1036/// Execute a tool call against the live `AppState`.
1037///
1038/// Why: We want the model's tool invocations to call the same Rust paths the
1039/// HTTP handlers use — no extra HTTP round-trip, no JSON re-parsing, and the
1040/// results always reflect this daemon's view of the world.
1041/// What: Parses `arguments` as JSON, dispatches by tool name, returns a JSON
1042/// value that becomes the `role: "tool"` message content. Errors are caught
1043/// and returned as `{"error": "..."}` JSON so the model can react.
1044/// Test: `execute_tool_dispatches_known_tools` covers the dispatch path and
1045/// the unknown-tool error case.
1046async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
1047    let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
1048    match name {
1049        "list_palaces" => execute_list_palaces(state).await,
1050        "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1051            Some(id) => execute_get_palace(state, id).await,
1052            None => json!({ "error": "missing required argument: palace_id" }),
1053        },
1054        "recall_memories" => {
1055            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1056            let q = parsed.get("query").and_then(|v| v.as_str());
1057            let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1058            match (pid, q) {
1059                (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
1060                _ => json!({ "error": "missing required argument(s): palace_id, query" }),
1061            }
1062        }
1063        "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1064            Some(id) => execute_list_drawers(state, id).await,
1065            None => json!({ "error": "missing required argument: palace_id" }),
1066        },
1067        "kg_query" => {
1068            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1069            let subj = parsed.get("subject").and_then(|v| v.as_str());
1070            match (pid, subj) {
1071                (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
1072                _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
1073            }
1074        }
1075        "get_config" => execute_get_config(state),
1076        "get_status" => execute_get_status(state).await,
1077        "get_dream_status" => execute_get_dream_status(state).await,
1078        "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1079            Some(id) => execute_get_palace_dream_status(state, id).await,
1080            None => json!({ "error": "missing required argument: palace_id" }),
1081        },
1082        "create_memory" => {
1083            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1084            let content = parsed.get("content").and_then(|v| v.as_str());
1085            let room = parsed.get("room").and_then(|v| v.as_str());
1086            let tags: Vec<String> = parsed
1087                .get("tags")
1088                .and_then(|v| v.as_array())
1089                .map(|arr| {
1090                    arr.iter()
1091                        .filter_map(|t| t.as_str().map(|s| s.to_string()))
1092                        .collect()
1093                })
1094                .unwrap_or_default();
1095            let importance = parsed
1096                .get("importance")
1097                .and_then(|v| v.as_f64())
1098                .map(|f| f as f32)
1099                .unwrap_or(0.5);
1100            match (pid, content) {
1101                (Some(p), Some(c)) => {
1102                    execute_create_memory(state, p, c, room, tags, importance).await
1103                }
1104                _ => json!({ "error": "missing required argument(s): palace_id, content" }),
1105            }
1106        }
1107        "kg_assert" => {
1108            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1109            let subj = parsed.get("subject").and_then(|v| v.as_str());
1110            let pred = parsed.get("predicate").and_then(|v| v.as_str());
1111            let obj = parsed.get("object").and_then(|v| v.as_str());
1112            let conf = parsed
1113                .get("confidence")
1114                .and_then(|v| v.as_f64())
1115                .map(|f| f as f32)
1116                .unwrap_or(1.0);
1117            match (pid, subj, pred, obj) {
1118                (Some(p), Some(s), Some(pr), Some(o)) => {
1119                    execute_kg_assert(state, p, s, pr, o, conf).await
1120                }
1121                _ => json!({
1122                    "error": "missing required argument(s): palace_id, subject, predicate, object"
1123                }),
1124            }
1125        }
1126        "memory_recall_all" => {
1127            let q = parsed.get("q").and_then(|v| v.as_str());
1128            let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1129            let deep = parsed
1130                .get("deep")
1131                .and_then(|v| v.as_bool())
1132                .unwrap_or(false);
1133            match q {
1134                Some(q) => execute_recall_all(state, q, top_k, deep).await,
1135                None => json!({ "error": "missing required argument: q" }),
1136            }
1137        }
1138        _ => json!({ "error": format!("unknown tool: {name}") }),
1139    }
1140}
1141
1142async fn execute_list_palaces(state: &AppState) -> Value {
1143    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1144        Ok(v) => v,
1145        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1146    };
1147    let out: Vec<Value> = palaces
1148        .into_iter()
1149        .map(|p| {
1150            let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1151            let info = palace_info_from(&p, handle.as_ref());
1152            serde_json::to_value(info).unwrap_or(json!({}))
1153        })
1154        .collect();
1155    json!(out)
1156}
1157
1158async fn execute_get_palace(state: &AppState, id: &str) -> Value {
1159    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1160        Ok(v) => v,
1161        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1162    };
1163    match palaces.into_iter().find(|p| p.id.0 == id) {
1164        Some(p) => {
1165            let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1166            serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
1167        }
1168        None => json!({ "error": format!("palace not found: {id}") }),
1169    }
1170}
1171
1172async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
1173    let handle = match state
1174        .registry
1175        .open_palace(&state.data_root, &PalaceId::new(palace_id))
1176    {
1177        Ok(h) => h,
1178        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1179    };
1180    match recall_with_default_embedder(&handle, query, top_k).await {
1181        Ok(hits) => json!(hits
1182            .into_iter()
1183            .map(|r| json!({
1184                "drawer_id": r.drawer.id.to_string(),
1185                "content": r.drawer.content,
1186                "importance": r.drawer.importance,
1187                "tags": r.drawer.tags,
1188                "score": r.score,
1189                "layer": r.layer,
1190            }))
1191            .collect::<Vec<_>>()),
1192        Err(e) => json!({ "error": format!("recall: {e:#}") }),
1193    }
1194}
1195
1196/// Execute a cross-palace recall and return JSON results tagged with palace id.
1197///
1198/// Why: Both the MCP `memory_recall_all` tool and the `GET /api/v1/recall`
1199/// HTTP route share the same wiring — list palaces, open handles, fan out via
1200/// `recall_across_palaces_with_default_embedder`, and serialize.
1201/// What: Lists every palace on disk, opens each (skipping any that fail with
1202/// a `tracing::warn!`), and delegates to the core fan-out. On success returns
1203/// a JSON array; on listing failure returns `{ "error": "..." }`.
1204/// Test: Indirectly via `recall_across_palaces_merges_results` (core merge
1205/// logic) and the HTTP/MCP integration paths.
1206async fn execute_recall_all(state: &AppState, query: &str, top_k: usize, deep: bool) -> Value {
1207    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1208        Ok(v) => v,
1209        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1210    };
1211    let mut handles = Vec::with_capacity(palaces.len());
1212    for p in &palaces {
1213        match state.registry.open_palace(&state.data_root, &p.id) {
1214            Ok(h) => handles.push(h),
1215            Err(e) => {
1216                tracing::warn!(palace = %p.id, "execute_recall_all: open failed: {e:#}");
1217            }
1218        }
1219    }
1220    if handles.is_empty() {
1221        return json!([]);
1222    }
1223    match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
1224        Ok(results) => json!(results
1225            .into_iter()
1226            .map(|r| json!({
1227                "palace_id": r.palace_id,
1228                "drawer_id": r.result.drawer.id.to_string(),
1229                "content": r.result.drawer.content,
1230                "importance": r.result.drawer.importance,
1231                "tags": r.result.drawer.tags,
1232                "score": r.result.score,
1233                "layer": r.result.layer,
1234            }))
1235            .collect::<Vec<_>>()),
1236        Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
1237    }
1238}
1239
1240async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
1241    let handle = match state
1242        .registry
1243        .open_palace(&state.data_root, &PalaceId::new(palace_id))
1244    {
1245        Ok(h) => h,
1246        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1247    };
1248    let drawers = handle.list_drawers(None, None, 200);
1249    serde_json::to_value(drawers).unwrap_or(json!([]))
1250}
1251
1252async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
1253    let handle = match state
1254        .registry
1255        .open_palace(&state.data_root, &PalaceId::new(palace_id))
1256    {
1257        Ok(h) => h,
1258        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1259    };
1260    match handle.kg.query_active(subject).await {
1261        Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
1262        Err(e) => json!({ "error": format!("kg query: {e:#}") }),
1263    }
1264}
1265
1266fn execute_get_config(state: &AppState) -> Value {
1267    let cfg = load_user_config().unwrap_or_default();
1268    json!({
1269        "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
1270        "openrouter_model": cfg.openrouter_model,
1271        "local_model": {
1272            "enabled": cfg.local_model.enabled,
1273            "base_url": cfg.local_model.base_url,
1274            "model": cfg.local_model.model,
1275        },
1276        "data_root": state.data_root.display().to_string(),
1277    })
1278}
1279
1280async fn execute_get_status(state: &AppState) -> Value {
1281    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1282    let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
1283    for p in &palaces {
1284        if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
1285            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
1286            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
1287            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
1288        }
1289    }
1290    json!({
1291        "version": state.version,
1292        "palace_count": palaces.len(),
1293        "default_palace": state.default_palace,
1294        "data_root": state.data_root.display().to_string(),
1295        "total_drawers": total_drawers,
1296        "total_vectors": total_vectors,
1297        "total_kg_triples": total_kg_triples,
1298    })
1299}
1300
1301async fn execute_get_dream_status(state: &AppState) -> Value {
1302    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1303    let mut out = DreamStatusPayload::default();
1304    let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1305    for p in palaces {
1306        let data_dir = state.data_root.join(p.id.as_str());
1307        let snap = match PersistedDreamStats::load(&data_dir) {
1308            Ok(Some(s)) => s,
1309            _ => continue,
1310        };
1311        out.merged = out.merged.saturating_add(snap.stats.merged);
1312        out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1313        out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1314        out.closets_updated = out
1315            .closets_updated
1316            .saturating_add(snap.stats.closets_updated);
1317        out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1318        latest = match latest {
1319            Some(t) if t >= snap.last_run_at => Some(t),
1320            _ => Some(snap.last_run_at),
1321        };
1322    }
1323    out.last_run_at = latest;
1324    serde_json::to_value(out).unwrap_or(json!({}))
1325}
1326
1327async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
1328    let data_dir = state.data_root.join(palace_id);
1329    if !data_dir.exists() {
1330        return json!({ "error": format!("palace not found: {palace_id}") });
1331    }
1332    match PersistedDreamStats::load(&data_dir) {
1333        Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
1334        Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
1335        Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
1336    }
1337}
1338
1339async fn execute_create_memory(
1340    state: &AppState,
1341    palace_id: &str,
1342    content: &str,
1343    room: Option<&str>,
1344    tags: Vec<String>,
1345    importance: f32,
1346) -> Value {
1347    let handle = match state
1348        .registry
1349        .open_palace(&state.data_root, &PalaceId::new(palace_id))
1350    {
1351        Ok(h) => h,
1352        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1353    };
1354    let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
1355    match handle
1356        .remember(content.to_string(), room, tags, importance)
1357        .await
1358    {
1359        Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
1360        Err(e) => json!({ "error": format!("remember: {e:#}") }),
1361    }
1362}
1363
1364async fn execute_kg_assert(
1365    state: &AppState,
1366    palace_id: &str,
1367    subject: &str,
1368    predicate: &str,
1369    object: &str,
1370    confidence: f32,
1371) -> Value {
1372    let handle = match state
1373        .registry
1374        .open_palace(&state.data_root, &PalaceId::new(palace_id))
1375    {
1376        Ok(h) => h,
1377        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1378    };
1379    let triple = Triple {
1380        subject: subject.to_string(),
1381        predicate: predicate.to_string(),
1382        object: object.to_string(),
1383        valid_from: chrono::Utc::now(),
1384        valid_to: None,
1385        confidence,
1386        provenance: Some("chat:assistant".to_string()),
1387    };
1388    match handle.kg.assert(triple).await {
1389        Ok(()) => json!({ "status": "asserted" }),
1390        Err(e) => json!({ "error": format!("kg assert: {e:#}") }),
1391    }
1392}
1393
1394async fn chat_handler(State(state): State<AppState>, Json(body): Json<ChatBody>) -> Response {
1395    // Select the active provider (Ollama auto-detect, else OpenRouter).
1396    let Some(provider) = state.chat_provider().await else {
1397        return (
1398            StatusCode::PRECONDITION_FAILED,
1399            "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
1400        )
1401            .into_response();
1402    };
1403
1404    // Resolve palace id (explicit > default).
1405    let palace_id = body
1406        .palace_id
1407        .clone()
1408        .or_else(|| state.default_palace.clone())
1409        .unwrap_or_default();
1410
1411    // Resolve / create chat session when a palace is bound.
1412    let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
1413        let store = match state.session_store(&palace_id) {
1414            Ok(s) => s,
1415            Err(e) => {
1416                tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
1417                return (
1418                    StatusCode::INTERNAL_SERVER_ERROR,
1419                    format!("session store: {e:#}"),
1420                )
1421                    .into_response();
1422            }
1423        };
1424        match body.session_id.clone() {
1425            Some(sid) => match store.get_session(&sid) {
1426                Ok(Some(s)) => (
1427                    Some(sid),
1428                    s.history
1429                        .into_iter()
1430                        .map(|m| ChatMessage {
1431                            role: m.role,
1432                            content: m.content,
1433                            tool_call_id: None,
1434                            tool_calls: None,
1435                        })
1436                        .collect(),
1437                ),
1438                _ => (Some(sid), body.history.clone()),
1439            },
1440            None => {
1441                let new_id = store.create_session(None).unwrap_or_else(|e| {
1442                    tracing::warn!("create_session failed: {e:#}");
1443                    String::new()
1444                });
1445                (
1446                    if new_id.is_empty() {
1447                        None
1448                    } else {
1449                        Some(new_id)
1450                    },
1451                    body.history.clone(),
1452                )
1453            }
1454        }
1455    } else {
1456        (None, body.history.clone())
1457    };
1458
1459    // Full palace roster for the identity block — names + ids, not just count,
1460    // so the model can pick the right one when the user names a palace.
1461    let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1462    let palace_count = all_palaces.len();
1463    let palace_roster: String = all_palaces
1464        .iter()
1465        .map(|p| format!("- {} (id: {})", p.name, p.id.0))
1466        .collect::<Vec<_>>()
1467        .join("\n");
1468
1469    // Config + global dream snapshot — give the model an honest view of what's
1470    // available so it doesn't invent tools or providers that aren't there.
1471    let cfg = load_user_config().unwrap_or_default();
1472    let active_provider_name = state
1473        .chat_provider()
1474        .await
1475        .map(|p| p.name().to_string())
1476        .unwrap_or_else(|| "none".to_string());
1477    let dream_snapshot = execute_get_dream_status(&state).await;
1478
1479    // Look up the selected palace's metadata (name/description) and open its
1480    // handle for live counts + recall context.
1481    let selected_palace_meta = if palace_id.is_empty() {
1482        None
1483    } else {
1484        all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
1485    };
1486
1487    let mut palace_block = String::new();
1488    let mut context = String::new();
1489    let mut palace_display_name = palace_id.clone();
1490
1491    if !palace_id.is_empty() {
1492        if let Ok(handle) = state
1493            .registry
1494            .open_palace(&state.data_root, &PalaceId::new(&palace_id))
1495        {
1496            // Live counts from the opened handle.
1497            let drawer_count = handle.drawers.read().len();
1498            let vector_count = handle.vector_store.index_size();
1499            let kg_triple_count = handle.kg.count_active_triples();
1500
1501            // Prefer the on-disk palace.json name/description; fall back to id.
1502            let (name, description) = match &selected_palace_meta {
1503                Some(p) => (p.name.clone(), p.description.clone()),
1504                None => (palace_id.clone(), None),
1505            };
1506            palace_display_name = name.clone();
1507
1508            palace_block.push_str(&format!(
1509                "Currently selected palace:\n\
1510                 - id: {id}\n\
1511                 - name: {name}\n",
1512                id = palace_id,
1513                name = name,
1514            ));
1515            if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
1516                palace_block.push_str(&format!("- description: {desc}\n"));
1517            }
1518            palace_block.push_str(&format!(
1519                "- drawers: {drawer_count}\n\
1520                 - vectors: {vector_count}\n\
1521                 - kg_triples: {kg_triple_count}\n",
1522            ));
1523            let identity_trimmed = handle.identity.trim();
1524            if !identity_trimmed.is_empty() {
1525                palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
1526            }
1527
1528            if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
1529                for r in hits.iter().take(5) {
1530                    context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content));
1531                }
1532            }
1533        }
1534    }
1535
1536    // Build the grounded system prompt with identity, palace, RAG, config,
1537    // dream-snapshot, and behavior blocks so the LLM never confuses
1538    // trusty-memory palaces with real-world architectural palaces.
1539    let mut system = String::new();
1540    system.push_str(&format!(
1541        "You are the assistant for trusty-memory, a machine-wide AI memory \
1542         service running locally on this user's machine. trusty-memory stores \
1543         knowledge in named \"palaces\" — isolated memory namespaces, each with \
1544         its own vector index (usearch HNSW) and temporal knowledge graph \
1545         (SQLite). Memories are organized as Palace -> Wing -> Room -> Closet \
1546         -> Drawer, where a Drawer is an atomic memory unit.\n\
1547         There are currently {palace_count} palace(s) on this machine.\n",
1548    ));
1549    if !palace_roster.is_empty() {
1550        system.push_str(&format!("Palaces:\n{palace_roster}\n"));
1551    }
1552    system.push('\n');
1553
1554    // Config block — what providers/models are wired up right now.
1555    system.push_str(&format!(
1556        "System configuration:\n\
1557         - active chat provider: {active_provider_name}\n\
1558         - openrouter model: {or_model}\n\
1559         - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
1560         - data root: {data_root}\n\n",
1561        or_model = cfg.openrouter_model,
1562        local_model = cfg.local_model.model,
1563        local_url = cfg.local_model.base_url,
1564        local_enabled = cfg.local_model.enabled,
1565        data_root = state.data_root.display(),
1566    ));
1567
1568    // Dream snapshot — give the model a sense of how stale memory state is.
1569    system.push_str(&format!(
1570        "Global dream status (background memory maintenance):\n{}\n\n",
1571        dream_snapshot,
1572    ));
1573
1574    if !palace_block.is_empty() {
1575        system.push_str(&palace_block);
1576        system.push('\n');
1577    }
1578
1579    if !context.is_empty() {
1580        system.push_str(&format!(
1581            "Relevant memories from the '{palace_display_name}' palace \
1582             (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
1583             {context}\n",
1584        ));
1585    }
1586
1587    system.push_str(
1588        "You have a set of tools to introspect and modify this trusty-memory \
1589         daemon. Prefer calling a tool over guessing — e.g. call \
1590         `list_palaces` rather than relying on the roster above if you need \
1591         live counts, and call `recall_memories` to search for facts you \
1592         don't have in context. When the user asks about \"palaces\", they \
1593         mean trusty-memory palaces (memory namespaces on this machine), not \
1594         architectural palaces like Versailles. If a tool returns an error, \
1595         report it honestly and don't fabricate results.",
1596    );
1597
1598    // Append the new user message to the in-memory history we'll persist.
1599    history.push(ChatMessage {
1600        role: "user".to_string(),
1601        content: body.message.clone(),
1602        tool_call_id: None,
1603        tool_calls: None,
1604    });
1605
1606    let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
1607    messages.push(ChatMessage {
1608        role: "system".to_string(),
1609        content: system,
1610        tool_call_id: None,
1611        tool_calls: None,
1612    });
1613    messages.extend(history.iter().cloned());
1614
1615    let tools = all_tools();
1616    let (sse_tx, sse_rx) =
1617        tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
1618
1619    // Capture session-persistence inputs.
1620    let session_store = if !palace_id.is_empty() && session_id.is_some() {
1621        state.session_store(&palace_id).ok()
1622    } else {
1623        None
1624    };
1625    let persist_session_id = session_id.clone();
1626
1627    // Drive the tool-execution loop in a background task so the response can
1628    // start streaming immediately.
1629    let loop_state = state.clone();
1630    tokio::spawn(async move {
1631        // Emit a leading session_id frame so the SPA can correlate this stream
1632        // with a persisted session row.
1633        if let Some(sid) = persist_session_id.as_deref() {
1634            let frame = format!("data: {}\n\n", json!({ "session_id": sid }));
1635            if sse_tx
1636                .send(Ok(axum::body::Bytes::from(frame)))
1637                .await
1638                .is_err()
1639            {
1640                return;
1641            }
1642        }
1643
1644        let mut final_assistant_text = String::new();
1645        let mut stream_err: Option<String> = None;
1646
1647        for round in 0..MAX_TOOL_ROUNDS {
1648            let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
1649            let messages_clone = messages.clone();
1650            let tools_clone = tools.clone();
1651            let provider_clone = provider.clone();
1652            let stream_handle = tokio::spawn(async move {
1653                provider_clone
1654                    .chat_stream(messages_clone, tools_clone, event_tx)
1655                    .await
1656            });
1657
1658            let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
1659            let mut round_assistant_text = String::new();
1660
1661            while let Some(event) = event_rx.recv().await {
1662                match event {
1663                    ChatEvent::Delta(text) => {
1664                        round_assistant_text.push_str(&text);
1665                        let frame = format!("data: {}\n\n", json!({ "delta": text }));
1666                        if sse_tx
1667                            .send(Ok(axum::body::Bytes::from(frame)))
1668                            .await
1669                            .is_err()
1670                        {
1671                            return;
1672                        }
1673                    }
1674                    ChatEvent::ToolCall(tc) => {
1675                        let frame = format!(
1676                            "data: {}\n\n",
1677                            json!({ "tool_call": {
1678                                "id": tc.id,
1679                                "name": tc.name,
1680                                "arguments": tc.arguments,
1681                            }})
1682                        );
1683                        let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
1684                        tool_calls_this_round.push(tc);
1685                    }
1686                    ChatEvent::Done => break,
1687                    ChatEvent::Error(e) => {
1688                        stream_err = Some(e);
1689                        break;
1690                    }
1691                }
1692            }
1693
1694            // Drain the spawned stream task; surface any error.
1695            match stream_handle.await {
1696                Ok(Ok(())) => {}
1697                Ok(Err(e)) => stream_err = Some(e.to_string()),
1698                Err(e) => stream_err = Some(format!("join: {e}")),
1699            }
1700
1701            if stream_err.is_some() {
1702                break;
1703            }
1704
1705            final_assistant_text.push_str(&round_assistant_text);
1706
1707            if tool_calls_this_round.is_empty() {
1708                // Model produced a plain answer — we're done.
1709                break;
1710            }
1711
1712            // Build the assistant message that requested these tool calls.
1713            let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
1714                .iter()
1715                .map(|tc| {
1716                    json!({
1717                        "id": tc.id,
1718                        "type": "function",
1719                        "function": { "name": tc.name, "arguments": tc.arguments },
1720                    })
1721                })
1722                .collect();
1723            messages.push(ChatMessage {
1724                role: "assistant".to_string(),
1725                content: round_assistant_text,
1726                tool_call_id: None,
1727                tool_calls: Some(assistant_tool_calls_json),
1728            });
1729
1730            // Execute each tool and append its result as a `role: "tool"`
1731            // message. The next loop iteration feeds these back to the model.
1732            for tc in &tool_calls_this_round {
1733                let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
1734                let result_str = result.to_string();
1735                let frame = format!(
1736                    "data: {}\n\n",
1737                    json!({ "tool_result": {
1738                        "id": tc.id,
1739                        "name": tc.name,
1740                        "content": &result_str,
1741                    }})
1742                );
1743                let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
1744                messages.push(ChatMessage {
1745                    role: "tool".to_string(),
1746                    content: result_str,
1747                    tool_call_id: Some(tc.id.clone()),
1748                    tool_calls: None,
1749                });
1750            }
1751
1752            // Safety net: log when we walk off the round limit.
1753            if round + 1 == MAX_TOOL_ROUNDS {
1754                tracing::warn!(
1755                    "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
1756                    MAX_TOOL_ROUNDS
1757                );
1758            }
1759        }
1760
1761        // Persist the completed conversation regardless of streaming error
1762        // (partial assistant reply still better than nothing).
1763        if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
1764            if !final_assistant_text.is_empty() {
1765                history.push(ChatMessage {
1766                    role: "assistant".into(),
1767                    content: final_assistant_text,
1768                    tool_call_id: None,
1769                    tool_calls: None,
1770                });
1771            }
1772            let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
1773                history
1774                    .iter()
1775                    .map(
1776                        |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
1777                            role: m.role.clone(),
1778                            content: m.content.clone(),
1779                        },
1780                    )
1781                    .collect();
1782            if let Err(e) = store.upsert_session(sid, &core_history) {
1783                tracing::warn!("upsert_session failed: {e:#}");
1784            }
1785        }
1786
1787        match stream_err {
1788            None => {
1789                let _ = sse_tx
1790                    .send(Ok(axum::body::Bytes::from("data: [DONE]\n\n")))
1791                    .await;
1792            }
1793            Some(e) => {
1794                let out = format!("data: {}\n\n", json!({ "error": e }));
1795                let _ = sse_tx.send(Ok(axum::body::Bytes::from(out))).await;
1796            }
1797        }
1798    });
1799
1800    let stream = tokio_stream::wrappers::ReceiverStream::new(sse_rx);
1801
1802    Response::builder()
1803        .header("Content-Type", "text/event-stream")
1804        .header("Cache-Control", "no-cache")
1805        .body(Body::from_stream(stream))
1806        .expect("static SSE response builds")
1807}
1808
1809// ---------------------------------------------------------------------------
1810// Providers + sessions
1811// ---------------------------------------------------------------------------
1812
1813/// GET /api/v1/chat/providers — report provider availability + active choice.
1814///
1815/// Why: The UI's chat panel surfaces whether the user has a local model
1816/// running or is hitting OpenRouter. Probing both upstreams here keeps that
1817/// logic on the server so the SPA stays dumb.
1818/// What: Calls `auto_detect_local_provider` (1s timeout) for Ollama and checks
1819/// for a non-empty OpenRouter key. Returns shape `{providers:[...], active}`.
1820/// Test: `providers_endpoint_returns_payload`.
1821async fn list_providers(State(state): State<AppState>) -> Json<Value> {
1822    let cfg = load_user_config().unwrap_or_default();
1823    let ollama_available = if cfg.local_model.enabled {
1824        trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
1825            .await
1826            .is_some()
1827    } else {
1828        false
1829    };
1830    let openrouter_available = !cfg.openrouter_api_key.is_empty();
1831    let active = state.chat_provider().await.map(|p| p.name().to_string());
1832    Json(json!({
1833        "providers": [
1834            {
1835                "name": "ollama",
1836                "model": cfg.local_model.model,
1837                "available": ollama_available,
1838            },
1839            {
1840                "name": "openrouter",
1841                "model": cfg.openrouter_model,
1842                "available": openrouter_available,
1843            }
1844        ],
1845        "active": active,
1846    }))
1847}
1848
1849#[derive(Deserialize, Default)]
1850struct CreateSessionBody {
1851    #[serde(default)]
1852    title: Option<String>,
1853}
1854
1855async fn create_chat_session(
1856    State(state): State<AppState>,
1857    AxumPath(id): AxumPath<String>,
1858    body: Option<Json<CreateSessionBody>>,
1859) -> Result<Json<Value>, ApiError> {
1860    let store = state
1861        .session_store(&id)
1862        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1863    let title = body.and_then(|b| b.0.title);
1864    let sid = store
1865        .create_session(title)
1866        .map_err(|e| ApiError::internal(format!("create session: {e:#}")))?;
1867    Ok(Json(json!({ "id": sid })))
1868}
1869
1870async fn list_chat_sessions(
1871    State(state): State<AppState>,
1872    AxumPath(id): AxumPath<String>,
1873) -> Result<Json<Value>, ApiError> {
1874    let store = state
1875        .session_store(&id)
1876        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1877    let metas = store
1878        .list_sessions()
1879        .map_err(|e| ApiError::internal(format!("list sessions: {e:#}")))?;
1880    Ok(Json(serde_json::to_value(metas).unwrap_or(json!([]))))
1881}
1882
1883async fn get_chat_session(
1884    State(state): State<AppState>,
1885    AxumPath((id, session_id)): AxumPath<(String, String)>,
1886) -> Result<Json<Value>, ApiError> {
1887    let store = state
1888        .session_store(&id)
1889        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1890    let s = store
1891        .get_session(&session_id)
1892        .map_err(|e| ApiError::internal(format!("get session: {e:#}")))?
1893        .ok_or_else(|| ApiError::not_found(format!("session not found: {session_id}")))?;
1894    Ok(Json(serde_json::to_value(s).unwrap_or(json!({}))))
1895}
1896
1897async fn delete_chat_session(
1898    State(state): State<AppState>,
1899    AxumPath((id, session_id)): AxumPath<(String, String)>,
1900) -> Result<StatusCode, ApiError> {
1901    let store = state
1902        .session_store(&id)
1903        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
1904    store
1905        .delete_session(&session_id)
1906        .map_err(|e| ApiError::internal(format!("delete session: {e:#}")))?;
1907    Ok(StatusCode::NO_CONTENT)
1908}
1909
1910// ---------------------------------------------------------------------------
1911// Helpers
1912// ---------------------------------------------------------------------------
1913
1914fn open_handle(
1915    state: &AppState,
1916    id: &str,
1917) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
1918    state
1919        .registry
1920        .open_palace(&state.data_root, &PalaceId::new(id))
1921        .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
1922}
1923
1924/// Lightweight error type for HTTP handlers.
1925struct ApiError {
1926    status: StatusCode,
1927    message: String,
1928}
1929
1930impl ApiError {
1931    fn bad_request(msg: impl Into<String>) -> Self {
1932        Self {
1933            status: StatusCode::BAD_REQUEST,
1934            message: msg.into(),
1935        }
1936    }
1937    fn not_found(msg: impl Into<String>) -> Self {
1938        Self {
1939            status: StatusCode::NOT_FOUND,
1940            message: msg.into(),
1941        }
1942    }
1943    fn internal(msg: impl Into<String>) -> Self {
1944        Self {
1945            status: StatusCode::INTERNAL_SERVER_ERROR,
1946            message: msg.into(),
1947        }
1948    }
1949}
1950
1951impl IntoResponse for ApiError {
1952    fn into_response(self) -> Response {
1953        (self.status, Json(json!({ "error": self.message }))).into_response()
1954    }
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959    use super::*;
1960    use axum::body::to_bytes;
1961    use axum::http::Request;
1962    use tower::util::ServiceExt;
1963
1964    fn test_state() -> AppState {
1965        let tmp = tempfile::tempdir().expect("tempdir");
1966        let root = tmp.path().to_path_buf();
1967        std::mem::forget(tmp);
1968        AppState::new(root)
1969    }
1970
1971    #[tokio::test]
1972    async fn health_endpoint_returns_ok() {
1973        let state = test_state();
1974        let app = router().with_state(state);
1975        let resp = app
1976            .oneshot(
1977                Request::builder()
1978                    .uri("/health")
1979                    .body(Body::empty())
1980                    .unwrap(),
1981            )
1982            .await
1983            .unwrap();
1984        assert_eq!(resp.status(), StatusCode::OK);
1985        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1986        let v: Value = serde_json::from_slice(&bytes).unwrap();
1987        assert_eq!(v["status"], "ok");
1988        assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1989    }
1990
1991    /// Issue #35 — `GET /health` carries the enriched resource block
1992    /// (`rss_mb`, `disk_bytes`, `cpu_pct`, `uptime_secs`).
1993    ///
1994    /// Why: external probes and the admin UI render these; the JSON contract
1995    /// must remain stable. `rss_mb` is sampled live so it is asserted only
1996    /// for a sane unit, not an exact value.
1997    /// What: drives `/health` through the router and asserts every new field
1998    /// deserialises with a plausible value.
1999    /// Test: this test.
2000    #[tokio::test]
2001    async fn health_endpoint_includes_resource_fields() {
2002        let state = test_state();
2003        let app = router().with_state(state);
2004        let resp = app
2005            .oneshot(
2006                Request::builder()
2007                    .uri("/health")
2008                    .body(Body::empty())
2009                    .unwrap(),
2010            )
2011            .await
2012            .unwrap();
2013        assert_eq!(resp.status(), StatusCode::OK);
2014        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2015        let v: Value = serde_json::from_slice(&bytes).unwrap();
2016        // rss_mb must be a sane unit (megabytes, not bytes).
2017        let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
2018        assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
2019        // cpu_pct is a non-negative percentage (first sample may be 0.0).
2020        let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
2021        assert!(cpu >= 0.0, "cpu_pct must be non-negative");
2022        // disk ticker has not run in this oneshot test → 0.
2023        assert_eq!(v["disk_bytes"].as_u64(), Some(0));
2024        // uptime_secs is present and a u64.
2025        assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
2026    }
2027
2028    /// Issue #35 — `GET /api/v1/logs/tail` returns the most recent buffered
2029    /// lines and the total count.
2030    ///
2031    /// Why: operators inspect a running daemon via this endpoint; it must
2032    /// surface exactly what the shared `LogBuffer` holds.
2033    /// What: attaches a `LogBuffer` to the state, pushes three lines, GETs
2034    /// `?n=2`, and asserts the tail + `total`.
2035    /// Test: this test.
2036    #[tokio::test]
2037    async fn logs_tail_returns_recent_lines() {
2038        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2039        buffer.push("line one".to_string());
2040        buffer.push("line two".to_string());
2041        buffer.push("line three".to_string());
2042        let state = test_state().with_log_buffer(buffer);
2043        let app = router().with_state(state);
2044        let resp = app
2045            .oneshot(
2046                Request::builder()
2047                    .uri("/api/v1/logs/tail?n=2")
2048                    .body(Body::empty())
2049                    .unwrap(),
2050            )
2051            .await
2052            .unwrap();
2053        assert_eq!(resp.status(), StatusCode::OK);
2054        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2055        let v: Value = serde_json::from_slice(&bytes).unwrap();
2056        let lines = v["lines"].as_array().expect("lines array");
2057        assert_eq!(lines.len(), 2, "n=2 must return two lines");
2058        assert_eq!(lines[0].as_str(), Some("line two"));
2059        assert_eq!(lines[1].as_str(), Some("line three"));
2060        assert_eq!(v["total"].as_u64(), Some(3));
2061    }
2062
2063    /// Issue #35 — `GET /api/v1/logs/tail?n=` is clamped to
2064    /// `[1, MAX_LOGS_TAIL_N]`.
2065    ///
2066    /// Why: a misconfigured client must not request more lines than the
2067    /// buffer holds, and `n=0` must still return at least one line.
2068    /// What: pushes five lines, requests `n=0` (clamps to 1) and an oversized
2069    /// `n` (clamps to the buffer length).
2070    /// Test: this test.
2071    #[tokio::test]
2072    async fn logs_tail_clamps_n() {
2073        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2074        for i in 0..5 {
2075            buffer.push(format!("l{i}"));
2076        }
2077        let state = test_state().with_log_buffer(buffer);
2078        let app = router().with_state(state);
2079
2080        // n=0 clamps up to 1.
2081        let resp = app
2082            .clone()
2083            .oneshot(
2084                Request::builder()
2085                    .uri("/api/v1/logs/tail?n=0")
2086                    .body(Body::empty())
2087                    .unwrap(),
2088            )
2089            .await
2090            .unwrap();
2091        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2092        let v: Value = serde_json::from_slice(&bytes).unwrap();
2093        assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2094
2095        // n far past MAX clamps down to the buffer length (5).
2096        let resp = app
2097            .oneshot(
2098                Request::builder()
2099                    .uri("/api/v1/logs/tail?n=999999")
2100                    .body(Body::empty())
2101                    .unwrap(),
2102            )
2103            .await
2104            .unwrap();
2105        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2106        let v: Value = serde_json::from_slice(&bytes).unwrap();
2107        assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2108    }
2109
2110    /// Issue #35 — `POST /api/v1/admin/stop` acknowledges the shutdown
2111    /// request with `{ ok, message }`.
2112    ///
2113    /// Why: the response shape is the documented contract for the admin UI's
2114    /// stop button.
2115    /// What: calls `admin_stop` directly and asserts the JSON body. It does
2116    /// NOT await the spawned exit task — that would terminate the test
2117    /// process — but the 200 ms delay before `process::exit` guarantees the
2118    /// test returns first.
2119    /// Test: this test.
2120    #[tokio::test]
2121    async fn admin_stop_returns_ok() {
2122        let state = test_state();
2123        let Json(body) = admin_stop(State(state)).await;
2124        assert_eq!(body["ok"], Value::Bool(true));
2125        assert_eq!(body["message"].as_str(), Some("shutting down"));
2126    }
2127
2128    #[tokio::test]
2129    async fn status_endpoint_returns_payload() {
2130        let state = test_state();
2131        let app = router().with_state(state);
2132        let resp = app
2133            .oneshot(
2134                Request::builder()
2135                    .uri("/api/v1/status")
2136                    .body(Body::empty())
2137                    .unwrap(),
2138            )
2139            .await
2140            .unwrap();
2141        assert_eq!(resp.status(), StatusCode::OK);
2142        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2143        let v: Value = serde_json::from_slice(&bytes).unwrap();
2144        assert!(v["version"].is_string());
2145        assert_eq!(v["palace_count"], 0);
2146    }
2147
2148    #[tokio::test]
2149    async fn unknown_api_returns_404() {
2150        let state = test_state();
2151        let app = router().with_state(state);
2152        let resp = app
2153            .oneshot(
2154                Request::builder()
2155                    .uri("/api/v1/does-not-exist")
2156                    .body(Body::empty())
2157                    .unwrap(),
2158            )
2159            .await
2160            .unwrap();
2161        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2162    }
2163
2164    #[tokio::test]
2165    async fn create_then_list_palace() {
2166        let state = test_state();
2167        let app = router().with_state(state.clone());
2168        let body = json!({"name": "web-test", "description": "from test"}).to_string();
2169        let resp = app
2170            .clone()
2171            .oneshot(
2172                Request::builder()
2173                    .method("POST")
2174                    .uri("/api/v1/palaces")
2175                    .header("content-type", "application/json")
2176                    .body(Body::from(body))
2177                    .unwrap(),
2178            )
2179            .await
2180            .unwrap();
2181        assert_eq!(resp.status(), StatusCode::OK);
2182
2183        let resp = app
2184            .oneshot(
2185                Request::builder()
2186                    .uri("/api/v1/palaces")
2187                    .body(Body::empty())
2188                    .unwrap(),
2189            )
2190            .await
2191            .unwrap();
2192        assert_eq!(resp.status(), StatusCode::OK);
2193        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2194        let v: Value = serde_json::from_slice(&bytes).unwrap();
2195        let arr = v.as_array().expect("array");
2196        assert!(arr.iter().any(|p| p["id"] == "web-test"));
2197    }
2198
2199    /// Why: The enriched status payload backs the dashboard's top-row stats;
2200    /// it must always include the new total_* counters, even on an empty data
2201    /// root, so the UI can render zeros without special-casing missing fields.
2202    /// What: Hit `/api/v1/status` on a fresh state and assert the new fields
2203    /// are present and set to 0.
2204    /// Test: This test itself.
2205    #[tokio::test]
2206    async fn status_includes_total_counters() {
2207        let state = test_state();
2208        let app = router().with_state(state);
2209        let resp = app
2210            .oneshot(
2211                Request::builder()
2212                    .uri("/api/v1/status")
2213                    .body(Body::empty())
2214                    .unwrap(),
2215            )
2216            .await
2217            .unwrap();
2218        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2219        let v: Value = serde_json::from_slice(&bytes).unwrap();
2220        assert_eq!(v["total_drawers"], 0);
2221        assert_eq!(v["total_vectors"], 0);
2222        assert_eq!(v["total_kg_triples"], 0);
2223    }
2224
2225    /// Why: `/api/v1/dream/status` must return a well-shaped payload even
2226    /// when no palace has ever run a dream cycle (so the dashboard's first
2227    /// load doesn't error).
2228    /// What: Hit the endpoint on a fresh state and assert `last_run_at` is
2229    /// null and the counters are zero.
2230    /// Test: This test itself.
2231    #[tokio::test]
2232    async fn dream_status_empty_returns_nulls() {
2233        let state = test_state();
2234        let app = router().with_state(state);
2235        let resp = app
2236            .oneshot(
2237                Request::builder()
2238                    .uri("/api/v1/dream/status")
2239                    .body(Body::empty())
2240                    .unwrap(),
2241            )
2242            .await
2243            .unwrap();
2244        assert_eq!(resp.status(), StatusCode::OK);
2245        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2246        let v: Value = serde_json::from_slice(&bytes).unwrap();
2247        assert!(v["last_run_at"].is_null());
2248        assert_eq!(v["merged"], 0);
2249        assert_eq!(v["pruned"], 0);
2250    }
2251
2252    /// Why: `/api/v1/chat/providers` must return a well-shaped payload even
2253    /// when no provider is available, so the SPA can render disabled states
2254    /// without special-casing missing fields.
2255    /// What: Hit the endpoint on a fresh state; assert it returns `providers`
2256    /// (an array of length 2) and an `active` field (possibly null).
2257    /// Test: This test itself.
2258    #[tokio::test]
2259    async fn providers_endpoint_returns_payload() {
2260        let state = test_state();
2261        let app = router().with_state(state);
2262        let resp = app
2263            .oneshot(
2264                Request::builder()
2265                    .uri("/api/v1/chat/providers")
2266                    .body(Body::empty())
2267                    .unwrap(),
2268            )
2269            .await
2270            .unwrap();
2271        assert_eq!(resp.status(), StatusCode::OK);
2272        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2273        let v: Value = serde_json::from_slice(&bytes).unwrap();
2274        let arr = v["providers"].as_array().expect("providers array");
2275        assert_eq!(arr.len(), 2);
2276        let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2277        assert!(names.contains(&"ollama"));
2278        assert!(names.contains(&"openrouter"));
2279        // `active` may be null when no provider is configured/reachable.
2280        assert!(v.get("active").is_some());
2281    }
2282
2283    /// Why: Chat-session CRUD must round-trip end-to-end through the HTTP
2284    /// surface — create returns an id, list shows it, get returns the
2285    /// (empty) history, delete removes it.
2286    /// What: Create a palace, then exercise the four session endpoints
2287    /// sequentially, asserting JSON shapes at each step.
2288    /// Test: This test itself.
2289    #[tokio::test]
2290    async fn chat_session_crud_round_trip() {
2291        let state = test_state();
2292        // Pre-create a palace dir so session store has a place to live.
2293        let palace = trusty_common::memory_core::Palace {
2294            id: PalaceId::new("sess-test"),
2295            name: "sess-test".to_string(),
2296            description: None,
2297            created_at: chrono::Utc::now(),
2298            data_dir: state.data_root.join("sess-test"),
2299        };
2300        state
2301            .registry
2302            .create_palace(&state.data_root, palace)
2303            .expect("create_palace");
2304        let app = router().with_state(state);
2305
2306        // Create
2307        let resp = app
2308            .clone()
2309            .oneshot(
2310                Request::builder()
2311                    .method("POST")
2312                    .uri("/api/v1/palaces/sess-test/chat/sessions")
2313                    .header("content-type", "application/json")
2314                    .body(Body::from(json!({"title":"first chat"}).to_string()))
2315                    .unwrap(),
2316            )
2317            .await
2318            .unwrap();
2319        assert_eq!(resp.status(), StatusCode::OK);
2320        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2321        let v: Value = serde_json::from_slice(&bytes).unwrap();
2322        let sid = v["id"].as_str().expect("session id").to_string();
2323
2324        // List
2325        let resp = app
2326            .clone()
2327            .oneshot(
2328                Request::builder()
2329                    .uri("/api/v1/palaces/sess-test/chat/sessions")
2330                    .body(Body::empty())
2331                    .unwrap(),
2332            )
2333            .await
2334            .unwrap();
2335        assert_eq!(resp.status(), StatusCode::OK);
2336        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2337        let v: Value = serde_json::from_slice(&bytes).unwrap();
2338        let arr = v.as_array().expect("array");
2339        assert!(arr.iter().any(|s| s["id"] == sid));
2340
2341        // Get
2342        let resp = app
2343            .clone()
2344            .oneshot(
2345                Request::builder()
2346                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2347                    .body(Body::empty())
2348                    .unwrap(),
2349            )
2350            .await
2351            .unwrap();
2352        assert_eq!(resp.status(), StatusCode::OK);
2353
2354        // Delete
2355        let resp = app
2356            .clone()
2357            .oneshot(
2358                Request::builder()
2359                    .method("DELETE")
2360                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2361                    .body(Body::empty())
2362                    .unwrap(),
2363            )
2364            .await
2365            .unwrap();
2366        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2367
2368        // Get after delete -> 404
2369        let resp = app
2370            .oneshot(
2371                Request::builder()
2372                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
2373                    .body(Body::empty())
2374                    .unwrap(),
2375            )
2376            .await
2377            .unwrap();
2378        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2379    }
2380
2381    /// Why: The chat assistant's tool surface is part of the public API — any
2382    /// drift in tool names or required-argument lists is a breaking change for
2383    /// the UI and any external automation. Pin the shape here so a refactor
2384    /// has to acknowledge it.
2385    /// What: Snapshots the names + every tool's `required` array.
2386    /// Test: This test itself.
2387    #[test]
2388    fn all_tools_returns_expected_set() {
2389        let tools = all_tools();
2390        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
2391        assert_eq!(
2392            names,
2393            vec![
2394                "list_palaces",
2395                "get_palace",
2396                "recall_memories",
2397                "list_drawers",
2398                "kg_query",
2399                "get_config",
2400                "get_status",
2401                "get_dream_status",
2402                "get_palace_dream_status",
2403                "create_memory",
2404                "kg_assert",
2405                "memory_recall_all",
2406            ]
2407        );
2408        // Every tool's `parameters` must be a JSON Schema object with a
2409        // `required` array (possibly empty).
2410        for t in &tools {
2411            assert_eq!(
2412                t.parameters["type"], "object",
2413                "tool {} schema type",
2414                t.name
2415            );
2416            assert!(
2417                t.parameters["required"].is_array(),
2418                "tool {} required not array",
2419                t.name
2420            );
2421        }
2422    }
2423
2424    /// Why: `execute_tool` is the bridge between the model's tool_call
2425    /// arguments and the live Rust core. We exercise the happy path
2426    /// (`list_palaces` on an empty registry returns `[]`) and the unknown-
2427    /// tool path (returns `{"error": "..."}`) to lock down both branches.
2428    /// What: Calls execute_tool against a fresh `AppState`.
2429    /// Test: This test itself.
2430    #[tokio::test]
2431    async fn execute_tool_dispatches_known_tools() {
2432        let state = test_state();
2433        let result = execute_tool("list_palaces", "{}", &state).await;
2434        assert!(
2435            result.is_array(),
2436            "list_palaces should be array, got {result}"
2437        );
2438        assert_eq!(result.as_array().unwrap().len(), 0);
2439
2440        let unknown = execute_tool("not_a_tool", "{}", &state).await;
2441        assert!(
2442            unknown["error"]
2443                .as_str()
2444                .unwrap_or("")
2445                .contains("unknown tool"),
2446            "expected unknown-tool error, got {unknown}"
2447        );
2448
2449        let missing = execute_tool("get_palace", "{}", &state).await;
2450        assert!(
2451            missing["error"]
2452                .as_str()
2453                .unwrap_or("")
2454                .contains("palace_id"),
2455            "expected missing-arg error, got {missing}"
2456        );
2457    }
2458
2459    /// Why: The SSE event bus is the dashboard's live-update transport;
2460    /// regressing it would silently break the UI. Subscribing before the
2461    /// emit guarantees the broadcast channel has a receiver when the
2462    /// handler fires, so we can deterministically observe the event.
2463    /// What: Subscribes to `state.events`, calls the `create_palace`
2464    /// handler through the router, then asserts a `PalaceCreated` event
2465    /// (and a follow-up status event from drawer mutation) flow through.
2466    /// Test: `cargo test -p trusty-memory-mcp sse_broadcast_emits_palace_created`.
2467    #[tokio::test]
2468    async fn sse_broadcast_emits_palace_created() {
2469        let state = test_state();
2470        let mut rx = state.events.subscribe();
2471        let app = router().with_state(state.clone());
2472        let body = json!({"name": "sse-test"}).to_string();
2473        let resp = app
2474            .oneshot(
2475                Request::builder()
2476                    .method("POST")
2477                    .uri("/api/v1/palaces")
2478                    .header("content-type", "application/json")
2479                    .body(Body::from(body))
2480                    .unwrap(),
2481            )
2482            .await
2483            .unwrap();
2484        assert_eq!(resp.status(), StatusCode::OK);
2485        // The handler should have emitted PalaceCreated before returning.
2486        let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
2487            .await
2488            .expect("event received within timeout")
2489            .expect("event channel still open");
2490        match event {
2491            DaemonEvent::PalaceCreated { id, name } => {
2492                assert_eq!(id, "sse-test");
2493                assert_eq!(name, "sse-test");
2494            }
2495            other => panic!("expected PalaceCreated, got {other:?}"),
2496        }
2497    }
2498
2499    /// Why: Confirm the `/sse` endpoint speaks `text/event-stream` and emits
2500    /// the initial `connected` frame so dashboard clients can rely on a
2501    /// known greeting.
2502    /// What: Issues a GET against `/sse`, reads the response body chunk,
2503    /// asserts the content-type header and the first SSE frame shape.
2504    /// Test: `cargo test -p trusty-memory-mcp sse_endpoint_emits_connected_frame`.
2505    #[tokio::test]
2506    async fn sse_endpoint_emits_connected_frame() {
2507        use axum::routing::get;
2508        let state = test_state();
2509        let app = router()
2510            .route("/sse", get(crate::sse_handler))
2511            .with_state(state);
2512        let resp = app
2513            .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
2514            .await
2515            .unwrap();
2516        assert_eq!(resp.status(), StatusCode::OK);
2517        assert_eq!(
2518            resp.headers()
2519                .get(header::CONTENT_TYPE)
2520                .and_then(|v| v.to_str().ok()),
2521            Some("text/event-stream")
2522        );
2523        // Read just the first chunk (the connected frame) — the stream stays
2524        // open otherwise, so we use a small read budget plus timeout.
2525        let body = resp.into_body();
2526        let bytes =
2527            tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
2528                .await
2529                .ok()
2530                .and_then(|r| r.ok())
2531                .unwrap_or_default();
2532        let text = String::from_utf8_lossy(&bytes);
2533        assert!(
2534            text.contains("\"type\":\"connected\""),
2535            "expected connected frame, got: {text}"
2536        );
2537    }
2538
2539    /// Why: `/api/v1/dream/status` must sum per-palace `dream_stats.json`
2540    /// counters and surface the most recent `last_run_at`. A regression that
2541    /// returned only the first palace's stats would silently break the
2542    /// "global dream activity" dashboard panel.
2543    /// What: Pre-seeds two palace dirs under the AppState root, writes a
2544    /// distinct `PersistedDreamStats` JSON file into each, hits the endpoint,
2545    /// and asserts the integer fields are summed and `last_run_at` equals the
2546    /// newer of the two timestamps.
2547    /// Test: This test itself.
2548    #[tokio::test]
2549    async fn dream_status_aggregates_across_palaces() {
2550        use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
2551
2552        let state = test_state();
2553        // Two palace directories — each must contain a `palace.json` so
2554        // `PalaceRegistry::list_palaces` sees them, plus a `dream_stats.json`
2555        // with distinct counter values.
2556        for (id, stats, ts) in [
2557            (
2558                "palace-a",
2559                DreamStats {
2560                    merged: 1,
2561                    pruned: 2,
2562                    compacted: 3,
2563                    closets_updated: 4,
2564                    duration_ms: 100,
2565                },
2566                chrono::Utc::now() - chrono::Duration::seconds(60),
2567            ),
2568            (
2569                "palace-b",
2570                DreamStats {
2571                    merged: 10,
2572                    pruned: 20,
2573                    compacted: 30,
2574                    closets_updated: 40,
2575                    duration_ms: 200,
2576                },
2577                chrono::Utc::now(),
2578            ),
2579        ] {
2580            let palace = trusty_common::memory_core::Palace {
2581                id: PalaceId::new(id),
2582                name: id.to_string(),
2583                description: None,
2584                created_at: chrono::Utc::now(),
2585                data_dir: state.data_root.join(id),
2586            };
2587            state
2588                .registry
2589                .create_palace(&state.data_root, palace)
2590                .expect("create palace");
2591            let persisted = PersistedDreamStats {
2592                last_run_at: ts,
2593                stats,
2594            };
2595            persisted
2596                .save(&state.data_root.join(id))
2597                .expect("save dream stats");
2598        }
2599
2600        let later = chrono::Utc::now();
2601        let app = router().with_state(state);
2602        let resp = app
2603            .oneshot(
2604                Request::builder()
2605                    .uri("/api/v1/dream/status")
2606                    .body(Body::empty())
2607                    .unwrap(),
2608            )
2609            .await
2610            .unwrap();
2611        assert_eq!(resp.status(), StatusCode::OK);
2612        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2613        let v: Value = serde_json::from_slice(&bytes).unwrap();
2614
2615        // Aggregated counters.
2616        assert_eq!(v["merged"], 11);
2617        assert_eq!(v["pruned"], 22);
2618        assert_eq!(v["compacted"], 33);
2619        assert_eq!(v["closets_updated"], 44);
2620        assert_eq!(v["duration_ms"], 300);
2621
2622        // `last_run_at` is the more-recent of the two timestamps.
2623        let last = v["last_run_at"].as_str().expect("last_run_at is string");
2624        let parsed: chrono::DateTime<chrono::Utc> = last
2625            .parse()
2626            .expect("last_run_at parses as RFC3339 timestamp");
2627        assert!(
2628            parsed <= later,
2629            "last_run_at ({parsed}) should not exceed wall clock ({later})"
2630        );
2631        // Must have picked palace-b's newer stamp, not palace-a's older one.
2632        let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
2633        assert!(
2634            parsed >= cutoff,
2635            "expected the newer (palace-b) timestamp; got {parsed}"
2636        );
2637    }
2638
2639    /// Why: `POST /api/v1/dream/run` triggers a dream cycle across every
2640    /// palace and must return the aggregated stats. Even when no palace
2641    /// has work to do (empty registry) the endpoint must round-trip 200
2642    /// with the well-formed payload shape so the dashboard's "Run now"
2643    /// button never fails the UI.
2644    /// What: Pre-creates one palace via the registry, posts to the endpoint,
2645    /// and asserts the response is 200 with all expected fields present.
2646    /// Deeper assertions (specific merged/pruned counts) are skipped here
2647    /// because running a full dream cycle requires the ONNX embedder load
2648    /// path and we want this test to stay fast and embedder-free.
2649    /// Test: This test itself.
2650    #[tokio::test]
2651    async fn dream_run_aggregates_stats() {
2652        let state = test_state();
2653        let palace = trusty_common::memory_core::Palace {
2654            id: PalaceId::new("dream-run-test"),
2655            name: "dream-run-test".to_string(),
2656            description: None,
2657            created_at: chrono::Utc::now(),
2658            data_dir: state.data_root.join("dream-run-test"),
2659        };
2660        state
2661            .registry
2662            .create_palace(&state.data_root, palace)
2663            .expect("create palace");
2664
2665        let app = router().with_state(state);
2666        let resp = app
2667            .oneshot(
2668                Request::builder()
2669                    .method("POST")
2670                    .uri("/api/v1/dream/run")
2671                    .body(Body::empty())
2672                    .unwrap(),
2673            )
2674            .await
2675            .unwrap();
2676        assert_eq!(resp.status(), StatusCode::OK);
2677        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2678        let v: Value = serde_json::from_slice(&bytes).unwrap();
2679
2680        // Shape: every aggregated counter must be present (even if zero) and
2681        // `last_run_at` is set by the handler to "now".
2682        for key in [
2683            "merged",
2684            "pruned",
2685            "compacted",
2686            "closets_updated",
2687            "duration_ms",
2688        ] {
2689            assert!(
2690                v.get(key).is_some(),
2691                "missing key {key} in dream_run payload: {v}"
2692            );
2693            assert!(
2694                v[key].is_u64() || v[key].is_i64(),
2695                "{key} should be integer, got {}",
2696                v[key]
2697            );
2698        }
2699        assert!(
2700            v["last_run_at"].is_string(),
2701            "last_run_at must be set by dream_run; got {v}"
2702        );
2703    }
2704
2705    #[tokio::test]
2706    async fn serves_index_html_fallback() {
2707        let state = test_state();
2708        let app = router().with_state(state);
2709        let resp = app
2710            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
2711            .await
2712            .unwrap();
2713        // Either OK with embedded HTML, or NOT_FOUND if assets not built.
2714        assert!(
2715            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
2716            "got {}",
2717            resp.status()
2718        );
2719    }
2720}