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,
21};
22use trusty_common::memory_core::PalaceRegistry;
23use uuid::Uuid;
24
25use super::helpers::{
26    collect_palace_stats, drawer_content_preview, drawer_snippet, is_reserved_system_palace,
27    palace_info_from, recall_entry_json,
28};
29use super::types::{
30    CreateDrawerBody, CreatePalaceBody, ListDrawersQuery, PalaceInfo, ServiceError, ServiceResult,
31    StatusPayload,
32};
33
34/// Hard cap on triples returned by the per-palace graph endpoint.
35pub(super) const KG_GRAPH_MAX_TRIPLES: usize = 5_000;
36
37// ---------------------------------------------------------------------------
38// MemoryService — pure business logic facade.
39// ---------------------------------------------------------------------------
40
41/// Wraps [`AppState`] and exposes one async method per logical operation.
42///
43/// Why: see module docs. Lets HTTP handlers stay thin and lets non-HTTP
44/// callers (chat tool dispatch, RPC bridges) reuse the same code paths.
45/// What: `Clone` (cheap — only the inner `AppState` is shared); construct
46/// with `MemoryService::new(state)`.
47/// Test: every method is covered by the corresponding handler test in
48/// `web::tests`.
49#[derive(Clone)]
50pub struct MemoryService {
51    pub(super) state: AppState,
52}
53
54impl MemoryService {
55    /// Construct a new service wrapper.
56    ///
57    /// Why: handlers cheaply re-wrap their `AppState` on every request; the
58    /// cost is just an `Arc` clone, so we don't bother caching the wrapper.
59    /// What: stores the `AppState` for later method calls.
60    /// Test: trivial — covered indirectly by every handler test.
61    pub fn new(state: AppState) -> Self {
62        Self { state }
63    }
64
65    /// Borrow the inner [`AppState`].
66    ///
67    /// Why: some handlers still need direct access (SSE broadcaster, session
68    /// store, etc.) while we incrementally extract code into the service.
69    /// What: returns a borrowed reference to the wrapped `AppState`.
70    /// Test: not directly tested; surface-level accessor.
71    pub fn state(&self) -> &AppState {
72        &self.state
73    }
74
75    // -----------------------------------------------------------------
76    // Status / config
77    // -----------------------------------------------------------------
78
79    /// Build the aggregate `/api/v1/status` payload.
80    ///
81    /// Why: dashboard widgets and the MCP `get_status` tool need the same
82    /// roll-up; centralising avoids drift between the two surfaces.
83    /// What: walks every persisted palace, sums drawer/vector/triple counts,
84    /// and returns the [`StatusPayload`].
85    /// Test: `status_endpoint_returns_payload`.
86    pub async fn status(&self) -> StatusPayload {
87        // The `/status` endpoint is the one place we still want a disk view —
88        // an operator hitting this endpoint right after restart (before
89        // `load_palaces_from_disk` finishes) should still see every persisted
90        // palace counted, even if it isn't in the in-memory registry yet.
91        let palaces = PalaceRegistry::list_palaces(&self.state.data_root).unwrap_or_default();
92        let palace_count = palaces.len();
93        let stats = collect_palace_stats(&self.state, palaces.iter().map(|p| &p.id));
94        StatusPayload {
95            version: self.state.version.clone(),
96            palace_count,
97            default_palace: self.state.default_palace.clone(),
98            data_root: self.state.data_root.display().to_string(),
99            total_drawers: stats.total_drawers,
100            total_vectors: stats.total_vectors,
101            total_kg_triples: stats.total_kg_triples,
102        }
103    }
104
105    /// Compute the aggregate `StatusChanged` event used by SSE consumers.
106    ///
107    /// Why: mutating handlers — and the periodic status ticker — push a
108    /// refreshed status snapshot so dashboards stay in sync without an
109    /// extra `/api/v1/status` request.
110    /// Why (issue #228): this used to call `PalaceRegistry::list_palaces`
111    /// (a synchronous disk walk) + `open_palace` (more disk I/O on first
112    /// call) for every palace on every emit. Since every persisted palace
113    /// is already loaded into the in-memory registry by
114    /// `AppState::load_palaces_from_disk` at startup (and every `create_palace`
115    /// keeps it in sync), iterating the in-memory registry returns the same
116    /// counts without touching disk.
117    /// What: iterates `state.registry.list()` (a `DashMap` snapshot) and
118    /// sums the live handle stats via [`collect_palace_stats`]. Returns a
119    /// `DaemonEvent::StatusChanged`. Palaces that fail to resolve in the
120    /// registry (race during shutdown) are silently skipped — the next
121    /// emit will catch them.
122    /// Test: indirectly via SSE integration tests; the math is identical to
123    /// the disk-walk implementation and the `status_endpoint_returns_payload`
124    /// test still passes against `status()` (which keeps the disk view for
125    /// the dedicated endpoint).
126    pub fn aggregate_status_event(&self) -> DaemonEvent {
127        let ids: Vec<PalaceId> = self.state.registry.list();
128        let stats = collect_palace_stats(&self.state, ids.iter());
129        DaemonEvent::StatusChanged {
130            total_drawers: stats.total_drawers,
131            total_vectors: stats.total_vectors,
132            total_kg_triples: stats.total_kg_triples,
133        }
134    }
135
136    // -----------------------------------------------------------------
137    // Palaces
138    // -----------------------------------------------------------------
139
140    /// List every palace on disk, enriched with live handle stats.
141    ///
142    /// Why: shared between the HTTP handler and the chat tool dispatcher;
143    /// both want the same `PalaceInfo` shape. Issue #185 added the
144    /// reserved-prefix filter so internal "system" palaces (e.g. the
145    /// `__health_probe__` palace used by `/health`) never surface in the
146    /// admin UI, TUI, or any user-facing roster.
147    /// What: walks the registry, drops any palace whose id starts with the
148    /// reserved `__` prefix, and builds a `PalaceInfo` per remaining row.
149    /// Test: `palace_list_includes_richer_counts`, `palace_list_includes_graph_counts`,
150    /// `health_probe_palace_is_invisible` (in `web::tests`).
151    pub async fn list_palaces(&self) -> ServiceResult<Vec<PalaceInfo>> {
152        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
153            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
154        let mut out = Vec::with_capacity(palaces.len());
155        for p in palaces {
156            if is_reserved_system_palace(&p.id) {
157                continue;
158            }
159            let handle = self
160                .state
161                .registry
162                .open_palace(&self.state.data_root, &p.id)
163                .ok();
164            out.push(palace_info_from(&p, handle.as_ref()));
165        }
166        Ok(out)
167    }
168
169    /// Create a new palace and emit the corresponding activity event.
170    ///
171    /// Why: trims duplicated work between the HTTP handler and any future
172    /// non-HTTP creation flow.
173    /// What: validates the name, builds the `Palace` row, calls
174    /// `PalaceRegistry::create_palace`, and emits `PalaceCreated`. Returns
175    /// the new palace id.
176    /// Test: covered indirectly by `palace_list_includes_richer_counts` (which
177    /// posts a palace through the HTTP layer then reads it back).
178    pub async fn create_palace(
179        &self,
180        body: CreatePalaceBody,
181        source: ActivitySource,
182    ) -> ServiceResult<String> {
183        let name = body.name.trim().to_string();
184        if name.is_empty() {
185            return Err(ServiceError::bad_request("name is required"));
186        }
187        // Issue #88 / Change 2: enforce palace = project mapping for
188        // HTTP-originated palace creation. The validation cwd is, in order of
189        // preference:
190        //   a. `body.cwd` — the caller explicitly supplied their project path
191        //      (correct for any client that is not the daemon itself).
192        //   b. `std::env::current_dir()` — daemon's own cwd, the pre-Change-2
193        //      fallback (rarely meaningful when the daemon is launched from ~).
194        // This keeps older clients that omit `cwd` working without a breaking
195        // change, while letting pin-file-aware clients get accurate validation.
196        // spec-001: `force=true` lets an application bypass the project-slug
197        // gate so it can create palaces under arbitrary slugs (e.g. one per
198        // app/tenant for chat-session storage). The env-var bypass remains for
199        // test contexts; both short-circuit the same validation call.
200        //
201        // KNOWN MVP LIMITATION (tracked follow-up): `force=true` bypasses slug
202        // validation with NO authorization check — any caller that can reach
203        // this endpoint can create a palace under an arbitrary slug, including
204        // one that collides with another tenant's namespace. This is intended
205        // for trusted / single-tenant callers only. Multi-tenant auth gating
206        // (verifying the caller owns the requested slug) is a tracked follow-up;
207        // do not expose `force` to untrusted clients until that lands.
208        let skip_enforcement =
209            std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
210        if !skip_enforcement && !body.force {
211            let cwd = body
212                .cwd
213                .as_deref()
214                .map(std::path::Path::new)
215                .map(|p| p.to_path_buf())
216                .or_else(|| std::env::current_dir().ok())
217                .unwrap_or_else(|| self.state.data_root.clone());
218            crate::project_root::validate_palace_name(&name, &cwd)
219                .map_err(|e| ServiceError::bad_request(e.to_string()))?;
220        }
221        let id = PalaceId::new(&name);
222        let palace = Palace {
223            id: id.clone(),
224            name: name.clone(),
225            description: body.description.filter(|s| !s.is_empty()),
226            created_at: chrono::Utc::now(),
227            data_dir: self.state.data_root.join(&name),
228        };
229        self.state
230            .registry
231            .create_palace(&self.state.data_root, palace)
232            .map_err(|e| ServiceError::internal(format!("create palace: {e:#}")))?;
233        // Issue #228: keep the in-memory palace-name cache in sync so writes
234        // to this palace can resolve `Palace.name` without a disk walk.
235        self.state.palace_names.insert(name.clone(), name.clone());
236        self.state.emit(DaemonEvent::PalaceCreated {
237            id: name.clone(),
238            name: name.clone(),
239            source,
240        });
241        Ok(name)
242    }
243
244    /// Delete a palace from disk, optionally rejecting non-empty palaces.
245    ///
246    /// Why: Issue #180 — operators need a way to drop an entire palace
247    /// without going through drawer-by-drawer deletion. Defaulting to a
248    /// "must be empty" guard prevents fat-finger destruction of populated
249    /// palaces; `force=true` is the explicit opt-in to the destructive path.
250    /// What: 1) confirms the palace exists on disk (else `NotFound`),
251    /// 2) when `!force`, lists drawers via the live handle and returns
252    /// `BadRequest("Palace has drawers; pass force=true to delete")` if
253    /// the palace is non-empty, 3) drops the in-memory registry entry so
254    /// future opens hit the (now-missing) disk state, 4) removes
255    /// `<data_root>/<palace_id>/` recursively via `tokio::fs::remove_dir_all`,
256    /// and 5) emits an aggregate `StatusChanged` so dashboards refresh.
257    /// Test: `delete_palace_removes_dir_when_empty`,
258    /// `delete_palace_refuses_when_drawers_present`,
259    /// `delete_palace_force_removes_populated_palace`,
260    /// `delete_palace_returns_not_found_for_missing_id` in `web::tests`.
261    pub async fn delete_palace(&self, palace_id: &str, force: bool) -> ServiceResult<()> {
262        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
263            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
264        if !palaces.iter().any(|p| p.id.0 == palace_id) {
265            return Err(ServiceError::not_found(format!(
266                "palace not found: {palace_id}"
267            )));
268        }
269        if !force {
270            // Open the palace just long enough to count its drawers; we don't
271            // hold the handle past this check because the caller is about to
272            // delete the on-disk directory.
273            if let Ok(handle) = self
274                .state
275                .registry
276                .open_palace(&self.state.data_root, &PalaceId::new(palace_id))
277            {
278                if !handle.drawers.read().is_empty() {
279                    return Err(ServiceError::conflict(
280                        "Palace has drawers; pass force=true to delete",
281                    ));
282                }
283            }
284        }
285        // Drop the cached `Arc<PalaceHandle>` and gap cache before unlinking
286        // the directory so subsequent reads can't be served from the stale
287        // in-memory state. The registry's `remove` is a no-op when the entry
288        // is absent (lazy-open palaces that no caller has touched yet).
289        self.state.registry.remove(&PalaceId::new(palace_id));
290        // Issue #228: drop the palace-name cache entry so future writes never
291        // resolve to a stale label.
292        self.state.palace_names.remove(palace_id);
293        let palace_dir = self.state.data_root.join(palace_id);
294        tokio::fs::remove_dir_all(&palace_dir).await.map_err(|e| {
295            ServiceError::internal(format!("remove palace dir {}: {e}", palace_dir.display()))
296        })?;
297        // Recompute aggregate totals so dashboards drop the deleted palace's
298        // counts. There's no dedicated `PalaceDeleted` event variant yet;
299        // `StatusChanged` is enough to keep the UI in sync.
300        self.state.emit(self.aggregate_status_event());
301        Ok(())
302    }
303
304    /// Rename a palace's display name without touching its data.
305    ///
306    /// Why: Operators need to fix typos and rebrand palaces without dropping
307    /// the underlying drawers / vectors / KG. The palace id (the directory
308    /// name on disk) is immutable — only the human-readable `name` field in
309    /// `palace.json` changes — so cached `PalaceHandle`s stay valid and no
310    /// registry invalidation is required.
311    /// What: 1) loads the palace via `PalaceStore::load_palace` (404 when the
312    /// directory or `palace.json` is missing), 2) trims the new name and
313    /// returns `BadRequest` when empty, 3) mutates `palace.name` and writes
314    /// the metadata back through the atomic `PalaceStore::save_palace`
315    /// (tmp file + rename), 4) emits an aggregate `StatusChanged` so
316    /// dashboards re-render the relabelled palace, 5) returns the updated
317    /// palace as JSON (enriched with the live handle stats, so callers see
318    /// drawer/vector/KG counts in the same shape as `GET /palaces/{id}`).
319    /// Test: `update_palace_name_renames_palace`,
320    /// `update_palace_name_rejects_empty_name`,
321    /// `update_palace_name_returns_not_found_for_missing_id` in `web::tests`.
322    pub async fn update_palace_name(&self, palace_id: &str, name: &str) -> Result<Value> {
323        let trimmed = name.trim();
324        if trimmed.is_empty() {
325            return Err(anyhow!("name must be non-empty after trimming"));
326        }
327        let palace_dir = self.state.data_root.join(palace_id);
328        let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
329            .map_err(|e| anyhow!("palace not found: {palace_id} ({e})"))?;
330        palace.name = trimmed.to_string();
331        trusty_common::memory_core::store::PalaceStore::save_palace(&palace)
332            .with_context(|| format!("save palace metadata for {palace_id}"))?;
333        // Issue #228: refresh the in-memory name cache so subsequent writes
334        // surface the new label without a disk walk.
335        self.state
336            .palace_names
337            .insert(palace_id.to_string(), trimmed.to_string());
338        let handle = self
339            .state
340            .registry
341            .open_palace(&self.state.data_root, &palace.id)
342            .ok();
343        let info = palace_info_from(&palace, handle.as_ref());
344        self.state.emit(self.aggregate_status_event());
345        serde_json::to_value(info).context("serialize palace info")
346    }
347
348    /// Typed variant of [`Self::update_palace_name`] used by the HTTP handler.
349    ///
350    /// Why: HTTP needs to distinguish 400 (empty name) from 404 (missing
351    /// palace) so the right status code is emitted; the chat / MCP tool
352    /// only cares about a `Result<Value>` because both errors are surfaced
353    /// as opaque MCP error strings. Keeping a typed variant alongside the
354    /// untyped one keeps the wire shape correct on both surfaces without
355    /// asking either caller to parse error strings.
356    /// What: same as [`Self::update_palace_name`] but returns
357    /// `ServiceError::BadRequest` for empty names and
358    /// `ServiceError::NotFound` for missing palace metadata.
359    /// Test: `update_palace_name_renames_palace`,
360    /// `update_palace_name_rejects_empty_name`,
361    /// `update_palace_name_returns_not_found_for_missing_id`.
362    pub async fn update_palace_name_typed(
363        &self,
364        palace_id: &str,
365        name: &str,
366    ) -> ServiceResult<Value> {
367        let trimmed = name.trim();
368        if trimmed.is_empty() {
369            return Err(ServiceError::bad_request(
370                "name must be non-empty after trimming",
371            ));
372        }
373        let palace_dir = self.state.data_root.join(palace_id);
374        let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
375            .map_err(|e| {
376            ServiceError::not_found(format!("palace not found: {palace_id} ({e})"))
377        })?;
378        palace.name = trimmed.to_string();
379        trusty_common::memory_core::store::PalaceStore::save_palace(&palace).map_err(|e| {
380            ServiceError::internal(format!("save palace metadata for {palace_id}: {e}"))
381        })?;
382        // Issue #228: refresh the in-memory name cache so subsequent writes
383        // surface the new label without a disk walk.
384        self.state
385            .palace_names
386            .insert(palace_id.to_string(), trimmed.to_string());
387        let handle = self
388            .state
389            .registry
390            .open_palace(&self.state.data_root, &palace.id)
391            .ok();
392        let info = palace_info_from(&palace, handle.as_ref());
393        self.state.emit(self.aggregate_status_event());
394        serde_json::to_value(info)
395            .map_err(|e| ServiceError::internal(format!("serialize palace info: {e}")))
396    }
397
398    /// Look up a single palace by id and enrich with live handle stats.
399    ///
400    /// Why: distinct 404 vs. 500 path is needed by both HTTP and chat callers.
401    /// What: returns `NotFound` when the id is unknown, otherwise a fully
402    /// populated `PalaceInfo`.
403    /// Test: indirectly via `health_endpoint_round_trip_with_palace_is_ok`.
404    pub async fn get_palace(&self, id: &str) -> ServiceResult<PalaceInfo> {
405        let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
406            .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
407        let palace = palaces
408            .into_iter()
409            .find(|p| p.id.0 == id)
410            .ok_or_else(|| ServiceError::not_found(format!("palace not found: {id}")))?;
411        let handle = self
412            .state
413            .registry
414            .open_palace(&self.state.data_root, &palace.id)
415            .ok();
416        Ok(palace_info_from(&palace, handle.as_ref()))
417    }
418
419    // -----------------------------------------------------------------
420    // Drawers
421    // -----------------------------------------------------------------
422
423    /// List drawers in a palace with optional room/tag filters and pagination.
424    ///
425    /// Why: deduplicates the open-handle + listing path between HTTP and chat,
426    /// and (issue #184) lets the TUI activity panel page through drawers in
427    /// creation-date order without breaking the importance-sorted default the
428    /// legacy callers rely on.
429    /// What: opens the palace handle, fetches a window of drawers, optionally
430    /// re-sorts by `created_at` descending when `sort = "created_desc"`
431    /// (leaving the importance-desc default untouched), then drops the
432    /// leading `offset` rows and keeps `limit`. For `created_desc` the
433    /// window must cover the full filtered set (otherwise the importance
434    /// pre-sort hides truly-recent low-importance drawers), so the window
435    /// is widened to a sane ceiling (`MAX_DRAWER_WINDOW`); the default
436    /// importance path keeps a tight `limit+offset` window.
437    /// Returns the serialised JSON array.
438    /// Test: `service::tests::list_drawers_creates_desc_paginates`.
439    pub async fn list_drawers(&self, id: &str, q: ListDrawersQuery) -> ServiceResult<Value> {
440        const MAX_DRAWER_WINDOW: usize = 10_000;
441        let handle = self.open_handle(id)?;
442        let room = q.room.as_deref().map(RoomType::parse);
443        let limit = q.limit.unwrap_or(50);
444        let offset = q.offset.unwrap_or(0);
445        let by_created = matches!(q.sort.as_deref(), Some("created_desc"));
446        // For created_desc the importance pre-sort would hide low-importance
447        // drawers that happen to be the most recent, so we need to fetch the
448        // full filtered set (capped at MAX_DRAWER_WINDOW). For importance
449        // ordering the legacy `limit + offset` window is sufficient.
450        let window = if by_created {
451            MAX_DRAWER_WINDOW
452        } else {
453            limit.saturating_add(offset).min(MAX_DRAWER_WINDOW)
454        };
455        let mut drawers = handle.list_drawers(room, q.tag.clone(), window);
456        if by_created {
457            drawers.sort_by_key(|d| std::cmp::Reverse(d.created_at));
458        }
459        let page: Vec<_> = drawers.into_iter().skip(offset).take(limit).collect();
460        // Issue #202: enrich every row with a short `snippet` derived from
461        // the drawer's content so the TUI activity panel can render a
462        // glanceable summary without re-parsing the full body. The
463        // snippet is whitespace-collapsed and bounded at
464        // `DRAWER_SNIPPET_MAX_CHARS` (60) — shorter than the SSE preview
465        // because the activity panel renders it on a single narrow row.
466        let payload: Vec<Value> = page
467            .into_iter()
468            .map(|drawer| {
469                let snippet = drawer_snippet(&drawer.content);
470                let mut value = serde_json::to_value(&drawer).unwrap_or_else(|_| json!({}));
471                if let Value::Object(ref mut map) = value {
472                    // `null` when the drawer has no usable content so
473                    // clients can distinguish "no body" from "empty body
474                    // after whitespace collapse".
475                    let snippet_value = if snippet.is_empty() {
476                        Value::Null
477                    } else {
478                        Value::String(snippet)
479                    };
480                    map.insert("snippet".to_string(), snippet_value);
481                }
482                value
483            })
484            .collect();
485        Ok(Value::Array(payload))
486    }
487
488    /// Store a new drawer and emit the matching activity events.
489    ///
490    /// Why: HTTP and chat both need the auto-KG-extraction follow-up; this
491    /// method keeps that side-effect chain in one place.
492    /// What: opens the palace, stores the drawer via `PalaceHandle::remember`,
493    /// emits `DrawerAdded` + `StatusChanged`, then triggers
494    /// `tools::auto_extract_and_assert`. Returns the new drawer id.
495    /// Test: `http_create_drawer_runs_auto_kg_extraction`.
496    pub async fn create_drawer(
497        &self,
498        id: &str,
499        body: CreateDrawerBody,
500        creator: CreatorInfo,
501        source: ActivitySource,
502    ) -> ServiceResult<Uuid> {
503        let handle = self.open_handle(id)?;
504        let room = body
505            .room
506            .as_deref()
507            .map(RoomType::parse)
508            .unwrap_or(RoomType::General);
509        let importance = body.importance.unwrap_or(0.5);
510        let content_preview = drawer_content_preview(&body.content);
511        let mut tags_with_creator = body.tags;
512        // Issue #202: project a bare-UUID session tag (when the caller
513        // passed one in the request body) into the reserved
514        // `creator:session=<first-8>` slot so the activity panel can
515        // surface session attribution without bespoke parsing.
516        if let Some(session_tag) = crate::attribution::session_tag_from_tags(&tags_with_creator) {
517            tags_with_creator.push(session_tag);
518        }
519        creator.merge_into(&mut tags_with_creator);
520        let content_for_kg = body.content.clone();
521        let tags_for_kg = tags_with_creator.clone();
522        let room_label_for_kg = crate::tools::room_label(&room);
523        let drawer_id = handle
524            .remember(body.content, room, tags_with_creator, importance)
525            .await
526            .map_err(|e| ServiceError::internal(format!("remember: {e:#}")))?;
527        let drawer_count = handle.drawers.read().len();
528        // Issue #228: resolve from the in-memory cache instead of re-walking
529        // the data root on every HTTP `create_drawer` call. Same cache the
530        // MCP `lookup_palace_name` helper consults.
531        let palace_name = self
532            .state
533            .palace_names
534            .get(id)
535            .map(|entry| entry.value().clone())
536            .unwrap_or_else(|| id.to_string());
537        self.state.emit(DaemonEvent::DrawerAdded {
538            palace_id: id.to_string(),
539            palace_name,
540            drawer_count,
541            timestamp: chrono::Utc::now(),
542            content_preview,
543            source,
544        });
545        // Issue #228: do NOT emit `StatusChanged` on every drawer create —
546        // the periodic ticker (`run_http_on`) refreshes aggregate totals on
547        // a fixed cadence so dashboards stay current without an O(N palaces)
548        // recompute on the write hot path.
549        crate::tools::auto_extract_and_assert(
550            &handle,
551            drawer_id,
552            &content_for_kg,
553            &tags_for_kg,
554            room_label_for_kg.as_deref(),
555        )
556        .await;
557        Ok(drawer_id)
558    }
559
560    /// Forget (delete) a drawer and emit the matching events.
561    ///
562    /// Why: same dedup story as `create_drawer`.
563    /// What: parses the drawer UUID, calls `PalaceHandle::forget`, emits
564    /// `DrawerDeleted` + `StatusChanged`.
565    /// Test: indirectly via the drawer-related HTTP tests.
566    pub async fn delete_drawer(
567        &self,
568        id: &str,
569        drawer_id: &str,
570        source: ActivitySource,
571    ) -> ServiceResult<()> {
572        let handle = self.open_handle(id)?;
573        let uuid = Uuid::parse_str(drawer_id)
574            .map_err(|_| ServiceError::bad_request("drawer_id must be a UUID"))?;
575        handle
576            .forget(uuid)
577            .await
578            .map_err(|e| ServiceError::internal(format!("forget: {e:#}")))?;
579        let drawer_count = handle.drawers.read().len();
580        self.state.emit(DaemonEvent::DrawerDeleted {
581            palace_id: id.to_string(),
582            drawer_count,
583            source,
584        });
585        // Issue #228: skip the per-write `StatusChanged` emit — the
586        // periodic ticker handles aggregate roll-ups.
587        Ok(())
588    }
589
590    // -----------------------------------------------------------------
591    // Recall
592    // -----------------------------------------------------------------
593
594    /// Per-palace recall (semantic search), optionally with deep retrieval.
595    ///
596    /// Why: HTTP and chat tools both perform the same fan-out logic.
597    /// What: opens the palace handle and dispatches to the shallow or deep
598    /// recall helper. Returns a JSON array of flattened drawer rows (the
599    /// `recall_entry_json` shape from issue #69).
600    /// Test: `recall_entry_json_hoists_drawer_fields`.
601    pub async fn recall(
602        &self,
603        id: &str,
604        query: &str,
605        top_k: usize,
606        deep: bool,
607    ) -> ServiceResult<Value> {
608        let handle = self.open_handle(id)?;
609        let results = if deep {
610            recall_deep_with_default_embedder(&handle, query, top_k).await
611        } else {
612            recall_with_default_embedder(&handle, query, top_k).await
613        }
614        .map_err(|e| ServiceError::internal(format!("recall: {e:#}")))?;
615        let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
616        Ok(json!(payload))
617    }
618
619    /// Cross-palace recall.
620    ///
621    /// Why: shared between `/api/v1/recall` and the `memory_recall_all` chat
622    /// tool. Encapsulating the open-everything-fanout-merge dance avoids
623    /// drift.
624    /// What: lists every palace, opens handles (skipping failures with a
625    /// `tracing::warn!`), delegates to
626    /// `recall_across_palaces_with_default_embedder`. Returns a JSON array.
627    /// Test: indirectly via `recall_across_palaces_merges_results` and the
628    /// MCP `memory_recall_all` integration paths.
629    pub async fn recall_all(&self, query: &str, top_k: usize, deep: bool) -> Value {
630        let palaces = match PalaceRegistry::list_palaces(&self.state.data_root) {
631            Ok(v) => v,
632            Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
633        };
634        let mut handles = Vec::with_capacity(palaces.len());
635        for p in &palaces {
636            match self
637                .state
638                .registry
639                .open_palace(&self.state.data_root, &p.id)
640            {
641                Ok(h) => handles.push(h),
642                Err(e) => {
643                    tracing::warn!(palace = %p.id, "recall_all: open failed: {e:#}");
644                }
645            }
646        }
647        if handles.is_empty() {
648            return json!([]);
649        }
650        match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
651            Ok(results) => json!(results
652                .into_iter()
653                .map(|r| json!({
654                    "palace_id": r.palace_id,
655                    "drawer_id": r.result.drawer.id.to_string(),
656                    "content": r.result.drawer.content,
657                    "importance": r.result.drawer.importance,
658                    "tags": r.result.drawer.tags,
659                    "score": r.result.score,
660                    "layer": r.result.layer,
661                }))
662                .collect::<Vec<_>>()),
663            Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
664        }
665    }
666}