use crate::AppState;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Arc;
use trusty_common::memory_core::palace::{Palace, PalaceId};
use trusty_common::memory_core::retrieval::RecallResult;
use trusty_common::memory_core::PalaceHandle;
use uuid::Uuid;
use super::types::{PalaceInfo, ServiceResult};
#[cfg(test)]
use super::core::MemoryService;
#[cfg(test)]
use super::types::ListDrawersQuery;
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,
completed_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 "), "");
}
}