Skip to main content

trusty_memory/service/
core.rs

1//! `MemoryService` — the pure business-logic facade over `AppState`.
2//!
3//! Why: lets the axum HTTP handlers stay thin one-liners and lets non-HTTP
4//! callers (chat tool dispatch, RPC bridges) reuse the same code paths without
5//! dragging axum types around (split out of the former monolithic `service.rs`,
6//! issue #607).
7//! What: the `MemoryService` struct + its full async method surface, moved
8//! verbatim. Each method returns `anyhow::Result<Value>` or a typed
9//! `ServiceResult`.
10//! Test: every method is covered by the corresponding handler test in
11//! `web::tests`.
12
13use crate::attribution::CreatorInfo;
14use crate::{ActivitySource, AppState, DaemonEvent};
15use anyhow::{anyhow, Context, Result};
16use serde_json::{json, Value};
17use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
18use trusty_common::memory_core::retrieval::{
19    recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
20    recall_with_default_embedder, RememberOptions,
21};
22use trusty_common::memory_core::store::PalaceStoreError;
23use trusty_common::memory_core::PalaceRegistry;
24use uuid::Uuid;
25
26use super::helpers::{
27    collect_palace_stats, drawer_content_preview, drawer_snippet, is_reserved_system_palace,
28    list_palaces_blocking, open_palaces_blocking, palace_info_from, recall_entry_json,
29};
30use super::types::{
31    CreateDrawerBody, CreatePalaceBody, ListDrawersQuery, PalaceInfo, ServiceError, ServiceResult,
32    StatusPayload,
33};
34
35/// Hard cap on triples returned by the per-palace graph endpoint.
36pub(super) const KG_GRAPH_MAX_TRIPLES: usize = 5_000;
37
38// ---------------------------------------------------------------------------
39// MemoryService — pure business logic facade.
40// ---------------------------------------------------------------------------
41
42/// Wraps [`AppState`] and exposes one async method per logical operation.
43///
44/// Why: see module docs. Lets HTTP handlers stay thin and lets non-HTTP
45/// callers (chat tool dispatch, RPC bridges) reuse the same code paths.
46/// What: `Clone` (cheap — only the inner `AppState` is shared); construct
47/// with `MemoryService::new(state)`.
48/// Test: every method is covered by the corresponding handler test in
49/// `web::tests`.
50#[derive(Clone)]
51pub struct MemoryService {
52    pub(super) state: AppState,
53}
54
55impl MemoryService {
56    /// Construct a new service wrapper.
57    ///
58    /// Why: handlers cheaply re-wrap their `AppState` on every request; the
59    /// cost is just an `Arc` clone, so we don't bother caching the wrapper.
60    /// What: stores the `AppState` for later method calls.
61    /// Test: trivial — covered indirectly by every handler test.
62    pub fn new(state: AppState) -> Self {
63        Self { state }
64    }
65
66    /// Borrow the inner [`AppState`].
67    ///
68    /// Why: some handlers still need direct access (SSE broadcaster, session
69    /// store, etc.) while we incrementally extract code into the service.
70    /// What: returns a borrowed reference to the wrapped `AppState`.
71    /// Test: not directly tested; surface-level accessor.
72    pub fn state(&self) -> &AppState {
73        &self.state
74    }
75
76    // -----------------------------------------------------------------
77    // Status / config
78    // -----------------------------------------------------------------
79
80    /// Build the aggregate `/api/v1/status` payload.
81    ///
82    /// Why: dashboard widgets and the MCP `get_status` tool need the same
83    /// roll-up; centralising avoids drift between the two surfaces.
84    /// What: walks every persisted palace for `palace_count`, then sums
85    /// drawer/vector/triple counts across the cache-resident subset and
86    /// returns the [`StatusPayload`].
87    /// Why (issue #4637): this used to open every persisted palace to sum
88    /// those three counts. With 5,794 palaces on disk against a 64-slot LRU
89    /// that is ~5,730 cold opens of ~1s each — the endpoint measurably never
90    /// responded. `palace_count` still reflects the true on-disk total (the
91    /// directory walk is cheap and now runs on the blocking pool); the totals
92    /// cover only cache-resident palaces and say so via `cached_palace_count`.
93    /// Test: `status_endpoint_returns_payload`,
94    /// `status_does_not_open_uncached_palaces`.
95    pub async fn status(&self) -> StatusPayload {
96        // The `/status` endpoint is the one place we still want a disk view —
97        // an operator hitting this endpoint right after restart (before
98        // `load_palaces_from_disk` finishes) should still see every persisted
99        // palace counted, even if it isn't in the in-memory registry yet.
100        let palaces = list_palaces_blocking(&self.state).await.unwrap_or_default();
101        let palace_count = palaces.len();
102        // #4637: peek() not open_palace() — full-registry open is O(n) cold disk I/O
103        let stats = collect_palace_stats(&self.state, palaces.iter().map(|p| &p.id));
104        StatusPayload {
105            version: self.state.version.clone(),
106            palace_count,
107            default_palace: self.state.default_palace.clone(),
108            data_root: self.state.data_root.display().to_string(),
109            total_drawers: stats.total_drawers,
110            total_vectors: stats.total_vectors,
111            total_kg_triples: stats.total_kg_triples,
112            cached_palace_count: stats.cached_palace_count,
113        }
114    }
115
116    /// Compute the aggregate `StatusChanged` event used by SSE consumers.
117    ///
118    /// Why: mutating handlers — and the periodic status ticker — push a
119    /// refreshed status snapshot so dashboards stay in sync without an
120    /// extra `/api/v1/status` request.
121    /// Why (issue #228): this used to call `PalaceRegistry::list_palaces`
122    /// (a synchronous disk walk) + `open_palace` (more disk I/O on first
123    /// call) for every palace on every emit. Since every persisted palace
124    /// is already loaded into the in-memory registry by
125    /// `AppState::load_palaces_from_disk` at startup (and every `create_palace`
126    /// keeps it in sync), iterating the in-memory registry returns the same
127    /// counts without touching disk.
128    /// What: iterates `state.registry.list()` (a `DashMap` snapshot) and
129    /// sums the live handle stats via [`collect_palace_stats`]. Returns a
130    /// `DaemonEvent::StatusChanged`. Palaces that fail to resolve in the
131    /// registry (race during shutdown) are silently skipped — the next
132    /// emit will catch them.
133    /// Test: indirectly via SSE integration tests; the math is identical to
134    /// the disk-walk implementation and the `status_endpoint_returns_payload`
135    /// test still passes against `status()` (which keeps the disk view for
136    /// the dedicated endpoint).
137    pub fn aggregate_status_event(&self) -> DaemonEvent {
138        let ids: Vec<PalaceId> = self.state.registry.list();
139        let stats = collect_palace_stats(&self.state, ids.iter());
140        DaemonEvent::StatusChanged {
141            total_drawers: stats.total_drawers,
142            total_vectors: stats.total_vectors,
143            total_kg_triples: stats.total_kg_triples,
144        }
145    }
146
147    // -----------------------------------------------------------------
148    // Palaces
149    // -----------------------------------------------------------------
150
151    /// List every palace on disk, enriched with live handle stats.
152    ///
153    /// Why: shared between the HTTP handler and the chat tool dispatcher;
154    /// both want the same `PalaceInfo` shape. Issue #185 added the
155    /// reserved-prefix filter so internal "system" palaces (e.g. the
156    /// `__health_probe__` palace used by `/health`) never surface in the
157    /// admin UI, TUI, or any user-facing roster.
158    /// What: walks the registry, drops any palace whose id starts with the
159    /// reserved `__` prefix, and builds a `PalaceInfo` per remaining row.
160    /// Why (issue #4637): this used to call `open_palace` per row purely to
161    /// enrich it with counts. At 5,794 palaces against a 64-slot LRU that is
162    /// ~90 minutes of cold, blocking disk I/O inline on the async executor —
163    /// and it evicted the entire working set on every call. Rows now come
164    /// from `PalaceRegistry::peek` (zero I/O, no LRU promotion, mirroring the
165    /// #1924 fix in `console_metrics.rs`). Uncached rows carry `cached: false`
166    /// and zero counts; a client that needs live counts for one palace should
167    /// fetch `GET /api/v1/palaces/{id}`, which still opens it.
168    /// Test: `palace_list_includes_richer_counts`, `palace_list_includes_graph_counts`,
169    /// `health_probe_palace_is_invisible` (in `web::tests`),
170    /// `list_palaces_does_not_open_uncached_palaces`.
171    pub async fn list_palaces(&self) -> ServiceResult<Vec<PalaceInfo>> {
172        let palaces = list_palaces_blocking(&self.state)
173            .await
174            .map_err(|e| ServiceError::internal(format!("{e:#}")))?;
175        let mut out = Vec::with_capacity(palaces.len());
176        for p in palaces {
177            if is_reserved_system_palace(&p.id) {
178                continue;
179            }
180            // #4637: peek() not open_palace() — full-registry open is O(n) cold disk I/O
181            let handle = self.state.registry.peek(&p.id);
182            out.push(palace_info_from(&p, handle.as_ref()));
183        }
184        Ok(out)
185    }
186
187    /// Every non-system palace with REAL counts, keeping per-palace failures.
188    ///
189    /// Why (#6286): [`Self::list_palaces`] answers placeholder zeros plus
190    /// `cached: false` for any palace not already resident, which is why the
191    /// monitor could not use it and fanned out one [`Self::get_palace`] per id
192    /// instead. That fan-out then dropped a palace whose call failed at
193    /// `debug!`, so the panel could show "12 palaces" over 9 rows and nothing
194    /// said why. This is the one call that answers what the fan-out was
195    /// assembling, and it reports a failure as a failure rather than as an
196    /// absence.
197    ///
198    /// What: one entry per non-system palace, in registry order. `Ok` carries
199    /// the same [`PalaceInfo`] `get_palace` builds — the palace is opened, so
200    /// the counts are measurements. `Err` carries the open failure's message.
201    /// A palace never silently vanishes and never becomes a row of zeros.
202    ///
203    /// **This opens every palace, and that is the point.** #4637 removed
204    /// exactly this from `list_palaces` because at 5,794 palaces a cold open per
205    /// row is ~90 minutes of blocking disk I/O. The cost is unchanged from the
206    /// N-call fan-out this replaces — the same opens, one round trip instead of
207    /// N — and after the first poll the registry is warm. A caller that wants
208    /// cheap approximate rows still has `list_palaces`.
209    ///
210    /// # Errors
211    ///
212    /// Only when the registry itself cannot be walked. A palace that will not
213    /// open is an `Err` entry, not an error for the whole call.
214    ///
215    /// Test: `rpc_palaces_list_reports_counts_per_palace`,
216    /// `rpc_palaces_list_reports_an_unreadable_palace_rather_than_dropping_it`.
217    pub async fn list_palaces_with_counts(
218        &self,
219    ) -> ServiceResult<Vec<(String, Result<PalaceInfo, String>)>> {
220        let palaces = list_palaces_blocking(&self.state)
221            .await
222            .map_err(|e| ServiceError::internal(format!("{e:#}")))?;
223        let mut out = Vec::with_capacity(palaces.len());
224        for p in palaces {
225            if is_reserved_system_palace(&p.id) {
226                continue;
227            }
228            let row = match self
229                .state
230                .registry
231                .open_palace(&self.state.data_root, &p.id)
232            {
233                Ok(handle) => Ok(palace_info_from(&p, Some(&handle))),
234                Err(e) => Err(format!("{e:#}")),
235            };
236            out.push((p.id.0.clone(), row));
237        }
238        Ok(out)
239    }
240
241    /// Create a new palace and emit the corresponding activity event.
242    ///
243    /// Why: trims duplicated work between the HTTP handler and any future
244    /// non-HTTP creation flow.
245    /// What: validates the name, builds the `Palace` row, calls
246    /// `PalaceRegistry::create_palace`, and emits `PalaceCreated`. Returns
247    /// the new palace id.
248    /// Test: covered indirectly by `palace_list_includes_richer_counts` (which
249    /// posts a palace through the HTTP layer then reads it back).
250    pub async fn create_palace(
251        &self,
252        body: CreatePalaceBody,
253        source: ActivitySource,
254    ) -> ServiceResult<String> {
255        let name = body.name.trim().to_string();
256        if name.is_empty() {
257            return Err(ServiceError::bad_request("name is required"));
258        }
259        // Issue #88 / Change 2: enforce palace = project mapping for
260        // HTTP-originated palace creation. The validation cwd is, in order of
261        // preference:
262        //   a. `body.cwd` — the caller explicitly supplied their project path
263        //      (correct for any client that is not the daemon itself).
264        //   b. `std::env::current_dir()` — daemon's own cwd, the pre-Change-2
265        //      fallback (rarely meaningful when the daemon is launched from ~).
266        // This keeps older clients that omit `cwd` working without a breaking
267        // change, while letting pin-file-aware clients get accurate validation.
268        // spec-001: `force=true` lets an application bypass the project-slug
269        // gate so it can create palaces under arbitrary slugs (e.g. one per
270        // app/tenant for chat-session storage). The env-var bypass remains for
271        // test contexts; both short-circuit the same validation call.
272        //
273        // Issue #1714: `force=true` bypasses slug validation entirely, so it
274        // is gated behind the minimal authz seam in `crate::authz` before any
275        // other check runs. In the default single-tenant mode this is a
276        // no-op (unchanged behaviour); in multi-tenant mode it fails closed
277        // until a real capability check lands. See `crate::authz` module
278        // docs for the full design rationale.
279        let skip_enforcement =
280            std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
281        if body.force {
282            crate::authz::authorize_force_palace_create(&self.state)
283                .map_err(|e| ServiceError::forbidden(e.to_string()))?;
284        }
285        if !skip_enforcement && !body.force {
286            let cwd = body
287                .cwd
288                .as_deref()
289                .map(std::path::Path::new)
290                .map(|p| p.to_path_buf())
291                .or_else(|| std::env::current_dir().ok())
292                .unwrap_or_else(|| self.state.data_root.clone());
293            crate::project_root::validate_palace_name(&name, &cwd)
294                .map_err(|e| ServiceError::bad_request(e.to_string()))?;
295        }
296        let id = PalaceId::new(&name);
297        let palace = Palace {
298            id: id.clone(),
299            name: name.clone(),
300            description: body.description.filter(|s| !s.is_empty()),
301            created_at: chrono::Utc::now(),
302            data_dir: self.state.data_root.join(&name),
303        };
304        self.state
305            .registry
306            .create_palace(&self.state.data_root, palace)
307            .map_err(|e| ServiceError::internal(format!("create palace: {e:#}")))?;
308        // Issue #228: keep the in-memory palace-name cache in sync so writes
309        // to this palace can resolve `Palace.name` without a disk walk.
310        self.state.palace_names.insert(name.clone(), name.clone());
311        self.state.emit(DaemonEvent::PalaceCreated {
312            id: name.clone(),
313            name: name.clone(),
314            source,
315        });
316        Ok(name)
317    }
318
319    /// Delete a palace from disk, optionally rejecting non-empty palaces.
320    ///
321    /// Why: Issue #180 — operators need a way to drop an entire palace
322    /// without going through drawer-by-drawer deletion. Defaulting to a
323    /// "must be empty" guard prevents fat-finger destruction of populated
324    /// palaces; `force=true` is the explicit opt-in to the destructive path.
325    /// What: 1) confirms the palace exists on disk (else `NotFound`),
326    /// 2) when `!force`, lists drawers via the live handle and returns
327    /// `BadRequest("Palace has drawers; pass force=true to delete")` if
328    /// the palace is non-empty, 3) drops the in-memory registry entry so
329    /// future opens hit the (now-missing) disk state, 4) removes
330    /// `<data_root>/<palace_id>/` recursively via `tokio::fs::remove_dir_all`,
331    /// and 5) emits an aggregate `StatusChanged` so dashboards refresh.
332    /// Test: `delete_palace_removes_dir_when_empty`,
333    /// `delete_palace_refuses_when_drawers_present`,
334    /// `delete_palace_force_removes_populated_palace`,
335    /// `delete_palace_returns_not_found_for_missing_id` in `web::tests`.
336    pub async fn delete_palace(&self, palace_id: &str, force: bool) -> ServiceResult<()> {
337        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
338            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
339        if !palaces.iter().any(|p| p.id.0 == palace_id) {
340            return Err(ServiceError::not_found(format!(
341                "palace not found: {palace_id}"
342            )));
343        }
344        if !force {
345            // Open the palace just long enough to count its drawers; we don't
346            // hold the handle past this check because the caller is about to
347            // delete the on-disk directory.
348            if let Ok(handle) = self
349                .state
350                .registry
351                .open_palace(&self.state.data_root, &PalaceId::new(palace_id))
352            {
353                if !handle.drawers.read().is_empty() {
354                    return Err(ServiceError::conflict(
355                        "Palace has drawers; pass force=true to delete",
356                    ));
357                }
358            }
359        }
360        // Drop the cached `Arc<PalaceHandle>` and gap cache before unlinking
361        // the directory so subsequent reads can't be served from the stale
362        // in-memory state. The registry's `remove` is a no-op when the entry
363        // is absent (lazy-open palaces that no caller has touched yet).
364        self.state.registry.remove(&PalaceId::new(palace_id));
365        // #4639: drop the cached chat_sessions.redb handle too — otherwise the
366        // fd survives `remove_dir_all` and pins the deleted inode forever.
367        self.state.session_stores.remove(palace_id);
368        // Issue #228: drop the palace-name cache entry so future writes never
369        // resolve to a stale label.
370        self.state.palace_names.remove(palace_id);
371        let palace_dir = self.state.data_root.join(palace_id);
372        tokio::fs::remove_dir_all(&palace_dir).await.map_err(|e| {
373            ServiceError::internal(format!("remove palace dir {}: {e}", palace_dir.display()))
374        })?;
375        // Recompute aggregate totals so dashboards drop the deleted palace's
376        // counts. There's no dedicated `PalaceDeleted` event variant yet;
377        // `StatusChanged` is enough to keep the UI in sync.
378        self.state.emit(self.aggregate_status_event());
379        Ok(())
380    }
381
382    /// Rename a palace's display name without touching its data.
383    ///
384    /// Why: Operators need to fix typos and rebrand palaces without dropping
385    /// the underlying drawers / vectors / KG. The palace id (the directory
386    /// name on disk) is immutable — only the human-readable `name` field in
387    /// `palace.json` changes — so cached `PalaceHandle`s stay valid and no
388    /// registry invalidation is required.
389    /// What: 1) loads the palace via `PalaceStore::load_palace` (404 when the
390    /// directory or `palace.json` is genuinely missing; a probe that cannot
391    /// determine whether it is there is a 500, not a 404 — #5549), 2) trims the
392    /// new name and
393    /// returns `BadRequest` when empty, 3) mutates `palace.name` and writes
394    /// the metadata back through the atomic `PalaceStore::save_palace`
395    /// (tmp file + rename), 4) emits an aggregate `StatusChanged` so
396    /// dashboards re-render the relabelled palace, 5) returns the updated
397    /// palace as JSON (enriched with the live handle stats, so callers see
398    /// drawer/vector/KG counts in the same shape as `GET /palaces/{id}`).
399    /// Test: `update_palace_name_renames_palace`,
400    /// `update_palace_name_rejects_empty_name`,
401    /// `update_palace_name_returns_not_found_for_missing_id` in `web::tests`.
402    pub async fn update_palace_name(&self, palace_id: &str, name: &str) -> Result<Value> {
403        let trimmed = name.trim();
404        if trimmed.is_empty() {
405            return Err(anyhow!("name must be non-empty after trimming"));
406        }
407        let palace_dir = self.state.data_root.join(palace_id);
408        let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
409            .map_err(|e| {
410            // #5549: only a genuine absence may be reported as "not found".
411            if matches!(&e, PalaceStoreError::NotFound(_)) {
412                anyhow!("palace not found: {palace_id} ({e})")
413            } else {
414                anyhow!("cannot load palace {palace_id}: {e}")
415            }
416        })?;
417        palace.name = trimmed.to_string();
418        trusty_common::memory_core::store::PalaceStore::save_palace(&palace)
419            .with_context(|| format!("save palace metadata for {palace_id}"))?;
420        // Issue #228: refresh the in-memory name cache so subsequent writes
421        // surface the new label without a disk walk.
422        self.state
423            .palace_names
424            .insert(palace_id.to_string(), trimmed.to_string());
425        let handle = self
426            .state
427            .registry
428            .open_palace(&self.state.data_root, &palace.id)
429            .ok();
430        let info = palace_info_from(&palace, handle.as_ref());
431        self.state.emit(self.aggregate_status_event());
432        serde_json::to_value(info).context("serialize palace info")
433    }
434
435    /// Typed variant of [`Self::update_palace_name`] used by the HTTP handler.
436    ///
437    /// Why: HTTP needs to distinguish 400 (empty name) from 404 (missing
438    /// palace) so the right status code is emitted; the chat / MCP tool
439    /// only cares about a `Result<Value>` because both errors are surfaced
440    /// as opaque MCP error strings. Keeping a typed variant alongside the
441    /// untyped one keeps the wire shape correct on both surfaces without
442    /// asking either caller to parse error strings.
443    /// What: same as [`Self::update_palace_name`] but returns
444    /// `ServiceError::BadRequest` for empty names and `ServiceError::NotFound`
445    /// for palace metadata that is genuinely absent. Metadata whose presence
446    /// cannot be determined — a denied or transient stat — is
447    /// `ServiceError::Internal`: a 404 would tell the client the palace does
448    /// not exist when nobody established that (#5549, ADR-0045).
449    /// Test: `update_palace_name_renames_palace`,
450    /// `update_palace_name_rejects_empty_name`,
451    /// `update_palace_name_returns_not_found_for_missing_id`,
452    /// `update_palace_name_reports_an_unstattable_palace_as_internal`.
453    pub async fn update_palace_name_typed(
454        &self,
455        palace_id: &str,
456        name: &str,
457    ) -> ServiceResult<Value> {
458        let trimmed = name.trim();
459        if trimmed.is_empty() {
460            return Err(ServiceError::bad_request(
461                "name must be non-empty after trimming",
462            ));
463        }
464        let palace_dir = self.state.data_root.join(palace_id);
465        let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
466            .map_err(|e| {
467            // #5549: `not_found` on every variant told the client the palace
468            // does not exist for a stat we were merely denied.
469            if matches!(&e, PalaceStoreError::NotFound(_)) {
470                ServiceError::not_found(format!("palace not found: {palace_id} ({e})"))
471            } else {
472                ServiceError::internal(format!("cannot load palace {palace_id}: {e}"))
473            }
474        })?;
475        palace.name = trimmed.to_string();
476        trusty_common::memory_core::store::PalaceStore::save_palace(&palace).map_err(|e| {
477            ServiceError::internal(format!("save palace metadata for {palace_id}: {e}"))
478        })?;
479        // Issue #228: refresh the in-memory name cache so subsequent writes
480        // surface the new label without a disk walk.
481        self.state
482            .palace_names
483            .insert(palace_id.to_string(), trimmed.to_string());
484        let handle = self
485            .state
486            .registry
487            .open_palace(&self.state.data_root, &palace.id)
488            .ok();
489        let info = palace_info_from(&palace, handle.as_ref());
490        self.state.emit(self.aggregate_status_event());
491        serde_json::to_value(info)
492            .map_err(|e| ServiceError::internal(format!("serialize palace info: {e}")))
493    }
494
495    /// Look up a single palace by id and enrich with live handle stats.
496    ///
497    /// Why: distinct 404 vs. 500 path is needed by both HTTP and chat callers.
498    /// What: returns `NotFound` when the id is unknown, otherwise a fully
499    /// populated `PalaceInfo`.
500    /// Test: indirectly via `health_endpoint_round_trip_with_palace_is_ok`.
501    pub async fn get_palace(&self, id: &str) -> ServiceResult<PalaceInfo> {
502        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
503            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
504        let palace = palaces
505            .into_iter()
506            .find(|p| p.id.0 == id)
507            .ok_or_else(|| ServiceError::not_found(format!("palace not found: {id}")))?;
508        let handle = self
509            .state
510            .registry
511            .open_palace(&self.state.data_root, &palace.id)
512            .ok();
513        Ok(palace_info_from(&palace, handle.as_ref()))
514    }
515
516    // -----------------------------------------------------------------
517    // Drawers
518    // -----------------------------------------------------------------
519
520    /// List drawers in a palace with optional room/tag filters and pagination.
521    ///
522    /// Why: deduplicates the open-handle + listing path between HTTP and chat,
523    /// and (issue #184) lets the TUI activity panel page through drawers in
524    /// creation-date order without breaking the importance-sorted default the
525    /// legacy callers rely on.
526    /// What: opens the palace handle, fetches a window of drawers, optionally
527    /// re-sorts by `created_at` descending when `sort = "created_desc"`
528    /// (leaving the importance-desc default untouched), then drops the
529    /// leading `offset` rows and keeps `limit`. For `created_desc` the
530    /// window must cover the full filtered set (otherwise the importance
531    /// pre-sort hides truly-recent low-importance drawers), so the window
532    /// is widened to a sane ceiling (`MAX_DRAWER_WINDOW`); the default
533    /// importance path keeps a tight `limit+offset` window.
534    /// Returns the serialised JSON array.
535    /// Test: `service::tests::list_drawers_creates_desc_paginates`.
536    pub async fn list_drawers(&self, id: &str, q: ListDrawersQuery) -> ServiceResult<Value> {
537        const MAX_DRAWER_WINDOW: usize = 10_000;
538        let handle = self.open_handle(id)?;
539        let room = q.room.as_deref().map(RoomType::parse);
540        let limit = q.limit.unwrap_or(50);
541        let offset = q.offset.unwrap_or(0);
542        let by_created = matches!(q.sort.as_deref(), Some("created_desc"));
543        // For created_desc the importance pre-sort would hide low-importance
544        // drawers that happen to be the most recent, so we need to fetch the
545        // full filtered set (capped at MAX_DRAWER_WINDOW). For importance
546        // ordering the legacy `limit + offset` window is sufficient.
547        let window = if by_created {
548            MAX_DRAWER_WINDOW
549        } else {
550            limit.saturating_add(offset).min(MAX_DRAWER_WINDOW)
551        };
552        let mut drawers = handle.list_drawers(room, q.tag.clone(), window);
553        if by_created {
554            drawers.sort_by_key(|d| std::cmp::Reverse(d.created_at));
555        }
556        let page: Vec<_> = drawers.into_iter().skip(offset).take(limit).collect();
557        // Issue #202: enrich every row with a short `snippet` derived from
558        // the drawer's content so the TUI activity panel can render a
559        // glanceable summary without re-parsing the full body. The
560        // snippet is whitespace-collapsed and bounded at
561        // `DRAWER_SNIPPET_MAX_CHARS` (60) — shorter than the SSE preview
562        // because the activity panel renders it on a single narrow row.
563        let payload: Vec<Value> = page
564            .into_iter()
565            .map(|drawer| {
566                let snippet = drawer_snippet(drawer.content());
567                let mut value = serde_json::to_value(&drawer).unwrap_or_else(|_| json!({}));
568                if let Value::Object(ref mut map) = value {
569                    // `null` when the drawer has no usable content so
570                    // clients can distinguish "no body" from "empty body
571                    // after whitespace collapse".
572                    let snippet_value = if snippet.is_empty() {
573                        Value::Null
574                    } else {
575                        Value::String(snippet)
576                    };
577                    map.insert("snippet".to_string(), snippet_value);
578                }
579                value
580            })
581            .collect();
582        Ok(Value::Array(payload))
583    }
584
585    /// Store a new drawer and emit the matching activity events.
586    ///
587    /// Why: HTTP and chat both need the auto-KG-extraction follow-up; this
588    /// method keeps that side-effect chain in one place.
589    /// What: opens the palace, stores the drawer via
590    /// `PalaceHandle::remember_with_options` (issue #3225: `body.force`
591    /// threads through as `RememberOptions::force`, letting a caller bypass
592    /// the QUALITY gates only — `allow_secret_like` is left at its default
593    /// `false`, so secret detection always still runs, `force` or not),
594    /// emits `DrawerAdded` + `StatusChanged`, then triggers
595    /// `tools::auto_extract_and_assert`. Returns the new drawer id.
596    /// Test: `http_create_drawer_runs_auto_kg_extraction`,
597    /// `create_drawer_rejects_json_content_without_force`,
598    /// `create_drawer_force_bypasses_quality_gate_for_json_content`.
599    pub async fn create_drawer(
600        &self,
601        id: &str,
602        body: CreateDrawerBody,
603        creator: CreatorInfo,
604        source: ActivitySource,
605    ) -> ServiceResult<Uuid> {
606        let handle = self.open_handle(id)?;
607        let room = body
608            .room
609            .as_deref()
610            .map(RoomType::parse)
611            .unwrap_or(RoomType::General);
612        let importance = body.importance.unwrap_or(0.5);
613        let force = body.force.unwrap_or(false);
614        let content_preview = drawer_content_preview(&body.content);
615        let mut tags_with_creator = body.tags;
616        // Issue #202: project a bare-UUID session tag (when the caller
617        // passed one in the request body) into the reserved
618        // `creator:session=<first-8>` slot so the activity panel can
619        // surface session attribution without bespoke parsing.
620        if let Some(session_tag) = crate::attribution::session_tag_from_tags(&tags_with_creator) {
621            tags_with_creator.push(session_tag);
622        }
623        creator.merge_into(&mut tags_with_creator);
624        let content_for_kg = body.content.clone();
625        let tags_for_kg = tags_with_creator.clone();
626        let room_label_for_kg = crate::tools::room_label(&room);
627        let drawer_id = handle
628            .remember_with_options(
629                body.content,
630                room,
631                tags_with_creator,
632                importance,
633                RememberOptions {
634                    force,
635                    ..Default::default()
636                },
637            )
638            .await
639            .map_err(|e| ServiceError::internal(format!("remember: {e:#}")))?;
640        let drawer_count = handle.drawers.read().len();
641        // Issue #228: resolve from the in-memory cache instead of re-walking
642        // the data root on every HTTP `create_drawer` call. Same cache the
643        // MCP `lookup_palace_name` helper consults.
644        let palace_name = self
645            .state
646            .palace_names
647            .get(id)
648            .map(|entry| entry.value().clone())
649            .unwrap_or_else(|| id.to_string());
650        self.state.emit(DaemonEvent::DrawerAdded {
651            palace_id: id.to_string(),
652            palace_name,
653            drawer_count,
654            timestamp: chrono::Utc::now(),
655            content_preview,
656            source,
657        });
658        // Issue #228: do NOT emit `StatusChanged` on every drawer create —
659        // the periodic ticker (`run_http_on`) refreshes aggregate totals on
660        // a fixed cadence so dashboards stay current without an O(N palaces)
661        // recompute on the write hot path.
662        crate::tools::auto_extract_and_assert(
663            &handle,
664            drawer_id,
665            &content_for_kg,
666            &tags_for_kg,
667            room_label_for_kg.as_deref(),
668        )
669        .await;
670        Ok(drawer_id)
671    }
672
673    /// Forget (delete) a drawer and emit the matching events.
674    ///
675    /// Why: same dedup story as `create_drawer`. #5231: `DELETE` on a drawer id
676    /// that was never stored used to answer `204 No Content`, the same as a
677    /// real delete — this now 404s, matching `delete_palace`.
678    /// What: parses the drawer UUID, calls `PalaceHandle::forget`, deletes the
679    /// drawer's BM25 document, maps `ForgetOutcome::NotFound` to
680    /// `ServiceError::not_found`, and emits `DrawerDeleted` only when a drawer
681    /// was actually removed. #5053: the lexical delete runs on this path for
682    /// the same reason it runs on the MCP one — `HTTP DELETE` and
683    /// `memory_forget` remove the same drawer, and the backfill indexes it
684    /// whichever way it was written, so a lexical copy left here is the same
685    /// stale document.
686    /// Test: `delete_drawer_404s_for_an_unknown_drawer_id`;
687    /// `tests/bm25_forget_delete.rs` covers the deletion contract itself.
688    pub async fn delete_drawer(
689        &self,
690        id: &str,
691        drawer_id: &str,
692        source: ActivitySource,
693    ) -> ServiceResult<()> {
694        let handle = self.open_handle(id)?;
695        let uuid = Uuid::parse_str(drawer_id)
696            .map_err(|_| ServiceError::bad_request("drawer_id must be a UUID"))?;
697        let outcome = handle
698            .forget(uuid)
699            .await
700            .map_err(|e| ServiceError::internal(format!("forget: {e:#}")))?;
701        // #5053: a drawer the user deleted must stop matching lexical queries.
702        crate::tools::bm25::bm25_delete_document(&self.state, handle.id.as_str(), uuid)
703            .await
704            .map_err(|e| ServiceError::internal(format!("{e:#}")))?;
705        if !outcome.is_deleted() {
706            return Err(ServiceError::not_found(format!(
707                "drawer '{drawer_id}' not found in palace '{id}'"
708            )));
709        }
710        let drawer_count = handle.drawers.read().len();
711        self.state.emit(DaemonEvent::DrawerDeleted {
712            palace_id: id.to_string(),
713            drawer_count,
714            source,
715        });
716        // Issue #228: skip the per-write `StatusChanged` emit — the
717        // periodic ticker handles aggregate roll-ups.
718        Ok(())
719    }
720
721    // -----------------------------------------------------------------
722    // Recall
723    // -----------------------------------------------------------------
724
725    /// Per-palace recall (semantic search), optionally with deep retrieval.
726    ///
727    /// Why: HTTP and chat tools both perform the same fan-out logic.
728    /// What: opens the palace handle and dispatches to the shallow or deep
729    /// recall helper. Returns a JSON array of flattened drawer rows (the
730    /// `recall_entry_json` shape from issue #69).
731    /// Test: `recall_entry_json_hoists_drawer_fields`.
732    pub async fn recall(
733        &self,
734        id: &str,
735        query: &str,
736        top_k: usize,
737        deep: bool,
738    ) -> ServiceResult<Value> {
739        let handle = self.open_handle(id)?;
740        let mut results = if deep {
741            recall_deep_with_default_embedder(&handle, query, top_k).await
742        } else {
743            recall_with_default_embedder(&handle, query, top_k).await
744        }
745        .map_err(|e| ServiceError::internal(format!("recall: {e:#}")))?;
746        // #5036: the lexical lane, on the path the UserPromptSubmit hook
747        // actually takes. `handle_memory_recall` has run vector and BM25 in
748        // parallel and RRF-fused them since #156; this route reached
749        // `retrieval::layers` directly and was vector-only, so a prompt with no
750        // lexical counterweight retrieved by vector centroid alone.
751        //
752        // Keyed on the RESOLVED palace id, never the caller's slug —
753        // `open_handle` follows aliases, and the corpus the backfill wrote
754        // belongs to the resolved palace.
755        //
756        // Reuses `fuse_bm25_into_recall` rather than deriving a second scorer:
757        // it only BOOSTS drawers the vector lane already returned and never
758        // promotes a BM25-only hit, so it has no scaling constant that can
759        // degenerate when the surviving set is empty — the failure that folded
760        // three earlier attempts at this wiring.
761        if let Some(hits) =
762            crate::tools::bm25::bm25_search_optional(&self.state, handle.id.as_str(), query, top_k)
763                .await
764        {
765            crate::tools::bm25::fuse_bm25_into_recall(&mut results, &hits, top_k);
766        }
767        let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
768        Ok(json!(payload))
769    }
770
771    /// Cross-palace recall.
772    ///
773    /// Why: shared between `/api/v1/recall` and the `memory_recall_all` chat
774    /// tool. Encapsulating the open-everything-fanout-merge dance avoids
775    /// drift.
776    /// What: lists every palace, opens handles (skipping failures with a
777    /// `tracing::warn!`), delegates to
778    /// `recall_across_palaces_with_default_embedder`. Returns a JSON array.
779    /// Why (issue #4637): unlike `list_palaces`/`status`, this route is NOT
780    /// converted to `peek()`. A cross-palace recall that answered from
781    /// cache-resident palaces only would silently omit ~98.9% of the corpus —
782    /// a wrong answer that looks like a right one, which is strictly worse
783    /// than a slow correct one. The open loop keeps opening every palace; it
784    /// just no longer does so inline on a tokio worker thread. Making this
785    /// route actually fast needs a different design (a shared cross-palace
786    /// index, or an explicit palace-scoped query), not a cache-only read.
787    /// Test: indirectly via `recall_across_palaces_merges_results` and the
788    /// MCP `memory_recall_all` integration paths;
789    /// `open_palaces_blocking_opens_every_palace` pins that uncached palaces
790    /// are still searched.
791    pub async fn recall_all(&self, query: &str, top_k: usize, deep: bool) -> Value {
792        let palaces = match list_palaces_blocking(&self.state).await {
793            Ok(v) => v,
794            Err(e) => return json!({ "error": format!("{e:#}") }),
795        };
796        // #4637: open_palace (not peek) is deliberate — recall must see every
797        // palace; the spawn_blocking hop keeps it off the async executor.
798        let handles = open_palaces_blocking(&self.state, &palaces, "recall_all").await;
799        if handles.is_empty() {
800            return json!([]);
801        }
802        match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
803            Ok(results) => json!(results
804                .into_iter()
805                .map(|r| json!({
806                    "palace_id": r.palace_id,
807                    "drawer_id": r.result.drawer.id.to_string(),
808                    "content": r.result.drawer.content(),
809                    "importance": r.result.drawer.importance,
810                    "tags": r.result.drawer.tags,
811                    "score": r.result.score,
812                    "layer": r.result.layer,
813                }))
814                .collect::<Vec<_>>()),
815            Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
816        }
817    }
818}