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 let skip_enforcement =
197 std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
198 if !skip_enforcement {
199 let cwd = body
200 .cwd
201 .as_deref()
202 .map(std::path::Path::new)
203 .map(|p| p.to_path_buf())
204 .or_else(|| std::env::current_dir().ok())
205 .unwrap_or_else(|| self.state.data_root.clone());
206 crate::project_root::validate_palace_name(&name, &cwd)
207 .map_err(|e| ServiceError::bad_request(e.to_string()))?;
208 }
209 let id = PalaceId::new(&name);
210 let palace = Palace {
211 id: id.clone(),
212 name: name.clone(),
213 description: body.description.filter(|s| !s.is_empty()),
214 created_at: chrono::Utc::now(),
215 data_dir: self.state.data_root.join(&name),
216 };
217 self.state
218 .registry
219 .create_palace(&self.state.data_root, palace)
220 .map_err(|e| ServiceError::internal(format!("create palace: {e:#}")))?;
221 // Issue #228: keep the in-memory palace-name cache in sync so writes
222 // to this palace can resolve `Palace.name` without a disk walk.
223 self.state.palace_names.insert(name.clone(), name.clone());
224 self.state.emit(DaemonEvent::PalaceCreated {
225 id: name.clone(),
226 name: name.clone(),
227 source,
228 });
229 Ok(name)
230 }
231
232 /// Delete a palace from disk, optionally rejecting non-empty palaces.
233 ///
234 /// Why: Issue #180 — operators need a way to drop an entire palace
235 /// without going through drawer-by-drawer deletion. Defaulting to a
236 /// "must be empty" guard prevents fat-finger destruction of populated
237 /// palaces; `force=true` is the explicit opt-in to the destructive path.
238 /// What: 1) confirms the palace exists on disk (else `NotFound`),
239 /// 2) when `!force`, lists drawers via the live handle and returns
240 /// `BadRequest("Palace has drawers; pass force=true to delete")` if
241 /// the palace is non-empty, 3) drops the in-memory registry entry so
242 /// future opens hit the (now-missing) disk state, 4) removes
243 /// `<data_root>/<palace_id>/` recursively via `tokio::fs::remove_dir_all`,
244 /// and 5) emits an aggregate `StatusChanged` so dashboards refresh.
245 /// Test: `delete_palace_removes_dir_when_empty`,
246 /// `delete_palace_refuses_when_drawers_present`,
247 /// `delete_palace_force_removes_populated_palace`,
248 /// `delete_palace_returns_not_found_for_missing_id` in `web::tests`.
249 pub async fn delete_palace(&self, palace_id: &str, force: bool) -> ServiceResult<()> {
250 let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
251 .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
252 if !palaces.iter().any(|p| p.id.0 == palace_id) {
253 return Err(ServiceError::not_found(format!(
254 "palace not found: {palace_id}"
255 )));
256 }
257 if !force {
258 // Open the palace just long enough to count its drawers; we don't
259 // hold the handle past this check because the caller is about to
260 // delete the on-disk directory.
261 if let Ok(handle) = self
262 .state
263 .registry
264 .open_palace(&self.state.data_root, &PalaceId::new(palace_id))
265 {
266 if !handle.drawers.read().is_empty() {
267 return Err(ServiceError::conflict(
268 "Palace has drawers; pass force=true to delete",
269 ));
270 }
271 }
272 }
273 // Drop the cached `Arc<PalaceHandle>` and gap cache before unlinking
274 // the directory so subsequent reads can't be served from the stale
275 // in-memory state. The registry's `remove` is a no-op when the entry
276 // is absent (lazy-open palaces that no caller has touched yet).
277 self.state.registry.remove(&PalaceId::new(palace_id));
278 // Issue #228: drop the palace-name cache entry so future writes never
279 // resolve to a stale label.
280 self.state.palace_names.remove(palace_id);
281 let palace_dir = self.state.data_root.join(palace_id);
282 tokio::fs::remove_dir_all(&palace_dir).await.map_err(|e| {
283 ServiceError::internal(format!("remove palace dir {}: {e}", palace_dir.display()))
284 })?;
285 // Recompute aggregate totals so dashboards drop the deleted palace's
286 // counts. There's no dedicated `PalaceDeleted` event variant yet;
287 // `StatusChanged` is enough to keep the UI in sync.
288 self.state.emit(self.aggregate_status_event());
289 Ok(())
290 }
291
292 /// Rename a palace's display name without touching its data.
293 ///
294 /// Why: Operators need to fix typos and rebrand palaces without dropping
295 /// the underlying drawers / vectors / KG. The palace id (the directory
296 /// name on disk) is immutable — only the human-readable `name` field in
297 /// `palace.json` changes — so cached `PalaceHandle`s stay valid and no
298 /// registry invalidation is required.
299 /// What: 1) loads the palace via `PalaceStore::load_palace` (404 when the
300 /// directory or `palace.json` is missing), 2) trims the new name and
301 /// returns `BadRequest` when empty, 3) mutates `palace.name` and writes
302 /// the metadata back through the atomic `PalaceStore::save_palace`
303 /// (tmp file + rename), 4) emits an aggregate `StatusChanged` so
304 /// dashboards re-render the relabelled palace, 5) returns the updated
305 /// palace as JSON (enriched with the live handle stats, so callers see
306 /// drawer/vector/KG counts in the same shape as `GET /palaces/{id}`).
307 /// Test: `update_palace_name_renames_palace`,
308 /// `update_palace_name_rejects_empty_name`,
309 /// `update_palace_name_returns_not_found_for_missing_id` in `web::tests`.
310 pub async fn update_palace_name(&self, palace_id: &str, name: &str) -> Result<Value> {
311 let trimmed = name.trim();
312 if trimmed.is_empty() {
313 return Err(anyhow!("name must be non-empty after trimming"));
314 }
315 let palace_dir = self.state.data_root.join(palace_id);
316 let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
317 .map_err(|e| anyhow!("palace not found: {palace_id} ({e})"))?;
318 palace.name = trimmed.to_string();
319 trusty_common::memory_core::store::PalaceStore::save_palace(&palace)
320 .with_context(|| format!("save palace metadata for {palace_id}"))?;
321 // Issue #228: refresh the in-memory name cache so subsequent writes
322 // surface the new label without a disk walk.
323 self.state
324 .palace_names
325 .insert(palace_id.to_string(), trimmed.to_string());
326 let handle = self
327 .state
328 .registry
329 .open_palace(&self.state.data_root, &palace.id)
330 .ok();
331 let info = palace_info_from(&palace, handle.as_ref());
332 self.state.emit(self.aggregate_status_event());
333 serde_json::to_value(info).context("serialize palace info")
334 }
335
336 /// Typed variant of [`Self::update_palace_name`] used by the HTTP handler.
337 ///
338 /// Why: HTTP needs to distinguish 400 (empty name) from 404 (missing
339 /// palace) so the right status code is emitted; the chat / MCP tool
340 /// only cares about a `Result<Value>` because both errors are surfaced
341 /// as opaque MCP error strings. Keeping a typed variant alongside the
342 /// untyped one keeps the wire shape correct on both surfaces without
343 /// asking either caller to parse error strings.
344 /// What: same as [`Self::update_palace_name`] but returns
345 /// `ServiceError::BadRequest` for empty names and
346 /// `ServiceError::NotFound` for missing palace metadata.
347 /// Test: `update_palace_name_renames_palace`,
348 /// `update_palace_name_rejects_empty_name`,
349 /// `update_palace_name_returns_not_found_for_missing_id`.
350 pub async fn update_palace_name_typed(
351 &self,
352 palace_id: &str,
353 name: &str,
354 ) -> ServiceResult<Value> {
355 let trimmed = name.trim();
356 if trimmed.is_empty() {
357 return Err(ServiceError::bad_request(
358 "name must be non-empty after trimming",
359 ));
360 }
361 let palace_dir = self.state.data_root.join(palace_id);
362 let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
363 .map_err(|e| {
364 ServiceError::not_found(format!("palace not found: {palace_id} ({e})"))
365 })?;
366 palace.name = trimmed.to_string();
367 trusty_common::memory_core::store::PalaceStore::save_palace(&palace).map_err(|e| {
368 ServiceError::internal(format!("save palace metadata for {palace_id}: {e}"))
369 })?;
370 // Issue #228: refresh the in-memory name cache so subsequent writes
371 // surface the new label without a disk walk.
372 self.state
373 .palace_names
374 .insert(palace_id.to_string(), trimmed.to_string());
375 let handle = self
376 .state
377 .registry
378 .open_palace(&self.state.data_root, &palace.id)
379 .ok();
380 let info = palace_info_from(&palace, handle.as_ref());
381 self.state.emit(self.aggregate_status_event());
382 serde_json::to_value(info)
383 .map_err(|e| ServiceError::internal(format!("serialize palace info: {e}")))
384 }
385
386 /// Look up a single palace by id and enrich with live handle stats.
387 ///
388 /// Why: distinct 404 vs. 500 path is needed by both HTTP and chat callers.
389 /// What: returns `NotFound` when the id is unknown, otherwise a fully
390 /// populated `PalaceInfo`.
391 /// Test: indirectly via `health_endpoint_round_trip_with_palace_is_ok`.
392 pub async fn get_palace(&self, id: &str) -> ServiceResult<PalaceInfo> {
393 let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
394 .map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
395 let palace = palaces
396 .into_iter()
397 .find(|p| p.id.0 == id)
398 .ok_or_else(|| ServiceError::not_found(format!("palace not found: {id}")))?;
399 let handle = self
400 .state
401 .registry
402 .open_palace(&self.state.data_root, &palace.id)
403 .ok();
404 Ok(palace_info_from(&palace, handle.as_ref()))
405 }
406
407 // -----------------------------------------------------------------
408 // Drawers
409 // -----------------------------------------------------------------
410
411 /// List drawers in a palace with optional room/tag filters and pagination.
412 ///
413 /// Why: deduplicates the open-handle + listing path between HTTP and chat,
414 /// and (issue #184) lets the TUI activity panel page through drawers in
415 /// creation-date order without breaking the importance-sorted default the
416 /// legacy callers rely on.
417 /// What: opens the palace handle, fetches a window of drawers, optionally
418 /// re-sorts by `created_at` descending when `sort = "created_desc"`
419 /// (leaving the importance-desc default untouched), then drops the
420 /// leading `offset` rows and keeps `limit`. For `created_desc` the
421 /// window must cover the full filtered set (otherwise the importance
422 /// pre-sort hides truly-recent low-importance drawers), so the window
423 /// is widened to a sane ceiling (`MAX_DRAWER_WINDOW`); the default
424 /// importance path keeps a tight `limit+offset` window.
425 /// Returns the serialised JSON array.
426 /// Test: `service::tests::list_drawers_creates_desc_paginates`.
427 pub async fn list_drawers(&self, id: &str, q: ListDrawersQuery) -> ServiceResult<Value> {
428 const MAX_DRAWER_WINDOW: usize = 10_000;
429 let handle = self.open_handle(id)?;
430 let room = q.room.as_deref().map(RoomType::parse);
431 let limit = q.limit.unwrap_or(50);
432 let offset = q.offset.unwrap_or(0);
433 let by_created = matches!(q.sort.as_deref(), Some("created_desc"));
434 // For created_desc the importance pre-sort would hide low-importance
435 // drawers that happen to be the most recent, so we need to fetch the
436 // full filtered set (capped at MAX_DRAWER_WINDOW). For importance
437 // ordering the legacy `limit + offset` window is sufficient.
438 let window = if by_created {
439 MAX_DRAWER_WINDOW
440 } else {
441 limit.saturating_add(offset).min(MAX_DRAWER_WINDOW)
442 };
443 let mut drawers = handle.list_drawers(room, q.tag.clone(), window);
444 if by_created {
445 drawers.sort_by_key(|d| std::cmp::Reverse(d.created_at));
446 }
447 let page: Vec<_> = drawers.into_iter().skip(offset).take(limit).collect();
448 // Issue #202: enrich every row with a short `snippet` derived from
449 // the drawer's content so the TUI activity panel can render a
450 // glanceable summary without re-parsing the full body. The
451 // snippet is whitespace-collapsed and bounded at
452 // `DRAWER_SNIPPET_MAX_CHARS` (60) — shorter than the SSE preview
453 // because the activity panel renders it on a single narrow row.
454 let payload: Vec<Value> = page
455 .into_iter()
456 .map(|drawer| {
457 let snippet = drawer_snippet(&drawer.content);
458 let mut value = serde_json::to_value(&drawer).unwrap_or_else(|_| json!({}));
459 if let Value::Object(ref mut map) = value {
460 // `null` when the drawer has no usable content so
461 // clients can distinguish "no body" from "empty body
462 // after whitespace collapse".
463 let snippet_value = if snippet.is_empty() {
464 Value::Null
465 } else {
466 Value::String(snippet)
467 };
468 map.insert("snippet".to_string(), snippet_value);
469 }
470 value
471 })
472 .collect();
473 Ok(Value::Array(payload))
474 }
475
476 /// Store a new drawer and emit the matching activity events.
477 ///
478 /// Why: HTTP and chat both need the auto-KG-extraction follow-up; this
479 /// method keeps that side-effect chain in one place.
480 /// What: opens the palace, stores the drawer via `PalaceHandle::remember`,
481 /// emits `DrawerAdded` + `StatusChanged`, then triggers
482 /// `tools::auto_extract_and_assert`. Returns the new drawer id.
483 /// Test: `http_create_drawer_runs_auto_kg_extraction`.
484 pub async fn create_drawer(
485 &self,
486 id: &str,
487 body: CreateDrawerBody,
488 creator: CreatorInfo,
489 source: ActivitySource,
490 ) -> ServiceResult<Uuid> {
491 let handle = self.open_handle(id)?;
492 let room = body
493 .room
494 .as_deref()
495 .map(RoomType::parse)
496 .unwrap_or(RoomType::General);
497 let importance = body.importance.unwrap_or(0.5);
498 let content_preview = drawer_content_preview(&body.content);
499 let mut tags_with_creator = body.tags;
500 // Issue #202: project a bare-UUID session tag (when the caller
501 // passed one in the request body) into the reserved
502 // `creator:session=<first-8>` slot so the activity panel can
503 // surface session attribution without bespoke parsing.
504 if let Some(session_tag) = crate::attribution::session_tag_from_tags(&tags_with_creator) {
505 tags_with_creator.push(session_tag);
506 }
507 creator.merge_into(&mut tags_with_creator);
508 let content_for_kg = body.content.clone();
509 let tags_for_kg = tags_with_creator.clone();
510 let room_label_for_kg = crate::tools::room_label(&room);
511 let drawer_id = handle
512 .remember(body.content, room, tags_with_creator, importance)
513 .await
514 .map_err(|e| ServiceError::internal(format!("remember: {e:#}")))?;
515 let drawer_count = handle.drawers.read().len();
516 // Issue #228: resolve from the in-memory cache instead of re-walking
517 // the data root on every HTTP `create_drawer` call. Same cache the
518 // MCP `lookup_palace_name` helper consults.
519 let palace_name = self
520 .state
521 .palace_names
522 .get(id)
523 .map(|entry| entry.value().clone())
524 .unwrap_or_else(|| id.to_string());
525 self.state.emit(DaemonEvent::DrawerAdded {
526 palace_id: id.to_string(),
527 palace_name,
528 drawer_count,
529 timestamp: chrono::Utc::now(),
530 content_preview,
531 source,
532 });
533 // Issue #228: do NOT emit `StatusChanged` on every drawer create —
534 // the periodic ticker (`run_http_on`) refreshes aggregate totals on
535 // a fixed cadence so dashboards stay current without an O(N palaces)
536 // recompute on the write hot path.
537 crate::tools::auto_extract_and_assert(
538 &handle,
539 drawer_id,
540 &content_for_kg,
541 &tags_for_kg,
542 room_label_for_kg.as_deref(),
543 )
544 .await;
545 Ok(drawer_id)
546 }
547
548 /// Forget (delete) a drawer and emit the matching events.
549 ///
550 /// Why: same dedup story as `create_drawer`.
551 /// What: parses the drawer UUID, calls `PalaceHandle::forget`, emits
552 /// `DrawerDeleted` + `StatusChanged`.
553 /// Test: indirectly via the drawer-related HTTP tests.
554 pub async fn delete_drawer(
555 &self,
556 id: &str,
557 drawer_id: &str,
558 source: ActivitySource,
559 ) -> ServiceResult<()> {
560 let handle = self.open_handle(id)?;
561 let uuid = Uuid::parse_str(drawer_id)
562 .map_err(|_| ServiceError::bad_request("drawer_id must be a UUID"))?;
563 handle
564 .forget(uuid)
565 .await
566 .map_err(|e| ServiceError::internal(format!("forget: {e:#}")))?;
567 let drawer_count = handle.drawers.read().len();
568 self.state.emit(DaemonEvent::DrawerDeleted {
569 palace_id: id.to_string(),
570 drawer_count,
571 source,
572 });
573 // Issue #228: skip the per-write `StatusChanged` emit — the
574 // periodic ticker handles aggregate roll-ups.
575 Ok(())
576 }
577
578 // -----------------------------------------------------------------
579 // Recall
580 // -----------------------------------------------------------------
581
582 /// Per-palace recall (semantic search), optionally with deep retrieval.
583 ///
584 /// Why: HTTP and chat tools both perform the same fan-out logic.
585 /// What: opens the palace handle and dispatches to the shallow or deep
586 /// recall helper. Returns a JSON array of flattened drawer rows (the
587 /// `recall_entry_json` shape from issue #69).
588 /// Test: `recall_entry_json_hoists_drawer_fields`.
589 pub async fn recall(
590 &self,
591 id: &str,
592 query: &str,
593 top_k: usize,
594 deep: bool,
595 ) -> ServiceResult<Value> {
596 let handle = self.open_handle(id)?;
597 let results = if deep {
598 recall_deep_with_default_embedder(&handle, query, top_k).await
599 } else {
600 recall_with_default_embedder(&handle, query, top_k).await
601 }
602 .map_err(|e| ServiceError::internal(format!("recall: {e:#}")))?;
603 let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
604 Ok(json!(payload))
605 }
606
607 /// Cross-palace recall.
608 ///
609 /// Why: shared between `/api/v1/recall` and the `memory_recall_all` chat
610 /// tool. Encapsulating the open-everything-fanout-merge dance avoids
611 /// drift.
612 /// What: lists every palace, opens handles (skipping failures with a
613 /// `tracing::warn!`), delegates to
614 /// `recall_across_palaces_with_default_embedder`. Returns a JSON array.
615 /// Test: indirectly via `recall_across_palaces_merges_results` and the
616 /// MCP `memory_recall_all` integration paths.
617 pub async fn recall_all(&self, query: &str, top_k: usize, deep: bool) -> Value {
618 let palaces = match PalaceRegistry::list_palaces(&self.state.data_root) {
619 Ok(v) => v,
620 Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
621 };
622 let mut handles = Vec::with_capacity(palaces.len());
623 for p in &palaces {
624 match self
625 .state
626 .registry
627 .open_palace(&self.state.data_root, &p.id)
628 {
629 Ok(h) => handles.push(h),
630 Err(e) => {
631 tracing::warn!(palace = %p.id, "recall_all: open failed: {e:#}");
632 }
633 }
634 }
635 if handles.is_empty() {
636 return json!([]);
637 }
638 match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
639 Ok(results) => json!(results
640 .into_iter()
641 .map(|r| json!({
642 "palace_id": r.palace_id,
643 "drawer_id": r.result.drawer.id.to_string(),
644 "content": r.result.drawer.content,
645 "importance": r.result.drawer.importance,
646 "tags": r.result.drawer.tags,
647 "score": r.result.score,
648 "layer": r.result.layer,
649 }))
650 .collect::<Vec<_>>()),
651 Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
652 }
653 }
654}