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::community::KnowledgeGap;
28use trusty_common::memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
29use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
30use trusty_common::memory_core::retrieval::{
31    recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
32    recall_with_default_embedder, RecallResult,
33};
34use trusty_common::memory_core::store::kg::Triple;
35use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
36use trusty_common::{ChatEvent, ChatMessage, ToolDef};
37use uuid::Uuid;
38
39/// Embedded UI assets produced by `pnpm build` in `ui/`.
40///
41/// Why: Single-binary deploys with no separate static-file dance. `build.rs`
42/// runs the Vite build before compilation so this folder is always populated.
43/// What: All files under `ui/dist/` are included in the binary.
44/// Test: `serves_index_html` confirms the SPA shell loads.
45#[derive(RustEmbed)]
46// Monorepo migration: upstream trusty-memory put the Svelte UI at the repo
47// root (`ui/dist/`), so the original path was `$CARGO_MANIFEST_DIR/../../ui/dist/`.
48// In the trusty-tools monorepo we keep the UI inside the crate to avoid
49// polluting the workspace root with per-crate asset directories.
50#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
51struct WebAssets;
52
53/// Build the public router with API routes + SPA asset fallback.
54///
55/// Why: `run_http` calls this so the same router shape is used in tests.
56/// What: All API routes under `/api/v1`, fallback to the SPA shell.
57/// Test: `serves_index_html` and `status_endpoint_returns_payload`.
58pub fn router() -> Router<AppState> {
59    // axum 0.8 path syntax uses `{param}` instead of `:param`. The shared
60    // `trusty_common::server::with_standard_middleware` layer brings in CORS,
61    // tracing, and gzip (with SSE excluded) so we don't drift from sibling
62    // trusty-* daemons.
63    let router = Router::new()
64        .route("/api/v1/status", get(status))
65        .route("/api/v1/config", get(config))
66        .route("/api/v1/palaces", get(list_palaces).post(create_palace))
67        .route("/api/v1/palaces/{id}", get(get_palace_handler))
68        .route(
69            "/api/v1/palaces/{id}/drawers",
70            get(list_drawers).post(create_drawer),
71        )
72        .route(
73            "/api/v1/palaces/{id}/drawers/{drawer_id}",
74            delete(delete_drawer),
75        )
76        // Issue #70 — `/memories` is a backward-compatible alias for `/drawers`.
77        // Some clients (and earlier docs) POST/GET against `…/memories`, which
78        // 404'd because only `/drawers` was registered. Aliasing here keeps
79        // both vocabularies working against the same handlers without breaking
80        // existing `/drawers` callers.
81        .route(
82            "/api/v1/palaces/{id}/memories",
83            get(list_drawers).post(create_drawer),
84        )
85        .route(
86            "/api/v1/palaces/{id}/memories/{drawer_id}",
87            delete(delete_drawer),
88        )
89        .route("/api/v1/palaces/{id}/recall", get(recall_handler))
90        .route("/api/v1/recall", get(recall_all_handler))
91        .route("/api/v1/palaces/{id}/kg", get(kg_query).post(kg_assert))
92        .route("/api/v1/palaces/{id}/kg/subjects", get(kg_list_subjects))
93        .route(
94            "/api/v1/palaces/{id}/kg/subjects_with_counts",
95            get(kg_list_subjects_with_counts),
96        )
97        .route("/api/v1/palaces/{id}/kg/all", get(kg_list_all))
98        .route("/api/v1/palaces/{id}/kg/count", get(kg_count))
99        .route(
100            "/api/v1/palaces/{id}/dream/status",
101            get(palace_dream_status),
102        )
103        .route("/api/v1/dream/status", get(dream_status))
104        .route("/api/v1/dream/run", post(dream_run))
105        .route("/api/v1/kg/gaps", get(kg_gaps_handler))
106        .route("/api/v1/kg/prompt-context", get(prompt_context_handler))
107        .route("/api/v1/kg/aliases", post(add_alias_handler))
108        .route(
109            "/api/v1/kg/prompt-facts",
110            get(list_prompt_facts_handler).delete(remove_prompt_fact_handler),
111        )
112        .route("/api/v1/chat", post(chat_handler))
113        .route("/api/v1/chat/providers", get(list_providers))
114        .route(
115            "/api/v1/palaces/{id}/chat/sessions",
116            get(list_chat_sessions).post(create_chat_session),
117        )
118        .route(
119            "/api/v1/palaces/{id}/chat/sessions/{session_id}",
120            get(get_chat_session).delete(delete_chat_session),
121        )
122        .route("/health", get(health))
123        .route("/api/v1/logs/tail", get(logs_tail))
124        .route("/api/v1/admin/stop", post(admin_stop))
125        .fallback(static_handler);
126
127    trusty_common::server::with_standard_middleware(router)
128}
129
130// ---------------------------------------------------------------------------
131// Health check
132// ---------------------------------------------------------------------------
133
134/// Liveness/version payload for `GET /health`.
135///
136/// Why: `daemon_probe` requires an HTTP 200 from `/health` to confirm that the
137/// port is owned by this daemon (and not a stale or foreign process). Issue
138/// #35 enriches it with process resource metrics so operators (and the admin
139/// UI) can see RSS, disk footprint, CPU, and uptime in one cheap call.
140/// What: Carries a fixed `status` string, the compile-time crate version, and
141/// the issue-#35 resource block (`rss_mb`, `disk_bytes`, `cpu_pct`,
142/// `uptime_secs`).
143/// Test: Asserted by `health_endpoint_returns_ok` and
144/// `health_endpoint_includes_resource_fields` in this module's tests.
145#[derive(serde::Serialize)]
146struct HealthResponse {
147    /// `"ok"` when the round-trip smoke test succeeds (or no palace exists
148    /// yet), `"degraded"` when store/recall is broken (issue #71). Owned
149    /// `String` so the handler can report different statuses without
150    /// requiring static lifetimes.
151    status: String,
152    /// Populated only when `status == "degraded"` (issue #71). Carries a
153    /// short phrase identifying which round-trip stage failed so operators
154    /// can triage quickly (e.g. `"store failed: ..."`).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    detail: Option<String>,
157    version: &'static str,
158    /// Current process Resident Set Size in megabytes (issue #35). Sampled
159    /// via the shared `SysMetrics` on each health request.
160    rss_mb: u64,
161    /// On-disk footprint of the daemon's `data_root` in bytes (issue #35):
162    /// the sum of every palace file. Refreshed by a background task every
163    /// 10 s; `0` until the first walk completes.
164    disk_bytes: u64,
165    /// Current process CPU usage as a percentage (issue #35), where `100.0`
166    /// means one fully-saturated core. The first reading after daemon start
167    /// may be `0.0` until a delta window exists.
168    cpu_pct: f32,
169    /// Seconds elapsed since the daemon started (issue #35).
170    uptime_secs: u64,
171    /// Bound `host:port` of the HTTP listener. Why: dynamic port selection
172    /// (7070..=7079 + OS fallback) means clients cannot assume `7070`; this
173    /// field advertises the real port without forcing them to read
174    /// `~/.trusty-memory/http_addr`. `None` when the daemon was constructed
175    /// without ever binding (tests that drive the router with `TestServer`).
176    #[serde(skip_serializing_if = "Option::is_none")]
177    addr: Option<String>,
178}
179
180/// `GET /health` — unauthenticated liveness probe with store/recall smoke test.
181///
182/// Why: Gives `daemon_probe` and external monitors a cheap way to confirm port
183/// ownership without touching palace state. Issue #35 additionally reports
184/// process RSS, CPU, the `data_root` disk footprint, and uptime. Issue #71
185/// upgrades the check to a full memory round-trip (store → recall → verify →
186/// delete) against the first palace so operators learn about store/recall
187/// regressions immediately instead of after a real request fails.
188/// What: Returns HTTP 200 with `{status, version, rss_mb, disk_bytes,
189/// cpu_pct, uptime_secs, detail?}`. RSS + CPU are sampled live; `disk_bytes`
190/// is read from the background ticker; `uptime_secs` is elapsed since
191/// `state.started_at`. When palaces exist, the handler attempts a full
192/// remember/recall/forget cycle on the first palace — `status` is `"ok"` on
193/// success (or when no palace exists yet), `"degraded"` with a `detail`
194/// string explaining the failing stage otherwise. The probe never returns
195/// non-200 so monitors keyed on HTTP status still see the daemon as up.
196/// Test: `health_endpoint_returns_ok`,
197/// `health_endpoint_includes_resource_fields`,
198/// `health_endpoint_round_trip_on_fresh_install_is_ok`,
199/// `health_endpoint_round_trip_with_palace_is_ok`.
200async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
201    let (rss_mb, cpu_pct) = {
202        let mut metrics = state.sys_metrics.lock().await;
203        metrics.sample()
204    };
205    let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
206    let uptime_secs = state.started_at.elapsed().as_secs();
207    let addr = state.bound_addr.get().map(|a| a.to_string());
208
209    let (status, detail) = match run_health_round_trip(&state).await {
210        Ok(()) => ("ok".to_string(), None),
211        Err(HealthProbeError::NoPalaces) => ("ok".to_string(), None),
212        Err(err) => {
213            tracing::warn!("/health round-trip degraded: {err}");
214            ("degraded".to_string(), Some(err.to_string()))
215        }
216    };
217
218    Json(HealthResponse {
219        status,
220        detail,
221        version: env!("CARGO_PKG_VERSION"),
222        rss_mb,
223        disk_bytes,
224        cpu_pct,
225        uptime_secs,
226        addr,
227    })
228}
229
230/// Stages of the `/health` round-trip that can fail (issue #71).
231///
232/// Why: `thiserror`-derived enum gives every failure point a stable phrase the
233/// handler can render into the `detail` field without printing implementation
234/// detail or full backtraces. `NoPalaces` is modelled as an error variant so
235/// the round-trip helper can short-circuit cleanly while letting the caller
236/// distinguish "skip" from real failures.
237/// What: One variant per stage (list, open, store, recall, missing-in-results,
238/// delete) plus the `NoPalaces` sentinel.
239/// Test: Exercised indirectly by the `health_endpoint_round_trip_*` tests.
240#[derive(Debug, thiserror::Error)]
241enum HealthProbeError {
242    #[error("no palaces present (skipped round-trip)")]
243    NoPalaces,
244    #[error("list palaces failed: {0}")]
245    ListPalaces(String),
246    #[error("open palace failed: {0}")]
247    OpenPalace(String),
248    #[error("store failed: {0}")]
249    Store(String),
250    #[error("recall failed: {0}")]
251    Recall(String),
252    #[error("recall did not return the probe drawer (id={0})")]
253    ProbeMissing(Uuid),
254    #[error("delete probe drawer failed: {0}")]
255    Delete(String),
256}
257
258/// Execute a remember/recall/forget cycle against the first persisted palace.
259///
260/// Why: `/health` used to return `status: "ok"` even when `POST /drawers` or
261/// the recall path was broken — only that the process was alive. Issue #71
262/// asks the probe to actually exercise the store and recall service layer
263/// (no HTTP loopback) so monitors detect data-plane regressions on the next
264/// poll instead of waiting for a real client to surface them.
265/// What: Lists palaces; if empty returns `NoPalaces` so the caller reports
266/// "ok" (no way to probe without a palace on a fresh install). Otherwise
267/// opens the first palace, stores a content-unique probe drawer via
268/// `PalaceHandle::remember`, runs `recall_with_default_embedder` with the
269/// probe phrase, asserts the new drawer is in the results, then deletes it
270/// via `PalaceHandle::forget`. Returns the first failing stage as a
271/// `HealthProbeError`.
272/// Test: Indirect — `health_endpoint_round_trip_with_palace_is_ok` and
273/// `health_endpoint_round_trip_on_fresh_install_is_ok`.
274async fn run_health_round_trip(state: &AppState) -> Result<(), HealthProbeError> {
275    let palaces = PalaceRegistry::list_palaces(&state.data_root)
276        .map_err(|e| HealthProbeError::ListPalaces(format!("{e:#}")))?;
277    let Some(palace) = palaces.into_iter().next() else {
278        return Err(HealthProbeError::NoPalaces);
279    };
280    let handle = state
281        .registry
282        .open_palace(&state.data_root, &palace.id)
283        .map_err(|e| HealthProbeError::OpenPalace(format!("{e:#}")))?;
284
285    // Content-unique probe phrase. `__trusty_memory_healthcheck__` makes the
286    // probe identifiable in logs / drawer dumps if a forget step is ever
287    // skipped (e.g. handler panic between store and delete); the UUID
288    // guarantees uniqueness across concurrent probes.
289    let probe_token = Uuid::new_v4();
290    let probe_content = format!("__trusty_memory_healthcheck__ probe {probe_token}");
291
292    let drawer_id = handle
293        .remember(
294            probe_content.clone(),
295            RoomType::General,
296            vec!["healthcheck".to_string()],
297            0.0,
298        )
299        .await
300        .map_err(|e| HealthProbeError::Store(format!("{e:#}")))?;
301
302    let recall_result = recall_with_default_embedder(&handle, &probe_content, 5).await;
303
304    // Always attempt cleanup, even when recall failed, so the probe never
305    // leaves drawers behind. Cleanup errors are reported only when no earlier
306    // failure exists; otherwise we keep the upstream failure as the root cause.
307    let delete_result = handle.forget(drawer_id).await;
308
309    match recall_result {
310        Ok(hits) => {
311            if !hits.iter().any(|hit| hit.drawer.id == drawer_id) {
312                return Err(HealthProbeError::ProbeMissing(drawer_id));
313            }
314        }
315        Err(e) => return Err(HealthProbeError::Recall(format!("{e:#}"))),
316    }
317
318    delete_result.map_err(|e| HealthProbeError::Delete(format!("{e:#}")))?;
319    Ok(())
320}
321
322// ---------------------------------------------------------------------------
323// Logs tail + admin stop (issue #35)
324// ---------------------------------------------------------------------------
325
326/// Default number of log lines returned by `GET /api/v1/logs/tail` when `n`
327/// is absent. 100 lines is enough context for a glance without a huge payload.
328const DEFAULT_LOGS_TAIL_N: usize = 100;
329
330/// Hard ceiling on `GET /api/v1/logs/tail?n=` — equal to the ring-buffer
331/// capacity, so a request can never ask for more lines than the buffer holds.
332const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
333
334fn default_logs_tail_n() -> usize {
335    DEFAULT_LOGS_TAIL_N
336}
337
338/// Query parameters for `GET /api/v1/logs/tail`.
339///
340/// Why (issue #35): callers ask for a bounded number of recent log lines;
341/// `n` defaults to a useful page size and is clamped server-side so a
342/// misconfigured client cannot request more lines than the buffer holds.
343/// What: `n` is optional; absent → [`DEFAULT_LOGS_TAIL_N`]. Clamped to
344/// `[1, MAX_LOGS_TAIL_N]` in the handler.
345/// Test: `logs_tail_clamps_n` exercises the clamp.
346#[derive(serde::Deserialize)]
347struct LogsTailParams {
348    #[serde(default = "default_logs_tail_n")]
349    n: usize,
350}
351
352/// `GET /api/v1/logs/tail?n=200` — return the most recent N tracing log lines.
353///
354/// Why (issue #35): operators debugging a running daemon want recent logs
355/// over HTTP without SSHing to the box or restarting with a different
356/// `RUST_LOG`. The in-memory ring buffer (fed by the `LogBufferLayer` wired
357/// into the subscriber at startup) makes this near-free.
358/// What: clamps `n` to `[1, MAX_LOGS_TAIL_N]`, drains the tail of
359/// `state.log_buffer`, and returns `{ "lines": [...], "total": <buffered> }`
360/// where `total` is the number of lines currently buffered (so callers can
361/// tell whether the ring has wrapped).
362/// Test: `logs_tail_returns_recent_lines` and `logs_tail_clamps_n`.
363async fn logs_tail(
364    State(state): State<AppState>,
365    Query(params): Query<LogsTailParams>,
366) -> Json<Value> {
367    let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
368    let lines = state.log_buffer.tail(n);
369    Json(serde_json::json!({
370        "lines": lines,
371        "total": state.log_buffer.len(),
372    }))
373}
374
375/// `POST /api/v1/admin/stop` — request a graceful shutdown of the daemon.
376///
377/// Why (issue #35): the admin UI and operators want a one-call way to stop
378/// the daemon without resolving its PID and sending a signal. The daemon is
379/// localhost-only and trusts every caller, so no auth is required.
380/// What: spawns a detached task that sleeps 200 ms (giving this HTTP response
381/// time to flush to the client) and then calls `std::process::exit(0)`.
382/// Returns `{ "ok": true, "message": "shutting down" }` immediately.
383/// Test: `admin_stop_returns_ok` asserts the response shape (it does not
384/// drive the real exit — that would terminate the test process).
385async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
386    tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
387    tokio::spawn(async {
388        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
389        std::process::exit(0);
390    });
391    Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
392}
393
394// ---------------------------------------------------------------------------
395// Static asset serving
396// ---------------------------------------------------------------------------
397
398/// Serve any embedded asset; fall back to `index.html` for SPA routes.
399///
400/// Why: Hash-based routing lives client-side, but `/assets/foo.js` etc. must
401/// resolve to the embedded file directly.
402/// What: Looks up the request path under `WebAssets`; if absent, returns
403/// `index.html`. Unknown paths under `/api/` return 404.
404/// Test: `serves_index_html`, `serves_static_asset`, `unknown_api_404`.
405async fn static_handler(req: Request<Body>) -> Response {
406    let path = req.uri().path().trim_start_matches('/').to_string();
407
408    if path.starts_with("api/") {
409        return (StatusCode::NOT_FOUND, "not found").into_response();
410    }
411
412    serve_embedded(&path).unwrap_or_else(|| {
413        // SPA fallback.
414        serve_embedded("index.html")
415            .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
416    })
417}
418
419fn serve_embedded(path: &str) -> Option<Response> {
420    let path = if path.is_empty() { "index.html" } else { path };
421    let asset = WebAssets::get(path)?;
422    let mime = mime_guess::from_path(path).first_or_octet_stream();
423    let body = Body::from(asset.data.into_owned());
424    let mut resp = Response::new(body);
425    resp.headers_mut().insert(
426        header::CONTENT_TYPE,
427        HeaderValue::from_str(mime.as_ref())
428            .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
429    );
430    Some(resp)
431}
432
433// ---------------------------------------------------------------------------
434// /api/v1/status, /api/v1/config
435// ---------------------------------------------------------------------------
436
437#[derive(Serialize)]
438struct StatusPayload {
439    version: String,
440    palace_count: usize,
441    default_palace: Option<String>,
442    data_root: String,
443    total_drawers: usize,
444    total_vectors: usize,
445    total_kg_triples: usize,
446}
447
448async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
449    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
450    let palace_count = palaces.len();
451    let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
452    for p in &palaces {
453        if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
454            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
455            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
456            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
457        }
458    }
459    Json(StatusPayload {
460        version: state.version.clone(),
461        palace_count,
462        default_palace: state.default_palace.clone(),
463        data_root: state.data_root.display().to_string(),
464        total_drawers,
465        total_vectors,
466        total_kg_triples,
467    })
468}
469
470#[derive(Serialize)]
471struct ConfigPayload {
472    openrouter_configured: bool,
473    model: String,
474    data_root: String,
475}
476
477async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
478    let cfg = load_user_config().unwrap_or_default();
479    Json(ConfigPayload {
480        openrouter_configured: !cfg.openrouter_api_key.is_empty(),
481        model: cfg.openrouter_model,
482        data_root: state.data_root.display().to_string(),
483    })
484}
485
486/// Minimal mirror of the user-config schema (the real type lives in the bin
487/// crate; replicating just the fields we need here avoids a cyclic dep).
488#[derive(Deserialize, Default, Clone)]
489struct UserConfigMin {
490    #[serde(default)]
491    openrouter: OpenRouterMin,
492    #[serde(default)]
493    local_model: LocalModelMin,
494    // Carry forward unknown sections by ignoring them on parse.
495}
496
497#[derive(Deserialize, Default, Clone)]
498struct OpenRouterMin {
499    #[serde(default)]
500    api_key: String,
501    #[serde(default)]
502    model: String,
503}
504
505#[derive(Deserialize, Clone)]
506struct LocalModelMin {
507    #[serde(default = "default_local_enabled")]
508    enabled: bool,
509    #[serde(default = "default_local_base_url")]
510    base_url: String,
511    #[serde(default = "default_local_model")]
512    model: String,
513}
514
515fn default_local_enabled() -> bool {
516    true
517}
518fn default_local_base_url() -> String {
519    "http://localhost:11434".to_string()
520}
521fn default_local_model() -> String {
522    "llama3.2".to_string()
523}
524
525impl Default for LocalModelMin {
526    fn default() -> Self {
527        Self {
528            enabled: default_local_enabled(),
529            base_url: default_local_base_url(),
530            model: default_local_model(),
531        }
532    }
533}
534
535#[derive(Clone)]
536pub(crate) struct LoadedUserConfig {
537    pub(crate) openrouter_api_key: String,
538    pub(crate) openrouter_model: String,
539    pub(crate) local_model: trusty_common::LocalModelConfig,
540}
541
542impl Default for LoadedUserConfig {
543    fn default() -> Self {
544        Self {
545            openrouter_api_key: String::new(),
546            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
547            local_model: trusty_common::LocalModelConfig::default(),
548        }
549    }
550}
551
552pub(crate) fn load_user_config() -> Option<LoadedUserConfig> {
553    let home = dirs::home_dir()?;
554    let path = home.join(".trusty-memory").join("config.toml");
555    if !path.exists() {
556        return Some(LoadedUserConfig::default());
557    }
558    let raw = std::fs::read_to_string(&path).ok()?;
559    let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
560    let model = if parsed.openrouter.model.is_empty() {
561        "anthropic/claude-3-5-sonnet".to_string()
562    } else {
563        parsed.openrouter.model
564    };
565    Some(LoadedUserConfig {
566        openrouter_api_key: parsed.openrouter.api_key,
567        openrouter_model: model,
568        local_model: trusty_common::LocalModelConfig {
569            enabled: parsed.local_model.enabled,
570            base_url: parsed.local_model.base_url,
571            model: parsed.local_model.model,
572        },
573    })
574}
575
576// ---------------------------------------------------------------------------
577// /api/v1/palaces
578// ---------------------------------------------------------------------------
579
580#[derive(Serialize)]
581struct PalaceInfo {
582    id: String,
583    name: String,
584    description: Option<String>,
585    drawer_count: usize,
586    vector_count: usize,
587    kg_triple_count: usize,
588    wing_count: usize,
589    created_at: chrono::DateTime<chrono::Utc>,
590    /// Max `created_at` across this palace's drawers, or `None` if empty.
591    ///
592    /// Why: The UI "sort by activity" mode needs a single timestamp per
593    /// palace so operators can spot recently-written palaces. Computing it
594    /// from the loaded drawer set avoids adding a per-write update path or a
595    /// new on-disk index.
596    /// What: `handle.drawers.read().iter().map(|d| d.created_at).max()`.
597    /// Null when the handle is unavailable or the palace has zero drawers.
598    /// Test: `palace_list_includes_last_write_at` (web tests, added below).
599    last_write_at: Option<chrono::DateTime<chrono::Utc>>,
600    /// Distinct-entity count in the KG adjacency (zero when no handle).
601    ///
602    /// Why: The operator TUI surfaces graph breadth alongside triple count;
603    /// a separate field avoids re-querying the KG for every dashboard tick.
604    /// What: `handle.kg.node_count()`. `#[serde(default)]` so older clients
605    /// that don't know the field still deserialise the payload.
606    /// Test: `palace_list_includes_graph_counts`.
607    #[serde(default)]
608    node_count: u64,
609    /// Directed-edge count in the KG adjacency (zero when no handle).
610    ///
611    /// Why: Companion to `node_count` for density at a glance.
612    /// What: `handle.kg.edge_count()`. `#[serde(default)]` for forward-compat.
613    /// Test: `palace_list_includes_graph_counts`.
614    #[serde(default)]
615    edge_count: u64,
616    /// Number of Louvain communities detected in the KG (zero when no handle).
617    ///
618    /// Why: The MEMORY tab shows a community tally so operators can spot
619    /// clustering at a glance without opening the KG explorer.
620    /// What: `handle.kg.community_count()`. `#[serde(default)]` for
621    /// forward-compat.
622    /// Test: `palace_list_includes_graph_counts`.
623    #[serde(default)]
624    community_count: u64,
625    /// `true` while a `Dreamer::dream_cycle` is running against this palace.
626    ///
627    /// Why: Drives the dreaming/compacting spinner in the operator TUI; the
628    /// dashboard polls `/api/v1/palaces` and needs a single boolean signal.
629    /// What: `handle.is_compacting()`, set by `CompactionGuard` in
630    /// `trusty_common::memory_core::dream`. `#[serde(default)]` so old clients
631    /// that don't expect the field deserialise as `false`.
632    /// Test: `palace_list_includes_graph_counts`.
633    #[serde(default)]
634    is_compacting: bool,
635}
636
637/// Build a `PalaceInfo` from a `Palace` row plus an optional opened handle.
638///
639/// Why: Both `list_palaces` and `get_palace_handler` need the same enriched
640/// shape; centralizing the field-pulling avoids drift.
641/// What: Reads drawer count, vector index size, active KG triple count, and
642/// derives wing_count from the number of distinct `room_id`s in the drawer
643/// table (until a dedicated wings/rooms table exists, distinct rooms-by-drawer
644/// is the closest proxy).
645/// Test: `palace_list_includes_richer_counts`.
646fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
647    let (
648        drawer_count,
649        vector_count,
650        kg_triple_count,
651        wing_count,
652        last_write_at,
653        node_count,
654        edge_count,
655        community_count,
656        is_compacting,
657    ) = if let Some(h) = handle {
658        let drawers = h.drawers.read();
659        let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
660        let last_write = drawers.iter().map(|d| d.created_at).max();
661        (
662            drawers.len(),
663            h.vector_store.index_size(),
664            h.kg.count_active_triples(),
665            distinct_rooms.len(),
666            last_write,
667            h.kg.node_count() as u64,
668            h.kg.edge_count() as u64,
669            h.kg.community_count() as u64,
670            h.is_compacting(),
671        )
672    } else {
673        (0, 0, 0, 0, None, 0, 0, 0, false)
674    };
675    PalaceInfo {
676        id: palace.id.0.clone(),
677        name: palace.name.clone(),
678        description: palace.description.clone(),
679        drawer_count,
680        vector_count,
681        kg_triple_count,
682        wing_count,
683        created_at: palace.created_at,
684        last_write_at,
685        node_count,
686        edge_count,
687        community_count,
688        is_compacting,
689    }
690}
691
692async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
693    let palaces = PalaceRegistry::list_palaces(&state.data_root)
694        .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
695    let mut out = Vec::with_capacity(palaces.len());
696    for p in palaces {
697        let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
698        out.push(palace_info_from(&p, handle.as_ref()));
699    }
700    Ok(Json(out))
701}
702
703#[derive(Deserialize)]
704struct CreatePalaceBody {
705    name: String,
706    #[serde(default)]
707    description: Option<String>,
708}
709
710async fn create_palace(
711    State(state): State<AppState>,
712    Json(body): Json<CreatePalaceBody>,
713) -> Result<Json<Value>, ApiError> {
714    let name = body.name.trim().to_string();
715    if name.is_empty() {
716        return Err(ApiError::bad_request("name is required"));
717    }
718    let id = PalaceId::new(&name);
719    let palace = Palace {
720        id: id.clone(),
721        name: name.clone(),
722        description: body.description.filter(|s| !s.is_empty()),
723        created_at: chrono::Utc::now(),
724        data_dir: state.data_root.join(&name),
725    };
726    state
727        .registry
728        .create_palace(&state.data_root, palace)
729        .map_err(|e| ApiError::internal(format!("create palace: {e:#}")))?;
730    state.emit(DaemonEvent::PalaceCreated {
731        id: name.clone(),
732        name: name.clone(),
733    });
734    Ok(Json(json!({ "id": name })))
735}
736
737async fn get_palace_handler(
738    State(state): State<AppState>,
739    AxumPath(id): AxumPath<String>,
740) -> Result<Json<PalaceInfo>, ApiError> {
741    let palaces = PalaceRegistry::list_palaces(&state.data_root)
742        .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
743    let palace = palaces
744        .into_iter()
745        .find(|p| p.id.0 == id)
746        .ok_or_else(|| ApiError::not_found(format!("palace not found: {id}")))?;
747    let handle = state
748        .registry
749        .open_palace(&state.data_root, &palace.id)
750        .ok();
751    Ok(Json(palace_info_from(&palace, handle.as_ref())))
752}
753
754// ---------------------------------------------------------------------------
755// Drawers
756// ---------------------------------------------------------------------------
757
758#[derive(Deserialize)]
759struct ListDrawersQuery {
760    #[serde(default)]
761    room: Option<String>,
762    #[serde(default)]
763    tag: Option<String>,
764    #[serde(default)]
765    limit: Option<usize>,
766}
767
768async fn list_drawers(
769    State(state): State<AppState>,
770    AxumPath(id): AxumPath<String>,
771    Query(q): Query<ListDrawersQuery>,
772) -> Result<Json<Value>, ApiError> {
773    let handle = open_handle(&state, &id)?;
774    let room = q.room.as_deref().map(RoomType::parse);
775    let drawers = handle.list_drawers(room, q.tag.clone(), q.limit.unwrap_or(50));
776    Ok(Json(serde_json::to_value(drawers).unwrap_or(json!([]))))
777}
778
779#[derive(Deserialize)]
780struct CreateDrawerBody {
781    content: String,
782    #[serde(default)]
783    room: Option<String>,
784    #[serde(default)]
785    tags: Vec<String>,
786    #[serde(default)]
787    importance: Option<f32>,
788}
789
790/// Maximum number of characters retained in a drawer's content preview.
791///
792/// Why: SSE consumers (TUI activity log, dashboard ticker) render the
793/// preview in a single line alongside the palace name; ~80 chars keeps the
794/// line readable on a 100-column terminal without truncating the palace
795/// label.
796const DRAWER_PREVIEW_MAX_CHARS: usize = 80;
797
798/// Build a single-line preview of drawer content for SSE events.
799///
800/// Why: the activity feed should show *what* was just stored, not only the
801/// running drawer count. Multiline / whitespace-heavy bodies otherwise blow
802/// out the log row, so we normalise whitespace and bound the length.
803/// What: collapses every run of ASCII / Unicode whitespace to a single space,
804/// trims leading/trailing whitespace, and truncates to
805/// [`DRAWER_PREVIEW_MAX_CHARS`] characters with a trailing `…` when cut.
806/// Test: `drawer_preview_collapses_whitespace_and_truncates`.
807fn drawer_content_preview(content: &str) -> String {
808    let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
809    if normalised.chars().count() <= DRAWER_PREVIEW_MAX_CHARS {
810        normalised
811    } else {
812        let kept: String = normalised
813            .chars()
814            .take(DRAWER_PREVIEW_MAX_CHARS.saturating_sub(1))
815            .collect();
816        format!("{kept}…")
817    }
818}
819
820async fn create_drawer(
821    State(state): State<AppState>,
822    AxumPath(id): AxumPath<String>,
823    Json(body): Json<CreateDrawerBody>,
824) -> Result<Json<Value>, ApiError> {
825    let handle = open_handle(&state, &id)?;
826    let room = body
827        .room
828        .as_deref()
829        .map(RoomType::parse)
830        .unwrap_or(RoomType::General);
831    let importance = body.importance.unwrap_or(0.5);
832    // Compute the preview *before* moving `body.content` into `remember` so
833    // the SSE activity feed can show what was actually stored.
834    let content_preview = drawer_content_preview(&body.content);
835    let drawer_id = handle
836        .remember(body.content, room, body.tags, importance)
837        .await
838        .map_err(|e| ApiError::internal(format!("remember: {e:#}")))?;
839    let drawer_count = handle.drawers.read().len();
840    let palace_name = PalaceRegistry::list_palaces(&state.data_root)
841        .ok()
842        .and_then(|ps| ps.into_iter().find(|p| p.id.0 == id).map(|p| p.name))
843        .unwrap_or_else(|| id.clone());
844    state.emit(DaemonEvent::DrawerAdded {
845        palace_id: id.clone(),
846        palace_name,
847        drawer_count,
848        timestamp: chrono::Utc::now(),
849        content_preview,
850    });
851    state.emit(aggregate_status_event(&state));
852    Ok(Json(json!({ "id": drawer_id })))
853}
854
855async fn delete_drawer(
856    State(state): State<AppState>,
857    AxumPath((id, drawer_id)): AxumPath<(String, String)>,
858) -> Result<StatusCode, ApiError> {
859    let handle = open_handle(&state, &id)?;
860    let uuid = Uuid::parse_str(&drawer_id)
861        .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
862    handle
863        .forget(uuid)
864        .await
865        .map_err(|e| ApiError::internal(format!("forget: {e:#}")))?;
866    let drawer_count = handle.drawers.read().len();
867    state.emit(DaemonEvent::DrawerDeleted {
868        palace_id: id.clone(),
869        drawer_count,
870    });
871    state.emit(aggregate_status_event(&state));
872    Ok(StatusCode::NO_CONTENT)
873}
874
875/// Compute the current aggregate `StatusChanged` event by walking all palaces.
876///
877/// Why: Several mutating handlers (drawer add/delete, dream run) need to push
878/// a refreshed status snapshot so dashboard stat cards stay in sync without
879/// the SPA having to issue an extra `/api/v1/status` request.
880/// What: Mirrors the math in the `status` handler — sums drawer count,
881/// vector index size, and active KG triples across every persisted palace.
882/// Test: Indirectly via the SSE integration tests that observe the event.
883fn aggregate_status_event(state: &AppState) -> DaemonEvent {
884    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
885    let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
886    for p in &palaces {
887        if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
888            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
889            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
890            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
891        }
892    }
893    DaemonEvent::StatusChanged {
894        total_drawers,
895        total_vectors,
896        total_kg_triples,
897    }
898}
899
900// ---------------------------------------------------------------------------
901// Recall
902// ---------------------------------------------------------------------------
903
904#[derive(Deserialize)]
905struct RecallQuery {
906    q: String,
907    #[serde(default)]
908    top_k: Option<usize>,
909    #[serde(default)]
910    deep: Option<bool>,
911}
912
913async fn recall_handler(
914    State(state): State<AppState>,
915    AxumPath(id): AxumPath<String>,
916    Query(q): Query<RecallQuery>,
917) -> Result<Json<Value>, ApiError> {
918    let handle = open_handle(&state, &id)?;
919    let top_k = q.top_k.unwrap_or(10);
920    let results = if q.deep.unwrap_or(false) {
921        recall_deep_with_default_embedder(&handle, &q.q, top_k).await
922    } else {
923        recall_with_default_embedder(&handle, &q.q, top_k).await
924    }
925    .map_err(|e| ApiError::internal(format!("recall: {e:#}")))?;
926
927    let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
928    Ok(Json(json!(payload)))
929}
930
931/// Flatten a [`RecallResult`] into a single JSON object with the drawer's
932/// fields hoisted to the top level.
933///
934/// Why: Issue #69 — the recall API previously nested the drawer under a
935/// `"drawer"` wrapper (`{"drawer": {"content": …}, "score": …}`), so every
936/// client that looked for `content`/`tags`/`importance` at the top level of an
937/// entry got nothing and recall always appeared to return `[]`. Hoisting the
938/// drawer fields makes `content` directly reachable while keeping `score` and
939/// `layer` alongside as ranking metadata.
940/// What: Serializes the [`Drawer`](trusty_common::memory_core::Drawer) to a
941/// JSON object and inserts `score` and `layer`. The `Drawer` schema has no
942/// `score`/`layer` keys, so there is no field collision. Falls back to a
943/// `{"score", "layer"}`-only object if the drawer fails to serialize (it never
944/// should — `Drawer` is plain `#[derive(Serialize)]` data).
945/// Test: `recall_entry_json_hoists_drawer_fields` asserts `content` is at the
946/// top level and the `drawer` wrapper key is absent.
947fn recall_entry_json(r: RecallResult) -> Value {
948    let mut obj = match serde_json::to_value(&r.drawer) {
949        Ok(Value::Object(map)) => map,
950        _ => serde_json::Map::new(),
951    };
952    obj.insert("score".to_string(), json!(r.score));
953    obj.insert("layer".to_string(), json!(r.layer));
954    Value::Object(obj)
955}
956
957/// `GET /api/v1/recall?q=<query>&top_k=<n>&deep=<bool>` — cross-palace semantic
958/// search.
959///
960/// Why: Agents and dashboard widgets often need the most relevant memories
961/// regardless of palace boundary; forcing the caller to issue one request per
962/// palace and merge client-side is both slower (no fan-out) and wrong (no
963/// dedup/rerank). Serving the merged top-k from the daemon collapses the
964/// round-trip and reuses the shared embedder singleton.
965/// What: Lists all palaces, opens each (skipping any that fail to open with a
966/// warning), and delegates to `execute_recall_all`. Returns a JSON array of
967/// `{ palace_id, drawer, score, layer }` entries sorted by score descending.
968/// Test: Exercised via `execute_recall_all` directly and through the MCP
969/// `memory_recall_all` tool dispatch.
970async fn recall_all_handler(
971    State(state): State<AppState>,
972    Query(q): Query<RecallQuery>,
973) -> Result<Json<Value>, ApiError> {
974    let top_k = q.top_k.unwrap_or(10);
975    let deep = q.deep.unwrap_or(false);
976    let value = execute_recall_all(&state, &q.q, top_k, deep).await;
977    if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
978        return Err(ApiError::internal(err.to_string()));
979    }
980    Ok(Json(value))
981}
982
983// ---------------------------------------------------------------------------
984// Knowledge Graph
985// ---------------------------------------------------------------------------
986
987#[derive(Deserialize)]
988struct KgQueryParams {
989    subject: String,
990}
991
992async fn kg_query(
993    State(state): State<AppState>,
994    AxumPath(id): AxumPath<String>,
995    Query(q): Query<KgQueryParams>,
996) -> Result<Json<Vec<Triple>>, ApiError> {
997    let handle = open_handle(&state, &id)?;
998    let triples = handle
999        .kg
1000        .query_active(&q.subject)
1001        .await
1002        .map_err(|e| ApiError::internal(format!("kg query: {e:#}")))?;
1003    Ok(Json(triples))
1004}
1005
1006#[derive(Deserialize)]
1007struct KgAssertBody {
1008    subject: String,
1009    predicate: String,
1010    object: String,
1011    #[serde(default)]
1012    confidence: Option<f32>,
1013    #[serde(default)]
1014    provenance: Option<String>,
1015}
1016
1017async fn kg_assert(
1018    State(state): State<AppState>,
1019    AxumPath(id): AxumPath<String>,
1020    Json(body): Json<KgAssertBody>,
1021) -> Result<StatusCode, ApiError> {
1022    let handle = open_handle(&state, &id)?;
1023    let triple = Triple {
1024        subject: body.subject,
1025        predicate: body.predicate,
1026        object: body.object,
1027        valid_from: chrono::Utc::now(),
1028        valid_to: None,
1029        confidence: body.confidence.unwrap_or(1.0),
1030        provenance: body.provenance,
1031    };
1032    handle
1033        .kg
1034        .assert(triple)
1035        .await
1036        .map_err(|e| ApiError::internal(format!("kg assert: {e:#}")))?;
1037    Ok(StatusCode::NO_CONTENT)
1038}
1039
1040/// Default page size for KG explorer list endpoints when caller omits `limit`.
1041///
1042/// Why: 50 is large enough to feel responsive in the SPA without dumping a
1043/// full graph in one request; matches the default the spec calls for.
1044const DEFAULT_KG_LIST_LIMIT: usize = 50;
1045
1046/// Hard ceiling on `limit` for KG explorer list endpoints.
1047///
1048/// Why: prevent a misconfigured client from asking the daemon to materialize
1049/// thousands of rows in one go; matches the spec's max=200.
1050const MAX_KG_LIST_LIMIT: usize = 200;
1051
1052fn default_kg_list_limit() -> usize {
1053    DEFAULT_KG_LIST_LIMIT
1054}
1055
1056/// Query parameters for `GET /api/v1/palaces/{id}/kg/subjects`.
1057///
1058/// Why: The KG Explorer's left panel asks for a bounded subject list; `limit`
1059/// is clamped server-side so the SPA cannot accidentally pull the whole graph.
1060/// What: `limit` defaults to [`DEFAULT_KG_LIST_LIMIT`] and is clamped to
1061/// `[1, MAX_KG_LIST_LIMIT]` in the handler.
1062/// Test: indirectly by the KG explorer UI; `kg_list_subjects_returns_distinct`
1063/// in the web tests below covers the happy path.
1064#[derive(Deserialize)]
1065struct KgListSubjectsParams {
1066    #[serde(default = "default_kg_list_limit")]
1067    limit: usize,
1068}
1069
1070/// `GET /api/v1/palaces/{id}/kg/subjects?limit=N` — list distinct active
1071/// subjects.
1072///
1073/// Why: The KG Explorer needs to browse subjects without a prior query (the
1074/// existing `kg_query` endpoint requires one). Surfacing this read on the
1075/// daemon avoids the SPA having to know how to issue SQL.
1076/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1077/// `KnowledgeGraph::list_subjects`. Returns a JSON array of strings.
1078/// Test: `kg_list_subjects_returns_distinct` (web tests).
1079async fn kg_list_subjects(
1080    State(state): State<AppState>,
1081    AxumPath(id): AxumPath<String>,
1082    Query(q): Query<KgListSubjectsParams>,
1083) -> Result<Json<Vec<String>>, ApiError> {
1084    let handle = open_handle(&state, &id)?;
1085    let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1086    let subjects = handle
1087        .kg
1088        .list_subjects(limit)
1089        .map_err(|e| ApiError::internal(format!("kg list_subjects: {e:#}")))?;
1090    Ok(Json(subjects))
1091}
1092
1093/// `GET /api/v1/palaces/{id}/kg/subjects_with_counts?limit=N` — list distinct
1094/// active subjects with their active-triple counts.
1095///
1096/// Why: The KG Explorer's subject list shows a count badge per subject and
1097/// supports sort-by-count. Returning the grouped counts in a single SQL pass
1098/// is cheaper than issuing one query per subject from the SPA.
1099/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1100/// `KnowledgeGraph::list_subjects_with_counts`. Returns a JSON array of
1101/// `{subject, count}` objects ordered alphabetically.
1102/// Test: indirectly via the KG Explorer UI; the core `list_subjects_with_counts`
1103/// test in `kg.rs` covers the SQL grouping.
1104async fn kg_list_subjects_with_counts(
1105    State(state): State<AppState>,
1106    AxumPath(id): AxumPath<String>,
1107    Query(q): Query<KgListSubjectsParams>,
1108) -> Result<Json<Vec<Value>>, ApiError> {
1109    let handle = open_handle(&state, &id)?;
1110    let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1111    let rows = handle
1112        .kg
1113        .list_subjects_with_counts(limit)
1114        .map_err(|e| ApiError::internal(format!("kg list_subjects_with_counts: {e:#}")))?;
1115    let out: Vec<Value> = rows
1116        .into_iter()
1117        .map(|(subject, count)| json!({ "subject": subject, "count": count }))
1118        .collect();
1119    Ok(Json(out))
1120}
1121
1122/// Query parameters for `GET /api/v1/palaces/{id}/kg/all`.
1123///
1124/// Why: The KG Explorer's "All" mode pages through every active triple;
1125/// `limit`+`offset` give the SPA stable prev/next controls.
1126/// What: defaults match `kg_list_subjects` for limit; `offset` defaults to 0.
1127/// Test: `kg_list_all_returns_paginated_triples` (web tests).
1128#[derive(Deserialize)]
1129struct KgListAllParams {
1130    #[serde(default = "default_kg_list_limit")]
1131    limit: usize,
1132    #[serde(default)]
1133    offset: usize,
1134}
1135
1136/// `GET /api/v1/palaces/{id}/kg/all?limit=N&offset=N` — list all active
1137/// triples ordered by `valid_from` descending.
1138///
1139/// Why: The KG Explorer's "All" mode wants a paged view across every active
1140/// triple regardless of subject. The existing `kg_query` requires a subject.
1141/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1142/// `KnowledgeGraph::list_active`. Returns a JSON array of `Triple` objects.
1143/// Test: `kg_list_all_returns_paginated_triples` (web tests).
1144async fn kg_list_all(
1145    State(state): State<AppState>,
1146    AxumPath(id): AxumPath<String>,
1147    Query(q): Query<KgListAllParams>,
1148) -> Result<Json<Vec<Triple>>, ApiError> {
1149    let handle = open_handle(&state, &id)?;
1150    let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1151    let triples = handle
1152        .kg
1153        .list_active(limit, q.offset)
1154        .await
1155        .map_err(|e| ApiError::internal(format!("kg list_active: {e:#}")))?;
1156    Ok(Json(triples))
1157}
1158
1159/// `GET /api/v1/palaces/{id}/kg/count` — count of currently-active triples.
1160///
1161/// Why: The KG Explorer header shows a quick "N triples" badge; computing the
1162/// count server-side avoids fetching every triple to count them.
1163/// What: returns `{ "active": N }` where N is `count_active_triples()` on the
1164/// palace's KG.
1165/// Test: indirectly via the same palace counts surfaced on `/api/v1/status`.
1166async fn kg_count(
1167    State(state): State<AppState>,
1168    AxumPath(id): AxumPath<String>,
1169) -> Result<Json<Value>, ApiError> {
1170    let handle = open_handle(&state, &id)?;
1171    let active = handle.kg.count_active_triples();
1172    Ok(Json(json!({ "active": active })))
1173}
1174
1175// ---------------------------------------------------------------------------
1176// Dream cycle status + on-demand run
1177// ---------------------------------------------------------------------------
1178
1179/// Wire payload for dream status endpoints — `last_run_at` may be null when no
1180/// cycle has run yet on this palace (or the aggregate has nothing to report).
1181#[derive(Serialize, Default)]
1182struct DreamStatusPayload {
1183    last_run_at: Option<chrono::DateTime<chrono::Utc>>,
1184    merged: usize,
1185    pruned: usize,
1186    compacted: usize,
1187    closets_updated: usize,
1188    duration_ms: u64,
1189}
1190
1191impl From<PersistedDreamStats> for DreamStatusPayload {
1192    fn from(p: PersistedDreamStats) -> Self {
1193        Self {
1194            last_run_at: Some(p.last_run_at),
1195            merged: p.stats.merged,
1196            pruned: p.stats.pruned,
1197            compacted: p.stats.compacted,
1198            closets_updated: p.stats.closets_updated,
1199            duration_ms: p.stats.duration_ms,
1200        }
1201    }
1202}
1203
1204/// GET /api/v1/dream/status — aggregate latest dream stats across all palaces.
1205///
1206/// Why: The dashboard wants a single "last dream cycle" panel rather than
1207/// per-palace details; we sum the per-palace counters and surface the most
1208/// recent `last_run_at` so operators can spot a stalled background loop.
1209/// What: Walks every palace, loads its `dream_stats.json` if present, sums
1210/// counts, and returns the max `last_run_at` (or null if no palace has run).
1211/// Test: `dream_status_aggregates_across_palaces` covers the read path.
1212async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
1213    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
1214    let mut out = DreamStatusPayload::default();
1215    let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
1216    for p in palaces {
1217        let data_dir = state.data_root.join(p.id.as_str());
1218        let snap = match PersistedDreamStats::load(&data_dir) {
1219            Ok(Some(s)) => s,
1220            _ => continue,
1221        };
1222        out.merged = out.merged.saturating_add(snap.stats.merged);
1223        out.pruned = out.pruned.saturating_add(snap.stats.pruned);
1224        out.compacted = out.compacted.saturating_add(snap.stats.compacted);
1225        out.closets_updated = out
1226            .closets_updated
1227            .saturating_add(snap.stats.closets_updated);
1228        out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
1229        latest = match latest {
1230            Some(t) if t >= snap.last_run_at => Some(t),
1231            _ => Some(snap.last_run_at),
1232        };
1233    }
1234    out.last_run_at = latest;
1235    Json(out)
1236}
1237
1238/// GET /api/v1/palaces/:id/dream/status — per-palace dream stats snapshot.
1239async fn palace_dream_status(
1240    State(state): State<AppState>,
1241    AxumPath(id): AxumPath<String>,
1242) -> Result<Json<DreamStatusPayload>, ApiError> {
1243    let data_dir = state.data_root.join(&id);
1244    if !data_dir.exists() {
1245        return Err(ApiError::not_found(format!("palace not found: {id}")));
1246    }
1247    let payload = match PersistedDreamStats::load(&data_dir) {
1248        Ok(Some(s)) => s.into(),
1249        Ok(None) => DreamStatusPayload::default(),
1250        Err(e) => return Err(ApiError::internal(format!("read dream stats: {e:#}"))),
1251    };
1252    Ok(Json(payload))
1253}
1254
1255/// POST /api/v1/dream/run — run a dream cycle across all palaces on demand.
1256///
1257/// Why: The dashboard exposes a "Run now" button so operators can force a
1258/// cycle without waiting for the idle clock; useful after a bulk ingest or
1259/// when diagnosing the dream loop itself.
1260/// What: Opens every persisted palace, runs `Dreamer::dream_cycle` with the
1261/// default config, and returns the aggregated stats plus the run timestamp.
1262/// Errors on individual palaces are logged but don't abort the sweep.
1263/// Test: `dream_run_aggregates_stats` covers the round-trip.
1264async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
1265    let palaces = PalaceRegistry::list_palaces(&state.data_root)
1266        .map_err(|e| ApiError::internal(format!("list palaces: {e:#}")))?;
1267    let dreamer = Dreamer::new(DreamConfig::default());
1268    let mut out = DreamStatusPayload::default();
1269    for p in palaces {
1270        let handle = match state.registry.open_palace(&state.data_root, &p.id) {
1271            Ok(h) => h,
1272            Err(e) => {
1273                tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
1274                continue;
1275            }
1276        };
1277        match dreamer.dream_cycle(&handle).await {
1278            Ok(stats) => {
1279                out.merged = out.merged.saturating_add(stats.merged);
1280                out.pruned = out.pruned.saturating_add(stats.pruned);
1281                out.compacted = out.compacted.saturating_add(stats.compacted);
1282                out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
1283                out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
1284            }
1285            Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
1286        }
1287        // Issue #53: refresh the community-detection cache after each
1288        // successful or failed cycle. Even if the dedup/decay pass errored we
1289        // still want a fresh gap snapshot — `knowledge_gaps()` reads the KG
1290        // directly and is independent of the dream pass results.
1291        refresh_gaps_cache(&state, &handle).await;
1292    }
1293    out.last_run_at = Some(chrono::Utc::now());
1294    state.emit(DaemonEvent::DreamCompleted {
1295        palace_id: None,
1296        merged: out.merged,
1297        pruned: out.pruned,
1298        compacted: out.compacted,
1299        closets_updated: out.closets_updated,
1300        duration_ms: out.duration_ms,
1301    });
1302    state.emit(aggregate_status_event(&state));
1303    Ok(Json(out))
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Knowledge gaps — community detection cache (issue #53)
1308// ---------------------------------------------------------------------------
1309
1310/// Wire shape for a single knowledge gap returned by `/api/v1/kg/gaps`.
1311///
1312/// Why: `KnowledgeGap` (in `trusty-common`) does not derive `Serialize`
1313/// because that would force serde into the memory-core feature surface; the
1314/// HTTP layer instead owns a narrow response struct mirroring its fields.
1315/// What: One-for-one wire representation of `KnowledgeGap` — entities, the
1316/// internal-density score, the cross-community bridge count, and the
1317/// LLM/template exploration hint.
1318/// Test: `kg_gaps_endpoint_returns_cached_gaps`.
1319#[derive(Serialize, Debug, Clone)]
1320pub struct KnowledgeGapResponse {
1321    pub entities: Vec<String>,
1322    pub internal_density: f32,
1323    pub external_bridges: usize,
1324    pub suggested_exploration: String,
1325}
1326
1327impl From<KnowledgeGap> for KnowledgeGapResponse {
1328    fn from(g: KnowledgeGap) -> Self {
1329        Self {
1330            entities: g.entities,
1331            internal_density: g.internal_density,
1332            external_bridges: g.external_bridges,
1333            suggested_exploration: g.suggested_exploration,
1334        }
1335    }
1336}
1337
1338#[derive(Deserialize)]
1339struct KgGapsQuery {
1340    #[serde(default)]
1341    palace: Option<String>,
1342}
1343
1344/// `GET /api/v1/kg/gaps?palace=<name>` — return the cached knowledge gaps.
1345///
1346/// Why: Issue #53 — surfaces the community-detection output computed by the
1347/// dream cycle so callers (dashboard, MCP tool, external tooling) can list
1348/// the sparse-cluster targets the model should explore next. Reading from
1349/// the in-memory cache means a `/kg/gaps` request never triggers a Louvain
1350/// run; it just clones the latest snapshot.
1351/// What: Resolves the palace from the optional `palace` query arg (falling
1352/// back to the daemon's `default_palace`, then erroring with 400 if neither
1353/// is set). Returns `[]` when the cache has no entry yet — the dream cycle
1354/// simply hasn't populated it. Returns 404 only when the palace name is
1355/// unknown to the registry (handle.open failed).
1356/// Test: `kg_gaps_endpoint_returns_cached_gaps`,
1357/// `kg_gaps_endpoint_returns_empty_when_uncached`.
1358async fn kg_gaps_handler(
1359    State(state): State<AppState>,
1360    Query(q): Query<KgGapsQuery>,
1361) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
1362    let palace_name = q
1363        .palace
1364        .clone()
1365        .or_else(|| state.default_palace.clone())
1366        .ok_or_else(|| {
1367            ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
1368        })?;
1369
1370    // Validate the palace exists; we don't strictly need the handle for the
1371    // cache lookup but we want a 404 rather than an empty-array masking a
1372    // typo in the palace name.
1373    let _handle = open_handle(&state, &palace_name)?;
1374
1375    let pid = PalaceId::new(&palace_name);
1376    let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
1377    let body: Vec<KnowledgeGapResponse> =
1378        gaps.into_iter().map(KnowledgeGapResponse::from).collect();
1379    Ok(Json(body))
1380}
1381
1382// ---------------------------------------------------------------------------
1383// Prompt-facts surface (issue #42)
1384// ---------------------------------------------------------------------------
1385
1386/// Query parameters shared by the prompt-context / prompt-facts read endpoints.
1387///
1388/// Why: Both `GET /api/v1/kg/prompt-context` and `GET /api/v1/kg/prompt-facts`
1389/// optionally accept a `palace` filter so callers can scope reads to a single
1390/// project namespace. A shared struct keeps the wire shape consistent.
1391/// What: A single optional `palace` query parameter. When omitted, handlers
1392/// span every palace in the registry (matching the MCP tool behaviour).
1393/// Test: `prompt_context_endpoint_returns_formatted_block`,
1394/// `list_prompt_facts_endpoint_returns_hot_triples`.
1395#[derive(Deserialize)]
1396struct PromptFactsQuery {
1397    // Accepted for forward-compat with the MCP tool surface, but ignored:
1398    // the prompt cache is registry-wide, so reads always span every palace.
1399    // We keep the field rather than ignoring `?palace=...` silently so a
1400    // future per-palace filter is a non-breaking schema addition.
1401    #[serde(default)]
1402    #[allow(dead_code)]
1403    palace: Option<String>,
1404}
1405
1406/// Wire shape for `POST /api/v1/kg/aliases`.
1407///
1408/// Why: Mirrors the `add_alias` MCP tool: a short → full mapping with an
1409/// optional palace target. Keeping the field names identical between the
1410/// HTTP and MCP surfaces makes documentation and client code reuse trivial.
1411/// What: Required `short` and `full`; optional `palace` (falls back to the
1412/// daemon default).
1413/// Test: `add_alias_endpoint_asserts_triple_and_refreshes_cache`.
1414#[derive(Deserialize)]
1415struct AddAliasRequest {
1416    short: String,
1417    full: String,
1418    #[serde(default)]
1419    palace: Option<String>,
1420}
1421
1422/// Wire shape for a single hot-predicate triple in JSON responses.
1423///
1424/// Why: `list_prompt_facts` returns a structured array rather than the
1425/// pre-formatted Markdown so dashboards and tooling can render their own
1426/// views over the raw data.
1427/// What: subject/predicate/object string trio matching the underlying KG row.
1428/// Test: `list_prompt_facts_endpoint_returns_hot_triples`.
1429#[derive(Serialize)]
1430struct PromptFactRow {
1431    subject: String,
1432    predicate: String,
1433    object: String,
1434}
1435
1436/// Query parameters for `DELETE /api/v1/kg/prompt-facts`.
1437///
1438/// Why: The MCP tool retracts the active interval for a `(subject, predicate)`
1439/// pair across every palace; the HTTP endpoint matches that contract so a
1440/// dashboard "Remove" button doesn't need to know which palace owns the fact.
1441/// What: Required `subject` and `predicate`; the issue spec mentions an
1442/// optional `object` filter but the underlying `KnowledgeGraph::retract` API
1443/// closes the entire `(subject, predicate)` interval — we accept `object`
1444/// for forward-compat but currently ignore it, mirroring the MCP tool.
1445/// Test: `remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache`.
1446#[derive(Deserialize)]
1447struct RemovePromptFactQuery {
1448    subject: String,
1449    predicate: String,
1450    #[serde(default)]
1451    #[allow(dead_code)]
1452    object: Option<String>,
1453    #[serde(default)]
1454    #[allow(dead_code)]
1455    palace: Option<String>,
1456}
1457
1458/// `GET /api/v1/kg/prompt-context` — return the formatted prompt-context block.
1459///
1460/// Why: Lets non-MCP callers (the admin UI, curl, integration tests) fetch
1461/// the same Markdown block the `get_prompt_context` tool returns, without
1462/// needing to speak JSON-RPC. The body is a plain text response so it can
1463/// be piped straight into a model prompt.
1464/// What: Reads the in-memory `prompt_context_cache` (already kept fresh by
1465/// any write that touches a hot predicate), returns the formatted string,
1466/// or a placeholder message when nothing has been stored yet.
1467/// Test: `prompt_context_endpoint_returns_formatted_block`.
1468async fn prompt_context_handler(
1469    State(state): State<AppState>,
1470    Query(_q): Query<PromptFactsQuery>,
1471) -> Result<Response, ApiError> {
1472    let cache_snapshot = {
1473        let guard = state
1474            .prompt_context_cache
1475            .read()
1476            .map_err(|e| ApiError::internal(format!("prompt cache lock poisoned: {e}")))?;
1477        guard.clone()
1478    };
1479    let body = if cache_snapshot.formatted.is_empty() {
1480        "No prompt facts stored yet.".to_string()
1481    } else {
1482        cache_snapshot.formatted
1483    };
1484    let mut resp = body.into_response();
1485    resp.headers_mut().insert(
1486        header::CONTENT_TYPE,
1487        HeaderValue::from_static("text/plain; charset=utf-8"),
1488    );
1489    Ok(resp)
1490}
1491
1492/// `POST /api/v1/kg/aliases` — assert a `(short, is_alias_for, full)` triple.
1493///
1494/// Why: HTTP counterpart to the `add_alias` MCP tool — lets the admin UI
1495/// (or an external automation) register aliases without speaking JSON-RPC.
1496/// What: Resolves the target palace (request body → daemon default), opens
1497/// the palace handle, asserts the alias triple, and rebuilds the prompt
1498/// cache so subsequent `GET /api/v1/kg/prompt-context` calls reflect the
1499/// write immediately.
1500/// Test: `add_alias_endpoint_asserts_triple_and_refreshes_cache`.
1501async fn add_alias_handler(
1502    State(state): State<AppState>,
1503    Json(req): Json<AddAliasRequest>,
1504) -> Result<Json<Value>, ApiError> {
1505    if req.short.is_empty() || req.full.is_empty() {
1506        return Err(ApiError::bad_request("short and full are required"));
1507    }
1508    let palace_name = req
1509        .palace
1510        .clone()
1511        .or_else(|| state.default_palace.clone())
1512        .ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
1513    let handle = open_handle(&state, &palace_name)?;
1514    let triple = Triple {
1515        subject: req.short.clone(),
1516        predicate: "is_alias_for".to_string(),
1517        object: req.full.clone(),
1518        valid_from: chrono::Utc::now(),
1519        valid_to: None,
1520        confidence: 1.0,
1521        provenance: Some("add_alias_http".to_string()),
1522    };
1523    handle
1524        .kg
1525        .assert(triple)
1526        .await
1527        .map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
1528    if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1529        tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
1530    }
1531    Ok(Json(json!({
1532        "subject": req.short,
1533        "predicate": "is_alias_for",
1534        "object": req.full,
1535        "palace": palace_name,
1536    })))
1537}
1538
1539/// `GET /api/v1/kg/prompt-facts` — list every active hot-predicate triple.
1540///
1541/// Why: Mirrors the `list_prompt_facts` MCP tool. Returning the raw triples
1542/// (rather than the formatted block) lets dashboards group, search, and
1543/// edit them with their own UI.
1544/// What: Calls `gather_hot_triples` over the live registry and serialises
1545/// each row as `{subject, predicate, object}`.
1546/// Test: `list_prompt_facts_endpoint_returns_hot_triples`.
1547async fn list_prompt_facts_handler(
1548    State(state): State<AppState>,
1549    Query(_q): Query<PromptFactsQuery>,
1550) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
1551    let triples = crate::prompt_facts::gather_hot_triples(&state)
1552        .await
1553        .map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
1554    let rows: Vec<PromptFactRow> = triples
1555        .into_iter()
1556        .map(|(subject, predicate, object)| PromptFactRow {
1557            subject,
1558            predicate,
1559            object,
1560        })
1561        .collect();
1562    Ok(Json(rows))
1563}
1564
1565/// `DELETE /api/v1/kg/prompt-facts?subject=...&predicate=...` — soft-delete
1566/// the active triple matching the given `(subject, predicate)` pair.
1567///
1568/// Why: HTTP counterpart to the `remove_prompt_fact` MCP tool. Mirrors the
1569/// retract-across-palaces semantics so a single call cleans up the fact
1570/// regardless of which palace stored it.
1571/// What: Iterates every palace, calls `kg.retract(subject, predicate)`, and
1572/// reports the total number of intervals closed. Rebuilds the prompt cache
1573/// when at least one retraction occurred.
1574/// Test: `remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache`.
1575async fn remove_prompt_fact_handler(
1576    State(state): State<AppState>,
1577    Query(q): Query<RemovePromptFactQuery>,
1578) -> Result<Json<Value>, ApiError> {
1579    if q.subject.is_empty() || q.predicate.is_empty() {
1580        return Err(ApiError::bad_request("subject and predicate are required"));
1581    }
1582    let mut closed_total: usize = 0;
1583    for palace_id in state.registry.list() {
1584        if let Some(handle) = state.registry.get(&palace_id) {
1585            match handle.kg.retract(&q.subject, &q.predicate).await {
1586                Ok(n) => closed_total += n,
1587                Err(e) => tracing::warn!(
1588                    palace = %palace_id.as_str(),
1589                    "HTTP retract failed: {e:#}",
1590                ),
1591            }
1592        }
1593    }
1594    if closed_total > 0 {
1595        if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1596            tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
1597        }
1598        Ok(Json(json!({"removed": true, "closed": closed_total})))
1599    } else {
1600        Ok(Json(json!({"removed": false, "reason": "not found"})))
1601    }
1602}
1603
1604/// Recompute the gaps for `handle` and write them to the registry cache.
1605///
1606/// Why: Wraps the post-dream-cycle bookkeeping in one place so the HTTP
1607/// `dream_run` handler and any future schedulers share the exact same
1608/// enrichment path. Issue #53 also asks for an LLM-generated
1609/// `suggested_exploration` when `OPENROUTER_API_KEY` is set — that step is
1610/// best-effort and never blocks cache population.
1611/// What: Calls `KnowledgeGraph::knowledge_gaps()`, optionally enriches the
1612/// `suggested_exploration` field via `enrich_gap_exploration`, then stores
1613/// the resulting vec on `state.registry`. Logs the gap count at `debug!`.
1614/// Test: Indirect via `kg_gaps_endpoint_returns_cached_gaps` (which runs a
1615/// dream cycle and then reads `/api/v1/kg/gaps`).
1616async fn refresh_gaps_cache(state: &AppState, handle: &Arc<PalaceHandle>) {
1617    let mut gaps = handle.kg.knowledge_gaps();
1618    // LLM enrichment is best-effort. We only attempt it when an API key is
1619    // present in the process environment; absence is the common case and the
1620    // template `suggested_exploration` from `find_communities` is already a
1621    // perfectly serviceable fallback.
1622    if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") {
1623        if !api_key.is_empty() {
1624            for gap in gaps.iter_mut() {
1625                if let Some(enriched) = enrich_gap_exploration(&api_key, gap).await {
1626                    gap.suggested_exploration = enriched;
1627                }
1628            }
1629        }
1630    }
1631    let gap_count = gaps.len();
1632    state.registry.set_gaps(handle.id.clone(), gaps);
1633    tracing::debug!(palace = %handle.id, gaps = gap_count, "community gaps updated");
1634}
1635
1636/// Ask OpenRouter for a focused exploration question for a single gap.
1637///
1638/// Why: Issue #53 — when an API key is available the dream cycle should
1639/// upgrade the templated `suggested_exploration` to a model-generated
1640/// research question. The result is cached for cheap re-reads, so the LLM
1641/// cost is paid at most once per dream cycle per gap rather than on every
1642/// `/kg/gaps` request.
1643/// What: Builds a short user prompt naming up to the first five entities in
1644/// the gap, calls `openrouter_chat` (deprecated but still the simplest
1645/// one-shot helper in `trusty-common`), and returns the trimmed completion
1646/// on success. Returns `None` on any error so the caller can fall back to
1647/// the template.
1648/// Test: Network-dependent — not unit-tested. Behavioural coverage comes
1649/// from manual runs of the dream cycle with `OPENROUTER_API_KEY` set.
1650async fn enrich_gap_exploration(api_key: &str, gap: &KnowledgeGap) -> Option<String> {
1651    // Limit the entity list we shove into the prompt so we don't blow the
1652    // token budget on a 1k-node community.
1653    let preview: Vec<&str> = gap.entities.iter().take(5).map(String::as_str).collect();
1654    if preview.is_empty() {
1655        return None;
1656    }
1657    let entities = preview.join(", ");
1658    let user = format!(
1659        "Given these related entities from a knowledge graph: {entities}. \
1660         Suggest one specific research question (single sentence, under 25 words) \
1661         that would help fill gaps in this knowledge cluster. Return only the question."
1662    );
1663    let messages = vec![trusty_common::ChatMessage {
1664        role: "user".to_string(),
1665        content: user,
1666        tool_call_id: None,
1667        tool_calls: None,
1668    }];
1669    // `openrouter_chat` is deprecated in favour of `OpenRouterProvider::chat_stream`,
1670    // but the one-shot helper is the right tool for this background, best-effort
1671    // enrichment — we don't need streaming and we explicitly tolerate failures.
1672    #[allow(deprecated)]
1673    let res = trusty_common::openrouter_chat(api_key, "openai/gpt-4o-mini", messages).await;
1674    match res {
1675        Ok(text) => {
1676            let trimmed = text.trim().to_string();
1677            if trimmed.is_empty() {
1678                None
1679            } else {
1680                Some(trimmed)
1681            }
1682        }
1683        Err(e) => {
1684            tracing::debug!("openrouter gap enrichment failed (using template): {e:#}");
1685            None
1686        }
1687    }
1688}
1689
1690// ---------------------------------------------------------------------------
1691// Chat (OpenRouter, SSE-streaming)
1692// ---------------------------------------------------------------------------
1693
1694#[derive(Deserialize)]
1695struct ChatBody {
1696    #[serde(default)]
1697    palace_id: Option<String>,
1698    message: String,
1699    #[serde(default)]
1700    history: Vec<ChatMessage>,
1701    /// Optional existing chat-session id; when provided we load+append+save.
1702    #[serde(default)]
1703    session_id: Option<String>,
1704}
1705
1706/// Hard cap on the number of `tool -> assistant` round trips per chat turn.
1707///
1708/// Why: Without a bound, a malicious or confused model could request tools
1709/// indefinitely; 10 is generous enough for any realistic plan-and-act loop
1710/// while still terminating quickly when the model gets stuck.
1711const MAX_TOOL_ROUNDS: usize = 10;
1712
1713/// Build the complete set of tool definitions the chat assistant can call.
1714///
1715/// Why: Centralizing the tool surface keeps the wire schema, the dispatcher in
1716/// `execute_tool`, and the system prompt in lock-step — adding a new tool means
1717/// editing this one function plus a match arm.
1718/// What: Returns the 11 read/write tools spanning palace introspection,
1719/// memory recall/create, KG read/write, and daemon status.
1720/// Test: `all_tools_returns_expected_set` asserts names and required-arg shape.
1721fn all_tools() -> Vec<ToolDef> {
1722    vec![
1723        ToolDef {
1724            name: "list_palaces".into(),
1725            description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
1726            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1727        },
1728        ToolDef {
1729            name: "get_palace".into(),
1730            description: "Get details for a specific palace by id.".into(),
1731            parameters: json!({
1732                "type": "object",
1733                "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
1734                "required": ["palace_id"],
1735            }),
1736        },
1737        ToolDef {
1738            name: "recall_memories".into(),
1739            description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
1740            parameters: json!({
1741                "type": "object",
1742                "properties": {
1743                    "palace_id": { "type": "string" },
1744                    "query": { "type": "string", "description": "Free-text query" },
1745                    "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
1746                },
1747                "required": ["palace_id", "query"],
1748            }),
1749        },
1750        ToolDef {
1751            name: "list_drawers".into(),
1752            description: "List all drawers (memories) in a palace, most recent first.".into(),
1753            parameters: json!({
1754                "type": "object",
1755                "properties": { "palace_id": { "type": "string" } },
1756                "required": ["palace_id"],
1757            }),
1758        },
1759        ToolDef {
1760            name: "kg_query".into(),
1761            description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
1762            parameters: json!({
1763                "type": "object",
1764                "properties": {
1765                    "palace_id": { "type": "string" },
1766                    "subject": { "type": "string" }
1767                },
1768                "required": ["palace_id", "subject"],
1769            }),
1770        },
1771        ToolDef {
1772            name: "get_config".into(),
1773            description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
1774            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1775        },
1776        ToolDef {
1777            name: "get_status".into(),
1778            description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
1779            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1780        },
1781        ToolDef {
1782            name: "get_dream_status".into(),
1783            description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
1784            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
1785        },
1786        ToolDef {
1787            name: "get_palace_dream_status".into(),
1788            description: "Get dreamer activity stats for a specific palace.".into(),
1789            parameters: json!({
1790                "type": "object",
1791                "properties": { "palace_id": { "type": "string" } },
1792                "required": ["palace_id"],
1793            }),
1794        },
1795        ToolDef {
1796            name: "create_memory".into(),
1797            description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
1798            parameters: json!({
1799                "type": "object",
1800                "properties": {
1801                    "palace_id": { "type": "string" },
1802                    "content": { "type": "string", "description": "Verbatim memory text" },
1803                    "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
1804                    "tags": { "type": "array", "items": { "type": "string" } },
1805                    "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
1806                },
1807                "required": ["palace_id", "content"],
1808            }),
1809        },
1810        ToolDef {
1811            name: "kg_assert".into(),
1812            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(),
1813            parameters: json!({
1814                "type": "object",
1815                "properties": {
1816                    "palace_id": { "type": "string" },
1817                    "subject": { "type": "string" },
1818                    "predicate": { "type": "string" },
1819                    "object": { "type": "string" },
1820                    "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
1821                },
1822                "required": ["palace_id", "subject", "predicate", "object"],
1823            }),
1824        },
1825        ToolDef {
1826            name: "memory_recall_all".into(),
1827            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(),
1828            parameters: json!({
1829                "type": "object",
1830                "properties": {
1831                    "q": { "type": "string", "description": "Free-text query" },
1832                    "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
1833                    "deep": { "type": "boolean", "default": false }
1834                },
1835                "required": ["q"],
1836            }),
1837        },
1838    ]
1839}
1840
1841/// Execute a tool call against the live `AppState`.
1842///
1843/// Why: We want the model's tool invocations to call the same Rust paths the
1844/// HTTP handlers use — no extra HTTP round-trip, no JSON re-parsing, and the
1845/// results always reflect this daemon's view of the world.
1846/// What: Parses `arguments` as JSON, dispatches by tool name, returns a JSON
1847/// value that becomes the `role: "tool"` message content. Errors are caught
1848/// and returned as `{"error": "..."}` JSON so the model can react.
1849/// Test: `execute_tool_dispatches_known_tools` covers the dispatch path and
1850/// the unknown-tool error case.
1851async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
1852    let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
1853    match name {
1854        "list_palaces" => execute_list_palaces(state).await,
1855        "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1856            Some(id) => execute_get_palace(state, id).await,
1857            None => json!({ "error": "missing required argument: palace_id" }),
1858        },
1859        "recall_memories" => {
1860            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1861            let q = parsed.get("query").and_then(|v| v.as_str());
1862            let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1863            match (pid, q) {
1864                (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
1865                _ => json!({ "error": "missing required argument(s): palace_id, query" }),
1866            }
1867        }
1868        "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1869            Some(id) => execute_list_drawers(state, id).await,
1870            None => json!({ "error": "missing required argument: palace_id" }),
1871        },
1872        "kg_query" => {
1873            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1874            let subj = parsed.get("subject").and_then(|v| v.as_str());
1875            match (pid, subj) {
1876                (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
1877                _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
1878            }
1879        }
1880        "get_config" => execute_get_config(state),
1881        "get_status" => execute_get_status(state).await,
1882        "get_dream_status" => execute_get_dream_status(state).await,
1883        "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
1884            Some(id) => execute_get_palace_dream_status(state, id).await,
1885            None => json!({ "error": "missing required argument: palace_id" }),
1886        },
1887        "create_memory" => {
1888            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1889            let content = parsed.get("content").and_then(|v| v.as_str());
1890            let room = parsed.get("room").and_then(|v| v.as_str());
1891            let tags: Vec<String> = parsed
1892                .get("tags")
1893                .and_then(|v| v.as_array())
1894                .map(|arr| {
1895                    arr.iter()
1896                        .filter_map(|t| t.as_str().map(|s| s.to_string()))
1897                        .collect()
1898                })
1899                .unwrap_or_default();
1900            let importance = parsed
1901                .get("importance")
1902                .and_then(|v| v.as_f64())
1903                .map(|f| f as f32)
1904                .unwrap_or(0.5);
1905            match (pid, content) {
1906                (Some(p), Some(c)) => {
1907                    execute_create_memory(state, p, c, room, tags, importance).await
1908                }
1909                _ => json!({ "error": "missing required argument(s): palace_id, content" }),
1910            }
1911        }
1912        "kg_assert" => {
1913            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
1914            let subj = parsed.get("subject").and_then(|v| v.as_str());
1915            let pred = parsed.get("predicate").and_then(|v| v.as_str());
1916            let obj = parsed.get("object").and_then(|v| v.as_str());
1917            let conf = parsed
1918                .get("confidence")
1919                .and_then(|v| v.as_f64())
1920                .map(|f| f as f32)
1921                .unwrap_or(1.0);
1922            match (pid, subj, pred, obj) {
1923                (Some(p), Some(s), Some(pr), Some(o)) => {
1924                    execute_kg_assert(state, p, s, pr, o, conf).await
1925                }
1926                _ => json!({
1927                    "error": "missing required argument(s): palace_id, subject, predicate, object"
1928                }),
1929            }
1930        }
1931        "memory_recall_all" => {
1932            let q = parsed.get("q").and_then(|v| v.as_str());
1933            let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1934            let deep = parsed
1935                .get("deep")
1936                .and_then(|v| v.as_bool())
1937                .unwrap_or(false);
1938            match q {
1939                Some(q) => execute_recall_all(state, q, top_k, deep).await,
1940                None => json!({ "error": "missing required argument: q" }),
1941            }
1942        }
1943        _ => json!({ "error": format!("unknown tool: {name}") }),
1944    }
1945}
1946
1947async fn execute_list_palaces(state: &AppState) -> Value {
1948    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1949        Ok(v) => v,
1950        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1951    };
1952    let out: Vec<Value> = palaces
1953        .into_iter()
1954        .map(|p| {
1955            let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1956            let info = palace_info_from(&p, handle.as_ref());
1957            serde_json::to_value(info).unwrap_or(json!({}))
1958        })
1959        .collect();
1960    json!(out)
1961}
1962
1963async fn execute_get_palace(state: &AppState, id: &str) -> Value {
1964    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
1965        Ok(v) => v,
1966        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
1967    };
1968    match palaces.into_iter().find(|p| p.id.0 == id) {
1969        Some(p) => {
1970            let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
1971            serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
1972        }
1973        None => json!({ "error": format!("palace not found: {id}") }),
1974    }
1975}
1976
1977async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
1978    let handle = match state
1979        .registry
1980        .open_palace(&state.data_root, &PalaceId::new(palace_id))
1981    {
1982        Ok(h) => h,
1983        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
1984    };
1985    match recall_with_default_embedder(&handle, query, top_k).await {
1986        Ok(hits) => json!(hits
1987            .into_iter()
1988            .map(|r| json!({
1989                "drawer_id": r.drawer.id.to_string(),
1990                "content": r.drawer.content,
1991                "importance": r.drawer.importance,
1992                "tags": r.drawer.tags,
1993                "score": r.score,
1994                "layer": r.layer,
1995            }))
1996            .collect::<Vec<_>>()),
1997        Err(e) => json!({ "error": format!("recall: {e:#}") }),
1998    }
1999}
2000
2001/// Execute a cross-palace recall and return JSON results tagged with palace id.
2002///
2003/// Why: Both the MCP `memory_recall_all` tool and the `GET /api/v1/recall`
2004/// HTTP route share the same wiring — list palaces, open handles, fan out via
2005/// `recall_across_palaces_with_default_embedder`, and serialize.
2006/// What: Lists every palace on disk, opens each (skipping any that fail with
2007/// a `tracing::warn!`), and delegates to the core fan-out. On success returns
2008/// a JSON array; on listing failure returns `{ "error": "..." }`.
2009/// Test: Indirectly via `recall_across_palaces_merges_results` (core merge
2010/// logic) and the HTTP/MCP integration paths.
2011async fn execute_recall_all(state: &AppState, query: &str, top_k: usize, deep: bool) -> Value {
2012    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
2013        Ok(v) => v,
2014        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
2015    };
2016    let mut handles = Vec::with_capacity(palaces.len());
2017    for p in &palaces {
2018        match state.registry.open_palace(&state.data_root, &p.id) {
2019            Ok(h) => handles.push(h),
2020            Err(e) => {
2021                tracing::warn!(palace = %p.id, "execute_recall_all: open failed: {e:#}");
2022            }
2023        }
2024    }
2025    if handles.is_empty() {
2026        return json!([]);
2027    }
2028    match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
2029        Ok(results) => json!(results
2030            .into_iter()
2031            .map(|r| json!({
2032                "palace_id": r.palace_id,
2033                "drawer_id": r.result.drawer.id.to_string(),
2034                "content": r.result.drawer.content,
2035                "importance": r.result.drawer.importance,
2036                "tags": r.result.drawer.tags,
2037                "score": r.result.score,
2038                "layer": r.result.layer,
2039            }))
2040            .collect::<Vec<_>>()),
2041        Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
2042    }
2043}
2044
2045async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
2046    let handle = match state
2047        .registry
2048        .open_palace(&state.data_root, &PalaceId::new(palace_id))
2049    {
2050        Ok(h) => h,
2051        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2052    };
2053    let drawers = handle.list_drawers(None, None, 200);
2054    serde_json::to_value(drawers).unwrap_or(json!([]))
2055}
2056
2057async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
2058    let handle = match state
2059        .registry
2060        .open_palace(&state.data_root, &PalaceId::new(palace_id))
2061    {
2062        Ok(h) => h,
2063        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2064    };
2065    match handle.kg.query_active(subject).await {
2066        Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
2067        Err(e) => json!({ "error": format!("kg query: {e:#}") }),
2068    }
2069}
2070
2071fn execute_get_config(state: &AppState) -> Value {
2072    let cfg = load_user_config().unwrap_or_default();
2073    json!({
2074        "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
2075        "openrouter_model": cfg.openrouter_model,
2076        "local_model": {
2077            "enabled": cfg.local_model.enabled,
2078            "base_url": cfg.local_model.base_url,
2079            "model": cfg.local_model.model,
2080        },
2081        "data_root": state.data_root.display().to_string(),
2082    })
2083}
2084
2085async fn execute_get_status(state: &AppState) -> Value {
2086    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2087    let (mut total_drawers, mut total_vectors, mut total_kg_triples) = (0usize, 0usize, 0usize);
2088    for p in &palaces {
2089        if let Ok(handle) = state.registry.open_palace(&state.data_root, &p.id) {
2090            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
2091            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
2092            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
2093        }
2094    }
2095    json!({
2096        "version": state.version,
2097        "palace_count": palaces.len(),
2098        "default_palace": state.default_palace,
2099        "data_root": state.data_root.display().to_string(),
2100        "total_drawers": total_drawers,
2101        "total_vectors": total_vectors,
2102        "total_kg_triples": total_kg_triples,
2103    })
2104}
2105
2106async fn execute_get_dream_status(state: &AppState) -> Value {
2107    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2108    let mut out = DreamStatusPayload::default();
2109    let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
2110    for p in palaces {
2111        let data_dir = state.data_root.join(p.id.as_str());
2112        let snap = match PersistedDreamStats::load(&data_dir) {
2113            Ok(Some(s)) => s,
2114            _ => continue,
2115        };
2116        out.merged = out.merged.saturating_add(snap.stats.merged);
2117        out.pruned = out.pruned.saturating_add(snap.stats.pruned);
2118        out.compacted = out.compacted.saturating_add(snap.stats.compacted);
2119        out.closets_updated = out
2120            .closets_updated
2121            .saturating_add(snap.stats.closets_updated);
2122        out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
2123        latest = match latest {
2124            Some(t) if t >= snap.last_run_at => Some(t),
2125            _ => Some(snap.last_run_at),
2126        };
2127    }
2128    out.last_run_at = latest;
2129    serde_json::to_value(out).unwrap_or(json!({}))
2130}
2131
2132async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
2133    let data_dir = state.data_root.join(palace_id);
2134    if !data_dir.exists() {
2135        return json!({ "error": format!("palace not found: {palace_id}") });
2136    }
2137    match PersistedDreamStats::load(&data_dir) {
2138        Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
2139        Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
2140        Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
2141    }
2142}
2143
2144async fn execute_create_memory(
2145    state: &AppState,
2146    palace_id: &str,
2147    content: &str,
2148    room: Option<&str>,
2149    tags: Vec<String>,
2150    importance: f32,
2151) -> Value {
2152    let handle = match state
2153        .registry
2154        .open_palace(&state.data_root, &PalaceId::new(palace_id))
2155    {
2156        Ok(h) => h,
2157        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2158    };
2159    let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
2160    match handle
2161        .remember(content.to_string(), room, tags, importance)
2162        .await
2163    {
2164        Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
2165        Err(e) => json!({ "error": format!("remember: {e:#}") }),
2166    }
2167}
2168
2169async fn execute_kg_assert(
2170    state: &AppState,
2171    palace_id: &str,
2172    subject: &str,
2173    predicate: &str,
2174    object: &str,
2175    confidence: f32,
2176) -> Value {
2177    let handle = match state
2178        .registry
2179        .open_palace(&state.data_root, &PalaceId::new(palace_id))
2180    {
2181        Ok(h) => h,
2182        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
2183    };
2184    let triple = Triple {
2185        subject: subject.to_string(),
2186        predicate: predicate.to_string(),
2187        object: object.to_string(),
2188        valid_from: chrono::Utc::now(),
2189        valid_to: None,
2190        confidence,
2191        provenance: Some("chat:assistant".to_string()),
2192    };
2193    match handle.kg.assert(triple).await {
2194        Ok(()) => json!({ "status": "asserted" }),
2195        Err(e) => json!({ "error": format!("kg assert: {e:#}") }),
2196    }
2197}
2198
2199async fn chat_handler(State(state): State<AppState>, Json(body): Json<ChatBody>) -> Response {
2200    // Select the active provider (Ollama auto-detect, else OpenRouter).
2201    let Some(provider) = state.chat_provider().await else {
2202        return (
2203            StatusCode::PRECONDITION_FAILED,
2204            "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
2205        )
2206            .into_response();
2207    };
2208
2209    // Resolve palace id (explicit > default).
2210    let palace_id = body
2211        .palace_id
2212        .clone()
2213        .or_else(|| state.default_palace.clone())
2214        .unwrap_or_default();
2215
2216    // Resolve / create chat session when a palace is bound.
2217    let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
2218        let store = match state.session_store(&palace_id) {
2219            Ok(s) => s,
2220            Err(e) => {
2221                tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
2222                return (
2223                    StatusCode::INTERNAL_SERVER_ERROR,
2224                    format!("session store: {e:#}"),
2225                )
2226                    .into_response();
2227            }
2228        };
2229        match body.session_id.clone() {
2230            Some(sid) => match store.get_session(&sid) {
2231                Ok(Some(s)) => (
2232                    Some(sid),
2233                    s.history
2234                        .into_iter()
2235                        .map(|m| ChatMessage {
2236                            role: m.role,
2237                            content: m.content,
2238                            tool_call_id: None,
2239                            tool_calls: None,
2240                        })
2241                        .collect(),
2242                ),
2243                _ => (Some(sid), body.history.clone()),
2244            },
2245            None => {
2246                let new_id = store.create_session(None).unwrap_or_else(|e| {
2247                    tracing::warn!("create_session failed: {e:#}");
2248                    String::new()
2249                });
2250                (
2251                    if new_id.is_empty() {
2252                        None
2253                    } else {
2254                        Some(new_id)
2255                    },
2256                    body.history.clone(),
2257                )
2258            }
2259        }
2260    } else {
2261        (None, body.history.clone())
2262    };
2263
2264    // Full palace roster for the identity block — names + ids, not just count,
2265    // so the model can pick the right one when the user names a palace.
2266    let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
2267    let palace_count = all_palaces.len();
2268    let palace_roster: String = all_palaces
2269        .iter()
2270        .map(|p| format!("- {} (id: {})", p.name, p.id.0))
2271        .collect::<Vec<_>>()
2272        .join("\n");
2273
2274    // Config + global dream snapshot — give the model an honest view of what's
2275    // available so it doesn't invent tools or providers that aren't there.
2276    let cfg = load_user_config().unwrap_or_default();
2277    let active_provider_name = state
2278        .chat_provider()
2279        .await
2280        .map(|p| p.name().to_string())
2281        .unwrap_or_else(|| "none".to_string());
2282    let dream_snapshot = execute_get_dream_status(&state).await;
2283
2284    // Look up the selected palace's metadata (name/description) and open its
2285    // handle for live counts + recall context.
2286    let selected_palace_meta = if palace_id.is_empty() {
2287        None
2288    } else {
2289        all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
2290    };
2291
2292    let mut palace_block = String::new();
2293    let mut context = String::new();
2294    let mut palace_display_name = palace_id.clone();
2295
2296    if !palace_id.is_empty() {
2297        if let Ok(handle) = state
2298            .registry
2299            .open_palace(&state.data_root, &PalaceId::new(&palace_id))
2300        {
2301            // Live counts from the opened handle.
2302            let drawer_count = handle.drawers.read().len();
2303            let vector_count = handle.vector_store.index_size();
2304            let kg_triple_count = handle.kg.count_active_triples();
2305
2306            // Prefer the on-disk palace.json name/description; fall back to id.
2307            let (name, description) = match &selected_palace_meta {
2308                Some(p) => (p.name.clone(), p.description.clone()),
2309                None => (palace_id.clone(), None),
2310            };
2311            palace_display_name = name.clone();
2312
2313            palace_block.push_str(&format!(
2314                "Currently selected palace:\n\
2315                 - id: {id}\n\
2316                 - name: {name}\n",
2317                id = palace_id,
2318                name = name,
2319            ));
2320            if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
2321                palace_block.push_str(&format!("- description: {desc}\n"));
2322            }
2323            palace_block.push_str(&format!(
2324                "- drawers: {drawer_count}\n\
2325                 - vectors: {vector_count}\n\
2326                 - kg_triples: {kg_triple_count}\n",
2327            ));
2328            let identity_trimmed = handle.identity.trim();
2329            if !identity_trimmed.is_empty() {
2330                palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
2331            }
2332
2333            if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
2334                for r in hits.iter().take(5) {
2335                    context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content));
2336                }
2337            }
2338        }
2339    }
2340
2341    // Build the grounded system prompt with identity, palace, RAG, config,
2342    // dream-snapshot, and behavior blocks so the LLM never confuses
2343    // trusty-memory palaces with real-world architectural palaces.
2344    let mut system = String::new();
2345    system.push_str(&format!(
2346        "You are the assistant for trusty-memory, a machine-wide AI memory \
2347         service running locally on this user's machine. trusty-memory stores \
2348         knowledge in named \"palaces\" — isolated memory namespaces, each with \
2349         its own vector index (usearch HNSW) and temporal knowledge graph \
2350         (SQLite). Memories are organized as Palace -> Wing -> Room -> Closet \
2351         -> Drawer, where a Drawer is an atomic memory unit.\n\
2352         There are currently {palace_count} palace(s) on this machine.\n",
2353    ));
2354    if !palace_roster.is_empty() {
2355        system.push_str(&format!("Palaces:\n{palace_roster}\n"));
2356    }
2357    system.push('\n');
2358
2359    // Config block — what providers/models are wired up right now.
2360    system.push_str(&format!(
2361        "System configuration:\n\
2362         - active chat provider: {active_provider_name}\n\
2363         - openrouter model: {or_model}\n\
2364         - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
2365         - data root: {data_root}\n\n",
2366        or_model = cfg.openrouter_model,
2367        local_model = cfg.local_model.model,
2368        local_url = cfg.local_model.base_url,
2369        local_enabled = cfg.local_model.enabled,
2370        data_root = state.data_root.display(),
2371    ));
2372
2373    // Dream snapshot — give the model a sense of how stale memory state is.
2374    system.push_str(&format!(
2375        "Global dream status (background memory maintenance):\n{}\n\n",
2376        dream_snapshot,
2377    ));
2378
2379    if !palace_block.is_empty() {
2380        system.push_str(&palace_block);
2381        system.push('\n');
2382    }
2383
2384    if !context.is_empty() {
2385        system.push_str(&format!(
2386            "Relevant memories from the '{palace_display_name}' palace \
2387             (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
2388             {context}\n",
2389        ));
2390    }
2391
2392    system.push_str(
2393        "You have a set of tools to introspect and modify this trusty-memory \
2394         daemon. Prefer calling a tool over guessing — e.g. call \
2395         `list_palaces` rather than relying on the roster above if you need \
2396         live counts, and call `recall_memories` to search for facts you \
2397         don't have in context. When the user asks about \"palaces\", they \
2398         mean trusty-memory palaces (memory namespaces on this machine), not \
2399         architectural palaces like Versailles. If a tool returns an error, \
2400         report it honestly and don't fabricate results.",
2401    );
2402
2403    // Append the new user message to the in-memory history we'll persist.
2404    history.push(ChatMessage {
2405        role: "user".to_string(),
2406        content: body.message.clone(),
2407        tool_call_id: None,
2408        tool_calls: None,
2409    });
2410
2411    let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
2412    messages.push(ChatMessage {
2413        role: "system".to_string(),
2414        content: system,
2415        tool_call_id: None,
2416        tool_calls: None,
2417    });
2418    messages.extend(history.iter().cloned());
2419
2420    let tools = all_tools();
2421    let (sse_tx, sse_rx) =
2422        tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
2423
2424    // Capture session-persistence inputs.
2425    let session_store = if !palace_id.is_empty() && session_id.is_some() {
2426        state.session_store(&palace_id).ok()
2427    } else {
2428        None
2429    };
2430    let persist_session_id = session_id.clone();
2431
2432    // Drive the tool-execution loop in a background task so the response can
2433    // start streaming immediately.
2434    let loop_state = state.clone();
2435    tokio::spawn(async move {
2436        // Emit a leading session_id frame so the SPA can correlate this stream
2437        // with a persisted session row.
2438        if let Some(sid) = persist_session_id.as_deref() {
2439            let frame = format!("data: {}\n\n", json!({ "session_id": sid }));
2440            if sse_tx
2441                .send(Ok(axum::body::Bytes::from(frame)))
2442                .await
2443                .is_err()
2444            {
2445                return;
2446            }
2447        }
2448
2449        let mut final_assistant_text = String::new();
2450        let mut stream_err: Option<String> = None;
2451
2452        for round in 0..MAX_TOOL_ROUNDS {
2453            let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
2454            let messages_clone = messages.clone();
2455            let tools_clone = tools.clone();
2456            let provider_clone = provider.clone();
2457            let stream_handle = tokio::spawn(async move {
2458                provider_clone
2459                    .chat_stream(messages_clone, tools_clone, event_tx)
2460                    .await
2461            });
2462
2463            let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
2464            let mut round_assistant_text = String::new();
2465
2466            while let Some(event) = event_rx.recv().await {
2467                match event {
2468                    ChatEvent::Delta(text) => {
2469                        round_assistant_text.push_str(&text);
2470                        let frame = format!("data: {}\n\n", json!({ "delta": text }));
2471                        if sse_tx
2472                            .send(Ok(axum::body::Bytes::from(frame)))
2473                            .await
2474                            .is_err()
2475                        {
2476                            return;
2477                        }
2478                    }
2479                    ChatEvent::ToolCall(tc) => {
2480                        let frame = format!(
2481                            "data: {}\n\n",
2482                            json!({ "tool_call": {
2483                                "id": tc.id,
2484                                "name": tc.name,
2485                                "arguments": tc.arguments,
2486                            }})
2487                        );
2488                        let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
2489                        tool_calls_this_round.push(tc);
2490                    }
2491                    ChatEvent::Done => break,
2492                    ChatEvent::Error(e) => {
2493                        stream_err = Some(e);
2494                        break;
2495                    }
2496                }
2497            }
2498
2499            // Drain the spawned stream task; surface any error.
2500            match stream_handle.await {
2501                Ok(Ok(())) => {}
2502                Ok(Err(e)) => stream_err = Some(e.to_string()),
2503                Err(e) => stream_err = Some(format!("join: {e}")),
2504            }
2505
2506            if stream_err.is_some() {
2507                break;
2508            }
2509
2510            final_assistant_text.push_str(&round_assistant_text);
2511
2512            if tool_calls_this_round.is_empty() {
2513                // Model produced a plain answer — we're done.
2514                break;
2515            }
2516
2517            // Build the assistant message that requested these tool calls.
2518            let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
2519                .iter()
2520                .map(|tc| {
2521                    json!({
2522                        "id": tc.id,
2523                        "type": "function",
2524                        "function": { "name": tc.name, "arguments": tc.arguments },
2525                    })
2526                })
2527                .collect();
2528            messages.push(ChatMessage {
2529                role: "assistant".to_string(),
2530                content: round_assistant_text,
2531                tool_call_id: None,
2532                tool_calls: Some(assistant_tool_calls_json),
2533            });
2534
2535            // Execute each tool and append its result as a `role: "tool"`
2536            // message. The next loop iteration feeds these back to the model.
2537            for tc in &tool_calls_this_round {
2538                let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
2539                let result_str = result.to_string();
2540                let frame = format!(
2541                    "data: {}\n\n",
2542                    json!({ "tool_result": {
2543                        "id": tc.id,
2544                        "name": tc.name,
2545                        "content": &result_str,
2546                    }})
2547                );
2548                let _ = sse_tx.send(Ok(axum::body::Bytes::from(frame))).await;
2549                messages.push(ChatMessage {
2550                    role: "tool".to_string(),
2551                    content: result_str,
2552                    tool_call_id: Some(tc.id.clone()),
2553                    tool_calls: None,
2554                });
2555            }
2556
2557            // Safety net: log when we walk off the round limit.
2558            if round + 1 == MAX_TOOL_ROUNDS {
2559                tracing::warn!(
2560                    "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
2561                    MAX_TOOL_ROUNDS
2562                );
2563            }
2564        }
2565
2566        // Persist the completed conversation regardless of streaming error
2567        // (partial assistant reply still better than nothing).
2568        if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
2569            if !final_assistant_text.is_empty() {
2570                history.push(ChatMessage {
2571                    role: "assistant".into(),
2572                    content: final_assistant_text,
2573                    tool_call_id: None,
2574                    tool_calls: None,
2575                });
2576            }
2577            let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
2578                history
2579                    .iter()
2580                    .map(
2581                        |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
2582                            role: m.role.clone(),
2583                            content: m.content.clone(),
2584                        },
2585                    )
2586                    .collect();
2587            if let Err(e) = store.upsert_session(sid, &core_history) {
2588                tracing::warn!("upsert_session failed: {e:#}");
2589            }
2590        }
2591
2592        match stream_err {
2593            None => {
2594                let _ = sse_tx
2595                    .send(Ok(axum::body::Bytes::from("data: [DONE]\n\n")))
2596                    .await;
2597            }
2598            Some(e) => {
2599                let out = format!("data: {}\n\n", json!({ "error": e }));
2600                let _ = sse_tx.send(Ok(axum::body::Bytes::from(out))).await;
2601            }
2602        }
2603    });
2604
2605    let stream = tokio_stream::wrappers::ReceiverStream::new(sse_rx);
2606
2607    Response::builder()
2608        .header("Content-Type", "text/event-stream")
2609        .header("Cache-Control", "no-cache")
2610        .body(Body::from_stream(stream))
2611        .expect("static SSE response builds")
2612}
2613
2614// ---------------------------------------------------------------------------
2615// Providers + sessions
2616// ---------------------------------------------------------------------------
2617
2618/// GET /api/v1/chat/providers — report provider availability + active choice.
2619///
2620/// Why: The UI's chat panel surfaces whether the user has a local model
2621/// running or is hitting OpenRouter. Probing both upstreams here keeps that
2622/// logic on the server so the SPA stays dumb.
2623/// What: Calls `auto_detect_local_provider` (1s timeout) for Ollama and checks
2624/// for a non-empty OpenRouter key. Returns shape `{providers:[...], active}`.
2625/// Test: `providers_endpoint_returns_payload`.
2626async fn list_providers(State(state): State<AppState>) -> Json<Value> {
2627    let cfg = load_user_config().unwrap_or_default();
2628    let ollama_available = if cfg.local_model.enabled {
2629        trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
2630            .await
2631            .is_some()
2632    } else {
2633        false
2634    };
2635    let openrouter_available = !cfg.openrouter_api_key.is_empty();
2636    let active = state.chat_provider().await.map(|p| p.name().to_string());
2637    Json(json!({
2638        "providers": [
2639            {
2640                "name": "ollama",
2641                "model": cfg.local_model.model,
2642                "available": ollama_available,
2643            },
2644            {
2645                "name": "openrouter",
2646                "model": cfg.openrouter_model,
2647                "available": openrouter_available,
2648            }
2649        ],
2650        "active": active,
2651    }))
2652}
2653
2654#[derive(Deserialize, Default)]
2655struct CreateSessionBody {
2656    #[serde(default)]
2657    title: Option<String>,
2658}
2659
2660async fn create_chat_session(
2661    State(state): State<AppState>,
2662    AxumPath(id): AxumPath<String>,
2663    body: Option<Json<CreateSessionBody>>,
2664) -> Result<Json<Value>, ApiError> {
2665    let store = state
2666        .session_store(&id)
2667        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2668    let title = body.and_then(|b| b.0.title);
2669    let sid = store
2670        .create_session(title)
2671        .map_err(|e| ApiError::internal(format!("create session: {e:#}")))?;
2672    Ok(Json(json!({ "id": sid })))
2673}
2674
2675async fn list_chat_sessions(
2676    State(state): State<AppState>,
2677    AxumPath(id): AxumPath<String>,
2678) -> Result<Json<Value>, ApiError> {
2679    let store = state
2680        .session_store(&id)
2681        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2682    let metas = store
2683        .list_sessions()
2684        .map_err(|e| ApiError::internal(format!("list sessions: {e:#}")))?;
2685    Ok(Json(serde_json::to_value(metas).unwrap_or(json!([]))))
2686}
2687
2688async fn get_chat_session(
2689    State(state): State<AppState>,
2690    AxumPath((id, session_id)): AxumPath<(String, String)>,
2691) -> Result<Json<Value>, ApiError> {
2692    let store = state
2693        .session_store(&id)
2694        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2695    let s = store
2696        .get_session(&session_id)
2697        .map_err(|e| ApiError::internal(format!("get session: {e:#}")))?
2698        .ok_or_else(|| ApiError::not_found(format!("session not found: {session_id}")))?;
2699    Ok(Json(serde_json::to_value(s).unwrap_or(json!({}))))
2700}
2701
2702async fn delete_chat_session(
2703    State(state): State<AppState>,
2704    AxumPath((id, session_id)): AxumPath<(String, String)>,
2705) -> Result<StatusCode, ApiError> {
2706    let store = state
2707        .session_store(&id)
2708        .map_err(|e| ApiError::internal(format!("session store: {e:#}")))?;
2709    store
2710        .delete_session(&session_id)
2711        .map_err(|e| ApiError::internal(format!("delete session: {e:#}")))?;
2712    Ok(StatusCode::NO_CONTENT)
2713}
2714
2715// ---------------------------------------------------------------------------
2716// Helpers
2717// ---------------------------------------------------------------------------
2718
2719fn open_handle(
2720    state: &AppState,
2721    id: &str,
2722) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
2723    state
2724        .registry
2725        .open_palace(&state.data_root, &PalaceId::new(id))
2726        .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
2727}
2728
2729/// Lightweight error type for HTTP handlers.
2730struct ApiError {
2731    status: StatusCode,
2732    message: String,
2733}
2734
2735impl ApiError {
2736    fn bad_request(msg: impl Into<String>) -> Self {
2737        Self {
2738            status: StatusCode::BAD_REQUEST,
2739            message: msg.into(),
2740        }
2741    }
2742    fn not_found(msg: impl Into<String>) -> Self {
2743        Self {
2744            status: StatusCode::NOT_FOUND,
2745            message: msg.into(),
2746        }
2747    }
2748    fn internal(msg: impl Into<String>) -> Self {
2749        Self {
2750            status: StatusCode::INTERNAL_SERVER_ERROR,
2751            message: msg.into(),
2752        }
2753    }
2754}
2755
2756impl IntoResponse for ApiError {
2757    fn into_response(self) -> Response {
2758        (self.status, Json(json!({ "error": self.message }))).into_response()
2759    }
2760}
2761
2762#[cfg(test)]
2763mod tests {
2764    use super::*;
2765    use axum::body::to_bytes;
2766    use axum::http::Request;
2767    use tower::util::ServiceExt;
2768
2769    fn test_state() -> AppState {
2770        let tmp = tempfile::tempdir().expect("tempdir");
2771        let root = tmp.path().to_path_buf();
2772        std::mem::forget(tmp);
2773        AppState::new(root)
2774    }
2775
2776    #[test]
2777    fn drawer_preview_collapses_whitespace_and_truncates() {
2778        // Short single-line content is returned verbatim.
2779        assert_eq!(drawer_content_preview("hello world"), "hello world");
2780
2781        // Multiline / tab-laden content collapses to single-spaced text.
2782        assert_eq!(
2783            drawer_content_preview("first line\n\nsecond\tline   third"),
2784            "first line second line third"
2785        );
2786
2787        // Leading / trailing whitespace is stripped.
2788        assert_eq!(drawer_content_preview("   padded   "), "padded");
2789
2790        // Empty content yields an empty preview (fallback signal for clients).
2791        assert_eq!(drawer_content_preview(""), "");
2792
2793        // Long content is truncated to DRAWER_PREVIEW_MAX_CHARS with an ellipsis.
2794        let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
2795        let preview = drawer_content_preview(&long);
2796        assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
2797        assert!(preview.ends_with('…'));
2798
2799        // Content right at the limit is not truncated.
2800        let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
2801        assert_eq!(drawer_content_preview(&exact), exact);
2802    }
2803
2804    #[tokio::test]
2805    async fn health_endpoint_returns_ok() {
2806        let state = test_state();
2807        let app = router().with_state(state);
2808        let resp = app
2809            .oneshot(
2810                Request::builder()
2811                    .uri("/health")
2812                    .body(Body::empty())
2813                    .unwrap(),
2814            )
2815            .await
2816            .unwrap();
2817        assert_eq!(resp.status(), StatusCode::OK);
2818        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2819        let v: Value = serde_json::from_slice(&bytes).unwrap();
2820        assert_eq!(v["status"], "ok");
2821        assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
2822    }
2823
2824    /// Issue #35 — `GET /health` carries the enriched resource block
2825    /// (`rss_mb`, `disk_bytes`, `cpu_pct`, `uptime_secs`).
2826    ///
2827    /// Why: external probes and the admin UI render these; the JSON contract
2828    /// must remain stable. `rss_mb` is sampled live so it is asserted only
2829    /// for a sane unit, not an exact value.
2830    /// What: drives `/health` through the router and asserts every new field
2831    /// deserialises with a plausible value.
2832    /// Test: this test.
2833    #[tokio::test]
2834    async fn health_endpoint_includes_resource_fields() {
2835        let state = test_state();
2836        let app = router().with_state(state);
2837        let resp = app
2838            .oneshot(
2839                Request::builder()
2840                    .uri("/health")
2841                    .body(Body::empty())
2842                    .unwrap(),
2843            )
2844            .await
2845            .unwrap();
2846        assert_eq!(resp.status(), StatusCode::OK);
2847        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2848        let v: Value = serde_json::from_slice(&bytes).unwrap();
2849        // rss_mb must be a sane unit (megabytes, not bytes).
2850        let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
2851        assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
2852        // cpu_pct is a non-negative percentage (first sample may be 0.0).
2853        let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
2854        assert!(cpu >= 0.0, "cpu_pct must be non-negative");
2855        // disk ticker has not run in this oneshot test → 0.
2856        assert_eq!(v["disk_bytes"].as_u64(), Some(0));
2857        // uptime_secs is present and a u64.
2858        assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
2859    }
2860
2861    /// Issue #71 — `GET /health` reports `status: "ok"` on a fresh install
2862    /// (no palaces) and never carries a `detail` field.
2863    ///
2864    /// Why: A daemon with zero palaces cannot run a meaningful round-trip
2865    /// (there is nothing to remember against), and reporting "degraded" in
2866    /// that case would alarm operators on first boot. The handler must
2867    /// treat "no palaces" as a clean state and skip the probe.
2868    /// What: Drives `/health` through the router with an empty `data_root`
2869    /// and asserts `status == "ok"` and the `detail` key is absent.
2870    /// Test: this test.
2871    #[tokio::test]
2872    async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
2873        let state = test_state();
2874        let app = router().with_state(state);
2875        let resp = app
2876            .oneshot(
2877                Request::builder()
2878                    .uri("/health")
2879                    .body(Body::empty())
2880                    .unwrap(),
2881            )
2882            .await
2883            .unwrap();
2884        assert_eq!(resp.status(), StatusCode::OK);
2885        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2886        let v: Value = serde_json::from_slice(&bytes).unwrap();
2887        assert_eq!(v["status"], "ok");
2888        assert!(
2889            v.get("detail").is_none() || v["detail"].is_null(),
2890            "fresh-install health must not carry a degraded detail (got {v:?})"
2891        );
2892    }
2893
2894    /// Issue #71 — `GET /health` exercises the full store/recall/forget
2895    /// cycle against the first palace and reports `status: "ok"` on success.
2896    ///
2897    /// Why: The whole point of issue #71 is to catch store/recall
2898    /// regressions at probe time rather than via real client traffic. This
2899    /// test creates a real palace, hits `/health`, and asserts the
2900    /// round-trip path is happy. Marked `#[ignore]` because
2901    /// `recall_with_default_embedder` pulls in the ONNX model and is too
2902    /// heavy for the default CI matrix — run with
2903    /// `cargo test -p trusty-memory -- --include-ignored` for local
2904    /// verification.
2905    /// What: Builds an `AppState` with a tempdir `data_root`, creates a
2906    /// `health-probe-palace` via `registry.create_palace`, hits `/health`,
2907    /// and asserts both the status and the absence of any `detail` field.
2908    /// Test: this test.
2909    #[tokio::test]
2910    #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2911    async fn health_endpoint_round_trip_with_palace_is_ok() {
2912        let state = test_state();
2913        let palace = trusty_common::memory_core::Palace {
2914            id: PalaceId::new("health-probe-palace"),
2915            name: "health-probe-palace".to_string(),
2916            description: None,
2917            created_at: chrono::Utc::now(),
2918            data_dir: state.data_root.join("health-probe-palace"),
2919        };
2920        state
2921            .registry
2922            .create_palace(&state.data_root, palace)
2923            .expect("create_palace");
2924
2925        let app = router().with_state(state);
2926        let resp = app
2927            .oneshot(
2928                Request::builder()
2929                    .uri("/health")
2930                    .body(Body::empty())
2931                    .unwrap(),
2932            )
2933            .await
2934            .unwrap();
2935        assert_eq!(resp.status(), StatusCode::OK);
2936        let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
2937        let v: Value = serde_json::from_slice(&bytes).unwrap();
2938        assert_eq!(
2939            v["status"], "ok",
2940            "round-trip should succeed against a fresh palace; got {v:?}"
2941        );
2942        assert!(
2943            v.get("detail").is_none() || v["detail"].is_null(),
2944            "successful round-trip must not carry a detail field (got {v:?})"
2945        );
2946    }
2947
2948    /// Issue #69 — `recall_entry_json` hoists the drawer's fields to the top
2949    /// level so `content` is directly reachable.
2950    ///
2951    /// Why: The recall API previously wrapped the drawer under a `"drawer"`
2952    /// key, so clients scanning the top level for `content`/`tags` found
2953    /// nothing and recall always looked empty. This locks the flattened shape
2954    /// in place so the regression cannot silently return.
2955    /// What: Builds a `RecallResult`, runs it through `recall_entry_json`, and
2956    /// asserts `content`, `tags`, and `importance` are at the top level, that
2957    /// `score`/`layer` sit alongside them, and that the old `drawer` wrapper
2958    /// key is gone.
2959    /// Test: this test.
2960    #[test]
2961    fn recall_entry_json_hoists_drawer_fields() {
2962        use trusty_common::memory_core::Drawer;
2963
2964        let room = Uuid::new_v4();
2965        let mut drawer = Drawer::new(room, "the answer is 42");
2966        drawer.tags = vec!["source:kuzu".to_string()];
2967        drawer.importance = 0.7;
2968
2969        let entry = recall_entry_json(RecallResult {
2970            drawer,
2971            score: 0.699,
2972            layer: 1,
2973        });
2974
2975        // Content must be reachable WITHOUT a `drawer` wrapper (issue #69).
2976        assert_eq!(
2977            entry.get("content").and_then(|v| v.as_str()),
2978            Some("the answer is 42"),
2979            "content must be at the top level, got {entry:?}"
2980        );
2981        assert!(
2982            entry.get("drawer").is_none(),
2983            "the legacy `drawer` wrapper must not be present, got {entry:?}"
2984        );
2985        // Other drawer fields are hoisted too.
2986        assert_eq!(
2987            entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2988            Some(7.0)
2989        );
2990        assert_eq!(
2991            entry["tags"][0].as_str(),
2992            Some("source:kuzu"),
2993            "tags must be hoisted, got {entry:?}"
2994        );
2995        // Ranking metadata sits alongside the hoisted fields.
2996        assert_eq!(entry["layer"].as_u64(), Some(1));
2997        assert!(
2998            entry["score"]
2999                .as_f64()
3000                .is_some_and(|s| (s - 0.699).abs() < 1e-6),
3001            "score must be preserved, got {entry:?}"
3002        );
3003    }
3004
3005    /// Issue #35 — `GET /api/v1/logs/tail` returns the most recent buffered
3006    /// lines and the total count.
3007    ///
3008    /// Why: operators inspect a running daemon via this endpoint; it must
3009    /// surface exactly what the shared `LogBuffer` holds.
3010    /// What: attaches a `LogBuffer` to the state, pushes three lines, GETs
3011    /// `?n=2`, and asserts the tail + `total`.
3012    /// Test: this test.
3013    #[tokio::test]
3014    async fn logs_tail_returns_recent_lines() {
3015        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
3016        buffer.push("line one".to_string());
3017        buffer.push("line two".to_string());
3018        buffer.push("line three".to_string());
3019        let state = test_state().with_log_buffer(buffer);
3020        let app = router().with_state(state);
3021        let resp = app
3022            .oneshot(
3023                Request::builder()
3024                    .uri("/api/v1/logs/tail?n=2")
3025                    .body(Body::empty())
3026                    .unwrap(),
3027            )
3028            .await
3029            .unwrap();
3030        assert_eq!(resp.status(), StatusCode::OK);
3031        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3032        let v: Value = serde_json::from_slice(&bytes).unwrap();
3033        let lines = v["lines"].as_array().expect("lines array");
3034        assert_eq!(lines.len(), 2, "n=2 must return two lines");
3035        assert_eq!(lines[0].as_str(), Some("line two"));
3036        assert_eq!(lines[1].as_str(), Some("line three"));
3037        assert_eq!(v["total"].as_u64(), Some(3));
3038    }
3039
3040    /// Issue #35 — `GET /api/v1/logs/tail?n=` is clamped to
3041    /// `[1, MAX_LOGS_TAIL_N]`.
3042    ///
3043    /// Why: a misconfigured client must not request more lines than the
3044    /// buffer holds, and `n=0` must still return at least one line.
3045    /// What: pushes five lines, requests `n=0` (clamps to 1) and an oversized
3046    /// `n` (clamps to the buffer length).
3047    /// Test: this test.
3048    #[tokio::test]
3049    async fn logs_tail_clamps_n() {
3050        let buffer = trusty_common::log_buffer::LogBuffer::new(100);
3051        for i in 0..5 {
3052            buffer.push(format!("l{i}"));
3053        }
3054        let state = test_state().with_log_buffer(buffer);
3055        let app = router().with_state(state);
3056
3057        // n=0 clamps up to 1.
3058        let resp = app
3059            .clone()
3060            .oneshot(
3061                Request::builder()
3062                    .uri("/api/v1/logs/tail?n=0")
3063                    .body(Body::empty())
3064                    .unwrap(),
3065            )
3066            .await
3067            .unwrap();
3068        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3069        let v: Value = serde_json::from_slice(&bytes).unwrap();
3070        assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
3071
3072        // n far past MAX clamps down to the buffer length (5).
3073        let resp = app
3074            .oneshot(
3075                Request::builder()
3076                    .uri("/api/v1/logs/tail?n=999999")
3077                    .body(Body::empty())
3078                    .unwrap(),
3079            )
3080            .await
3081            .unwrap();
3082        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3083        let v: Value = serde_json::from_slice(&bytes).unwrap();
3084        assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
3085    }
3086
3087    /// Issue #35 — `POST /api/v1/admin/stop` acknowledges the shutdown
3088    /// request with `{ ok, message }`.
3089    ///
3090    /// Why: the response shape is the documented contract for the admin UI's
3091    /// stop button.
3092    /// What: calls `admin_stop` directly and asserts the JSON body. It does
3093    /// NOT await the spawned exit task — that would terminate the test
3094    /// process — but the 200 ms delay before `process::exit` guarantees the
3095    /// test returns first.
3096    /// Test: this test.
3097    #[tokio::test]
3098    async fn admin_stop_returns_ok() {
3099        let state = test_state();
3100        let Json(body) = admin_stop(State(state)).await;
3101        assert_eq!(body["ok"], Value::Bool(true));
3102        assert_eq!(body["message"].as_str(), Some("shutting down"));
3103    }
3104
3105    #[tokio::test]
3106    async fn status_endpoint_returns_payload() {
3107        let state = test_state();
3108        let app = router().with_state(state);
3109        let resp = app
3110            .oneshot(
3111                Request::builder()
3112                    .uri("/api/v1/status")
3113                    .body(Body::empty())
3114                    .unwrap(),
3115            )
3116            .await
3117            .unwrap();
3118        assert_eq!(resp.status(), StatusCode::OK);
3119        let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3120        let v: Value = serde_json::from_slice(&bytes).unwrap();
3121        assert!(v["version"].is_string());
3122        assert_eq!(v["palace_count"], 0);
3123    }
3124
3125    #[tokio::test]
3126    async fn unknown_api_returns_404() {
3127        let state = test_state();
3128        let app = router().with_state(state);
3129        let resp = app
3130            .oneshot(
3131                Request::builder()
3132                    .uri("/api/v1/does-not-exist")
3133                    .body(Body::empty())
3134                    .unwrap(),
3135            )
3136            .await
3137            .unwrap();
3138        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3139    }
3140
3141    /// Issue #70 — `…/memories` is a working alias for `…/drawers`.
3142    ///
3143    /// Why: Clients that POST/GET against `…/memories` previously hit a 404
3144    /// because only `/drawers` was registered, which silently broke every
3145    /// store call (and pushed callers onto an OOM-prone CLI fallback). The
3146    /// alias must route to the same handler as `/drawers`.
3147    /// What: Creates a real palace via the registry, then GETs the `/memories`
3148    /// alias and asserts a 200 with a JSON array body (the list-drawers shape).
3149    /// Uses GET, not POST, so the test stays embedder-free (no ONNX load).
3150    /// Test: this test.
3151    #[tokio::test]
3152    async fn memories_alias_routes_to_drawers() {
3153        let state = test_state();
3154        let palace = Palace {
3155            id: PalaceId::new("alias-test"),
3156            name: "alias-test".to_string(),
3157            description: None,
3158            created_at: chrono::Utc::now(),
3159            data_dir: state.data_root.join("alias-test"),
3160        };
3161        state
3162            .registry
3163            .create_palace(&state.data_root, palace)
3164            .expect("create_palace");
3165
3166        let app = router().with_state(state);
3167        let resp = app
3168            .oneshot(
3169                Request::builder()
3170                    .uri("/api/v1/palaces/alias-test/memories")
3171                    .body(Body::empty())
3172                    .unwrap(),
3173            )
3174            .await
3175            .unwrap();
3176        assert_eq!(
3177            resp.status(),
3178            StatusCode::OK,
3179            "the /memories alias must resolve to list_drawers, not 404"
3180        );
3181        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3182        let v: Value = serde_json::from_slice(&bytes).unwrap();
3183        assert!(
3184            v.is_array(),
3185            "the alias must return the list-drawers array shape, got {v:?}"
3186        );
3187    }
3188
3189    #[tokio::test]
3190    async fn create_then_list_palace() {
3191        let state = test_state();
3192        let app = router().with_state(state.clone());
3193        let body = json!({"name": "web-test", "description": "from test"}).to_string();
3194        let resp = app
3195            .clone()
3196            .oneshot(
3197                Request::builder()
3198                    .method("POST")
3199                    .uri("/api/v1/palaces")
3200                    .header("content-type", "application/json")
3201                    .body(Body::from(body))
3202                    .unwrap(),
3203            )
3204            .await
3205            .unwrap();
3206        assert_eq!(resp.status(), StatusCode::OK);
3207
3208        let resp = app
3209            .oneshot(
3210                Request::builder()
3211                    .uri("/api/v1/palaces")
3212                    .body(Body::empty())
3213                    .unwrap(),
3214            )
3215            .await
3216            .unwrap();
3217        assert_eq!(resp.status(), StatusCode::OK);
3218        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3219        let v: Value = serde_json::from_slice(&bytes).unwrap();
3220        let arr = v.as_array().expect("array");
3221        assert!(arr.iter().any(|p| p["id"] == "web-test"));
3222    }
3223
3224    /// Why: The operator TUI's MEMORY tab reads `node_count`, `edge_count`,
3225    /// `community_count`, and `is_compacting` straight off the
3226    /// `/api/v1/palaces` payload. If any of those fields disappear or change
3227    /// type the spinner / counters break silently. Pin the shape here.
3228    /// What: Creates a palace, lists `/api/v1/palaces`, and asserts every new
3229    /// field is present and typed as expected (numbers default to 0, the
3230    /// compacting flag defaults to false on a freshly-opened palace).
3231    /// Test: This test itself.
3232    #[tokio::test]
3233    async fn palace_list_includes_graph_counts() {
3234        let state = test_state();
3235        let app = router().with_state(state.clone());
3236        let body = json!({"name": "graph-counts", "description": null}).to_string();
3237        let resp = app
3238            .clone()
3239            .oneshot(
3240                Request::builder()
3241                    .method("POST")
3242                    .uri("/api/v1/palaces")
3243                    .header("content-type", "application/json")
3244                    .body(Body::from(body))
3245                    .unwrap(),
3246            )
3247            .await
3248            .unwrap();
3249        assert_eq!(resp.status(), StatusCode::OK);
3250
3251        let resp = app
3252            .oneshot(
3253                Request::builder()
3254                    .uri("/api/v1/palaces")
3255                    .body(Body::empty())
3256                    .unwrap(),
3257            )
3258            .await
3259            .unwrap();
3260        assert_eq!(resp.status(), StatusCode::OK);
3261        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3262        let v: Value = serde_json::from_slice(&bytes).unwrap();
3263        let arr = v.as_array().expect("array");
3264        let row = arr
3265            .iter()
3266            .find(|p| p["id"] == "graph-counts")
3267            .expect("created palace must appear in list");
3268        assert_eq!(row["node_count"].as_u64(), Some(0));
3269        assert_eq!(row["edge_count"].as_u64(), Some(0));
3270        assert_eq!(row["community_count"].as_u64(), Some(0));
3271        assert_eq!(row["is_compacting"].as_bool(), Some(false));
3272    }
3273
3274    /// Why: The enriched status payload backs the dashboard's top-row stats;
3275    /// it must always include the new total_* counters, even on an empty data
3276    /// root, so the UI can render zeros without special-casing missing fields.
3277    /// What: Hit `/api/v1/status` on a fresh state and assert the new fields
3278    /// are present and set to 0.
3279    /// Test: This test itself.
3280    #[tokio::test]
3281    async fn status_includes_total_counters() {
3282        let state = test_state();
3283        let app = router().with_state(state);
3284        let resp = app
3285            .oneshot(
3286                Request::builder()
3287                    .uri("/api/v1/status")
3288                    .body(Body::empty())
3289                    .unwrap(),
3290            )
3291            .await
3292            .unwrap();
3293        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3294        let v: Value = serde_json::from_slice(&bytes).unwrap();
3295        assert_eq!(v["total_drawers"], 0);
3296        assert_eq!(v["total_vectors"], 0);
3297        assert_eq!(v["total_kg_triples"], 0);
3298    }
3299
3300    /// Why: `/api/v1/dream/status` must return a well-shaped payload even
3301    /// when no palace has ever run a dream cycle (so the dashboard's first
3302    /// load doesn't error).
3303    /// What: Hit the endpoint on a fresh state and assert `last_run_at` is
3304    /// null and the counters are zero.
3305    /// Test: This test itself.
3306    #[tokio::test]
3307    async fn dream_status_empty_returns_nulls() {
3308        let state = test_state();
3309        let app = router().with_state(state);
3310        let resp = app
3311            .oneshot(
3312                Request::builder()
3313                    .uri("/api/v1/dream/status")
3314                    .body(Body::empty())
3315                    .unwrap(),
3316            )
3317            .await
3318            .unwrap();
3319        assert_eq!(resp.status(), StatusCode::OK);
3320        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3321        let v: Value = serde_json::from_slice(&bytes).unwrap();
3322        assert!(v["last_run_at"].is_null());
3323        assert_eq!(v["merged"], 0);
3324        assert_eq!(v["pruned"], 0);
3325    }
3326
3327    /// Why: `/api/v1/chat/providers` must return a well-shaped payload even
3328    /// when no provider is available, so the SPA can render disabled states
3329    /// without special-casing missing fields.
3330    /// What: Hit the endpoint on a fresh state; assert it returns `providers`
3331    /// (an array of length 2) and an `active` field (possibly null).
3332    /// Test: This test itself.
3333    #[tokio::test]
3334    async fn providers_endpoint_returns_payload() {
3335        let state = test_state();
3336        let app = router().with_state(state);
3337        let resp = app
3338            .oneshot(
3339                Request::builder()
3340                    .uri("/api/v1/chat/providers")
3341                    .body(Body::empty())
3342                    .unwrap(),
3343            )
3344            .await
3345            .unwrap();
3346        assert_eq!(resp.status(), StatusCode::OK);
3347        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3348        let v: Value = serde_json::from_slice(&bytes).unwrap();
3349        let arr = v["providers"].as_array().expect("providers array");
3350        assert_eq!(arr.len(), 2);
3351        let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
3352        assert!(names.contains(&"ollama"));
3353        assert!(names.contains(&"openrouter"));
3354        // `active` may be null when no provider is configured/reachable.
3355        assert!(v.get("active").is_some());
3356    }
3357
3358    /// Why: Chat-session CRUD must round-trip end-to-end through the HTTP
3359    /// surface — create returns an id, list shows it, get returns the
3360    /// (empty) history, delete removes it.
3361    /// What: Create a palace, then exercise the four session endpoints
3362    /// sequentially, asserting JSON shapes at each step.
3363    /// Test: This test itself.
3364    #[tokio::test]
3365    async fn chat_session_crud_round_trip() {
3366        let state = test_state();
3367        // Pre-create a palace dir so session store has a place to live.
3368        let palace = trusty_common::memory_core::Palace {
3369            id: PalaceId::new("sess-test"),
3370            name: "sess-test".to_string(),
3371            description: None,
3372            created_at: chrono::Utc::now(),
3373            data_dir: state.data_root.join("sess-test"),
3374        };
3375        state
3376            .registry
3377            .create_palace(&state.data_root, palace)
3378            .expect("create_palace");
3379        let app = router().with_state(state);
3380
3381        // Create
3382        let resp = app
3383            .clone()
3384            .oneshot(
3385                Request::builder()
3386                    .method("POST")
3387                    .uri("/api/v1/palaces/sess-test/chat/sessions")
3388                    .header("content-type", "application/json")
3389                    .body(Body::from(json!({"title":"first chat"}).to_string()))
3390                    .unwrap(),
3391            )
3392            .await
3393            .unwrap();
3394        assert_eq!(resp.status(), StatusCode::OK);
3395        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3396        let v: Value = serde_json::from_slice(&bytes).unwrap();
3397        let sid = v["id"].as_str().expect("session id").to_string();
3398
3399        // List
3400        let resp = app
3401            .clone()
3402            .oneshot(
3403                Request::builder()
3404                    .uri("/api/v1/palaces/sess-test/chat/sessions")
3405                    .body(Body::empty())
3406                    .unwrap(),
3407            )
3408            .await
3409            .unwrap();
3410        assert_eq!(resp.status(), StatusCode::OK);
3411        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3412        let v: Value = serde_json::from_slice(&bytes).unwrap();
3413        let arr = v.as_array().expect("array");
3414        assert!(arr.iter().any(|s| s["id"] == sid));
3415
3416        // Get
3417        let resp = app
3418            .clone()
3419            .oneshot(
3420                Request::builder()
3421                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3422                    .body(Body::empty())
3423                    .unwrap(),
3424            )
3425            .await
3426            .unwrap();
3427        assert_eq!(resp.status(), StatusCode::OK);
3428
3429        // Delete
3430        let resp = app
3431            .clone()
3432            .oneshot(
3433                Request::builder()
3434                    .method("DELETE")
3435                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3436                    .body(Body::empty())
3437                    .unwrap(),
3438            )
3439            .await
3440            .unwrap();
3441        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3442
3443        // Get after delete -> 404
3444        let resp = app
3445            .oneshot(
3446                Request::builder()
3447                    .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3448                    .body(Body::empty())
3449                    .unwrap(),
3450            )
3451            .await
3452            .unwrap();
3453        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3454    }
3455
3456    /// Why: The chat assistant's tool surface is part of the public API — any
3457    /// drift in tool names or required-argument lists is a breaking change for
3458    /// the UI and any external automation. Pin the shape here so a refactor
3459    /// has to acknowledge it.
3460    /// What: Snapshots the names + every tool's `required` array.
3461    /// Test: This test itself.
3462    #[test]
3463    fn all_tools_returns_expected_set() {
3464        let tools = all_tools();
3465        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3466        assert_eq!(
3467            names,
3468            vec![
3469                "list_palaces",
3470                "get_palace",
3471                "recall_memories",
3472                "list_drawers",
3473                "kg_query",
3474                "get_config",
3475                "get_status",
3476                "get_dream_status",
3477                "get_palace_dream_status",
3478                "create_memory",
3479                "kg_assert",
3480                "memory_recall_all",
3481            ]
3482        );
3483        // Every tool's `parameters` must be a JSON Schema object with a
3484        // `required` array (possibly empty).
3485        for t in &tools {
3486            assert_eq!(
3487                t.parameters["type"], "object",
3488                "tool {} schema type",
3489                t.name
3490            );
3491            assert!(
3492                t.parameters["required"].is_array(),
3493                "tool {} required not array",
3494                t.name
3495            );
3496        }
3497    }
3498
3499    /// Why: `execute_tool` is the bridge between the model's tool_call
3500    /// arguments and the live Rust core. We exercise the happy path
3501    /// (`list_palaces` on an empty registry returns `[]`) and the unknown-
3502    /// tool path (returns `{"error": "..."}`) to lock down both branches.
3503    /// What: Calls execute_tool against a fresh `AppState`.
3504    /// Test: This test itself.
3505    #[tokio::test]
3506    async fn execute_tool_dispatches_known_tools() {
3507        let state = test_state();
3508        let result = execute_tool("list_palaces", "{}", &state).await;
3509        assert!(
3510            result.is_array(),
3511            "list_palaces should be array, got {result}"
3512        );
3513        assert_eq!(result.as_array().unwrap().len(), 0);
3514
3515        let unknown = execute_tool("not_a_tool", "{}", &state).await;
3516        assert!(
3517            unknown["error"]
3518                .as_str()
3519                .unwrap_or("")
3520                .contains("unknown tool"),
3521            "expected unknown-tool error, got {unknown}"
3522        );
3523
3524        let missing = execute_tool("get_palace", "{}", &state).await;
3525        assert!(
3526            missing["error"]
3527                .as_str()
3528                .unwrap_or("")
3529                .contains("palace_id"),
3530            "expected missing-arg error, got {missing}"
3531        );
3532    }
3533
3534    /// Why: The SSE event bus is the dashboard's live-update transport;
3535    /// regressing it would silently break the UI. Subscribing before the
3536    /// emit guarantees the broadcast channel has a receiver when the
3537    /// handler fires, so we can deterministically observe the event.
3538    /// What: Subscribes to `state.events`, calls the `create_palace`
3539    /// handler through the router, then asserts a `PalaceCreated` event
3540    /// (and a follow-up status event from drawer mutation) flow through.
3541    /// Test: `cargo test -p trusty-memory-mcp sse_broadcast_emits_palace_created`.
3542    #[tokio::test]
3543    async fn sse_broadcast_emits_palace_created() {
3544        let state = test_state();
3545        let mut rx = state.events.subscribe();
3546        let app = router().with_state(state.clone());
3547        let body = json!({"name": "sse-test"}).to_string();
3548        let resp = app
3549            .oneshot(
3550                Request::builder()
3551                    .method("POST")
3552                    .uri("/api/v1/palaces")
3553                    .header("content-type", "application/json")
3554                    .body(Body::from(body))
3555                    .unwrap(),
3556            )
3557            .await
3558            .unwrap();
3559        assert_eq!(resp.status(), StatusCode::OK);
3560        // The handler should have emitted PalaceCreated before returning.
3561        let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3562            .await
3563            .expect("event received within timeout")
3564            .expect("event channel still open");
3565        match event {
3566            DaemonEvent::PalaceCreated { id, name } => {
3567                assert_eq!(id, "sse-test");
3568                assert_eq!(name, "sse-test");
3569            }
3570            other => panic!("expected PalaceCreated, got {other:?}"),
3571        }
3572    }
3573
3574    /// Why: Confirm the `/sse` endpoint speaks `text/event-stream` and emits
3575    /// the initial `connected` frame so dashboard clients can rely on a
3576    /// known greeting.
3577    /// What: Issues a GET against `/sse`, reads the response body chunk,
3578    /// asserts the content-type header and the first SSE frame shape.
3579    /// Test: `cargo test -p trusty-memory-mcp sse_endpoint_emits_connected_frame`.
3580    #[tokio::test]
3581    async fn sse_endpoint_emits_connected_frame() {
3582        use axum::routing::get;
3583        let state = test_state();
3584        let app = router()
3585            .route("/sse", get(crate::sse_handler))
3586            .with_state(state);
3587        let resp = app
3588            .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3589            .await
3590            .unwrap();
3591        assert_eq!(resp.status(), StatusCode::OK);
3592        assert_eq!(
3593            resp.headers()
3594                .get(header::CONTENT_TYPE)
3595                .and_then(|v| v.to_str().ok()),
3596            Some("text/event-stream")
3597        );
3598        // Read just the first chunk (the connected frame) — the stream stays
3599        // open otherwise, so we use a small read budget plus timeout.
3600        let body = resp.into_body();
3601        let bytes =
3602            tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3603                .await
3604                .ok()
3605                .and_then(|r| r.ok())
3606                .unwrap_or_default();
3607        let text = String::from_utf8_lossy(&bytes);
3608        assert!(
3609            text.contains("\"type\":\"connected\""),
3610            "expected connected frame, got: {text}"
3611        );
3612    }
3613
3614    /// Why: `/api/v1/dream/status` must sum per-palace `dream_stats.json`
3615    /// counters and surface the most recent `last_run_at`. A regression that
3616    /// returned only the first palace's stats would silently break the
3617    /// "global dream activity" dashboard panel.
3618    /// What: Pre-seeds two palace dirs under the AppState root, writes a
3619    /// distinct `PersistedDreamStats` JSON file into each, hits the endpoint,
3620    /// and asserts the integer fields are summed and `last_run_at` equals the
3621    /// newer of the two timestamps.
3622    /// Test: This test itself.
3623    #[tokio::test]
3624    async fn dream_status_aggregates_across_palaces() {
3625        use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3626
3627        let state = test_state();
3628        // Two palace directories — each must contain a `palace.json` so
3629        // `PalaceRegistry::list_palaces` sees them, plus a `dream_stats.json`
3630        // with distinct counter values.
3631        for (id, stats, ts) in [
3632            (
3633                "palace-a",
3634                DreamStats {
3635                    merged: 1,
3636                    pruned: 2,
3637                    compacted: 3,
3638                    closets_updated: 4,
3639                    duration_ms: 100,
3640                },
3641                chrono::Utc::now() - chrono::Duration::seconds(60),
3642            ),
3643            (
3644                "palace-b",
3645                DreamStats {
3646                    merged: 10,
3647                    pruned: 20,
3648                    compacted: 30,
3649                    closets_updated: 40,
3650                    duration_ms: 200,
3651                },
3652                chrono::Utc::now(),
3653            ),
3654        ] {
3655            let palace = trusty_common::memory_core::Palace {
3656                id: PalaceId::new(id),
3657                name: id.to_string(),
3658                description: None,
3659                created_at: chrono::Utc::now(),
3660                data_dir: state.data_root.join(id),
3661            };
3662            state
3663                .registry
3664                .create_palace(&state.data_root, palace)
3665                .expect("create palace");
3666            let persisted = PersistedDreamStats {
3667                last_run_at: ts,
3668                stats,
3669            };
3670            persisted
3671                .save(&state.data_root.join(id))
3672                .expect("save dream stats");
3673        }
3674
3675        let later = chrono::Utc::now();
3676        let app = router().with_state(state);
3677        let resp = app
3678            .oneshot(
3679                Request::builder()
3680                    .uri("/api/v1/dream/status")
3681                    .body(Body::empty())
3682                    .unwrap(),
3683            )
3684            .await
3685            .unwrap();
3686        assert_eq!(resp.status(), StatusCode::OK);
3687        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3688        let v: Value = serde_json::from_slice(&bytes).unwrap();
3689
3690        // Aggregated counters.
3691        assert_eq!(v["merged"], 11);
3692        assert_eq!(v["pruned"], 22);
3693        assert_eq!(v["compacted"], 33);
3694        assert_eq!(v["closets_updated"], 44);
3695        assert_eq!(v["duration_ms"], 300);
3696
3697        // `last_run_at` is the more-recent of the two timestamps.
3698        let last = v["last_run_at"].as_str().expect("last_run_at is string");
3699        let parsed: chrono::DateTime<chrono::Utc> = last
3700            .parse()
3701            .expect("last_run_at parses as RFC3339 timestamp");
3702        assert!(
3703            parsed <= later,
3704            "last_run_at ({parsed}) should not exceed wall clock ({later})"
3705        );
3706        // Must have picked palace-b's newer stamp, not palace-a's older one.
3707        let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3708        assert!(
3709            parsed >= cutoff,
3710            "expected the newer (palace-b) timestamp; got {parsed}"
3711        );
3712    }
3713
3714    /// Why: `POST /api/v1/dream/run` triggers a dream cycle across every
3715    /// palace and must return the aggregated stats. Even when no palace
3716    /// has work to do (empty registry) the endpoint must round-trip 200
3717    /// with the well-formed payload shape so the dashboard's "Run now"
3718    /// button never fails the UI.
3719    /// What: Pre-creates one palace via the registry, posts to the endpoint,
3720    /// and asserts the response is 200 with all expected fields present.
3721    /// Deeper assertions (specific merged/pruned counts) are skipped here
3722    /// because running a full dream cycle requires the ONNX embedder load
3723    /// path and we want this test to stay fast and embedder-free.
3724    /// Test: This test itself.
3725    #[tokio::test]
3726    async fn dream_run_aggregates_stats() {
3727        let state = test_state();
3728        let palace = trusty_common::memory_core::Palace {
3729            id: PalaceId::new("dream-run-test"),
3730            name: "dream-run-test".to_string(),
3731            description: None,
3732            created_at: chrono::Utc::now(),
3733            data_dir: state.data_root.join("dream-run-test"),
3734        };
3735        state
3736            .registry
3737            .create_palace(&state.data_root, palace)
3738            .expect("create palace");
3739
3740        let app = router().with_state(state);
3741        let resp = app
3742            .oneshot(
3743                Request::builder()
3744                    .method("POST")
3745                    .uri("/api/v1/dream/run")
3746                    .body(Body::empty())
3747                    .unwrap(),
3748            )
3749            .await
3750            .unwrap();
3751        assert_eq!(resp.status(), StatusCode::OK);
3752        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3753        let v: Value = serde_json::from_slice(&bytes).unwrap();
3754
3755        // Shape: every aggregated counter must be present (even if zero) and
3756        // `last_run_at` is set by the handler to "now".
3757        for key in [
3758            "merged",
3759            "pruned",
3760            "compacted",
3761            "closets_updated",
3762            "duration_ms",
3763        ] {
3764            assert!(
3765                v.get(key).is_some(),
3766                "missing key {key} in dream_run payload: {v}"
3767            );
3768            assert!(
3769                v[key].is_u64() || v[key].is_i64(),
3770                "{key} should be integer, got {}",
3771                v[key]
3772            );
3773        }
3774        assert!(
3775            v["last_run_at"].is_string(),
3776            "last_run_at must be set by dream_run; got {v}"
3777        );
3778    }
3779
3780    /// Why: Issue #53 — when the dream cycle has not yet run for a palace,
3781    /// `/api/v1/kg/gaps` must return an empty array (200 OK), not 404 or
3782    /// 500. The cache miss is a meaningful, non-error state.
3783    /// What: Creates a palace, queries `/api/v1/kg/gaps?palace=...`, asserts
3784    /// the response is `200` with body `[]`.
3785    /// Test: this test itself.
3786    #[tokio::test]
3787    async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3788        let state = test_state();
3789        let palace = trusty_common::memory_core::Palace {
3790            id: PalaceId::new("gaps-empty"),
3791            name: "gaps-empty".to_string(),
3792            description: None,
3793            created_at: chrono::Utc::now(),
3794            data_dir: state.data_root.join("gaps-empty"),
3795        };
3796        state
3797            .registry
3798            .create_palace(&state.data_root, palace)
3799            .expect("create palace");
3800
3801        let app = router().with_state(state);
3802        let resp = app
3803            .oneshot(
3804                Request::builder()
3805                    .uri("/api/v1/kg/gaps?palace=gaps-empty")
3806                    .body(Body::empty())
3807                    .unwrap(),
3808            )
3809            .await
3810            .unwrap();
3811        assert_eq!(resp.status(), StatusCode::OK);
3812        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3813        let v: Value = serde_json::from_slice(&bytes).unwrap();
3814        assert_eq!(v.as_array().expect("array").len(), 0);
3815    }
3816
3817    /// Why: Issue #53 — when the cache *has* been populated (by the dream
3818    /// cycle in production, or by direct seeding here), the endpoint must
3819    /// return each gap with the four wire fields.
3820    /// What: Seeds the registry cache via `set_gaps` directly, then GETs
3821    /// `/api/v1/kg/gaps?palace=...` and asserts the JSON shape.
3822    /// Test: this test itself.
3823    #[tokio::test]
3824    async fn kg_gaps_endpoint_returns_cached_gaps() {
3825        use trusty_common::memory_core::community::KnowledgeGap;
3826
3827        let state = test_state();
3828        let palace = trusty_common::memory_core::Palace {
3829            id: PalaceId::new("gaps-seed"),
3830            name: "gaps-seed".to_string(),
3831            description: None,
3832            created_at: chrono::Utc::now(),
3833            data_dir: state.data_root.join("gaps-seed"),
3834        };
3835        state
3836            .registry
3837            .create_palace(&state.data_root, palace)
3838            .expect("create palace");
3839
3840        state.registry.set_gaps(
3841            PalaceId::new("gaps-seed"),
3842            vec![KnowledgeGap {
3843                entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3844                internal_density: 0.15,
3845                external_bridges: 2,
3846                suggested_exploration: "Explore connections between foo and related concepts"
3847                    .to_string(),
3848            }],
3849        );
3850
3851        let app = router().with_state(state);
3852        let resp = app
3853            .oneshot(
3854                Request::builder()
3855                    .uri("/api/v1/kg/gaps?palace=gaps-seed")
3856                    .body(Body::empty())
3857                    .unwrap(),
3858            )
3859            .await
3860            .unwrap();
3861        assert_eq!(resp.status(), StatusCode::OK);
3862        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3863        let v: Value = serde_json::from_slice(&bytes).unwrap();
3864        let arr = v.as_array().expect("array");
3865        assert_eq!(arr.len(), 1);
3866        assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3867        assert_eq!(arr[0]["external_bridges"], 2);
3868        assert!(arr[0]["suggested_exploration"]
3869            .as_str()
3870            .unwrap()
3871            .contains("foo"));
3872    }
3873
3874    /// Why: The KG Explorer UI calls `/api/v1/palaces/{id}/kg/subjects` to
3875    /// populate the left panel; the endpoint must return distinct active
3876    /// subjects as a JSON string array.
3877    /// What: Creates a palace, asserts two triples via the existing kg endpoint,
3878    /// then GETs the subjects route and asserts the shape.
3879    /// Test: this test itself.
3880    #[tokio::test]
3881    async fn kg_list_subjects_returns_distinct() {
3882        let state = test_state();
3883        let app = router().with_state(state.clone());
3884
3885        // Create palace.
3886        let resp = app
3887            .clone()
3888            .oneshot(
3889                Request::builder()
3890                    .method("POST")
3891                    .uri("/api/v1/palaces")
3892                    .header("content-type", "application/json")
3893                    .body(Body::from(json!({"name": "kg-list"}).to_string()))
3894                    .unwrap(),
3895            )
3896            .await
3897            .unwrap();
3898        assert_eq!(resp.status(), StatusCode::OK);
3899
3900        // Assert two triples on distinct subjects.
3901        for subj in ["alpha", "beta"] {
3902            let body = json!({
3903                "subject": subj,
3904                "predicate": "is",
3905                "object": "thing",
3906            })
3907            .to_string();
3908            let r = app
3909                .clone()
3910                .oneshot(
3911                    Request::builder()
3912                        .method("POST")
3913                        .uri("/api/v1/palaces/kg-list/kg")
3914                        .header("content-type", "application/json")
3915                        .body(Body::from(body))
3916                        .unwrap(),
3917                )
3918                .await
3919                .unwrap();
3920            assert_eq!(r.status(), StatusCode::NO_CONTENT);
3921        }
3922
3923        let resp = app
3924            .oneshot(
3925                Request::builder()
3926                    .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3927                    .body(Body::empty())
3928                    .unwrap(),
3929            )
3930            .await
3931            .unwrap();
3932        assert_eq!(resp.status(), StatusCode::OK);
3933        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3934        let v: Value = serde_json::from_slice(&bytes).unwrap();
3935        let arr = v.as_array().expect("subjects must be array");
3936        let subjects: Vec<String> = arr
3937            .iter()
3938            .filter_map(|x| x.as_str().map(String::from))
3939            .collect();
3940        assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3941    }
3942
3943    /// Why: KG Explorer's "All" mode pages through every active triple via
3944    /// `/api/v1/palaces/{id}/kg/all`; the endpoint must return a JSON array of
3945    /// `Triple` rows ordered by `valid_from` DESC.
3946    /// What: Creates a palace, asserts a triple, then GETs the all route and
3947    /// asserts the response is an array with the expected shape.
3948    /// Test: this test itself.
3949    #[tokio::test]
3950    async fn kg_list_all_returns_paginated_triples() {
3951        let state = test_state();
3952        let app = router().with_state(state.clone());
3953
3954        let resp = app
3955            .clone()
3956            .oneshot(
3957                Request::builder()
3958                    .method("POST")
3959                    .uri("/api/v1/palaces")
3960                    .header("content-type", "application/json")
3961                    .body(Body::from(json!({"name": "kg-all"}).to_string()))
3962                    .unwrap(),
3963            )
3964            .await
3965            .unwrap();
3966        assert_eq!(resp.status(), StatusCode::OK);
3967
3968        let body = json!({
3969            "subject": "alpha",
3970            "predicate": "is",
3971            "object": "thing",
3972        })
3973        .to_string();
3974        let r = app
3975            .clone()
3976            .oneshot(
3977                Request::builder()
3978                    .method("POST")
3979                    .uri("/api/v1/palaces/kg-all/kg")
3980                    .header("content-type", "application/json")
3981                    .body(Body::from(body))
3982                    .unwrap(),
3983            )
3984            .await
3985            .unwrap();
3986        assert_eq!(r.status(), StatusCode::NO_CONTENT);
3987
3988        let resp = app
3989            .oneshot(
3990                Request::builder()
3991                    .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3992                    .body(Body::empty())
3993                    .unwrap(),
3994            )
3995            .await
3996            .unwrap();
3997        assert_eq!(resp.status(), StatusCode::OK);
3998        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3999        let v: Value = serde_json::from_slice(&bytes).unwrap();
4000        let arr = v.as_array().expect("triples must be array");
4001        assert_eq!(arr.len(), 1);
4002        assert_eq!(arr[0]["subject"], "alpha");
4003        assert_eq!(arr[0]["predicate"], "is");
4004        assert_eq!(arr[0]["object"], "thing");
4005    }
4006
4007    /// Why (issue #42): `GET /api/v1/kg/prompt-context` must serve the
4008    /// formatted Markdown block from the in-memory cache (or a placeholder
4009    /// when empty). Mirrors the MCP `get_prompt_context` tool but over HTTP.
4010    #[tokio::test]
4011    async fn prompt_context_endpoint_returns_formatted_block() {
4012        let state = test_state();
4013
4014        // Empty cache returns the placeholder text.
4015        let app = router().with_state(state.clone());
4016        let resp = app
4017            .oneshot(
4018                Request::builder()
4019                    .uri("/api/v1/kg/prompt-context")
4020                    .body(Body::empty())
4021                    .unwrap(),
4022            )
4023            .await
4024            .unwrap();
4025        assert_eq!(resp.status(), StatusCode::OK);
4026        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4027        let text = String::from_utf8(bytes.to_vec()).unwrap();
4028        assert_eq!(text, "No prompt facts stored yet.");
4029
4030        // Populate the cache and re-fetch.
4031        {
4032            let mut guard = state.prompt_context_cache.write().expect("write lock");
4033            let triples = vec![(
4034                "tga".to_string(),
4035                "is_alias_for".to_string(),
4036                "trusty-git-analytics".to_string(),
4037            )];
4038            let formatted = crate::prompt_facts::build_prompt_context(&triples);
4039            *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
4040        }
4041        let app = router().with_state(state);
4042        let resp = app
4043            .oneshot(
4044                Request::builder()
4045                    .uri("/api/v1/kg/prompt-context")
4046                    .body(Body::empty())
4047                    .unwrap(),
4048            )
4049            .await
4050            .unwrap();
4051        assert_eq!(resp.status(), StatusCode::OK);
4052        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4053        let text = String::from_utf8(bytes.to_vec()).unwrap();
4054        assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
4055    }
4056
4057    /// Why (issue #42): `POST /api/v1/kg/aliases` must assert the alias as
4058    /// an `is_alias_for` triple AND refresh the prompt cache so subsequent
4059    /// reads see the new alias.
4060    #[tokio::test]
4061    async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
4062        let tmp = tempfile::tempdir().expect("tempdir");
4063        let root = tmp.path().to_path_buf();
4064        std::mem::forget(tmp);
4065        let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
4066        let palace = trusty_common::memory_core::Palace {
4067            id: PalaceId::new("aliases"),
4068            name: "aliases".to_string(),
4069            description: None,
4070            created_at: chrono::Utc::now(),
4071            data_dir: state.data_root.join("aliases"),
4072        };
4073        state
4074            .registry
4075            .create_palace(&state.data_root, palace)
4076            .expect("create palace");
4077
4078        let body = json!({"short": "tm", "full": "trusty-memory"});
4079        let app = router().with_state(state.clone());
4080        let resp = app
4081            .oneshot(
4082                Request::builder()
4083                    .method("POST")
4084                    .uri("/api/v1/kg/aliases")
4085                    .header("content-type", "application/json")
4086                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
4087                    .unwrap(),
4088            )
4089            .await
4090            .unwrap();
4091        assert_eq!(resp.status(), StatusCode::OK);
4092        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4093        let v: Value = serde_json::from_slice(&bytes).unwrap();
4094        assert_eq!(v["subject"], "tm");
4095        assert_eq!(v["object"], "trusty-memory");
4096
4097        // The prompt cache must reflect the new alias.
4098        let guard = state.prompt_context_cache.read().expect("read lock");
4099        assert!(
4100            guard.formatted.contains("tm → trusty-memory"),
4101            "cache missing alias; got: {}",
4102            guard.formatted
4103        );
4104    }
4105
4106    /// Why (issue #42): `GET /api/v1/kg/prompt-facts` returns the structured
4107    /// JSON array of every hot-predicate triple across the registry (so a
4108    /// dashboard can render its own table).
4109    #[tokio::test]
4110    async fn list_prompt_facts_endpoint_returns_hot_triples() {
4111        let tmp = tempfile::tempdir().expect("tempdir");
4112        let root = tmp.path().to_path_buf();
4113        std::mem::forget(tmp);
4114        let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4115        let palace = trusty_common::memory_core::Palace {
4116            id: PalaceId::new("listfacts"),
4117            name: "listfacts".to_string(),
4118            description: None,
4119            created_at: chrono::Utc::now(),
4120            data_dir: state.data_root.join("listfacts"),
4121        };
4122        let handle = state
4123            .registry
4124            .create_palace(&state.data_root, palace)
4125            .expect("create palace");
4126
4127        // Insert one hot triple and one non-hot triple; only the hot one
4128        // should surface.
4129        handle
4130            .kg
4131            .assert(Triple {
4132                subject: "ts".to_string(),
4133                predicate: "is_alias_for".to_string(),
4134                object: "trusty-search".to_string(),
4135                valid_from: chrono::Utc::now(),
4136                valid_to: None,
4137                confidence: 1.0,
4138                provenance: None,
4139            })
4140            .await
4141            .expect("assert alias");
4142        handle
4143            .kg
4144            .assert(Triple {
4145                subject: "alice".to_string(),
4146                predicate: "works_at".to_string(),
4147                object: "Acme".to_string(),
4148                valid_from: chrono::Utc::now(),
4149                valid_to: None,
4150                confidence: 1.0,
4151                provenance: None,
4152            })
4153            .await
4154            .expect("assert works_at");
4155
4156        let app = router().with_state(state);
4157        let resp = app
4158            .oneshot(
4159                Request::builder()
4160                    .uri("/api/v1/kg/prompt-facts")
4161                    .body(Body::empty())
4162                    .unwrap(),
4163            )
4164            .await
4165            .unwrap();
4166        assert_eq!(resp.status(), StatusCode::OK);
4167        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4168        let v: Value = serde_json::from_slice(&bytes).unwrap();
4169        let arr = v.as_array().expect("array");
4170        assert!(
4171            arr.iter().any(|r| r["subject"] == "ts"
4172                && r["predicate"] == "is_alias_for"
4173                && r["object"] == "trusty-search"),
4174            "missing ts alias; got {arr:?}"
4175        );
4176        // The non-hot `works_at` triple must not be present.
4177        assert!(
4178            !arr.iter().any(|r| r["predicate"] == "works_at"),
4179            "non-hot triple leaked into prompt facts: {arr:?}"
4180        );
4181    }
4182
4183    /// Why (issue #42): `DELETE /api/v1/kg/prompt-facts` must retract the
4184    /// interval and refresh the cache; the next list call must omit it.
4185    #[tokio::test]
4186    async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4187        let tmp = tempfile::tempdir().expect("tempdir");
4188        let root = tmp.path().to_path_buf();
4189        std::mem::forget(tmp);
4190        let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4191        let palace = trusty_common::memory_core::Palace {
4192            id: PalaceId::new("rmfacts"),
4193            name: "rmfacts".to_string(),
4194            description: None,
4195            created_at: chrono::Utc::now(),
4196            data_dir: state.data_root.join("rmfacts"),
4197        };
4198        let handle = state
4199            .registry
4200            .create_palace(&state.data_root, palace)
4201            .expect("create palace");
4202
4203        handle
4204            .kg
4205            .assert(Triple {
4206                subject: "ta".to_string(),
4207                predicate: "is_alias_for".to_string(),
4208                object: "trusty-analyze".to_string(),
4209                valid_from: chrono::Utc::now(),
4210                valid_to: None,
4211                confidence: 1.0,
4212                provenance: None,
4213            })
4214            .await
4215            .expect("assert alias");
4216        // Prime the cache so we can observe the removal effect.
4217        crate::prompt_facts::rebuild_prompt_cache(&state)
4218            .await
4219            .expect("rebuild prompt cache");
4220
4221        let app = router().with_state(state.clone());
4222        let resp = app
4223            .oneshot(
4224                Request::builder()
4225                    .method("DELETE")
4226                    .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4227                    .body(Body::empty())
4228                    .unwrap(),
4229            )
4230            .await
4231            .unwrap();
4232        assert_eq!(resp.status(), StatusCode::OK);
4233        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4234        let v: Value = serde_json::from_slice(&bytes).unwrap();
4235        assert_eq!(v["removed"], true);
4236        assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4237
4238        // Cache must no longer contain the alias.
4239        {
4240            let guard = state.prompt_context_cache.read().expect("read lock");
4241            assert!(
4242                !guard.formatted.contains("ta → trusty-analyze"),
4243                "alias still in cache after delete: {}",
4244                guard.formatted
4245            );
4246        }
4247
4248        // Removing a non-existent fact returns removed=false.
4249        let app = router().with_state(state);
4250        let resp = app
4251            .oneshot(
4252                Request::builder()
4253                    .method("DELETE")
4254                    .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4255                    .body(Body::empty())
4256                    .unwrap(),
4257            )
4258            .await
4259            .unwrap();
4260        assert_eq!(resp.status(), StatusCode::OK);
4261        let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4262        let v: Value = serde_json::from_slice(&bytes).unwrap();
4263        assert_eq!(v["removed"], false);
4264    }
4265
4266    #[tokio::test]
4267    async fn serves_index_html_fallback() {
4268        let state = test_state();
4269        let app = router().with_state(state);
4270        let resp = app
4271            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4272            .await
4273            .unwrap();
4274        // Either OK with embedded HTML, or NOT_FOUND if assets not built.
4275        assert!(
4276            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4277            "got {}",
4278            resp.status()
4279        );
4280    }
4281}