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