Skip to main content

trusty_memory/
service.rs

1//! `MemoryService` — pure business-logic facade over `AppState`.
2//!
3//! Why: `web.rs` previously hosted ~5700 lines that mingled axum extraction,
4//! JSON wire shapes, and business logic. Moving the logic into a struct with
5//! `anyhow::Result<Value>` methods lets the HTTP handlers stay one-liners
6//! and lets non-HTTP callers (chat tool dispatch, future RPC bridges) reuse
7//! the same code paths without dragging axum types around.
8//! What: A zero-cost wrapper around [`AppState`] exposing one async method
9//! per logical operation. Each method returns either `anyhow::Result<Value>`
10//! (for handlers that already wrap errors with `ApiError::internal`) or a
11//! domain-specific result the handler maps into JSON.
12//! Test: Each method is covered indirectly via the corresponding HTTP test in
13//! `web::tests` (the handlers delegate here verbatim).
14//!
15//! Hard constraint (issue #151): no behaviour change. Every method's success
16//! and failure shapes match what the handler used to produce inline.
17
18use crate::attribution::CreatorInfo;
19use crate::{ActivityFilter, ActivitySource, AppState, DaemonEvent};
20use anyhow::{anyhow, Context, Result};
21use serde::{Deserialize, Serialize};
22use serde_json::{json, Value};
23use std::collections::HashSet;
24use std::sync::Arc;
25use trusty_common::memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
26use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
27use trusty_common::memory_core::retrieval::{
28    recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
29    recall_with_default_embedder, RecallResult,
30};
31use trusty_common::memory_core::store::kg::Triple;
32use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
33use uuid::Uuid;
34
35// ---------------------------------------------------------------------------
36// Wire types shared between HTTP handlers and the service layer.
37// ---------------------------------------------------------------------------
38
39/// Serializable palace summary used by `GET /api/v1/palaces` and
40/// `GET /api/v1/palaces/{id}`.
41///
42/// Why: Both endpoints return the same enriched shape; centralising the
43/// type in the service layer keeps the wire contract single-source.
44/// What: Mirrors the legacy `PalaceInfo` struct verbatim — counts, timestamps,
45/// graph stats, and the `is_compacting` flag.
46/// Test: `palace_list_includes_richer_counts`, `palace_list_includes_graph_counts`.
47#[derive(Serialize, Clone, Debug)]
48pub struct PalaceInfo {
49    pub id: String,
50    pub name: String,
51    pub description: Option<String>,
52    pub drawer_count: usize,
53    pub vector_count: usize,
54    pub kg_triple_count: usize,
55    pub wing_count: usize,
56    pub created_at: chrono::DateTime<chrono::Utc>,
57    pub last_write_at: Option<chrono::DateTime<chrono::Utc>>,
58    #[serde(default)]
59    pub node_count: u64,
60    #[serde(default)]
61    pub edge_count: u64,
62    #[serde(default)]
63    pub community_count: u64,
64    #[serde(default)]
65    pub is_compacting: bool,
66}
67
68/// Dream statistics wire shape used by both per-palace and aggregate endpoints.
69///
70/// Why: Lifted out of `web.rs` so the service layer owns the type the chat
71/// dispatcher and HTTP handlers both serialise. Stays identical to the
72/// pre-refactor shape.
73/// What: All fields are saturating sums across one or more palaces; the
74/// `last_run_at` is the max across them (or `None` when no palace has run).
75/// Test: `dream_status_aggregates_across_palaces`, `dream_run_aggregates_stats`.
76#[derive(Serialize, Default, Clone, Debug)]
77pub struct DreamStatusPayload {
78    pub last_run_at: Option<chrono::DateTime<chrono::Utc>>,
79    pub merged: usize,
80    pub pruned: usize,
81    pub compacted: usize,
82    pub closets_updated: usize,
83    pub duration_ms: u64,
84}
85
86impl From<PersistedDreamStats> for DreamStatusPayload {
87    fn from(p: PersistedDreamStats) -> Self {
88        Self {
89            last_run_at: Some(p.last_run_at),
90            merged: p.stats.merged,
91            pruned: p.stats.pruned,
92            compacted: p.stats.compacted,
93            closets_updated: p.stats.closets_updated,
94            duration_ms: p.stats.duration_ms,
95        }
96    }
97}
98
99/// `POST /api/v1/palaces` body — service-facing version.
100#[derive(Deserialize, Clone, Debug)]
101pub struct CreatePalaceBody {
102    pub name: String,
103    #[serde(default)]
104    pub description: Option<String>,
105}
106
107/// `POST /api/v1/palaces/{id}/drawers` body — service-facing version.
108#[derive(Deserialize, Clone, Debug)]
109pub struct CreateDrawerBody {
110    pub content: String,
111    #[serde(default)]
112    pub room: Option<String>,
113    #[serde(default)]
114    pub tags: Vec<String>,
115    #[serde(default)]
116    pub importance: Option<f32>,
117}
118
119/// `GET /api/v1/palaces/{id}/drawers` query — service-facing version.
120///
121/// Why: the TUI activity panel (#184) needs paged access to a palace's
122/// drawers in newest-first order. Adding `offset` and `sort` to the existing
123/// query struct keeps the surface compatible (both fields default to absent)
124/// while letting the panel walk through arbitrarily many drawers.
125/// What: optional `room` / `tag` filters, a `limit` (default 50 in the
126/// handler), an `offset` for pagination, and a `sort` selector — `importance`
127/// (the legacy default, descending) or `created_desc` (newest first).
128/// Test: `list_drawers_creates_desc_paginates` in `service::tests`.
129#[derive(Deserialize, Default, Clone, Debug)]
130pub struct ListDrawersQuery {
131    #[serde(default)]
132    pub room: Option<String>,
133    #[serde(default)]
134    pub tag: Option<String>,
135    #[serde(default)]
136    pub limit: Option<usize>,
137    /// Number of drawers to skip before returning results. Combined with
138    /// `limit` this paginates the result set. Defaults to 0.
139    #[serde(default)]
140    pub offset: Option<usize>,
141    /// Sort selector: `"importance"` (default — importance descending,
142    /// preserving legacy behaviour) or `"created_desc"` (creation date
143    /// descending, newest first — used by the TUI activity panel).
144    #[serde(default)]
145    pub sort: Option<String>,
146}
147
148/// `POST /api/v1/palaces/{id}/kg` body — service-facing version.
149#[derive(Deserialize, Clone, Debug)]
150pub struct KgAssertBody {
151    pub subject: String,
152    pub predicate: String,
153    pub object: String,
154    #[serde(default)]
155    pub confidence: Option<f32>,
156    #[serde(default)]
157    pub provenance: Option<String>,
158}
159
160/// Knowledge-graph "graph payload" used by `GET /api/v1/palaces/{id}/kg/graph`.
161#[derive(Serialize, Clone, Debug)]
162pub struct KgGraphPayload {
163    pub triples: Vec<Triple>,
164    pub node_count: u64,
165    pub edge_count: u64,
166    pub community_count: u64,
167}
168
169/// Status payload returned by `GET /api/v1/status`.
170#[derive(Serialize, Clone, Debug)]
171pub struct StatusPayload {
172    pub version: String,
173    pub palace_count: usize,
174    pub default_palace: Option<String>,
175    pub data_root: String,
176    pub total_drawers: usize,
177    pub total_vectors: usize,
178    pub total_kg_triples: usize,
179}
180
181/// Service-level error type that maps cleanly onto HTTP status codes.
182///
183/// Why: handlers want to render 400/404/409/500 from a single point; the
184/// service methods produce a typed error so the binding layer can pick the
185/// right status without parsing strings.
186/// What: four variants matching the legacy `ApiError` constructors plus a
187/// dedicated `Conflict` for state-clash errors (issue #180: deleting a
188/// non-empty palace without `force`).
189/// Test: indirectly via the HTTP tests for the corresponding endpoints.
190#[derive(Debug, thiserror::Error)]
191pub enum ServiceError {
192    #[error("{0}")]
193    BadRequest(String),
194    #[error("{0}")]
195    NotFound(String),
196    #[error("{0}")]
197    Conflict(String),
198    #[error("{0}")]
199    Internal(String),
200}
201
202impl ServiceError {
203    pub fn bad_request(msg: impl Into<String>) -> Self {
204        Self::BadRequest(msg.into())
205    }
206    pub fn not_found(msg: impl Into<String>) -> Self {
207        Self::NotFound(msg.into())
208    }
209    /// Build a 409 Conflict service error.
210    ///
211    /// Why: palace-delete (issue #180) needs to surface a distinct
212    /// "state precondition failed" status when the caller asks to delete a
213    /// non-empty palace without `force=true`. 400 would be misleading
214    /// (the request itself is well-formed) and 404 would lie about the
215    /// resource's existence.
216    /// What: wraps the message in `ServiceError::Conflict`.
217    /// Test: `delete_palace_refuses_when_drawers_present` in `web::tests`.
218    pub fn conflict(msg: impl Into<String>) -> Self {
219        Self::Conflict(msg.into())
220    }
221    pub fn internal(msg: impl Into<String>) -> Self {
222        Self::Internal(msg.into())
223    }
224}
225
226/// Result alias used across the service layer.
227pub type ServiceResult<T> = std::result::Result<T, ServiceError>;
228
229/// Hard cap on triples returned by the per-palace graph endpoint.
230const KG_GRAPH_MAX_TRIPLES: usize = 5_000;
231
232// ---------------------------------------------------------------------------
233// MemoryService — pure business logic facade.
234// ---------------------------------------------------------------------------
235
236/// Wraps [`AppState`] and exposes one async method per logical operation.
237///
238/// Why: see module docs. Lets HTTP handlers stay thin and lets non-HTTP
239/// callers (chat tool dispatch, RPC bridges) reuse the same code paths.
240/// What: `Clone` (cheap — only the inner `AppState` is shared); construct
241/// with `MemoryService::new(state)`.
242/// Test: every method is covered by the corresponding handler test in
243/// `web::tests`.
244#[derive(Clone)]
245pub struct MemoryService {
246    state: AppState,
247}
248
249impl MemoryService {
250    /// Construct a new service wrapper.
251    ///
252    /// Why: handlers cheaply re-wrap their `AppState` on every request; the
253    /// cost is just an `Arc` clone, so we don't bother caching the wrapper.
254    /// What: stores the `AppState` for later method calls.
255    /// Test: trivial — covered indirectly by every handler test.
256    pub fn new(state: AppState) -> Self {
257        Self { state }
258    }
259
260    /// Borrow the inner [`AppState`].
261    ///
262    /// Why: some handlers still need direct access (SSE broadcaster, session
263    /// store, etc.) while we incrementally extract code into the service.
264    /// What: returns a borrowed reference to the wrapped `AppState`.
265    /// Test: not directly tested; surface-level accessor.
266    pub fn state(&self) -> &AppState {
267        &self.state
268    }
269
270    // -----------------------------------------------------------------
271    // Status / config
272    // -----------------------------------------------------------------
273
274    /// Build the aggregate `/api/v1/status` payload.
275    ///
276    /// Why: dashboard widgets and the MCP `get_status` tool need the same
277    /// roll-up; centralising avoids drift between the two surfaces.
278    /// What: walks every persisted palace, sums drawer/vector/triple counts,
279    /// and returns the [`StatusPayload`].
280    /// Test: `status_endpoint_returns_payload`.
281    pub async fn status(&self) -> StatusPayload {
282        // The `/status` endpoint is the one place we still want a disk view —
283        // an operator hitting this endpoint right after restart (before
284        // `load_palaces_from_disk` finishes) should still see every persisted
285        // palace counted, even if it isn't in the in-memory registry yet.
286        let palaces = PalaceRegistry::list_palaces(&self.state.data_root).unwrap_or_default();
287        let palace_count = palaces.len();
288        let stats = collect_palace_stats(&self.state, palaces.iter().map(|p| &p.id));
289        StatusPayload {
290            version: self.state.version.clone(),
291            palace_count,
292            default_palace: self.state.default_palace.clone(),
293            data_root: self.state.data_root.display().to_string(),
294            total_drawers: stats.total_drawers,
295            total_vectors: stats.total_vectors,
296            total_kg_triples: stats.total_kg_triples,
297        }
298    }
299
300    /// Compute the aggregate `StatusChanged` event used by SSE consumers.
301    ///
302    /// Why: mutating handlers — and the periodic status ticker — push a
303    /// refreshed status snapshot so dashboards stay in sync without an
304    /// extra `/api/v1/status` request.
305    /// Why (issue #228): this used to call `PalaceRegistry::list_palaces`
306    /// (a synchronous disk walk) + `open_palace` (more disk I/O on first
307    /// call) for every palace on every emit. Since every persisted palace
308    /// is already loaded into the in-memory registry by
309    /// `AppState::load_palaces_from_disk` at startup (and every `create_palace`
310    /// keeps it in sync), iterating the in-memory registry returns the same
311    /// counts without touching disk.
312    /// What: iterates `state.registry.list()` (a `DashMap` snapshot) and
313    /// sums the live handle stats via [`collect_palace_stats`]. Returns a
314    /// `DaemonEvent::StatusChanged`. Palaces that fail to resolve in the
315    /// registry (race during shutdown) are silently skipped — the next
316    /// emit will catch them.
317    /// Test: indirectly via SSE integration tests; the math is identical to
318    /// the disk-walk implementation and the `status_endpoint_returns_payload`
319    /// test still passes against `status()` (which keeps the disk view for
320    /// the dedicated endpoint).
321    pub fn aggregate_status_event(&self) -> DaemonEvent {
322        let ids: Vec<PalaceId> = self.state.registry.list();
323        let stats = collect_palace_stats(&self.state, ids.iter());
324        DaemonEvent::StatusChanged {
325            total_drawers: stats.total_drawers,
326            total_vectors: stats.total_vectors,
327            total_kg_triples: stats.total_kg_triples,
328        }
329    }
330
331    // -----------------------------------------------------------------
332    // Palaces
333    // -----------------------------------------------------------------
334
335    /// List every palace on disk, enriched with live handle stats.
336    ///
337    /// Why: shared between the HTTP handler and the chat tool dispatcher;
338    /// both want the same `PalaceInfo` shape. Issue #185 added the
339    /// reserved-prefix filter so internal "system" palaces (e.g. the
340    /// `__health_probe__` palace used by `/health`) never surface in the
341    /// admin UI, TUI, or any user-facing roster.
342    /// What: walks the registry, drops any palace whose id starts with the
343    /// reserved `__` prefix, and builds a `PalaceInfo` per remaining row.
344    /// Test: `palace_list_includes_richer_counts`, `palace_list_includes_graph_counts`,
345    /// `health_probe_palace_is_invisible` (in `web::tests`).
346    pub async fn list_palaces(&self) -> ServiceResult<Vec<PalaceInfo>> {
347        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
348            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
349        let mut out = Vec::with_capacity(palaces.len());
350        for p in palaces {
351            if is_reserved_system_palace(&p.id) {
352                continue;
353            }
354            let handle = self
355                .state
356                .registry
357                .open_palace(&self.state.data_root, &p.id)
358                .ok();
359            out.push(palace_info_from(&p, handle.as_ref()));
360        }
361        Ok(out)
362    }
363
364    /// Create a new palace and emit the corresponding activity event.
365    ///
366    /// Why: trims duplicated work between the HTTP handler and any future
367    /// non-HTTP creation flow.
368    /// What: validates the name, builds the `Palace` row, calls
369    /// `PalaceRegistry::create_palace`, and emits `PalaceCreated`. Returns
370    /// the new palace id.
371    /// Test: covered indirectly by `palace_list_includes_richer_counts` (which
372    /// posts a palace through the HTTP layer then reads it back).
373    pub async fn create_palace(
374        &self,
375        body: CreatePalaceBody,
376        source: ActivitySource,
377    ) -> ServiceResult<String> {
378        let name = body.name.trim().to_string();
379        if name.is_empty() {
380            return Err(ServiceError::bad_request("name is required"));
381        }
382        // Issue #88: enforce palace = project mapping for HTTP-originated
383        // palace creation, mirroring the MCP path in `tools::handle_palace_create`.
384        let skip_enforcement =
385            std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
386        if !skip_enforcement {
387            let cwd = std::env::current_dir().unwrap_or_else(|_| self.state.data_root.clone());
388            crate::project_root::validate_palace_name(&name, &cwd)
389                .map_err(|e| ServiceError::bad_request(e.to_string()))?;
390        }
391        let id = PalaceId::new(&name);
392        let palace = Palace {
393            id: id.clone(),
394            name: name.clone(),
395            description: body.description.filter(|s| !s.is_empty()),
396            created_at: chrono::Utc::now(),
397            data_dir: self.state.data_root.join(&name),
398        };
399        self.state
400            .registry
401            .create_palace(&self.state.data_root, palace)
402            .map_err(|e| ServiceError::internal(format!("create palace: {e:#}")))?;
403        // Issue #228: keep the in-memory palace-name cache in sync so writes
404        // to this palace can resolve `Palace.name` without a disk walk.
405        self.state.palace_names.insert(name.clone(), name.clone());
406        self.state.emit(DaemonEvent::PalaceCreated {
407            id: name.clone(),
408            name: name.clone(),
409            source,
410        });
411        Ok(name)
412    }
413
414    /// Delete a palace from disk, optionally rejecting non-empty palaces.
415    ///
416    /// Why: Issue #180 — operators need a way to drop an entire palace
417    /// without going through drawer-by-drawer deletion. Defaulting to a
418    /// "must be empty" guard prevents fat-finger destruction of populated
419    /// palaces; `force=true` is the explicit opt-in to the destructive path.
420    /// What: 1) confirms the palace exists on disk (else `NotFound`),
421    /// 2) when `!force`, lists drawers via the live handle and returns
422    /// `BadRequest("Palace has drawers; pass force=true to delete")` if
423    /// the palace is non-empty, 3) drops the in-memory registry entry so
424    /// future opens hit the (now-missing) disk state, 4) removes
425    /// `<data_root>/<palace_id>/` recursively via `tokio::fs::remove_dir_all`,
426    /// and 5) emits an aggregate `StatusChanged` so dashboards refresh.
427    /// Test: `delete_palace_removes_dir_when_empty`,
428    /// `delete_palace_refuses_when_drawers_present`,
429    /// `delete_palace_force_removes_populated_palace`,
430    /// `delete_palace_returns_not_found_for_missing_id` in `web::tests`.
431    pub async fn delete_palace(&self, palace_id: &str, force: bool) -> ServiceResult<()> {
432        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
433            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
434        if !palaces.iter().any(|p| p.id.0 == palace_id) {
435            return Err(ServiceError::not_found(format!(
436                "palace not found: {palace_id}"
437            )));
438        }
439        if !force {
440            // Open the palace just long enough to count its drawers; we don't
441            // hold the handle past this check because the caller is about to
442            // delete the on-disk directory.
443            if let Ok(handle) = self
444                .state
445                .registry
446                .open_palace(&self.state.data_root, &PalaceId::new(palace_id))
447            {
448                if !handle.drawers.read().is_empty() {
449                    return Err(ServiceError::conflict(
450                        "Palace has drawers; pass force=true to delete",
451                    ));
452                }
453            }
454        }
455        // Drop the cached `Arc<PalaceHandle>` and gap cache before unlinking
456        // the directory so subsequent reads can't be served from the stale
457        // in-memory state. The registry's `remove` is a no-op when the entry
458        // is absent (lazy-open palaces that no caller has touched yet).
459        self.state.registry.remove(&PalaceId::new(palace_id));
460        // Issue #228: drop the palace-name cache entry so future writes never
461        // resolve to a stale label.
462        self.state.palace_names.remove(palace_id);
463        let palace_dir = self.state.data_root.join(palace_id);
464        tokio::fs::remove_dir_all(&palace_dir).await.map_err(|e| {
465            ServiceError::internal(format!("remove palace dir {}: {e}", palace_dir.display()))
466        })?;
467        // Recompute aggregate totals so dashboards drop the deleted palace's
468        // counts. There's no dedicated `PalaceDeleted` event variant yet;
469        // `StatusChanged` is enough to keep the UI in sync.
470        self.state.emit(self.aggregate_status_event());
471        Ok(())
472    }
473
474    /// Rename a palace's display name without touching its data.
475    ///
476    /// Why: Operators need to fix typos and rebrand palaces without dropping
477    /// the underlying drawers / vectors / KG. The palace id (the directory
478    /// name on disk) is immutable — only the human-readable `name` field in
479    /// `palace.json` changes — so cached `PalaceHandle`s stay valid and no
480    /// registry invalidation is required.
481    /// What: 1) loads the palace via `PalaceStore::load_palace` (404 when the
482    /// directory or `palace.json` is missing), 2) trims the new name and
483    /// returns `BadRequest` when empty, 3) mutates `palace.name` and writes
484    /// the metadata back through the atomic `PalaceStore::save_palace`
485    /// (tmp file + rename), 4) emits an aggregate `StatusChanged` so
486    /// dashboards re-render the relabelled palace, 5) returns the updated
487    /// palace as JSON (enriched with the live handle stats, so callers see
488    /// drawer/vector/KG counts in the same shape as `GET /palaces/{id}`).
489    /// Test: `update_palace_name_renames_palace`,
490    /// `update_palace_name_rejects_empty_name`,
491    /// `update_palace_name_returns_not_found_for_missing_id` in `web::tests`.
492    pub async fn update_palace_name(&self, palace_id: &str, name: &str) -> Result<Value> {
493        let trimmed = name.trim();
494        if trimmed.is_empty() {
495            return Err(anyhow!("name must be non-empty after trimming"));
496        }
497        let palace_dir = self.state.data_root.join(palace_id);
498        let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
499            .map_err(|e| anyhow!("palace not found: {palace_id} ({e})"))?;
500        palace.name = trimmed.to_string();
501        trusty_common::memory_core::store::PalaceStore::save_palace(&palace)
502            .with_context(|| format!("save palace metadata for {palace_id}"))?;
503        // Issue #228: refresh the in-memory name cache so subsequent writes
504        // surface the new label without a disk walk.
505        self.state
506            .palace_names
507            .insert(palace_id.to_string(), trimmed.to_string());
508        let handle = self
509            .state
510            .registry
511            .open_palace(&self.state.data_root, &palace.id)
512            .ok();
513        let info = palace_info_from(&palace, handle.as_ref());
514        self.state.emit(self.aggregate_status_event());
515        serde_json::to_value(info).context("serialize palace info")
516    }
517
518    /// Typed variant of [`Self::update_palace_name`] used by the HTTP handler.
519    ///
520    /// Why: HTTP needs to distinguish 400 (empty name) from 404 (missing
521    /// palace) so the right status code is emitted; the chat / MCP tool
522    /// only cares about a `Result<Value>` because both errors are surfaced
523    /// as opaque MCP error strings. Keeping a typed variant alongside the
524    /// untyped one keeps the wire shape correct on both surfaces without
525    /// asking either caller to parse error strings.
526    /// What: same as [`Self::update_palace_name`] but returns
527    /// `ServiceError::BadRequest` for empty names and
528    /// `ServiceError::NotFound` for missing palace metadata.
529    /// Test: `update_palace_name_renames_palace`,
530    /// `update_palace_name_rejects_empty_name`,
531    /// `update_palace_name_returns_not_found_for_missing_id`.
532    pub async fn update_palace_name_typed(
533        &self,
534        palace_id: &str,
535        name: &str,
536    ) -> ServiceResult<Value> {
537        let trimmed = name.trim();
538        if trimmed.is_empty() {
539            return Err(ServiceError::bad_request(
540                "name must be non-empty after trimming",
541            ));
542        }
543        let palace_dir = self.state.data_root.join(palace_id);
544        let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
545            .map_err(|e| {
546            ServiceError::not_found(format!("palace not found: {palace_id} ({e})"))
547        })?;
548        palace.name = trimmed.to_string();
549        trusty_common::memory_core::store::PalaceStore::save_palace(&palace).map_err(|e| {
550            ServiceError::internal(format!("save palace metadata for {palace_id}: {e}"))
551        })?;
552        // Issue #228: refresh the in-memory name cache so subsequent writes
553        // surface the new label without a disk walk.
554        self.state
555            .palace_names
556            .insert(palace_id.to_string(), trimmed.to_string());
557        let handle = self
558            .state
559            .registry
560            .open_palace(&self.state.data_root, &palace.id)
561            .ok();
562        let info = palace_info_from(&palace, handle.as_ref());
563        self.state.emit(self.aggregate_status_event());
564        serde_json::to_value(info)
565            .map_err(|e| ServiceError::internal(format!("serialize palace info: {e}")))
566    }
567
568    /// Look up a single palace by id and enrich with live handle stats.
569    ///
570    /// Why: distinct 404 vs. 500 path is needed by both HTTP and chat callers.
571    /// What: returns `NotFound` when the id is unknown, otherwise a fully
572    /// populated `PalaceInfo`.
573    /// Test: indirectly via `health_endpoint_round_trip_with_palace_is_ok`.
574    pub async fn get_palace(&self, id: &str) -> ServiceResult<PalaceInfo> {
575        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
576            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
577        let palace = palaces
578            .into_iter()
579            .find(|p| p.id.0 == id)
580            .ok_or_else(|| ServiceError::not_found(format!("palace not found: {id}")))?;
581        let handle = self
582            .state
583            .registry
584            .open_palace(&self.state.data_root, &palace.id)
585            .ok();
586        Ok(palace_info_from(&palace, handle.as_ref()))
587    }
588
589    // -----------------------------------------------------------------
590    // Drawers
591    // -----------------------------------------------------------------
592
593    /// List drawers in a palace with optional room/tag filters and pagination.
594    ///
595    /// Why: deduplicates the open-handle + listing path between HTTP and chat,
596    /// and (issue #184) lets the TUI activity panel page through drawers in
597    /// creation-date order without breaking the importance-sorted default the
598    /// legacy callers rely on.
599    /// What: opens the palace handle, fetches a window of drawers, optionally
600    /// re-sorts by `created_at` descending when `sort = "created_desc"`
601    /// (leaving the importance-desc default untouched), then drops the
602    /// leading `offset` rows and keeps `limit`. For `created_desc` the
603    /// window must cover the full filtered set (otherwise the importance
604    /// pre-sort hides truly-recent low-importance drawers), so the window
605    /// is widened to a sane ceiling (`MAX_DRAWER_WINDOW`); the default
606    /// importance path keeps a tight `limit+offset` window.
607    /// Returns the serialised JSON array.
608    /// Test: `service::tests::list_drawers_creates_desc_paginates`.
609    pub async fn list_drawers(&self, id: &str, q: ListDrawersQuery) -> ServiceResult<Value> {
610        const MAX_DRAWER_WINDOW: usize = 10_000;
611        let handle = self.open_handle(id)?;
612        let room = q.room.as_deref().map(RoomType::parse);
613        let limit = q.limit.unwrap_or(50);
614        let offset = q.offset.unwrap_or(0);
615        let by_created = matches!(q.sort.as_deref(), Some("created_desc"));
616        // For created_desc the importance pre-sort would hide low-importance
617        // drawers that happen to be the most recent, so we need to fetch the
618        // full filtered set (capped at MAX_DRAWER_WINDOW). For importance
619        // ordering the legacy `limit + offset` window is sufficient.
620        let window = if by_created {
621            MAX_DRAWER_WINDOW
622        } else {
623            limit.saturating_add(offset).min(MAX_DRAWER_WINDOW)
624        };
625        let mut drawers = handle.list_drawers(room, q.tag.clone(), window);
626        if by_created {
627            drawers.sort_by_key(|d| std::cmp::Reverse(d.created_at));
628        }
629        let page: Vec<_> = drawers.into_iter().skip(offset).take(limit).collect();
630        // Issue #202: enrich every row with a short `snippet` derived from
631        // the drawer's content so the TUI activity panel can render a
632        // glanceable summary without re-parsing the full body. The
633        // snippet is whitespace-collapsed and bounded at
634        // `DRAWER_SNIPPET_MAX_CHARS` (60) — shorter than the SSE preview
635        // because the activity panel renders it on a single narrow row.
636        let payload: Vec<Value> = page
637            .into_iter()
638            .map(|drawer| {
639                let snippet = drawer_snippet(&drawer.content);
640                let mut value = serde_json::to_value(&drawer).unwrap_or_else(|_| json!({}));
641                if let Value::Object(ref mut map) = value {
642                    // `null` when the drawer has no usable content so
643                    // clients can distinguish "no body" from "empty body
644                    // after whitespace collapse".
645                    let snippet_value = if snippet.is_empty() {
646                        Value::Null
647                    } else {
648                        Value::String(snippet)
649                    };
650                    map.insert("snippet".to_string(), snippet_value);
651                }
652                value
653            })
654            .collect();
655        Ok(Value::Array(payload))
656    }
657
658    /// Store a new drawer and emit the matching activity events.
659    ///
660    /// Why: HTTP and chat both need the auto-KG-extraction follow-up; this
661    /// method keeps that side-effect chain in one place.
662    /// What: opens the palace, stores the drawer via `PalaceHandle::remember`,
663    /// emits `DrawerAdded` + `StatusChanged`, then triggers
664    /// `tools::auto_extract_and_assert`. Returns the new drawer id.
665    /// Test: `http_create_drawer_runs_auto_kg_extraction`.
666    pub async fn create_drawer(
667        &self,
668        id: &str,
669        body: CreateDrawerBody,
670        creator: CreatorInfo,
671        source: ActivitySource,
672    ) -> ServiceResult<Uuid> {
673        let handle = self.open_handle(id)?;
674        let room = body
675            .room
676            .as_deref()
677            .map(RoomType::parse)
678            .unwrap_or(RoomType::General);
679        let importance = body.importance.unwrap_or(0.5);
680        let content_preview = drawer_content_preview(&body.content);
681        let mut tags_with_creator = body.tags;
682        // Issue #202: project a bare-UUID session tag (when the caller
683        // passed one in the request body) into the reserved
684        // `creator:session=<first-8>` slot so the activity panel can
685        // surface session attribution without bespoke parsing.
686        if let Some(session_tag) = crate::attribution::session_tag_from_tags(&tags_with_creator) {
687            tags_with_creator.push(session_tag);
688        }
689        creator.merge_into(&mut tags_with_creator);
690        let content_for_kg = body.content.clone();
691        let tags_for_kg = tags_with_creator.clone();
692        let room_label_for_kg = crate::tools::room_label(&room);
693        let drawer_id = handle
694            .remember(body.content, room, tags_with_creator, importance)
695            .await
696            .map_err(|e| ServiceError::internal(format!("remember: {e:#}")))?;
697        let drawer_count = handle.drawers.read().len();
698        // Issue #228: resolve from the in-memory cache instead of re-walking
699        // the data root on every HTTP `create_drawer` call. Same cache the
700        // MCP `lookup_palace_name` helper consults.
701        let palace_name = self
702            .state
703            .palace_names
704            .get(id)
705            .map(|entry| entry.value().clone())
706            .unwrap_or_else(|| id.to_string());
707        self.state.emit(DaemonEvent::DrawerAdded {
708            palace_id: id.to_string(),
709            palace_name,
710            drawer_count,
711            timestamp: chrono::Utc::now(),
712            content_preview,
713            source,
714        });
715        // Issue #228: do NOT emit `StatusChanged` on every drawer create —
716        // the periodic ticker (`run_http_on`) refreshes aggregate totals on
717        // a fixed cadence so dashboards stay current without an O(N palaces)
718        // recompute on the write hot path.
719        crate::tools::auto_extract_and_assert(
720            &handle,
721            drawer_id,
722            &content_for_kg,
723            &tags_for_kg,
724            room_label_for_kg.as_deref(),
725        )
726        .await;
727        Ok(drawer_id)
728    }
729
730    /// Forget (delete) a drawer and emit the matching events.
731    ///
732    /// Why: same dedup story as `create_drawer`.
733    /// What: parses the drawer UUID, calls `PalaceHandle::forget`, emits
734    /// `DrawerDeleted` + `StatusChanged`.
735    /// Test: indirectly via the drawer-related HTTP tests.
736    pub async fn delete_drawer(
737        &self,
738        id: &str,
739        drawer_id: &str,
740        source: ActivitySource,
741    ) -> ServiceResult<()> {
742        let handle = self.open_handle(id)?;
743        let uuid = Uuid::parse_str(drawer_id)
744            .map_err(|_| ServiceError::bad_request("drawer_id must be a UUID"))?;
745        handle
746            .forget(uuid)
747            .await
748            .map_err(|e| ServiceError::internal(format!("forget: {e:#}")))?;
749        let drawer_count = handle.drawers.read().len();
750        self.state.emit(DaemonEvent::DrawerDeleted {
751            palace_id: id.to_string(),
752            drawer_count,
753            source,
754        });
755        // Issue #228: skip the per-write `StatusChanged` emit — the
756        // periodic ticker handles aggregate roll-ups.
757        Ok(())
758    }
759
760    // -----------------------------------------------------------------
761    // Recall
762    // -----------------------------------------------------------------
763
764    /// Per-palace recall (semantic search), optionally with deep retrieval.
765    ///
766    /// Why: HTTP and chat tools both perform the same fan-out logic.
767    /// What: opens the palace handle and dispatches to the shallow or deep
768    /// recall helper. Returns a JSON array of flattened drawer rows (the
769    /// `recall_entry_json` shape from issue #69).
770    /// Test: `recall_entry_json_hoists_drawer_fields`.
771    pub async fn recall(
772        &self,
773        id: &str,
774        query: &str,
775        top_k: usize,
776        deep: bool,
777    ) -> ServiceResult<Value> {
778        let handle = self.open_handle(id)?;
779        let results = if deep {
780            recall_deep_with_default_embedder(&handle, query, top_k).await
781        } else {
782            recall_with_default_embedder(&handle, query, top_k).await
783        }
784        .map_err(|e| ServiceError::internal(format!("recall: {e:#}")))?;
785        let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
786        Ok(json!(payload))
787    }
788
789    /// Cross-palace recall.
790    ///
791    /// Why: shared between `/api/v1/recall` and the `memory_recall_all` chat
792    /// tool. Encapsulating the open-everything-fanout-merge dance avoids
793    /// drift.
794    /// What: lists every palace, opens handles (skipping failures with a
795    /// `tracing::warn!`), delegates to
796    /// `recall_across_palaces_with_default_embedder`. Returns a JSON array.
797    /// Test: indirectly via `recall_across_palaces_merges_results` and the
798    /// MCP `memory_recall_all` integration paths.
799    pub async fn recall_all(&self, query: &str, top_k: usize, deep: bool) -> Value {
800        let palaces = match PalaceRegistry::list_palaces(&self.state.data_root) {
801            Ok(v) => v,
802            Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
803        };
804        let mut handles = Vec::with_capacity(palaces.len());
805        for p in &palaces {
806            match self
807                .state
808                .registry
809                .open_palace(&self.state.data_root, &p.id)
810            {
811                Ok(h) => handles.push(h),
812                Err(e) => {
813                    tracing::warn!(palace = %p.id, "recall_all: open failed: {e:#}");
814                }
815            }
816        }
817        if handles.is_empty() {
818            return json!([]);
819        }
820        match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
821            Ok(results) => json!(results
822                .into_iter()
823                .map(|r| json!({
824                    "palace_id": r.palace_id,
825                    "drawer_id": r.result.drawer.id.to_string(),
826                    "content": r.result.drawer.content,
827                    "importance": r.result.drawer.importance,
828                    "tags": r.result.drawer.tags,
829                    "score": r.result.score,
830                    "layer": r.result.layer,
831                }))
832                .collect::<Vec<_>>()),
833            Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
834        }
835    }
836
837    // -----------------------------------------------------------------
838    // Knowledge graph
839    // -----------------------------------------------------------------
840
841    /// Query the KG for all active triples whose subject matches.
842    pub async fn kg_query(&self, id: &str, subject: &str) -> ServiceResult<Vec<Triple>> {
843        let handle = self.open_handle(id)?;
844        handle
845            .kg
846            .query_active(subject)
847            .await
848            .map_err(|e| ServiceError::internal(format!("kg query: {e:#}")))
849    }
850
851    /// Assert a triple in the KG.
852    pub async fn kg_assert(&self, id: &str, body: KgAssertBody) -> ServiceResult<()> {
853        let handle = self.open_handle(id)?;
854        let triple = Triple {
855            subject: body.subject,
856            predicate: body.predicate,
857            object: body.object,
858            valid_from: chrono::Utc::now(),
859            valid_to: None,
860            confidence: body.confidence.unwrap_or(1.0),
861            provenance: body.provenance,
862        };
863        handle
864            .kg
865            .assert(triple)
866            .await
867            .map_err(|e| ServiceError::internal(format!("kg assert: {e:#}")))
868    }
869
870    /// Retract the single active triple identified by `(subject, predicate)`.
871    ///
872    /// Why: Issue #278 — the `DELETE /kg/triples/<id>` HTTP endpoint needs a
873    /// service-layer method so the HTTP handler stays a thin adapter.
874    /// What: Opens the palace handle, calls `KnowledgeGraph::retract`, and
875    /// maps the closed count to a 204/404 signal: `Ok(true)` when at least
876    /// one interval was closed, `Ok(false)` when no active triple matched.
877    /// Test: Covered by `kg_delete_triple_returns_204_on_success` in
878    /// `web::tests`.
879    pub async fn kg_retract_triple(
880        &self,
881        id: &str,
882        subject: &str,
883        predicate: &str,
884    ) -> ServiceResult<bool> {
885        let handle = self.open_handle(id)?;
886        let closed = handle
887            .kg
888            .retract(subject, predicate)
889            .await
890            .map_err(|e| ServiceError::internal(format!("kg retract: {e:#}")))?;
891        Ok(closed > 0)
892    }
893
894    /// List distinct subjects in the KG.
895    pub async fn kg_list_subjects(&self, id: &str, limit: usize) -> ServiceResult<Vec<String>> {
896        let handle = self.open_handle(id)?;
897        handle
898            .kg
899            .list_subjects(limit)
900            .map_err(|e| ServiceError::internal(format!("kg list_subjects: {e:#}")))
901    }
902
903    /// List distinct subjects in the KG paired with their active-triple count.
904    pub async fn kg_list_subjects_with_counts(
905        &self,
906        id: &str,
907        limit: usize,
908    ) -> ServiceResult<Vec<(String, u64)>> {
909        let handle = self.open_handle(id)?;
910        handle
911            .kg
912            .list_subjects_with_counts(limit)
913            .map_err(|e| ServiceError::internal(format!("kg list_subjects_with_counts: {e:#}")))
914    }
915
916    /// Page through every active triple.
917    pub async fn kg_list_all(
918        &self,
919        id: &str,
920        limit: usize,
921        offset: usize,
922    ) -> ServiceResult<Vec<Triple>> {
923        let handle = self.open_handle(id)?;
924        handle
925            .kg
926            .list_active(limit, offset)
927            .await
928            .map_err(|e| ServiceError::internal(format!("kg list_active: {e:#}")))
929    }
930
931    /// Return the count of currently-active triples.
932    pub async fn kg_count(&self, id: &str) -> ServiceResult<usize> {
933        let handle = self.open_handle(id)?;
934        Ok(handle.kg.count_active_triples())
935    }
936
937    /// Build the per-palace visual graph payload.
938    pub async fn kg_graph(&self, id: &str) -> ServiceResult<KgGraphPayload> {
939        let handle = self.open_handle(id)?;
940        let triples = handle
941            .kg
942            .list_active(KG_GRAPH_MAX_TRIPLES, 0)
943            .await
944            .map_err(|e| ServiceError::internal(format!("kg list_active: {e:#}")))?;
945        Ok(KgGraphPayload {
946            triples,
947            node_count: handle.kg.node_count() as u64,
948            edge_count: handle.kg.edge_count() as u64,
949            community_count: handle.kg.community_count() as u64,
950        })
951    }
952
953    // -----------------------------------------------------------------
954    // Dream cycle
955    // -----------------------------------------------------------------
956
957    /// Aggregate dream stats across every persisted palace.
958    pub async fn dream_status_aggregate(&self) -> DreamStatusPayload {
959        let palaces = PalaceRegistry::list_palaces(&self.state.data_root).unwrap_or_default();
960        let mut out = DreamStatusPayload::default();
961        let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
962        for p in palaces {
963            let data_dir = self.state.data_root.join(p.id.as_str());
964            let snap = match PersistedDreamStats::load(&data_dir) {
965                Ok(Some(s)) => s,
966                _ => continue,
967            };
968            out.merged = out.merged.saturating_add(snap.stats.merged);
969            out.pruned = out.pruned.saturating_add(snap.stats.pruned);
970            out.compacted = out.compacted.saturating_add(snap.stats.compacted);
971            out.closets_updated = out
972                .closets_updated
973                .saturating_add(snap.stats.closets_updated);
974            out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
975            latest = match latest {
976                Some(t) if t >= snap.last_run_at => Some(t),
977                _ => Some(snap.last_run_at),
978            };
979        }
980        out.last_run_at = latest;
981        out
982    }
983
984    /// Per-palace dream stats snapshot.
985    pub async fn dream_status_for_palace(&self, id: &str) -> ServiceResult<DreamStatusPayload> {
986        let data_dir = self.state.data_root.join(id);
987        if !data_dir.exists() {
988            return Err(ServiceError::not_found(format!("palace not found: {id}")));
989        }
990        match PersistedDreamStats::load(&data_dir) {
991            Ok(Some(s)) => Ok(s.into()),
992            Ok(None) => Ok(DreamStatusPayload::default()),
993            Err(e) => Err(ServiceError::internal(format!("read dream stats: {e:#}"))),
994        }
995    }
996
997    /// Run a dream cycle across every palace.
998    pub async fn dream_run(&self) -> ServiceResult<DreamStatusPayload> {
999        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
1000            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
1001        let dreamer = Dreamer::new(DreamConfig::default());
1002        let mut out = DreamStatusPayload::default();
1003        for p in palaces {
1004            let handle = match self
1005                .state
1006                .registry
1007                .open_palace(&self.state.data_root, &p.id)
1008            {
1009                Ok(h) => h,
1010                Err(e) => {
1011                    tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
1012                    continue;
1013                }
1014            };
1015            match dreamer.dream_cycle(&handle).await {
1016                Ok(stats) => {
1017                    out.merged = out.merged.saturating_add(stats.merged);
1018                    out.pruned = out.pruned.saturating_add(stats.pruned);
1019                    out.compacted = out.compacted.saturating_add(stats.compacted);
1020                    out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
1021                    out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
1022                }
1023                Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
1024            }
1025            refresh_gaps_cache(&self.state, &handle).await;
1026        }
1027        out.last_run_at = Some(chrono::Utc::now());
1028        self.state.emit(DaemonEvent::DreamCompleted {
1029            palace_id: None,
1030            merged: out.merged,
1031            pruned: out.pruned,
1032            compacted: out.compacted,
1033            closets_updated: out.closets_updated,
1034            duration_ms: out.duration_ms,
1035            source: ActivitySource::Http,
1036        });
1037        self.state.emit(self.aggregate_status_event());
1038        Ok(out)
1039    }
1040
1041    // -----------------------------------------------------------------
1042    // Activity log
1043    // -----------------------------------------------------------------
1044
1045    /// Paginated activity-log read.
1046    pub async fn list_activity(
1047        &self,
1048        filter: ActivityFilter,
1049        limit: usize,
1050        offset: usize,
1051    ) -> ServiceResult<(Vec<crate::ActivityEntry>, u64)> {
1052        let entries = self
1053            .state
1054            .activity_log
1055            .list(&filter, limit, offset)
1056            .map_err(|e| ServiceError::internal(format!("activity list: {e:#}")))?;
1057        let total = self
1058            .state
1059            .activity_log
1060            .count()
1061            .map_err(|e| ServiceError::internal(format!("activity count: {e:#}")))?;
1062        Ok((entries, total))
1063    }
1064
1065    // -----------------------------------------------------------------
1066    // Internal helper — open a palace handle or return 404.
1067    // -----------------------------------------------------------------
1068
1069    /// Open the named palace, returning `ServiceError::NotFound` on failure.
1070    pub fn open_handle(&self, id: &str) -> ServiceResult<Arc<PalaceHandle>> {
1071        self.state
1072            .registry
1073            .open_palace(&self.state.data_root, &PalaceId::new(id))
1074            .map_err(|e| ServiceError::not_found(format!("palace not found: {id} ({e:#})")))
1075    }
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Free helper functions kept module-public so `web.rs` and `chat.rs` can use
1080// them without going through the `MemoryService` wrapper. Each is a thin
1081// transform (no IO, no global state).
1082// ---------------------------------------------------------------------------
1083
1084/// Maximum characters retained in a drawer's content preview.
1085pub const DRAWER_PREVIEW_MAX_CHARS: usize = 80;
1086
1087/// Maximum characters retained in a drawer-row snippet (issue #202).
1088///
1089/// Why: the TUI activity panel renders the snippet inline at the end of a
1090/// narrow row (`<id> <ts> <creator>  <snippet>`); 60 chars is short
1091/// enough to keep the row readable while still showing the key phrase
1092/// of most drawers.
1093/// What: 60 characters; the trailing `…` from [`drawer_snippet`] counts
1094/// against this budget.
1095/// Test: `drawer_snippet_truncates_long_content`.
1096pub const DRAWER_SNIPPET_MAX_CHARS: usize = 60;
1097
1098/// Build a single-line preview of drawer content for SSE events.
1099///
1100/// Why: the activity feed should show *what* was just stored; multiline /
1101/// whitespace-heavy bodies otherwise blow out the log row.
1102/// What: collapses whitespace, trims, truncates to
1103/// [`DRAWER_PREVIEW_MAX_CHARS`] with `…` when cut.
1104/// Test: `drawer_preview_collapses_whitespace_and_truncates`.
1105pub fn drawer_content_preview(content: &str) -> String {
1106    let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
1107    if normalised.chars().count() <= DRAWER_PREVIEW_MAX_CHARS {
1108        normalised
1109    } else {
1110        let kept: String = normalised
1111            .chars()
1112            .take(DRAWER_PREVIEW_MAX_CHARS.saturating_sub(1))
1113            .collect();
1114        format!("{kept}…")
1115    }
1116}
1117
1118/// Build a short snippet from a drawer's content for the TUI activity panel
1119/// row (issue #202).
1120///
1121/// Why: the activity panel renders one row per drawer at narrow column
1122/// width; a 60-char whitespace-collapsed snippet is long enough to convey
1123/// the gist but short enough to fit inline with the id / timestamp /
1124/// creator columns. Re-using the preview's whitespace-collapse rule keeps
1125/// SSE and `/drawers` snippets visually consistent.
1126/// What: collapses whitespace, trims, truncates to
1127/// [`DRAWER_SNIPPET_MAX_CHARS`] (60) with a trailing `…` when cut.
1128/// Returns the empty string for empty / whitespace-only content so the
1129/// caller can omit the `snippet` field entirely.
1130/// Test: `drawer_snippet_truncates_long_content`,
1131/// `drawer_snippet_handles_empty_content`.
1132pub fn drawer_snippet(content: &str) -> String {
1133    let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
1134    if normalised.chars().count() <= DRAWER_SNIPPET_MAX_CHARS {
1135        normalised
1136    } else {
1137        let kept: String = normalised
1138            .chars()
1139            .take(DRAWER_SNIPPET_MAX_CHARS.saturating_sub(1))
1140            .collect();
1141        format!("{kept}…")
1142    }
1143}
1144
1145/// Flatten a [`RecallResult`] into a single JSON object with the drawer's
1146/// fields hoisted to the top level (issue #69 shape).
1147///
1148/// Why: clients look for `content`/`tags`/`importance` at the top level of an
1149/// entry; nesting under `"drawer"` made recall appear empty.
1150/// What: serialises the drawer then inserts `score`/`layer`.
1151/// Test: `recall_entry_json_hoists_drawer_fields`.
1152pub fn recall_entry_json(r: RecallResult) -> Value {
1153    let mut obj = match serde_json::to_value(&r.drawer) {
1154        Ok(Value::Object(map)) => map,
1155        _ => serde_json::Map::new(),
1156    };
1157    obj.insert("score".to_string(), json!(r.score));
1158    obj.insert("layer".to_string(), json!(r.layer));
1159    Value::Object(obj)
1160}
1161
1162/// Reserved-prefix predicate for "system" palaces hidden from user listings.
1163///
1164/// Why: Issue #185 — the `/health` round-trip writes probe drawers into a
1165/// dedicated `__health_probe__` palace. That palace exists on disk but must
1166/// never appear in the admin UI, TUI, chat-tool palace roster, or any other
1167/// user-facing surface. Centralising the predicate here keeps the convention
1168/// (any palace id starting with `__`) in one place so future system palaces
1169/// inherit the same hidden-from-users behaviour automatically.
1170/// What: Returns `true` iff `id.as_str()` starts with the double-underscore
1171/// prefix. Pure function over the id — no I/O, no allocation.
1172/// Test: covered indirectly by `health_probe_palace_is_invisible` in
1173/// `web::tests` (drives a full `/health` round-trip and asserts the probe
1174/// palace does not appear in `MemoryService::list_palaces`).
1175pub(crate) fn is_reserved_system_palace(id: &PalaceId) -> bool {
1176    id.as_str().starts_with("__")
1177}
1178
1179/// Aggregate counts summed across one or more palaces.
1180///
1181/// Why (issue #228): both `status()` (the `/api/v1/status` endpoint) and
1182/// `aggregate_status_event()` (the SSE `StatusChanged` payload) sum the same
1183/// three numbers across every persisted palace. The original implementation
1184/// inlined the same `for p in palaces` loop in both methods. Sharing a
1185/// single helper eliminates the byte-for-byte duplicate and makes future
1186/// changes (e.g. adding a `total_vectors_orphaned` field) land in one place.
1187/// What: saturating sums of `drawers.read().len()`, `vector_store.index_size()`,
1188/// and `kg.count_active_triples()` across the supplied palace ids.
1189/// Test: indirectly via `status_endpoint_returns_payload` and any SSE test
1190/// that observes `StatusChanged`.
1191pub(crate) struct PalaceStats {
1192    pub total_drawers: usize,
1193    pub total_vectors: usize,
1194    pub total_kg_triples: usize,
1195}
1196
1197/// Sum drawer / vector / KG-triple counts across `ids`, skipping palaces that
1198/// cannot be opened.
1199///
1200/// Why (issue #228): centralises the previously-duplicated loop from
1201/// `status()` and `aggregate_status_event()`. Callers pass an iterator of
1202/// `PalaceId` so the helper works for both the on-disk view (used by
1203/// `status()`) and the in-memory registry view (used by
1204/// `aggregate_status_event()` on the SSE hot path).
1205/// What: for each id, calls `registry.open_palace` (cheap when the handle is
1206/// already cached, slow only on first-ever open) and accumulates the three
1207/// counts via `saturating_add` so overflow is impossible. Palaces that fail
1208/// to open are silently skipped — one bad palace must not blank the
1209/// dashboard.
1210/// Test: indirectly through `status_endpoint_returns_payload`.
1211pub(crate) fn collect_palace_stats<'a, I>(state: &AppState, ids: I) -> PalaceStats
1212where
1213    I: IntoIterator<Item = &'a PalaceId>,
1214{
1215    let (mut total_drawers, mut total_vectors, mut total_kg_triples): (usize, usize, usize) =
1216        (0, 0, 0);
1217    for id in ids {
1218        if let Ok(handle) = state.registry.open_palace(&state.data_root, id) {
1219            total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
1220            total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
1221            total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
1222        }
1223    }
1224    PalaceStats {
1225        total_drawers,
1226        total_vectors,
1227        total_kg_triples,
1228    }
1229}
1230
1231/// Build a `PalaceInfo` from a `Palace` row plus an optional opened handle.
1232///
1233/// Why: both `list_palaces` and `get_palace` need the same enriched shape;
1234/// the helper avoids field-set drift between them.
1235/// What: reads drawer/vector/triple counts, distinct rooms, max
1236/// `created_at`, KG node/edge/community counts, and the `is_compacting` flag.
1237/// Test: `palace_list_includes_richer_counts`, `palace_list_includes_graph_counts`.
1238pub fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
1239    let (
1240        drawer_count,
1241        vector_count,
1242        kg_triple_count,
1243        wing_count,
1244        last_write_at,
1245        node_count,
1246        edge_count,
1247        community_count,
1248        is_compacting,
1249    ) = if let Some(h) = handle {
1250        let drawers = h.drawers.read();
1251        let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
1252        let last_write = drawers.iter().map(|d| d.created_at).max();
1253        (
1254            drawers.len(),
1255            h.vector_store.index_size(),
1256            h.kg.count_active_triples(),
1257            distinct_rooms.len(),
1258            last_write,
1259            h.kg.node_count() as u64,
1260            h.kg.edge_count() as u64,
1261            h.kg.community_count() as u64,
1262            h.is_compacting(),
1263        )
1264    } else {
1265        (0, 0, 0, 0, None, 0, 0, 0, false)
1266    };
1267    PalaceInfo {
1268        id: palace.id.0.clone(),
1269        name: palace.name.clone(),
1270        description: palace.description.clone(),
1271        drawer_count,
1272        vector_count,
1273        kg_triple_count,
1274        wing_count,
1275        created_at: palace.created_at,
1276        last_write_at,
1277        node_count,
1278        edge_count,
1279        community_count,
1280        is_compacting,
1281    }
1282}
1283
1284/// Recompute the gaps for `handle` and write them to the registry cache.
1285///
1286/// Why: the dream-run path needs this post-cycle bookkeeping; pulling it out
1287/// of `web.rs` keeps the dream code on one side of the wall.
1288/// What: calls `knowledge_gaps()`, optionally enriches via
1289/// `enrich_gap_exploration`, stores on `state.registry`. Logs gap count.
1290/// Test: indirectly via `kg_gaps_endpoint_returns_cached_gaps`.
1291pub async fn refresh_gaps_cache(state: &AppState, handle: &Arc<PalaceHandle>) {
1292    let mut gaps = handle.kg.knowledge_gaps();
1293    if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") {
1294        if !api_key.is_empty() {
1295            for gap in gaps.iter_mut() {
1296                if let Some(enriched) = enrich_gap_exploration(&api_key, gap).await {
1297                    gap.suggested_exploration = enriched;
1298                }
1299            }
1300        }
1301    }
1302    let gap_count = gaps.len();
1303    state.registry.set_gaps(handle.id.clone(), gaps);
1304    tracing::debug!(palace = %handle.id, gaps = gap_count, "community gaps updated");
1305}
1306
1307/// Ask OpenRouter for a focused exploration question for a single gap.
1308///
1309/// Why: see `refresh_gaps_cache`.
1310/// What: builds a short user prompt, calls `openrouter_chat`, returns the
1311/// trimmed completion (or `None` on any failure).
1312/// Test: network-dependent — not unit-tested.
1313pub async fn enrich_gap_exploration(
1314    api_key: &str,
1315    gap: &trusty_common::memory_core::community::KnowledgeGap,
1316) -> Option<String> {
1317    let preview: Vec<&str> = gap.entities.iter().take(5).map(String::as_str).collect();
1318    if preview.is_empty() {
1319        return None;
1320    }
1321    let entities = preview.join(", ");
1322    let user = format!(
1323        "Given these related entities from a knowledge graph: {entities}. \
1324         Suggest one specific research question (single sentence, under 25 words) \
1325         that would help fill gaps in this knowledge cluster. Return only the question."
1326    );
1327    let messages = vec![trusty_common::ChatMessage {
1328        role: "user".to_string(),
1329        content: user,
1330        tool_call_id: None,
1331        tool_calls: None,
1332    }];
1333    #[allow(deprecated)]
1334    let res = trusty_common::openrouter_chat(api_key, "openai/gpt-4o-mini", messages).await;
1335    match res {
1336        Ok(text) => {
1337            let trimmed = text.trim().to_string();
1338            if trimmed.is_empty() {
1339                None
1340            } else {
1341                Some(trimmed)
1342            }
1343        }
1344        Err(e) => {
1345            tracing::debug!("openrouter gap enrichment failed (using template): {e:#}");
1346            None
1347        }
1348    }
1349}
1350
1351// ---------------------------------------------------------------------------
1352// User config — moved from `web.rs` so chat and HTTP both load it cheaply.
1353// ---------------------------------------------------------------------------
1354
1355/// Minimal mirror of the user-config schema.
1356#[derive(Deserialize, Default, Clone)]
1357struct UserConfigMin {
1358    #[serde(default)]
1359    openrouter: OpenRouterMin,
1360    #[serde(default)]
1361    local_model: LocalModelMin,
1362}
1363
1364#[derive(Deserialize, Default, Clone)]
1365struct OpenRouterMin {
1366    #[serde(default)]
1367    api_key: String,
1368    #[serde(default)]
1369    model: String,
1370}
1371
1372#[derive(Deserialize, Clone)]
1373struct LocalModelMin {
1374    #[serde(default = "default_local_enabled")]
1375    enabled: bool,
1376    #[serde(default = "default_local_base_url")]
1377    base_url: String,
1378    #[serde(default = "default_local_model")]
1379    model: String,
1380}
1381
1382fn default_local_enabled() -> bool {
1383    true
1384}
1385fn default_local_base_url() -> String {
1386    "http://localhost:11434".to_string()
1387}
1388fn default_local_model() -> String {
1389    "llama3.2".to_string()
1390}
1391
1392impl Default for LocalModelMin {
1393    fn default() -> Self {
1394        Self {
1395            enabled: default_local_enabled(),
1396            base_url: default_local_base_url(),
1397            model: default_local_model(),
1398        }
1399    }
1400}
1401
1402/// Loaded user config (mirrors the public `LoadedUserConfig` from `web.rs`).
1403#[derive(Clone)]
1404pub struct LoadedUserConfig {
1405    pub openrouter_api_key: String,
1406    pub openrouter_model: String,
1407    pub local_model: trusty_common::LocalModelConfig,
1408}
1409
1410impl Default for LoadedUserConfig {
1411    fn default() -> Self {
1412        Self {
1413            openrouter_api_key: String::new(),
1414            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
1415            local_model: trusty_common::LocalModelConfig::default(),
1416        }
1417    }
1418}
1419
1420/// Read the user's `~/.trusty-memory/config.toml`, falling back to defaults.
1421///
1422/// Why: shared between HTTP config endpoint, chat tool dispatch, and
1423/// provider auto-detection.
1424/// What: returns `Some(LoadedUserConfig)` even when the file is missing
1425/// (so callers see defaults consistently); `None` only when the home
1426/// directory itself can't be resolved.
1427/// Test: indirectly via `config_endpoint_returns_payload`.
1428pub fn load_user_config() -> Option<LoadedUserConfig> {
1429    let home = dirs::home_dir()?;
1430    let path = home.join(".trusty-memory").join("config.toml");
1431    if !path.exists() {
1432        return Some(LoadedUserConfig::default());
1433    }
1434    let raw = std::fs::read_to_string(&path).ok()?;
1435    let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
1436    let model = if parsed.openrouter.model.is_empty() {
1437        "anthropic/claude-3-5-sonnet".to_string()
1438    } else {
1439        parsed.openrouter.model
1440    };
1441    Some(LoadedUserConfig {
1442        openrouter_api_key: parsed.openrouter.api_key,
1443        openrouter_model: model,
1444        local_model: trusty_common::LocalModelConfig {
1445            enabled: parsed.local_model.enabled,
1446            base_url: parsed.local_model.base_url,
1447            model: parsed.local_model.model,
1448        },
1449    })
1450}
1451
1452// ---------------------------------------------------------------------------
1453// Convenience helpers for callers that want `anyhow::Result<Value>` shape.
1454// ---------------------------------------------------------------------------
1455
1456/// Convert a `ServiceResult<T>` into `anyhow::Result<Value>` using a serializer.
1457///
1458/// Why: the chat tool dispatcher needs uniform `Result<Value>` returns to
1459/// shove into the LLM's `role: "tool"` message.
1460/// What: serialises `T` to JSON; on `Err`, returns the message as an
1461/// `anyhow::Error`. The HTTP layer does *not* go through this — it preserves
1462/// the `ServiceError` variant for status-code mapping.
1463/// Test: trivial wrapper; covered indirectly by the chat tests.
1464pub fn service_result_to_anyhow<T: serde::Serialize>(r: ServiceResult<T>) -> Result<Value> {
1465    match r {
1466        Ok(v) => serde_json::to_value(v).context("serialize service result"),
1467        Err(e) => Err(anyhow!("{e}")),
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use chrono::{Duration as ChronoDuration, Utc};
1475    use trusty_common::memory_core::palace::{Drawer, Palace};
1476
1477    fn test_state() -> AppState {
1478        let tmp = tempfile::tempdir().expect("tempdir");
1479        let root = tmp.path().to_path_buf();
1480        // Leak the TempDir guard so the directory survives the test body.
1481        std::mem::forget(tmp);
1482        AppState::new(root)
1483    }
1484
1485    /// Issue #184 — `sort=created_desc` paginates newest-first and the
1486    /// importance default is preserved.
1487    ///
1488    /// Why: the TUI activity panel needs a stable creation-date ordering with
1489    /// offset pagination; the legacy importance-desc default must keep
1490    /// working for other callers (e.g. chat tool `list_drawers`).
1491    /// What: provisions a fresh palace, drops five drawers in with
1492    /// monotonically older `created_at` and shuffled importance, then drives
1493    /// `MemoryService::list_drawers` with two pages of `limit=2` and asserts
1494    /// the order is newest-first across both pages. Re-runs the same call
1495    /// with `sort` unset and confirms the order changes (importance-based).
1496    /// Test: this test.
1497    #[tokio::test]
1498    async fn list_drawers_creates_desc_paginates() {
1499        let state = test_state();
1500        // Provision a fresh palace via the registry.
1501        let palace = Palace {
1502            id: PalaceId::new("paging-test"),
1503            name: "paging-test".to_string(),
1504            description: None,
1505            created_at: Utc::now(),
1506            data_dir: state.data_root.join("paging-test"),
1507        };
1508        state
1509            .registry
1510            .create_palace(&state.data_root, palace)
1511            .expect("create_palace");
1512
1513        // Open the handle and seed five drawers with staggered timestamps and
1514        // shuffled importance.
1515        let handle = state
1516            .registry
1517            .open_palace(&state.data_root, &PalaceId::new("paging-test"))
1518            .expect("open_palace");
1519        let room_id = Uuid::nil();
1520        let now = Utc::now();
1521        // Index 0 is newest; index 4 is oldest.
1522        for (i, importance) in [0.1f32, 0.9, 0.3, 0.7, 0.5].iter().enumerate() {
1523            let drawer = Drawer {
1524                id: Uuid::new_v4(),
1525                room_id,
1526                content: format!("drawer-{i}"),
1527                importance: *importance,
1528                source_file: None,
1529                created_at: now - ChronoDuration::seconds(i as i64),
1530                tags: vec![format!("idx:{i}")],
1531                last_accessed_at: None,
1532                access_count: 0,
1533                drawer_type: Default::default(),
1534                expires_at: None,
1535            };
1536            handle.add_drawer(drawer);
1537        }
1538        // The handle is `Arc<PalaceHandle>` and the registry caches it; drop
1539        // ours so the service can re-open from cache.
1540        drop(handle);
1541
1542        let service = MemoryService::new(state.clone());
1543
1544        // Page 1 (newest two) under created_desc — expects idx:0 then idx:1.
1545        let page1 = service
1546            .list_drawers(
1547                "paging-test",
1548                ListDrawersQuery {
1549                    limit: Some(2),
1550                    offset: Some(0),
1551                    sort: Some("created_desc".into()),
1552                    ..Default::default()
1553                },
1554            )
1555            .await
1556            .expect("page 1");
1557        let arr = page1.as_array().expect("array");
1558        assert_eq!(arr.len(), 2, "page 1 must have 2 rows");
1559        assert_eq!(arr[0]["content"].as_str(), Some("drawer-0"));
1560        assert_eq!(arr[1]["content"].as_str(), Some("drawer-1"));
1561
1562        // Page 2 — expects idx:2 then idx:3.
1563        let page2 = service
1564            .list_drawers(
1565                "paging-test",
1566                ListDrawersQuery {
1567                    limit: Some(2),
1568                    offset: Some(2),
1569                    sort: Some("created_desc".into()),
1570                    ..Default::default()
1571                },
1572            )
1573            .await
1574            .expect("page 2");
1575        let arr = page2.as_array().expect("array");
1576        assert_eq!(arr.len(), 2, "page 2 must have 2 rows");
1577        assert_eq!(arr[0]["content"].as_str(), Some("drawer-2"));
1578        assert_eq!(arr[1]["content"].as_str(), Some("drawer-3"));
1579
1580        // Page 3 — expects idx:4 alone.
1581        let page3 = service
1582            .list_drawers(
1583                "paging-test",
1584                ListDrawersQuery {
1585                    limit: Some(2),
1586                    offset: Some(4),
1587                    sort: Some("created_desc".into()),
1588                    ..Default::default()
1589                },
1590            )
1591            .await
1592            .expect("page 3");
1593        let arr = page3.as_array().expect("array");
1594        assert_eq!(arr.len(), 1, "page 3 (tail) must have 1 row");
1595        assert_eq!(arr[0]["content"].as_str(), Some("drawer-4"));
1596
1597        // Importance-desc default — first row is the highest-importance
1598        // drawer (idx:1 had importance 0.9), confirming we did not break
1599        // the legacy callers.
1600        let legacy = service
1601            .list_drawers(
1602                "paging-test",
1603                ListDrawersQuery {
1604                    limit: Some(1),
1605                    ..Default::default()
1606                },
1607            )
1608            .await
1609            .expect("legacy");
1610        let arr = legacy.as_array().expect("array");
1611        assert_eq!(arr.len(), 1);
1612        assert_eq!(
1613            arr[0]["content"].as_str(),
1614            Some("drawer-1"),
1615            "importance default should surface drawer with importance 0.9 first",
1616        );
1617
1618        // Issue #202: every row carries an enriched `snippet` field
1619        // derived from the drawer body so the TUI activity panel can
1620        // render a glanceable summary without re-parsing.
1621        assert_eq!(
1622            arr[0]["snippet"].as_str(),
1623            Some("drawer-1"),
1624            "snippet must be populated for non-empty drawer content",
1625        );
1626    }
1627
1628    /// Why: issue #202 — the snippet helper must collapse whitespace,
1629    /// trim, and cap at [`DRAWER_SNIPPET_MAX_CHARS`] with a trailing `…`
1630    /// when the body overflows, matching the SSE preview's shape but at
1631    /// a tighter width.
1632    /// What: feeds a multiline / whitespace-heavy body and asserts both
1633    /// the truncation and the collapse rule.
1634    /// Test: itself.
1635    #[test]
1636    fn drawer_snippet_truncates_long_content() {
1637        // Short content round-trips verbatim.
1638        assert_eq!(drawer_snippet("hello world"), "hello world");
1639
1640        // Whitespace is collapsed.
1641        assert_eq!(
1642            drawer_snippet("first line\n\nsecond\tline   third"),
1643            "first line second line third",
1644        );
1645
1646        // Padding is trimmed.
1647        assert_eq!(drawer_snippet("   padded   "), "padded");
1648
1649        // A body longer than the cap is truncated and ends with `…`.
1650        let long = "a".repeat(200);
1651        let snippet = drawer_snippet(&long);
1652        assert_eq!(snippet.chars().count(), DRAWER_SNIPPET_MAX_CHARS);
1653        assert!(
1654            snippet.ends_with('…'),
1655            "long body must be truncated with ellipsis",
1656        );
1657
1658        // A body sized exactly at the cap is preserved verbatim.
1659        let exact = "a".repeat(DRAWER_SNIPPET_MAX_CHARS);
1660        assert_eq!(drawer_snippet(&exact), exact);
1661    }
1662
1663    /// Why: empty / whitespace-only bodies must produce an empty
1664    /// snippet so the `list_drawers` shaper can omit the `snippet`
1665    /// field (rendered as `null` on the wire) instead of an empty
1666    /// string. The TUI relies on this distinction to skip the snippet
1667    /// column entirely when the body has no usable preview.
1668    /// What: feeds empty and whitespace-only strings.
1669    /// Test: itself.
1670    #[test]
1671    fn drawer_snippet_handles_empty_content() {
1672        assert_eq!(drawer_snippet(""), "");
1673        assert_eq!(drawer_snippet("   \n\t  "), "");
1674    }
1675}