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