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