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