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