use anyhow::{Context, Result};
use std::collections::BTreeMap;
use trusty_common::memory_core::palace::PalaceId;
use trusty_common::memory_core::store::kg::{KnowledgeGraph, Triple};
use trusty_common::memory_core::store::OpenIntent;
use crate::kg_extract::{
extract_triples, is_stop_token, ExtractInput, AUTO_PROVENANCE, DRAWER_SUBJECT_PREFIX,
ROOM_SUBJECT_PREFIX, TAG_SUBJECT_PREFIX, TOPIC_SUBJECT_PREFIX,
};
use crate::{resolve_palace_registry_dir, AppState};
const STRUCTURAL_PREFIXES: &[&str] = &[
DRAWER_SUBJECT_PREFIX,
TAG_SUBJECT_PREFIX,
TOPIC_SUBJECT_PREFIX,
ROOM_SUBJECT_PREFIX,
];
const PURGE_SCAN_PAGE: usize = 1000;
#[derive(Debug, Clone)]
pub struct PalaceRebuildSummary {
pub palace_id: String,
pub drawers_scanned: usize,
pub triples_asserted: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct KgRebuildOptions {
pub palace: Option<String>,
pub purge_stale_subjects: bool,
pub dry_run: bool,
}
pub async fn handle_kg_rebuild(palace: Option<String>) -> Result<()> {
handle_kg_rebuild_with(KgRebuildOptions {
palace,
..Default::default()
})
.await
}
pub async fn handle_kg_rebuild_with(opts: KgRebuildOptions) -> Result<()> {
let data_dir = trusty_common::resolve_data_dir("trusty-memory")
.context("resolve trusty-memory data dir")?;
let data_root = resolve_palace_registry_dir(data_dir);
let state = AppState::new(data_root);
let palace = opts.palace.clone();
if opts.dry_run {
println!("kg-rebuild: DRY RUN — nothing is written: no palace is hydrated, no triple is asserted, no subject is deleted");
let failures = report_purge(&state, palace.as_deref(), false).await?;
if failures > 0 {
anyhow::bail!("kg-rebuild purge: {failures} palace(s) could not be scanned");
}
return Ok(());
}
let loaded = state
.load_palaces_from_disk()
.await
.context("load palaces from disk")?;
tracing::info!(palaces_loaded = loaded, "kg-rebuild: palaces opened");
let summaries = rebuild_palaces(&state, palace.as_deref()).await?;
let mut total_drawers = 0usize;
let mut total_triples = 0usize;
let mut total_errors = 0usize;
for s in &summaries {
if let Some(e) = &s.error {
total_errors += 1;
eprintln!(
"[error] palace={} drawers={} triples={} error={}",
s.palace_id, s.drawers_scanned, s.triples_asserted, e
);
} else {
println!(
"[ok] palace={} drawers={} triples={}",
s.palace_id, s.drawers_scanned, s.triples_asserted
);
}
total_drawers += s.drawers_scanned;
total_triples += s.triples_asserted;
}
println!(
"kg-rebuild complete: {} palaces processed, {} drawers scanned, {} triples asserted, {} errors",
summaries.len(),
total_drawers,
total_triples,
total_errors
);
if opts.purge_stale_subjects {
let failures = report_purge(&state, palace.as_deref(), true).await?;
if failures > 0 {
anyhow::bail!(
"kg-rebuild purge: {failures} subject deletion(s) failed — see the [purge-FAILED] lines above"
);
}
}
Ok(())
}
async fn report_purge(state: &AppState, palace_filter: Option<&str>, apply: bool) -> Result<usize> {
let summaries = purge_palaces(state, palace_filter, apply).await?;
let mut total_subjects = 0usize;
let mut total_rows = 0usize;
let failures = count_purge_failures(&summaries);
for s in &summaries {
if let Some(e) = &s.error {
eprintln!("[purge-error] palace={} error={}", s.palace_id, e);
if s.failed.is_empty() {
continue;
}
}
if apply {
for subject in &s.deleted {
println!("[purge] palace={} deleted subject={}", s.palace_id, subject);
}
for (subject, err) in &s.failed {
eprintln!(
"[purge-FAILED] palace={} subject={} error={}",
s.palace_id, subject, err
);
}
total_subjects += s.deleted.len();
} else {
for subject in &s.selected {
println!(
"[purge] palace={} would delete subject={}",
s.palace_id, subject
);
}
total_subjects += s.selected.len();
}
total_rows += s.rows_closed;
}
if apply {
println!(
"kg-rebuild purge: {total_subjects} subjects deleted, {total_rows} rows closed, {failures} failed"
);
} else {
println!("kg-rebuild purge: {total_subjects} subjects would be deleted (dry run)");
}
Ok(failures)
}
pub fn count_purge_failures(summaries: &[PalacePurgeSummary]) -> usize {
summaries
.iter()
.map(|s| {
if !s.failed.is_empty() {
s.failed.len()
} else if s.error.is_some() {
1
} else {
0
}
})
.sum()
}
#[derive(Debug, Clone)]
pub struct PalacePurgeSummary {
pub palace_id: String,
pub selected: Vec<String>,
pub deleted: Vec<String>,
pub failed: Vec<(String, String)>,
pub rows_closed: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PurgeOutcome {
pub deleted: Vec<String>,
pub failed: Vec<(String, String)>,
pub rows_closed: usize,
}
pub fn apply_deletions<F>(subjects: &[String], mut delete: F) -> PurgeOutcome
where
F: FnMut(&str) -> Result<usize>,
{
let mut out = PurgeOutcome::default();
for subject in subjects {
match delete(subject) {
Ok(n) => {
out.rows_closed += n;
out.deleted.push(subject.clone());
}
Err(e) => out.failed.push((subject.clone(), format!("{e:#}"))),
}
}
out
}
pub async fn purge_palaces(
state: &AppState,
palace_filter: Option<&str>,
apply: bool,
) -> Result<Vec<PalacePurgeSummary>> {
let mut out: Vec<PalacePurgeSummary> = Vec::new();
let palaces = trusty_common::memory_core::PalaceRegistry::list_palaces(&state.data_root)
.unwrap_or_default();
for palace in palaces {
let id = palace.id.0.clone();
if let Some(filter) = palace_filter {
if filter != id {
continue;
}
}
let summary = purge_one(state, &id, apply)
.await
.unwrap_or_else(|e| PalacePurgeSummary {
palace_id: id.clone(),
selected: Vec::new(),
deleted: Vec::new(),
failed: Vec::new(),
rows_closed: 0,
error: Some(format!("{e:#}")),
});
out.push(summary);
}
Ok(out)
}
async fn purge_one(state: &AppState, palace_id: &str, apply: bool) -> Result<PalacePurgeSummary> {
let kg_path = state.data_root.join(palace_id).join("kg.db");
let intent = if apply {
OpenIntent::Writer
} else {
OpenIntent::ReadOnlyClient
};
let kg = KnowledgeGraph::open_with_intent(&kg_path, intent)
.with_context(|| format!("open kg for palace {palace_id}"))?;
let mut active: Vec<Triple> = Vec::new();
let mut offset = 0usize;
loop {
let page = kg
.list_active(PURGE_SCAN_PAGE, offset)
.await
.with_context(|| format!("scan active triples in {palace_id}"))?;
let n = page.len();
active.extend(page);
if n < PURGE_SCAN_PAGE {
break;
}
offset += n;
}
let selected = stale_subject_candidates(&active);
let outcome = if apply {
let store = kg.store();
let subjects = selected.clone();
tokio::task::spawn_blocking(move || {
apply_deletions(&subjects, |s| store.delete_by_subject(s))
})
.await
.with_context(|| format!("purge delete task for palace {palace_id}"))?
} else {
PurgeOutcome::default()
};
let error = if outcome.failed.is_empty() {
None
} else {
Some(format!(
"{} of {} selected subject(s) failed to delete",
outcome.failed.len(),
selected.len()
))
};
Ok(PalacePurgeSummary {
palace_id: palace_id.to_string(),
selected,
deleted: outcome.deleted,
failed: outcome.failed,
rows_closed: outcome.rows_closed,
error,
})
}
pub fn stale_subject_candidates(active: &[Triple]) -> Vec<String> {
let mut by_subject: BTreeMap<&str, bool> = BTreeMap::new();
for t in active {
if STRUCTURAL_PREFIXES.iter().any(|p| t.subject.starts_with(p)) {
continue;
}
if !is_stop_token(&t.subject) {
continue;
}
let all_auto = by_subject.entry(t.subject.as_str()).or_insert(true);
*all_auto &= t.provenance.as_deref() == Some(AUTO_PROVENANCE);
}
by_subject
.into_iter()
.filter(|(_, all_auto)| *all_auto)
.map(|(s, _)| s.to_string())
.collect()
}
pub async fn rebuild_palaces(
state: &AppState,
palace_filter: Option<&str>,
) -> Result<Vec<PalaceRebuildSummary>> {
let mut out: Vec<PalaceRebuildSummary> = Vec::new();
let palaces = trusty_common::memory_core::PalaceRegistry::list_palaces(&state.data_root)
.unwrap_or_default();
for palace in palaces {
let id = palace.id.0.clone();
if let Some(filter) = palace_filter {
if filter != id {
continue;
}
}
let summary = rebuild_one(state, &id)
.await
.unwrap_or_else(|e| PalaceRebuildSummary {
palace_id: id.clone(),
drawers_scanned: 0,
triples_asserted: 0,
error: Some(format!("{e:#}")),
});
out.push(summary);
}
Ok(out)
}
async fn rebuild_one(state: &AppState, palace_id: &str) -> Result<PalaceRebuildSummary> {
let pid = PalaceId::new(palace_id);
let handle = state
.registry
.open_palace(&state.data_root, &pid)
.with_context(|| format!("open palace {palace_id}"))?;
let drawers = handle.drawers.read().clone();
let mut asserted = 0usize;
for d in &drawers {
let room = room_id_to_label(d.room_id);
let triples = extract_triples(&ExtractInput {
drawer_id: d.id,
content: &d.content,
tags: &d.tags,
room: room.as_deref(),
});
for triple in triples {
let s = triple.subject.clone();
let p = triple.predicate.clone();
match handle.kg.assert(triple).await {
Ok(()) => asserted += 1,
Err(e) => tracing::warn!(
palace = %palace_id,
drawer_id = %d.id,
subject = %s,
predicate = %p,
"kg-rebuild: assert failed (non-fatal): {e:#}",
),
}
}
}
Ok(PalaceRebuildSummary {
palace_id: palace_id.to_string(),
drawers_scanned: drawers.len(),
triples_asserted: asserted,
error: None,
})
}
fn room_id_to_label(_room_id: uuid::Uuid) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn seed_embedder() {
trusty_common::memory_core::retrieval::seed_shared_embedder_with_mock();
}
fn active_triple(subject: &str, provenance: Option<&str>) -> Triple {
Triple {
subject: subject.to_string(),
predicate: "is-a".to_string(),
object: "thing".to_string(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 0.6,
provenance: provenance.map(|p| p.to_string()),
}
}
#[test]
fn purge_selects_only_stopword_subjects() {
let active = vec![
active_triple("them", Some(AUTO_PROVENANCE)),
active_triple("the", Some(AUTO_PROVENANCE)),
active_triple("x", Some(AUTO_PROVENANCE)),
active_triple("rustc", Some(AUTO_PROVENANCE)),
active_triple("trusty-memory", Some(AUTO_PROVENANCE)),
active_triple("go", Some(AUTO_PROVENANCE)),
];
let got = stale_subject_candidates(&active);
assert_eq!(
got,
vec!["the".to_string(), "them".to_string(), "x".to_string()],
"selection must be the stopword/short subjects only, sorted"
);
}
#[test]
fn purge_skips_namespaced_subjects() {
let active = vec![
active_triple("tag:the", Some(AUTO_PROVENANCE)),
active_triple("topic:it", Some(AUTO_PROVENANCE)),
active_triple("room:a", Some(AUTO_PROVENANCE)),
active_triple(
&format!("{DRAWER_SUBJECT_PREFIX}{}", uuid::Uuid::new_v4()),
Some(AUTO_PROVENANCE),
),
];
assert!(
stale_subject_candidates(&active).is_empty(),
"namespaced subjects must never be purged"
);
}
#[test]
fn purge_selects_a_legacy_subject_with_welded_punctuation() {
let active = vec![
active_triple("(\"the", Some(AUTO_PROVENANCE)),
active_triple("`it`", Some(AUTO_PROVENANCE)),
active_triple("`redb`", Some(AUTO_PROVENANCE)),
];
assert_eq!(
stale_subject_candidates(&active),
vec!["(\"the".to_string(), "`it`".to_string()],
"legacy punctuated stopword subjects must still be selected, \
and a legacy punctuated real entity must not be"
);
}
#[test]
fn purge_spares_a_subject_with_a_manual_triple() {
let mixed = vec![
active_triple("them", Some(AUTO_PROVENANCE)),
active_triple("them", Some("kg_assert")),
];
assert!(
stale_subject_candidates(&mixed).is_empty(),
"a manual triple must protect the subject"
);
let unprovenanced = vec![active_triple("them", None)];
assert!(
stale_subject_candidates(&unprovenanced).is_empty(),
"an unstamped triple is not provably auto-extracted"
);
let auto_only = vec![active_triple("them", Some(AUTO_PROVENANCE))];
assert_eq!(
stale_subject_candidates(&auto_only),
vec!["them".to_string()]
);
}
#[test]
fn purge_is_off_by_default() {
let opts = KgRebuildOptions::default();
assert!(!opts.purge_stale_subjects, "purge must be opt-in");
assert!(!opts.dry_run);
}
#[test]
fn purge_reports_a_failed_delete_as_failed_not_deleted() {
let subjects = vec!["them".to_string(), "the".to_string(), "it".to_string()];
let outcome = apply_deletions(&subjects, |s| {
if s == "the" {
anyhow::bail!("store is read-only")
} else {
Ok(2)
}
});
assert_eq!(
outcome.deleted,
vec!["them".to_string(), "it".to_string()],
"only subjects whose delete returned Ok may be reported as deleted"
);
assert_eq!(outcome.failed.len(), 1, "the failing subject must be kept");
assert_eq!(outcome.failed[0].0, "the");
assert!(
outcome.failed[0].1.contains("store is read-only"),
"the failure must carry its error text, got {:?}",
outcome.failed[0].1
);
assert_eq!(
outcome.rows_closed, 4,
"a failed delete must contribute no closed rows"
);
}
fn summary_of(
palace_id: &str,
selected: &[&str],
deleted: &[&str],
failed: &[(&str, &str)],
error: Option<&str>,
) -> PalacePurgeSummary {
PalacePurgeSummary {
palace_id: palace_id.to_string(),
selected: selected.iter().map(|s| s.to_string()).collect(),
deleted: deleted.iter().map(|s| s.to_string()).collect(),
failed: failed
.iter()
.map(|(s, e)| (s.to_string(), e.to_string()))
.collect(),
rows_closed: deleted.len(),
error: error.map(|e| e.to_string()),
}
}
#[test]
fn count_purge_failures_never_double_counts_a_palace() {
let open_failed = summary_of("a", &[], &[], &[], Some("open kg for palace a: bad file"));
let subject_failed = summary_of(
"b",
&["them", "the"],
&["them"],
&[("the", "store is read-only")],
Some("1 of 2 selected subject(s) failed to delete"),
);
let clean = summary_of("c", &["it"], &["it"], &[], None);
assert_eq!(
count_purge_failures(std::slice::from_ref(&open_failed)),
1,
"a palace that could not be opened is one failure"
);
assert_eq!(
count_purge_failures(std::slice::from_ref(&subject_failed)),
1,
"the error merely summarises the one subject failure; it must not add a second"
);
assert_eq!(count_purge_failures(std::slice::from_ref(&clean)), 0);
assert_eq!(
count_purge_failures(&[open_failed, subject_failed, clean]),
2,
"aggregate must be 1 + 1 + 0"
);
}
#[tokio::test]
async fn purge_counts_an_unopenable_palace_as_exactly_one_failure() -> Result<()> {
seed_embedder();
let tmp = tempfile::tempdir()?;
let data_root = tmp.path().to_path_buf();
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
{
let state = AppState::new(data_root.clone());
state.set_ready();
let _ =
crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "a"})).await?;
}
let mut record: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(data_root.join("a/palace.json"))?)?;
if let Some(obj) = record.as_object_mut() {
obj.insert("id".to_string(), json!("b"));
if obj.contains_key("name") {
obj.insert("name".to_string(), json!("b"));
}
}
let b_dir = data_root.join("b");
std::fs::create_dir_all(&b_dir)?;
std::fs::write(b_dir.join("palace.json"), serde_json::to_string(&record)?)?;
std::fs::create_dir(b_dir.join("kg.redb"))?;
let state = AppState::new(data_root.clone());
let summaries = purge_palaces(&state, Some("b"), true).await?;
assert!(
summaries[0].error.is_some(),
"an unopenable palace must set the palace-level error"
);
assert!(
summaries[0].failed.is_empty() && summaries[0].deleted.is_empty(),
"nothing was attempted per-subject, so both lists stay empty"
);
let failures = report_purge(&state, Some("b"), true).await?;
assert_eq!(
failures, 1,
"an unopenable palace is exactly one failure, and a non-zero count is what makes the command exit non-zero"
);
Ok(())
}
#[tokio::test]
async fn purge_dry_run_does_not_prune_expired_drawers() -> Result<()> {
use trusty_common::memory_core::palace::{Drawer, DrawerType};
use trusty_common::memory_core::store::kg::KnowledgeGraph;
seed_embedder();
let tmp = tempfile::tempdir()?;
let data_root = tmp.path().to_path_buf();
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let before = {
let state = AppState::new(data_root.clone());
state.set_ready();
let _ =
crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "a"})).await?;
let handle = state
.registry
.open_palace(&state.data_root, &PalaceId::new("a"))?;
let mut drawer = Drawer::new(uuid::Uuid::new_v4(), "an expired session event");
drawer.drawer_type = DrawerType::SessionEvent;
drawer.expires_at = Some(chrono::Utc::now() - chrono::Duration::days(1));
drawer.fact_key = None; handle.kg.upsert_drawer(&drawer).await?;
let n = handle.kg.load_drawers()?.len();
drop(handle);
n
};
assert!(before > 0, "fixture must seed at least one drawer");
{
let state = AppState::new(data_root.clone());
let summaries = purge_palaces(&state, Some("a"), false).await?;
assert_eq!(summaries.len(), 1, "palace 'a' must be scanned");
}
let kg = KnowledgeGraph::open(&data_root.join("a").join("kg.db"))?;
let after = kg.load_drawers()?.len();
assert_eq!(
before, after,
"a dry run must not delete drawer rows; {before} before, {after} after"
);
Ok(())
}
#[tokio::test]
async fn purge_deletes_stale_subjects_only_when_applied() -> Result<()> {
seed_embedder();
let tmp = tempfile::tempdir()?;
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let state = AppState::new(tmp.path().to_path_buf());
state.set_ready();
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "a"})).await?;
let handle = state
.registry
.open_palace(&state.data_root, &PalaceId::new("a"))?;
handle
.kg
.assert(active_triple("them", Some(AUTO_PROVENANCE)))
.await?;
handle
.kg
.assert(active_triple("rustc", Some(AUTO_PROVENANCE)))
.await?;
let dry = purge_palaces(&state, Some("a"), false).await?;
assert_eq!(dry.len(), 1);
assert_eq!(dry[0].selected, vec!["them".to_string()]);
assert!(dry[0].deleted.is_empty(), "a dry run deletes nothing");
assert_eq!(dry[0].rows_closed, 0, "a dry run must not close any row");
assert!(
!handle.kg.query_active("them").await?.is_empty(),
"dry run must leave the stale triple in place"
);
let applied = purge_palaces(&state, Some("a"), true).await?;
assert_eq!(applied[0].selected, vec!["them".to_string()]);
assert_eq!(applied[0].deleted, vec!["them".to_string()]);
assert!(applied[0].failed.is_empty(), "no delete should have failed");
assert!(applied[0].error.is_none(), "a clean purge sets no error");
assert!(
applied[0].rows_closed > 0,
"applied purge must close at least one row"
);
assert!(
handle.kg.query_active("them").await?.is_empty(),
"applied purge must remove the stale triple"
);
assert!(
!handle.kg.query_active("rustc").await?.is_empty(),
"applied purge must not touch a real entity subject"
);
Ok(())
}
#[tokio::test]
async fn kg_rebuild_processes_all_drawers() -> Result<()> {
seed_embedder();
let tmp = tempfile::tempdir()?;
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let state = AppState::new(tmp.path().to_path_buf());
state.set_ready();
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "a"})).await?;
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "b"})).await?;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "a",
"text": "The Rustc compiler is a fast tool for the Rust language",
"tags": ["compiler"],
"room": "Backend",
}),
)
.await?;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "b",
"text": "Cargo build is a tool that compiles every #rust crate",
"tags": ["tooling"],
}),
)
.await?;
let summaries = rebuild_palaces(&state, None).await?;
assert_eq!(summaries.len(), 2, "expected both palaces processed");
for s in &summaries {
assert!(
s.error.is_none(),
"palace {} errored: {:?}",
s.palace_id,
s.error
);
assert_eq!(
s.drawers_scanned, 1,
"palace {} expected one drawer",
s.palace_id
);
assert!(
s.triples_asserted > 0,
"palace {} expected non-zero triples",
s.palace_id
);
}
Ok(())
}
#[tokio::test]
async fn rebuild_does_not_supersede_a_good_object_from_a_later_line() -> Result<()> {
seed_embedder();
let tmp = tempfile::tempdir()?;
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let state = AppState::new(tmp.path().to_path_buf());
state.set_ready();
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "a"})).await?;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "a",
"text": "trusty-search is a daemon\ncargo builds it from source",
"tags": ["infra"],
}),
)
.await?;
let summaries = rebuild_palaces(&state, Some("a")).await?;
assert_eq!(summaries.len(), 1);
assert!(summaries[0].error.is_none(), "{:?}", summaries[0].error);
let handle = state
.registry
.open_palace(&state.data_root, &PalaceId::new("a"))?;
let objects: Vec<String> = handle
.kg
.query_active("trusty-search")
.await?
.into_iter()
.filter(|t| t.predicate == "is-a")
.map(|t| t.object)
.collect();
assert_eq!(
objects,
vec!["daemon".to_string()],
"the active is-a object must come from the line the marker is on"
);
Ok(())
}
#[tokio::test]
async fn kg_rebuild_processes_named_palace_only() -> Result<()> {
seed_embedder();
let tmp = tempfile::tempdir()?;
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let state = AppState::new(tmp.path().to_path_buf());
state.set_ready();
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "a"})).await?;
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": "b"})).await?;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "a",
"text": "The Rustc compiler is a fast tool for Rust language users",
"tags": ["compiler"],
}),
)
.await?;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "b",
"text": "Cargo build is a tool that compiles every Rust crate locally",
"tags": ["tooling"],
}),
)
.await?;
let summaries = rebuild_palaces(&state, Some("a")).await?;
assert_eq!(summaries.len(), 1, "only palace 'a' should be processed");
assert_eq!(summaries[0].palace_id, "a");
Ok(())
}
}