use crate::service::core_kg::{DEFAULT_KG_LIST_LIMIT, MAX_KG_LIST_LIMIT};
use crate::AppState;
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use trusty_common::memory_core::palace::PalaceId;
use trusty_common::memory_core::store::kg::Triple;
use super::helpers::{open_palace_handle, resolve_palace};
pub(crate) async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_assert")?;
let palace = palace.as_str();
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
.to_string();
let predicate = args
.get("predicate")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
.to_string();
let object = args
.get("object")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
.to_string();
let confidence = args
.get("confidence")
.and_then(|v| v.as_f64())
.map(|c| (c as f32).clamp(0.0, 1.0))
.unwrap_or(1.0);
let provenance = args
.get("provenance")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let handle = open_palace_handle(state, palace)?;
let _admission =
crate::prompt_facts::check_tier_s_admission(state, &handle, &subject, &predicate, &object)
.await?;
let triple = Triple {
subject,
predicate,
object,
valid_from: chrono::Utc::now(),
valid_to: None,
confidence,
provenance,
};
let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
handle.kg.assert(triple).await.context("kg.assert")?;
if is_hot {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
}
}
Ok(json!({ "status": "asserted" }))
}
pub(crate) async fn handle_kg_retract_triple(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_retract_triple")?;
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_retract_triple: missing 'subject'"))?
.to_string();
let predicate = args
.get("predicate")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_retract_triple: missing 'predicate'"))?
.to_string();
let object = args
.get("object")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_retract_triple: missing 'object'"))?
.to_string();
let handle = open_palace_handle(state, &palace)?;
let closed = handle
.kg
.retract_triple(&subject, &predicate, &object)
.await
.context("kg.retract_triple")?;
if closed > 0 && crate::prompt_facts::is_hot_predicate(&predicate) {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after kg_retract_triple failed: {e:#}");
}
}
let mut response = json!({
"palace": palace,
"subject": subject,
"predicate": predicate,
"object": object,
"closed": closed,
"retracted": closed > 0,
});
if closed == 0 {
response["reason"] = Value::String(
"no active triple with that exact (subject, predicate, object)".to_string(),
);
}
Ok(response)
}
pub(crate) async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
let short = args
.get("short")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
.to_string();
let full = args
.get("full")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
.to_string();
let extra = args
.get("extra")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let palace = resolve_palace(state, &args, "add_alias")?;
let handle = open_palace_handle(state, &palace)?;
let object = match extra.as_deref() {
Some(e) if !e.is_empty() => format!("{full} ({e})"),
_ => full.clone(),
};
let _admission = crate::prompt_facts::check_tier_s_admission(
state,
&handle,
&short,
"is_alias_for",
&object,
)
.await?;
let triple = Triple {
subject: short.clone(),
predicate: "is_alias_for".to_string(),
object,
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: Some("add_alias".to_string()),
};
handle
.kg
.assert(triple)
.await
.context("kg.assert (alias)")?;
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
}
Ok(json!({ "asserted": true, "short": short, "full": full }))
}
pub(crate) async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
let facts = crate::prompt_facts::gather_hot_facts(state).await?;
let payload: Vec<Value> = facts
.into_iter()
.map(|f| {
json!({
"subject": f.subject,
"predicate": f.predicate,
"object": f.object,
"affirmed_at": f.affirmed_at.to_rfc3339(),
})
})
.collect();
Ok(json!({ "facts": payload }))
}
pub(crate) async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
.to_string();
let predicate = args
.get("predicate")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
.to_string();
let mut closed_total: usize = 0;
for palace_id in state.registry.list() {
if let Some(handle) = state.registry.get(&palace_id) {
match handle.kg.retract(&subject, &predicate).await {
Ok(n) => closed_total += n,
Err(e) => tracing::warn!(
palace = %palace_id.as_str(),
"retract failed: {e:#}",
),
}
}
}
if closed_total > 0 {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
}
Ok(json!({ "removed": true, "closed": closed_total }))
} else {
Ok(json!({ "removed": false, "reason": "not found" }))
}
}
pub(crate) async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_query")?;
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
let handle = open_palace_handle(state, &palace)?;
let triples = handle
.kg
.query_active(subject)
.await
.context("kg.query_active")?;
let payload: Vec<Value> = triples
.iter()
.map(|t| {
json!({
"subject": t.subject,
"predicate": t.predicate,
"object": t.object,
"valid_from": t.valid_from.to_rfc3339(),
"valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
"confidence": t.confidence,
"provenance": t.provenance,
})
})
.collect();
let total_active = handle
.kg
.count_active_triples()
.with_context(|| format!("kg.count_active_triples for palace {palace}"))?;
let mut response = json!({
"subject": subject,
"triples": payload,
"kg_triple_count": total_active,
});
if let Some(miss) = crate::bootstrap::KgMiss::classify(triples.len(), total_active) {
response["graph_state"] = Value::String(miss.wire_value().to_string());
response["hint"] = Value::String(miss.hint().to_string());
}
Ok(response)
}
pub(crate) async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_gaps")?;
let _handle = open_palace_handle(state, &palace)?;
let pid = PalaceId::new(&palace);
let cached = state.registry.get_gaps(&pid).unwrap_or_default();
let payload: Vec<Value> = cached
.into_iter()
.map(|g| {
json!({
"entities": g.entities,
"internal_density": g.internal_density,
"external_bridges": g.external_bridges,
"suggested_exploration": g.suggested_exploration,
})
})
.collect();
Ok(json!({ "palace": palace, "gaps": payload }))
}
pub(crate) async fn handle_kg_list_subjects(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_list_subjects")?;
let handle = open_palace_handle(state, &palace)?;
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map_or(DEFAULT_KG_LIST_LIMIT, |n| n as usize)
.clamp(1, MAX_KG_LIST_LIMIT);
let with_counts = args
.get("with_counts")
.and_then(Value::as_bool)
.unwrap_or(false);
let fetch_limit = limit + 1;
let (subjects, truncated): (Vec<Value>, bool) = if with_counts {
let mut rows = handle
.kg
.list_subjects_with_counts(fetch_limit)
.context("kg.list_subjects_with_counts")?;
let truncated = rows.len() > limit;
rows.truncate(limit);
let subjects = rows
.into_iter()
.map(|(subject, count)| json!({ "subject": subject, "count": count }))
.collect();
(subjects, truncated)
} else {
let mut rows = handle
.kg
.list_subjects(fetch_limit)
.context("kg.list_subjects")?;
let truncated = rows.len() > limit;
rows.truncate(limit);
let subjects = rows.into_iter().map(Value::String).collect();
(subjects, truncated)
};
Ok(json!({
"palace": palace,
"truncated": truncated,
"with_counts": with_counts,
"subjects": subjects,
}))
}
pub(crate) async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let cache_snapshot = {
let guard = state.prompt_context_cache.read().await;
guard.clone()
};
let body = if let Some(q) = query.as_deref() {
let needle = q.to_lowercase();
let filtered: Vec<(String, String, String)> = cache_snapshot
.triples
.into_iter()
.filter(|(subject, _predicate, object)| {
subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
})
.collect();
let formatted = crate::prompt_facts::build_prompt_context(&filtered);
if formatted.is_empty() {
"No project context found matching your query.".to_string()
} else {
formatted
}
} else if cache_snapshot.formatted.is_empty() {
"No prompt facts stored yet.".to_string()
} else {
cache_snapshot.formatted
};
Ok(Value::String(body))
}
pub(crate) async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "discover_aliases")?;
let project_root = args
.get("project_root")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
let handle = open_palace_handle(state, &palace)?;
let mut already_known = 0usize;
let mut newly_asserted = 0usize;
let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
let mut rejected: Vec<Value> = Vec::new();
let mut rejected_reason: Option<String> = None;
for d in &discoveries {
let active = handle
.kg
.query_active(&d.short)
.await
.context("kg.query_active")?;
let exists = active
.iter()
.any(|t| t.predicate == "is_alias_for" && t.object == d.full);
if exists {
already_known += 1;
continue;
}
let _admission = match crate::prompt_facts::check_tier_s_admission(
state,
&handle,
&d.short,
"is_alias_for",
&d.full,
)
.await
{
Ok(a) => a,
Err(e) => {
if rejected_reason.is_none() {
rejected_reason = Some(format!("{e:#}"));
}
rejected.push(json!({ "short": d.short, "full": d.full }));
continue;
}
};
let triple = Triple {
subject: d.short.clone(),
predicate: "is_alias_for".to_string(),
object: d.full.clone(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
};
handle
.kg
.assert(triple)
.await
.context("kg.assert (discover)")?;
newly_asserted += 1;
reported.push(json!({
"short": d.short,
"full": d.full,
"source": d.source.as_str(),
}));
}
if newly_asserted > 0 {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
}
}
Ok(json!({
"discovered": reported,
"already_known": already_known,
"new": newly_asserted,
"palace": palace,
"complete": rejected.is_empty(),
"rejected": rejected,
"rejected_reason": rejected_reason,
}))
}
pub(crate) async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_bootstrap")?;
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from);
let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
.await
.context("bootstrap_palace")?;
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
}
crate::bootstrap::result_to_json(&result)
}
pub(crate) async fn handle_upgrade_tool(state: &AppState, args: Value) -> Result<Value> {
let check = args.get("check").and_then(Value::as_bool).unwrap_or(true);
let confirm = args
.get("confirm")
.and_then(Value::as_bool)
.unwrap_or(false);
let crate_name = env!("CARGO_PKG_NAME");
let current = env!("CARGO_PKG_VERSION");
let info = trusty_common::update::check_crates_io(crate_name, current).await;
let (latest, is_update) = match &info {
Some(u) => (u.latest.as_str(), true),
None => (current, false),
};
if check || !confirm {
let msg = if is_update {
format!(
"Update available: {crate_name} {latest} (you have {current}). \
Call with confirm=true to install."
)
} else {
format!("{crate_name} {current} is already up to date.")
};
return Ok(
serde_json::json!({ "status": "checked", "current": current, "latest": latest, "update_available": is_update, "message": msg }),
);
}
if !is_update {
return Ok(serde_json::json!({
"status": "up_to_date",
"current": current,
"message": format!("{crate_name} {current} is already up to date — nothing to install.")
}));
}
let upgrade_state = state.update_available.clone();
let latest_owned = latest.to_string();
let crate_name_owned = crate_name.to_string();
let response = serde_json::json!({
"status": "installing",
"current": current,
"latest": latest_owned,
"message": format!(
"Installing {crate_name} {latest_owned} — daemon will restart automatically \
under launchd, or you will be prompted to restart manually."
)
});
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
match trusty_common::update::upgrade_and_restart(&crate_name_owned, &crate_name_owned).await
{
Ok(Some(hint)) => {
tracing::info!("{hint}");
eprintln!("{hint}");
}
Ok(None) => {}
Err(e) => {
tracing::error!("upgrade_and_restart failed: {e:#}");
eprintln!("[trusty-memory] upgrade failed: {e:#}");
if let Ok(mut g) = upgrade_state.lock() {
*g = None;
}
}
}
});
Ok(response)
}