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