use crate::memory_core::embed::Embedder;
use crate::memory_core::palace::Drawer;
use crate::memory_core::retrieval::{
ChunkPlanner, PalaceHandle, embed_chunks_within, embed_planned_within, shared_embedder,
};
use crate::memory_core::timeouts;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub(crate) const STOP_WORDS: &[&str] = &[
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "of", "in", "on", "at",
"to", "for", "with", "and", "or", "but", "not", "no", "yes", "i", "you", "he", "she", "it",
"we", "they", "this", "that", "these", "those", "as", "by", "from", "into", "over", "under",
"if", "then", "than", "so", "do", "does", "did", "have", "has", "had", "will", "would",
"shall", "should", "can", "could", "may", "might", "must", "about", "any", "all", "some",
"more", "most", "such", "ok", "up", "my", "us", "me", "am", "go", "id", "vs", "hi", "oh",
];
pub(crate) const MIN_KEYWORD_CHARS: usize = 2;
pub(crate) fn normalize_keyword(raw: &str) -> String {
raw.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect()
}
pub fn extract_keywords(content: &str) -> Vec<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out: Vec<String> = Vec::new();
for raw in content.split_whitespace() {
let token = normalize_keyword(raw);
if token.chars().count() < MIN_KEYWORD_CHARS {
continue;
}
if STOP_WORDS.iter().any(|s| *s == token) {
continue;
}
if seen.insert(token.clone()) {
out.push(token);
}
}
out
}
pub(crate) fn is_low_quality_content(content: &str, min_words: usize) -> bool {
if crate::memory_core::filter::blocklist_match(content).is_some() {
return true;
}
let word_count = content.split_whitespace().count();
word_count < min_words
}
pub(crate) fn char_safe_prefix(s: &str, max_bytes: usize) -> &str {
&s[..s.floor_char_boundary(max_bytes)]
}
pub(crate) fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) const RULING_TAG: &str = "ruling";
pub(crate) const MERGE_MAX_BYTES: usize = 4 * 1024;
const MERGE_SEPARATOR: &str = "\n\nAlso: ";
pub(crate) fn merged_drawer(survivor: &Drawer, loser: &Drawer) -> Option<Drawer> {
let mut merged = survivor.clone();
if !survivor.content().contains(loser.content()) {
let len = survivor.content().len() + MERGE_SEPARATOR.len() + loser.content().len();
if len > MERGE_MAX_BYTES {
return None;
}
merged.set_content(format!(
"{}{MERGE_SEPARATOR}{}",
survivor.content(),
loser.content()
));
}
merged.importance = merged.importance.max(loser.importance);
for tag in &loser.tags {
if !merged.tags.contains(tag) {
merged.tags.push(tag.clone());
}
}
Some(merged)
}
pub(crate) fn pick_survivor<'a>(a: &'a Drawer, b: &'a Drawer) -> Option<(&'a Drawer, &'a Drawer)> {
if a.is_tier_c() && b.is_tier_c() {
return None;
}
let rank = |d: &Drawer| (d.is_tier_c(), d.tags.iter().any(|t| t == RULING_TAG));
let order = rank(a)
.cmp(&rank(b))
.then(a.created_at.cmp(&b.created_at))
.then(a.importance.total_cmp(&b.importance));
Some(if order.is_lt() { (b, a) } else { (a, b) })
}
pub(crate) async fn persist_merge(
handle: &Arc<PalaceHandle>,
a: Uuid,
b: Uuid,
embedder: &(dyn Embedder + Send + Sync),
embed_timeout: Duration,
) -> Result<Option<(Uuid, Uuid)>> {
let _write_guard = timeouts::lock_with_timeout(
&handle.write_mutex,
timeouts::write_lock_timeout(),
handle.id.as_str(),
)
.await?;
let _order = handle.commit_mutex.lock().await;
let (a, b) = {
let drawers = handle.drawers.read();
let find = |id: Uuid| drawers.iter().find(|d| d.id == id).cloned();
(find(a), find(b))
};
let (Some(a), Some(b)) = (a, b) else {
return Ok(None);
};
let Some((survivor, loser)) = pick_survivor(&a, &b) else {
return Ok(None);
};
let Some(merged) = merged_drawer(survivor, loser) else {
tracing::info!(
palace = %handle.id, survivor = %survivor.id, loser = %loser.id,
"dream dedup: merge would pass {MERGE_MAX_BYTES} bytes; both drawers kept"
);
return Ok(None);
};
let reembed = merged.content() != survivor.content();
if reembed {
replace_group(handle, embedder, merged.id, merged.content(), embed_timeout)
.await
.with_context(|| format!("re-embed merged dedup survivor {}", merged.id))?;
}
if let Err(e) = handle.kg.upsert_drawer(&merged).await {
if reembed {
restore_group(handle, embedder, survivor, embed_timeout).await;
}
return Err(e).with_context(|| format!("persist merged dedup survivor {}", merged.id));
}
let pair = (survivor.id, loser.id);
if let Some(row) = handle.drawers.write().iter_mut().find(|d| d.id == pair.0) {
*row = merged;
}
Ok(Some(pair))
}
async fn replace_group(
handle: &PalaceHandle,
embedder: &(dyn Embedder + Send + Sync),
id: Uuid,
text: &str,
embed_timeout: Duration,
) -> Result<()> {
let vecs = embed_chunks_within(embedder, text, embed_timeout).await?;
handle.vector_store.upsert_chunks(id, vecs).await
}
async fn restore_group(
handle: &PalaceHandle,
embedder: &(dyn Embedder + Send + Sync),
survivor: &Drawer,
embed_timeout: Duration,
) {
let restored = replace_group(
handle,
embedder,
survivor.id,
survivor.content(),
embed_timeout,
)
.await;
if let Err(e) = restored {
tracing::error!(
palace = %handle.id, drawer = %survivor.id,
"#9275: merge not committed and the survivor's vectors not restored: {e:#}"
);
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RebuildReport {
pub(crate) rebuilt: usize,
pub(crate) skipped: usize,
}
pub(crate) async fn rebuild_index_from_drawers(
handle: &Arc<PalaceHandle>,
started: std::time::Instant,
budget: Duration,
) -> Result<RebuildReport> {
rebuild_index_with(handle, None, started, budget).await
}
pub(crate) async fn rebuild_index_with(
handle: &Arc<PalaceHandle>,
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
started: std::time::Instant,
budget: Duration,
) -> Result<RebuildReport> {
let _write_guard = timeouts::lock_with_timeout(
&handle.write_mutex,
timeouts::write_lock_timeout(),
handle.id.as_str(),
)
.await?;
let snapshot: Vec<Drawer> = handle.drawers.read().clone();
let reset = || {
handle
.vector_store
.reset()
.context("reset vector index for rebuild")
};
if snapshot.is_empty() {
reset()?;
return Ok(RebuildReport::default());
}
let embedder = match embedder {
Some(e) => Arc::clone(e),
None => shared_embedder()
.await
.context("acquire shared embedder for dream rebuild")?,
};
let planner = ChunkPlanner::for_embedder(embedder.as_ref())
.context("build the chunk planner for dream rebuild")?;
reset()?;
let per_chunk = timeouts::embed_batch_timeout();
let mut report = RebuildReport::default();
for drawer in snapshot.iter() {
if started.elapsed() >= budget {
break;
}
match rebuild_one(
handle,
embedder.as_ref(),
planner.as_ref(),
drawer,
per_chunk,
)
.await
{
Ok(()) => report.rebuilt += 1,
Err(e) => {
tracing::warn!(
palace = %handle.id, drawer = %drawer.id,
"#9275: dream rebuild skipped a drawer; it has no vector: {e:#}"
);
report.skipped += 1;
}
}
}
Ok(report)
}
async fn rebuild_one(
handle: &PalaceHandle,
embedder: &(dyn Embedder + Send + Sync),
planner: Option<&ChunkPlanner>,
drawer: &Drawer,
per_chunk: Duration,
) -> Result<()> {
let chunks: Vec<String> = match planner {
Some(p) => p.split(drawer.content())?,
None => vec![drawer.content()],
}
.into_iter()
.map(str::to_string)
.collect();
let vecs = embed_planned_within(embedder, &chunks, per_chunk).await?;
handle.vector_store.upsert_chunks(drawer.id, vecs).await
}
pub(crate) fn build_closet_index(drawers: &[Drawer]) -> HashMap<String, Vec<Uuid>> {
let mut new_index: HashMap<String, Vec<Uuid>> = HashMap::new();
for drawer in drawers.iter() {
for kw in extract_keywords(drawer.content()) {
new_index.entry(kw).or_default().push(drawer.id);
}
}
new_index
}