use crate::attribution::CreatorInfo;
use crate::{ActivityFilter, ActivitySource, AppState, DaemonEvent};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Arc;
use trusty_common::memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
use trusty_common::memory_core::retrieval::{
recall_across_palaces_with_default_embedder, recall_deep_with_default_embedder,
recall_with_default_embedder, RecallResult,
};
use trusty_common::memory_core::store::kg::Triple;
use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
use uuid::Uuid;
#[derive(Serialize, Clone, Debug)]
pub struct PalaceInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
pub drawer_count: usize,
pub vector_count: usize,
pub kg_triple_count: usize,
pub wing_count: usize,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_write_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub node_count: u64,
#[serde(default)]
pub edge_count: u64,
#[serde(default)]
pub community_count: u64,
#[serde(default)]
pub is_compacting: bool,
}
#[derive(Serialize, Default, Clone, Debug)]
pub struct DreamStatusPayload {
pub last_run_at: Option<chrono::DateTime<chrono::Utc>>,
pub merged: usize,
pub pruned: usize,
pub compacted: usize,
pub closets_updated: usize,
pub duration_ms: u64,
}
impl From<PersistedDreamStats> for DreamStatusPayload {
fn from(p: PersistedDreamStats) -> Self {
Self {
last_run_at: Some(p.last_run_at),
merged: p.stats.merged,
pruned: p.stats.pruned,
compacted: p.stats.compacted,
closets_updated: p.stats.closets_updated,
duration_ms: p.stats.duration_ms,
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct CreatePalaceBody {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct CreateDrawerBody {
pub content: String,
#[serde(default)]
pub room: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub importance: Option<f32>,
}
#[derive(Deserialize, Default, Clone, Debug)]
pub struct ListDrawersQuery {
#[serde(default)]
pub room: Option<String>,
#[serde(default)]
pub tag: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub offset: Option<usize>,
#[serde(default)]
pub sort: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct KgAssertBody {
pub subject: String,
pub predicate: String,
pub object: String,
#[serde(default)]
pub confidence: Option<f32>,
#[serde(default)]
pub provenance: Option<String>,
}
#[derive(Serialize, Clone, Debug)]
pub struct KgGraphPayload {
pub triples: Vec<Triple>,
pub node_count: u64,
pub edge_count: u64,
pub community_count: u64,
}
#[derive(Serialize, Clone, Debug)]
pub struct StatusPayload {
pub version: String,
pub palace_count: usize,
pub default_palace: Option<String>,
pub data_root: String,
pub total_drawers: usize,
pub total_vectors: usize,
pub total_kg_triples: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Conflict(String),
#[error("{0}")]
Internal(String),
}
impl ServiceError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::NotFound(msg.into())
}
pub fn conflict(msg: impl Into<String>) -> Self {
Self::Conflict(msg.into())
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}
pub type ServiceResult<T> = std::result::Result<T, ServiceError>;
const KG_GRAPH_MAX_TRIPLES: usize = 5_000;
#[derive(Clone)]
pub struct MemoryService {
state: AppState,
}
impl MemoryService {
pub fn new(state: AppState) -> Self {
Self { state }
}
pub fn state(&self) -> &AppState {
&self.state
}
pub async fn status(&self) -> StatusPayload {
let palaces = PalaceRegistry::list_palaces(&self.state.data_root).unwrap_or_default();
let palace_count = palaces.len();
let stats = collect_palace_stats(&self.state, palaces.iter().map(|p| &p.id));
StatusPayload {
version: self.state.version.clone(),
palace_count,
default_palace: self.state.default_palace.clone(),
data_root: self.state.data_root.display().to_string(),
total_drawers: stats.total_drawers,
total_vectors: stats.total_vectors,
total_kg_triples: stats.total_kg_triples,
}
}
pub fn aggregate_status_event(&self) -> DaemonEvent {
let ids: Vec<PalaceId> = self.state.registry.list();
let stats = collect_palace_stats(&self.state, ids.iter());
DaemonEvent::StatusChanged {
total_drawers: stats.total_drawers,
total_vectors: stats.total_vectors,
total_kg_triples: stats.total_kg_triples,
}
}
pub async fn list_palaces(&self) -> ServiceResult<Vec<PalaceInfo>> {
let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
.map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
let mut out = Vec::with_capacity(palaces.len());
for p in palaces {
if is_reserved_system_palace(&p.id) {
continue;
}
let handle = self
.state
.registry
.open_palace(&self.state.data_root, &p.id)
.ok();
out.push(palace_info_from(&p, handle.as_ref()));
}
Ok(out)
}
pub async fn create_palace(
&self,
body: CreatePalaceBody,
source: ActivitySource,
) -> ServiceResult<String> {
let name = body.name.trim().to_string();
if name.is_empty() {
return Err(ServiceError::bad_request("name is required"));
}
let skip_enforcement =
std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
if !skip_enforcement {
let cwd = std::env::current_dir().unwrap_or_else(|_| self.state.data_root.clone());
crate::project_root::validate_palace_name(&name, &cwd)
.map_err(|e| ServiceError::bad_request(e.to_string()))?;
}
let id = PalaceId::new(&name);
let palace = Palace {
id: id.clone(),
name: name.clone(),
description: body.description.filter(|s| !s.is_empty()),
created_at: chrono::Utc::now(),
data_dir: self.state.data_root.join(&name),
};
self.state
.registry
.create_palace(&self.state.data_root, palace)
.map_err(|e| ServiceError::internal(format!("create palace: {e:#}")))?;
self.state.palace_names.insert(name.clone(), name.clone());
self.state.emit(DaemonEvent::PalaceCreated {
id: name.clone(),
name: name.clone(),
source,
});
Ok(name)
}
pub async fn delete_palace(&self, palace_id: &str, force: bool) -> ServiceResult<()> {
let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
.map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
if !palaces.iter().any(|p| p.id.0 == palace_id) {
return Err(ServiceError::not_found(format!(
"palace not found: {palace_id}"
)));
}
if !force {
if let Ok(handle) = self
.state
.registry
.open_palace(&self.state.data_root, &PalaceId::new(palace_id))
{
if !handle.drawers.read().is_empty() {
return Err(ServiceError::conflict(
"Palace has drawers; pass force=true to delete",
));
}
}
}
self.state.registry.remove(&PalaceId::new(palace_id));
self.state.palace_names.remove(palace_id);
let palace_dir = self.state.data_root.join(palace_id);
tokio::fs::remove_dir_all(&palace_dir).await.map_err(|e| {
ServiceError::internal(format!("remove palace dir {}: {e}", palace_dir.display()))
})?;
self.state.emit(self.aggregate_status_event());
Ok(())
}
pub async fn update_palace_name(&self, palace_id: &str, name: &str) -> Result<Value> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(anyhow!("name must be non-empty after trimming"));
}
let palace_dir = self.state.data_root.join(palace_id);
let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
.map_err(|e| anyhow!("palace not found: {palace_id} ({e})"))?;
palace.name = trimmed.to_string();
trusty_common::memory_core::store::PalaceStore::save_palace(&palace)
.with_context(|| format!("save palace metadata for {palace_id}"))?;
self.state
.palace_names
.insert(palace_id.to_string(), trimmed.to_string());
let handle = self
.state
.registry
.open_palace(&self.state.data_root, &palace.id)
.ok();
let info = palace_info_from(&palace, handle.as_ref());
self.state.emit(self.aggregate_status_event());
serde_json::to_value(info).context("serialize palace info")
}
pub async fn update_palace_name_typed(
&self,
palace_id: &str,
name: &str,
) -> ServiceResult<Value> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(ServiceError::bad_request(
"name must be non-empty after trimming",
));
}
let palace_dir = self.state.data_root.join(palace_id);
let mut palace = trusty_common::memory_core::store::PalaceStore::load_palace(&palace_dir)
.map_err(|e| {
ServiceError::not_found(format!("palace not found: {palace_id} ({e})"))
})?;
palace.name = trimmed.to_string();
trusty_common::memory_core::store::PalaceStore::save_palace(&palace).map_err(|e| {
ServiceError::internal(format!("save palace metadata for {palace_id}: {e}"))
})?;
self.state
.palace_names
.insert(palace_id.to_string(), trimmed.to_string());
let handle = self
.state
.registry
.open_palace(&self.state.data_root, &palace.id)
.ok();
let info = palace_info_from(&palace, handle.as_ref());
self.state.emit(self.aggregate_status_event());
serde_json::to_value(info)
.map_err(|e| ServiceError::internal(format!("serialize palace info: {e}")))
}
pub async fn get_palace(&self, id: &str) -> ServiceResult<PalaceInfo> {
let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
.map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
let palace = palaces
.into_iter()
.find(|p| p.id.0 == id)
.ok_or_else(|| ServiceError::not_found(format!("palace not found: {id}")))?;
let handle = self
.state
.registry
.open_palace(&self.state.data_root, &palace.id)
.ok();
Ok(palace_info_from(&palace, handle.as_ref()))
}
pub async fn list_drawers(&self, id: &str, q: ListDrawersQuery) -> ServiceResult<Value> {
const MAX_DRAWER_WINDOW: usize = 10_000;
let handle = self.open_handle(id)?;
let room = q.room.as_deref().map(RoomType::parse);
let limit = q.limit.unwrap_or(50);
let offset = q.offset.unwrap_or(0);
let by_created = matches!(q.sort.as_deref(), Some("created_desc"));
let window = if by_created {
MAX_DRAWER_WINDOW
} else {
limit.saturating_add(offset).min(MAX_DRAWER_WINDOW)
};
let mut drawers = handle.list_drawers(room, q.tag.clone(), window);
if by_created {
drawers.sort_by_key(|d| std::cmp::Reverse(d.created_at));
}
let page: Vec<_> = drawers.into_iter().skip(offset).take(limit).collect();
let payload: Vec<Value> = page
.into_iter()
.map(|drawer| {
let snippet = drawer_snippet(&drawer.content);
let mut value = serde_json::to_value(&drawer).unwrap_or_else(|_| json!({}));
if let Value::Object(ref mut map) = value {
let snippet_value = if snippet.is_empty() {
Value::Null
} else {
Value::String(snippet)
};
map.insert("snippet".to_string(), snippet_value);
}
value
})
.collect();
Ok(Value::Array(payload))
}
pub async fn create_drawer(
&self,
id: &str,
body: CreateDrawerBody,
creator: CreatorInfo,
source: ActivitySource,
) -> ServiceResult<Uuid> {
let handle = self.open_handle(id)?;
let room = body
.room
.as_deref()
.map(RoomType::parse)
.unwrap_or(RoomType::General);
let importance = body.importance.unwrap_or(0.5);
let content_preview = drawer_content_preview(&body.content);
let mut tags_with_creator = body.tags;
if let Some(session_tag) = crate::attribution::session_tag_from_tags(&tags_with_creator) {
tags_with_creator.push(session_tag);
}
creator.merge_into(&mut tags_with_creator);
let content_for_kg = body.content.clone();
let tags_for_kg = tags_with_creator.clone();
let room_label_for_kg = crate::tools::room_label(&room);
let drawer_id = handle
.remember(body.content, room, tags_with_creator, importance)
.await
.map_err(|e| ServiceError::internal(format!("remember: {e:#}")))?;
let drawer_count = handle.drawers.read().len();
let palace_name = self
.state
.palace_names
.get(id)
.map(|entry| entry.value().clone())
.unwrap_or_else(|| id.to_string());
self.state.emit(DaemonEvent::DrawerAdded {
palace_id: id.to_string(),
palace_name,
drawer_count,
timestamp: chrono::Utc::now(),
content_preview,
source,
});
crate::tools::auto_extract_and_assert(
&handle,
drawer_id,
&content_for_kg,
&tags_for_kg,
room_label_for_kg.as_deref(),
)
.await;
Ok(drawer_id)
}
pub async fn delete_drawer(
&self,
id: &str,
drawer_id: &str,
source: ActivitySource,
) -> ServiceResult<()> {
let handle = self.open_handle(id)?;
let uuid = Uuid::parse_str(drawer_id)
.map_err(|_| ServiceError::bad_request("drawer_id must be a UUID"))?;
handle
.forget(uuid)
.await
.map_err(|e| ServiceError::internal(format!("forget: {e:#}")))?;
let drawer_count = handle.drawers.read().len();
self.state.emit(DaemonEvent::DrawerDeleted {
palace_id: id.to_string(),
drawer_count,
source,
});
Ok(())
}
pub async fn recall(
&self,
id: &str,
query: &str,
top_k: usize,
deep: bool,
) -> ServiceResult<Value> {
let handle = self.open_handle(id)?;
let results = if deep {
recall_deep_with_default_embedder(&handle, query, top_k).await
} else {
recall_with_default_embedder(&handle, query, top_k).await
}
.map_err(|e| ServiceError::internal(format!("recall: {e:#}")))?;
let payload: Vec<Value> = results.into_iter().map(recall_entry_json).collect();
Ok(json!(payload))
}
pub async fn recall_all(&self, query: &str, top_k: usize, deep: bool) -> Value {
let palaces = match PalaceRegistry::list_palaces(&self.state.data_root) {
Ok(v) => v,
Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
};
let mut handles = Vec::with_capacity(palaces.len());
for p in &palaces {
match self
.state
.registry
.open_palace(&self.state.data_root, &p.id)
{
Ok(h) => handles.push(h),
Err(e) => {
tracing::warn!(palace = %p.id, "recall_all: open failed: {e:#}");
}
}
}
if handles.is_empty() {
return json!([]);
}
match recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await {
Ok(results) => json!(results
.into_iter()
.map(|r| json!({
"palace_id": r.palace_id,
"drawer_id": r.result.drawer.id.to_string(),
"content": r.result.drawer.content,
"importance": r.result.drawer.importance,
"tags": r.result.drawer.tags,
"score": r.result.score,
"layer": r.result.layer,
}))
.collect::<Vec<_>>()),
Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
}
}
pub async fn kg_query(&self, id: &str, subject: &str) -> ServiceResult<Vec<Triple>> {
let handle = self.open_handle(id)?;
handle
.kg
.query_active(subject)
.await
.map_err(|e| ServiceError::internal(format!("kg query: {e:#}")))
}
pub async fn kg_assert(&self, id: &str, body: KgAssertBody) -> ServiceResult<()> {
let handle = self.open_handle(id)?;
let triple = Triple {
subject: body.subject,
predicate: body.predicate,
object: body.object,
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: body.confidence.unwrap_or(1.0),
provenance: body.provenance,
};
handle
.kg
.assert(triple)
.await
.map_err(|e| ServiceError::internal(format!("kg assert: {e:#}")))
}
pub async fn kg_retract_triple(
&self,
id: &str,
subject: &str,
predicate: &str,
) -> ServiceResult<bool> {
let handle = self.open_handle(id)?;
let closed = handle
.kg
.retract(subject, predicate)
.await
.map_err(|e| ServiceError::internal(format!("kg retract: {e:#}")))?;
Ok(closed > 0)
}
pub async fn kg_list_subjects(&self, id: &str, limit: usize) -> ServiceResult<Vec<String>> {
let handle = self.open_handle(id)?;
handle
.kg
.list_subjects(limit)
.map_err(|e| ServiceError::internal(format!("kg list_subjects: {e:#}")))
}
pub async fn kg_list_subjects_with_counts(
&self,
id: &str,
limit: usize,
) -> ServiceResult<Vec<(String, u64)>> {
let handle = self.open_handle(id)?;
handle
.kg
.list_subjects_with_counts(limit)
.map_err(|e| ServiceError::internal(format!("kg list_subjects_with_counts: {e:#}")))
}
pub async fn kg_list_all(
&self,
id: &str,
limit: usize,
offset: usize,
) -> ServiceResult<Vec<Triple>> {
let handle = self.open_handle(id)?;
handle
.kg
.list_active(limit, offset)
.await
.map_err(|e| ServiceError::internal(format!("kg list_active: {e:#}")))
}
pub async fn kg_count(&self, id: &str) -> ServiceResult<usize> {
let handle = self.open_handle(id)?;
Ok(handle.kg.count_active_triples())
}
pub async fn kg_graph(&self, id: &str) -> ServiceResult<KgGraphPayload> {
let handle = self.open_handle(id)?;
let triples = handle
.kg
.list_active(KG_GRAPH_MAX_TRIPLES, 0)
.await
.map_err(|e| ServiceError::internal(format!("kg list_active: {e:#}")))?;
Ok(KgGraphPayload {
triples,
node_count: handle.kg.node_count() as u64,
edge_count: handle.kg.edge_count() as u64,
community_count: handle.kg.community_count() as u64,
})
}
pub async fn dream_status_aggregate(&self) -> DreamStatusPayload {
let palaces = PalaceRegistry::list_palaces(&self.state.data_root).unwrap_or_default();
let mut out = DreamStatusPayload::default();
let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
for p in palaces {
let data_dir = self.state.data_root.join(p.id.as_str());
let snap = match PersistedDreamStats::load(&data_dir) {
Ok(Some(s)) => s,
_ => continue,
};
out.merged = out.merged.saturating_add(snap.stats.merged);
out.pruned = out.pruned.saturating_add(snap.stats.pruned);
out.compacted = out.compacted.saturating_add(snap.stats.compacted);
out.closets_updated = out
.closets_updated
.saturating_add(snap.stats.closets_updated);
out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
latest = match latest {
Some(t) if t >= snap.last_run_at => Some(t),
_ => Some(snap.last_run_at),
};
}
out.last_run_at = latest;
out
}
pub async fn dream_status_for_palace(&self, id: &str) -> ServiceResult<DreamStatusPayload> {
let data_dir = self.state.data_root.join(id);
if !data_dir.exists() {
return Err(ServiceError::not_found(format!("palace not found: {id}")));
}
match PersistedDreamStats::load(&data_dir) {
Ok(Some(s)) => Ok(s.into()),
Ok(None) => Ok(DreamStatusPayload::default()),
Err(e) => Err(ServiceError::internal(format!("read dream stats: {e:#}"))),
}
}
pub async fn dream_run(&self) -> ServiceResult<DreamStatusPayload> {
let palaces = PalaceRegistry::list_palaces(&self.state.data_root)
.map_err(|e| ServiceError::internal(format!("list palaces: {e:#}")))?;
let dreamer = Dreamer::new(DreamConfig::default());
let mut out = DreamStatusPayload::default();
for p in palaces {
let handle = match self
.state
.registry
.open_palace(&self.state.data_root, &p.id)
{
Ok(h) => h,
Err(e) => {
tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
continue;
}
};
match dreamer.dream_cycle(&handle).await {
Ok(stats) => {
out.merged = out.merged.saturating_add(stats.merged);
out.pruned = out.pruned.saturating_add(stats.pruned);
out.compacted = out.compacted.saturating_add(stats.compacted);
out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
}
Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
}
refresh_gaps_cache(&self.state, &handle).await;
}
out.last_run_at = Some(chrono::Utc::now());
self.state.emit(DaemonEvent::DreamCompleted {
palace_id: None,
merged: out.merged,
pruned: out.pruned,
compacted: out.compacted,
closets_updated: out.closets_updated,
duration_ms: out.duration_ms,
source: ActivitySource::Http,
});
self.state.emit(self.aggregate_status_event());
Ok(out)
}
pub async fn list_activity(
&self,
filter: ActivityFilter,
limit: usize,
offset: usize,
) -> ServiceResult<(Vec<crate::ActivityEntry>, u64)> {
let entries = self
.state
.activity_log
.list(&filter, limit, offset)
.map_err(|e| ServiceError::internal(format!("activity list: {e:#}")))?;
let total = self
.state
.activity_log
.count()
.map_err(|e| ServiceError::internal(format!("activity count: {e:#}")))?;
Ok((entries, total))
}
pub fn open_handle(&self, id: &str) -> ServiceResult<Arc<PalaceHandle>> {
self.state
.registry
.open_palace(&self.state.data_root, &PalaceId::new(id))
.map_err(|e| ServiceError::not_found(format!("palace not found: {id} ({e:#})")))
}
}
pub const DRAWER_PREVIEW_MAX_CHARS: usize = 80;
pub const DRAWER_SNIPPET_MAX_CHARS: usize = 60;
pub fn drawer_content_preview(content: &str) -> String {
let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
if normalised.chars().count() <= DRAWER_PREVIEW_MAX_CHARS {
normalised
} else {
let kept: String = normalised
.chars()
.take(DRAWER_PREVIEW_MAX_CHARS.saturating_sub(1))
.collect();
format!("{kept}…")
}
}
pub fn drawer_snippet(content: &str) -> String {
let normalised: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
if normalised.chars().count() <= DRAWER_SNIPPET_MAX_CHARS {
normalised
} else {
let kept: String = normalised
.chars()
.take(DRAWER_SNIPPET_MAX_CHARS.saturating_sub(1))
.collect();
format!("{kept}…")
}
}
pub fn recall_entry_json(r: RecallResult) -> Value {
let mut obj = match serde_json::to_value(&r.drawer) {
Ok(Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
obj.insert("score".to_string(), json!(r.score));
obj.insert("layer".to_string(), json!(r.layer));
Value::Object(obj)
}
pub(crate) fn is_reserved_system_palace(id: &PalaceId) -> bool {
id.as_str().starts_with("__")
}
pub(crate) struct PalaceStats {
pub total_drawers: usize,
pub total_vectors: usize,
pub total_kg_triples: usize,
}
pub(crate) fn collect_palace_stats<'a, I>(state: &AppState, ids: I) -> PalaceStats
where
I: IntoIterator<Item = &'a PalaceId>,
{
let (mut total_drawers, mut total_vectors, mut total_kg_triples): (usize, usize, usize) =
(0, 0, 0);
for id in ids {
if let Ok(handle) = state.registry.open_palace(&state.data_root, id) {
total_drawers = total_drawers.saturating_add(handle.drawers.read().len());
total_vectors = total_vectors.saturating_add(handle.vector_store.index_size());
total_kg_triples = total_kg_triples.saturating_add(handle.kg.count_active_triples());
}
}
PalaceStats {
total_drawers,
total_vectors,
total_kg_triples,
}
}
pub fn palace_info_from(palace: &Palace, handle: Option<&Arc<PalaceHandle>>) -> PalaceInfo {
let (
drawer_count,
vector_count,
kg_triple_count,
wing_count,
last_write_at,
node_count,
edge_count,
community_count,
is_compacting,
) = if let Some(h) = handle {
let drawers = h.drawers.read();
let distinct_rooms: HashSet<Uuid> = drawers.iter().map(|d| d.room_id).collect();
let last_write = drawers.iter().map(|d| d.created_at).max();
(
drawers.len(),
h.vector_store.index_size(),
h.kg.count_active_triples(),
distinct_rooms.len(),
last_write,
h.kg.node_count() as u64,
h.kg.edge_count() as u64,
h.kg.community_count() as u64,
h.is_compacting(),
)
} else {
(0, 0, 0, 0, None, 0, 0, 0, false)
};
PalaceInfo {
id: palace.id.0.clone(),
name: palace.name.clone(),
description: palace.description.clone(),
drawer_count,
vector_count,
kg_triple_count,
wing_count,
created_at: palace.created_at,
last_write_at,
node_count,
edge_count,
community_count,
is_compacting,
}
}
pub async fn refresh_gaps_cache(state: &AppState, handle: &Arc<PalaceHandle>) {
let mut gaps = handle.kg.knowledge_gaps();
if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") {
if !api_key.is_empty() {
for gap in gaps.iter_mut() {
if let Some(enriched) = enrich_gap_exploration(&api_key, gap).await {
gap.suggested_exploration = enriched;
}
}
}
}
let gap_count = gaps.len();
state.registry.set_gaps(handle.id.clone(), gaps);
tracing::debug!(palace = %handle.id, gaps = gap_count, "community gaps updated");
}
pub async fn enrich_gap_exploration(
api_key: &str,
gap: &trusty_common::memory_core::community::KnowledgeGap,
) -> Option<String> {
let preview: Vec<&str> = gap.entities.iter().take(5).map(String::as_str).collect();
if preview.is_empty() {
return None;
}
let entities = preview.join(", ");
let user = format!(
"Given these related entities from a knowledge graph: {entities}. \
Suggest one specific research question (single sentence, under 25 words) \
that would help fill gaps in this knowledge cluster. Return only the question."
);
let messages = vec![trusty_common::ChatMessage {
role: "user".to_string(),
content: user,
tool_call_id: None,
tool_calls: None,
}];
#[allow(deprecated)]
let res = trusty_common::openrouter_chat(api_key, "openai/gpt-4o-mini", messages).await;
match res {
Ok(text) => {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
Err(e) => {
tracing::debug!("openrouter gap enrichment failed (using template): {e:#}");
None
}
}
}
#[derive(Deserialize, Default, Clone)]
struct UserConfigMin {
#[serde(default)]
openrouter: OpenRouterMin,
#[serde(default)]
local_model: LocalModelMin,
}
#[derive(Deserialize, Default, Clone)]
struct OpenRouterMin {
#[serde(default)]
api_key: String,
#[serde(default)]
model: String,
}
#[derive(Deserialize, Clone)]
struct LocalModelMin {
#[serde(default = "default_local_enabled")]
enabled: bool,
#[serde(default = "default_local_base_url")]
base_url: String,
#[serde(default = "default_local_model")]
model: String,
}
fn default_local_enabled() -> bool {
true
}
fn default_local_base_url() -> String {
"http://localhost:11434".to_string()
}
fn default_local_model() -> String {
"llama3.2".to_string()
}
impl Default for LocalModelMin {
fn default() -> Self {
Self {
enabled: default_local_enabled(),
base_url: default_local_base_url(),
model: default_local_model(),
}
}
}
#[derive(Clone)]
pub struct LoadedUserConfig {
pub openrouter_api_key: String,
pub openrouter_model: String,
pub local_model: trusty_common::LocalModelConfig,
}
impl Default for LoadedUserConfig {
fn default() -> Self {
Self {
openrouter_api_key: String::new(),
openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
local_model: trusty_common::LocalModelConfig::default(),
}
}
}
pub fn load_user_config() -> Option<LoadedUserConfig> {
let home = dirs::home_dir()?;
let path = home.join(".trusty-memory").join("config.toml");
if !path.exists() {
return Some(LoadedUserConfig::default());
}
let raw = std::fs::read_to_string(&path).ok()?;
let parsed: UserConfigMin = toml::from_str(&raw).unwrap_or_default();
let model = if parsed.openrouter.model.is_empty() {
"anthropic/claude-3-5-sonnet".to_string()
} else {
parsed.openrouter.model
};
Some(LoadedUserConfig {
openrouter_api_key: parsed.openrouter.api_key,
openrouter_model: model,
local_model: trusty_common::LocalModelConfig {
enabled: parsed.local_model.enabled,
base_url: parsed.local_model.base_url,
model: parsed.local_model.model,
},
})
}
pub fn service_result_to_anyhow<T: serde::Serialize>(r: ServiceResult<T>) -> Result<Value> {
match r {
Ok(v) => serde_json::to_value(v).context("serialize service result"),
Err(e) => Err(anyhow!("{e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration as ChronoDuration, Utc};
use trusty_common::memory_core::palace::{Drawer, Palace};
fn test_state() -> AppState {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
AppState::new(root)
}
#[tokio::test]
async fn list_drawers_creates_desc_paginates() {
let state = test_state();
let palace = Palace {
id: PalaceId::new("paging-test"),
name: "paging-test".to_string(),
description: None,
created_at: Utc::now(),
data_dir: state.data_root.join("paging-test"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create_palace");
let handle = state
.registry
.open_palace(&state.data_root, &PalaceId::new("paging-test"))
.expect("open_palace");
let room_id = Uuid::nil();
let now = Utc::now();
for (i, importance) in [0.1f32, 0.9, 0.3, 0.7, 0.5].iter().enumerate() {
let drawer = Drawer {
id: Uuid::new_v4(),
room_id,
content: format!("drawer-{i}"),
importance: *importance,
source_file: None,
created_at: now - ChronoDuration::seconds(i as i64),
tags: vec![format!("idx:{i}")],
last_accessed_at: None,
access_count: 0,
drawer_type: Default::default(),
expires_at: None,
};
handle.add_drawer(drawer);
}
drop(handle);
let service = MemoryService::new(state.clone());
let page1 = service
.list_drawers(
"paging-test",
ListDrawersQuery {
limit: Some(2),
offset: Some(0),
sort: Some("created_desc".into()),
..Default::default()
},
)
.await
.expect("page 1");
let arr = page1.as_array().expect("array");
assert_eq!(arr.len(), 2, "page 1 must have 2 rows");
assert_eq!(arr[0]["content"].as_str(), Some("drawer-0"));
assert_eq!(arr[1]["content"].as_str(), Some("drawer-1"));
let page2 = service
.list_drawers(
"paging-test",
ListDrawersQuery {
limit: Some(2),
offset: Some(2),
sort: Some("created_desc".into()),
..Default::default()
},
)
.await
.expect("page 2");
let arr = page2.as_array().expect("array");
assert_eq!(arr.len(), 2, "page 2 must have 2 rows");
assert_eq!(arr[0]["content"].as_str(), Some("drawer-2"));
assert_eq!(arr[1]["content"].as_str(), Some("drawer-3"));
let page3 = service
.list_drawers(
"paging-test",
ListDrawersQuery {
limit: Some(2),
offset: Some(4),
sort: Some("created_desc".into()),
..Default::default()
},
)
.await
.expect("page 3");
let arr = page3.as_array().expect("array");
assert_eq!(arr.len(), 1, "page 3 (tail) must have 1 row");
assert_eq!(arr[0]["content"].as_str(), Some("drawer-4"));
let legacy = service
.list_drawers(
"paging-test",
ListDrawersQuery {
limit: Some(1),
..Default::default()
},
)
.await
.expect("legacy");
let arr = legacy.as_array().expect("array");
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0]["content"].as_str(),
Some("drawer-1"),
"importance default should surface drawer with importance 0.9 first",
);
assert_eq!(
arr[0]["snippet"].as_str(),
Some("drawer-1"),
"snippet must be populated for non-empty drawer content",
);
}
#[test]
fn drawer_snippet_truncates_long_content() {
assert_eq!(drawer_snippet("hello world"), "hello world");
assert_eq!(
drawer_snippet("first line\n\nsecond\tline third"),
"first line second line third",
);
assert_eq!(drawer_snippet(" padded "), "padded");
let long = "a".repeat(200);
let snippet = drawer_snippet(&long);
assert_eq!(snippet.chars().count(), DRAWER_SNIPPET_MAX_CHARS);
assert!(
snippet.ends_with('…'),
"long body must be truncated with ellipsis",
);
let exact = "a".repeat(DRAWER_SNIPPET_MAX_CHARS);
assert_eq!(drawer_snippet(&exact), exact);
}
#[test]
fn drawer_snippet_handles_empty_content() {
assert_eq!(drawer_snippet(""), "");
assert_eq!(drawer_snippet(" \n\t "), "");
}
}