use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use tracing::{debug, info, warn};
use clawft_platform::Platform;
use clawft_types::{ClawftError, Result};
use crate::agent::memory::MemoryStore;
use crate::agent::sink::{ConversationSink, Turn};
pub const DEFAULT_MAX_TURNS: usize = 32;
pub const DEFAULT_EVERY_K_TURNS: usize = 10;
pub const DEFAULT_EVERY_T_MINUTES: u64 = 30;
const MAX_FACTS: usize = 24;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsolidationConfig {
pub max_turns: usize,
pub every_k_turns: Option<usize>,
pub every_t_minutes: Option<u64>,
pub enabled: bool,
pub topic_merge: bool,
}
impl Default for ConsolidationConfig {
fn default() -> Self {
Self {
max_turns: DEFAULT_MAX_TURNS,
every_k_turns: Some(DEFAULT_EVERY_K_TURNS),
every_t_minutes: Some(DEFAULT_EVERY_T_MINUTES),
enabled: true,
topic_merge: false,
}
}
}
impl ConsolidationConfig {
pub fn every_k_turns(k: usize) -> Self {
Self {
every_k_turns: Some(k.max(1)),
every_t_minutes: None,
..Self::default()
}
}
pub fn every_t_minutes(t: u64) -> Self {
Self {
every_k_turns: None,
every_t_minutes: Some(t.max(1)),
..Self::default()
}
}
pub fn with_topic_merge(mut self, on: bool) -> Self {
self.topic_merge = on;
self
}
pub fn with_cadence(every_k_turns: Option<usize>, every_t_minutes: Option<u64>) -> Self {
Self {
every_k_turns: every_k_turns.filter(|&k| k > 0),
every_t_minutes: every_t_minutes.filter(|&t| t > 0),
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistilledMemory {
pub conv_id: String,
pub fingerprint: String,
pub turn_count: usize,
pub facts: Vec<String>,
pub memory_block: String,
pub history_entry: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsolidationOutcome {
Written,
AlreadyPresent,
NoFacts,
Empty,
SkippedNotDue,
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsolidationReport {
pub conv_id: String,
pub outcome: ConsolidationOutcome,
pub fingerprint: Option<String>,
pub facts_written: usize,
pub turns_read: usize,
}
#[derive(Debug, Clone, Default)]
struct ConvState {
last_turn_count: usize,
last_run_ms: Option<u64>,
last_fingerprint: Option<String>,
}
pub struct MemoryConsolidator<P: Platform> {
store: MemoryStore<P>,
config: ConsolidationConfig,
state: Mutex<HashMap<String, ConvState>>,
}
impl<P: Platform> MemoryConsolidator<P> {
pub fn new(store: MemoryStore<P>, config: ConsolidationConfig) -> Self {
Self {
store,
config,
state: Mutex::new(HashMap::new()),
}
}
pub fn with_defaults(store: MemoryStore<P>) -> Self {
Self::new(store, ConsolidationConfig::default())
}
pub fn config(&self) -> &ConsolidationConfig {
&self.config
}
pub fn set_config(&mut self, config: ConsolidationConfig) {
self.config = config;
}
pub fn store(&self) -> &MemoryStore<P> {
&self.store
}
pub fn is_due(&self, conv_id: &str, current_turn_count: usize, now_ms: u64) -> bool {
if !self.config.enabled {
return false;
}
if current_turn_count == 0 {
return false;
}
let k = self.config.every_k_turns;
let t = self.config.every_t_minutes;
if k.is_none() && t.is_none() {
return false;
}
let guard = match self.state.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let st = guard.get(conv_id);
let turn_due = match (k, st) {
(Some(k), Some(s)) if s.last_run_ms.is_some() => {
current_turn_count.saturating_sub(s.last_turn_count) >= k
}
(Some(_), _) => true, (None, _) => false,
};
let time_due = match (t, st) {
(Some(mins), Some(s)) => match s.last_run_ms {
Some(prev) => {
let elapsed = now_ms.saturating_sub(prev);
elapsed >= mins.saturating_mul(60_000)
}
None => true, },
(Some(_), None) => true, (None, _) => false,
};
turn_due || time_due
}
pub async fn run_if_due(
&self,
sink: &dyn ConversationSink,
conv_id: &str,
now_ms: u64,
) -> Result<ConsolidationReport> {
if !self.config.enabled {
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::Disabled,
fingerprint: None,
facts_written: 0,
turns_read: 0,
});
}
let turns = self.load_turns(sink, conv_id).await?;
let turn_count = turns.len();
if !self.is_due(conv_id, turn_count, now_ms) {
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::SkippedNotDue,
fingerprint: None,
facts_written: 0,
turns_read: turn_count,
});
}
self.consolidate_loaded(conv_id, &turns, now_ms).await
}
pub async fn consolidate(
&self,
sink: &dyn ConversationSink,
conv_id: &str,
now_ms: u64,
) -> Result<ConsolidationReport> {
let turns = self.load_turns(sink, conv_id).await?;
self.consolidate_loaded(conv_id, &turns, now_ms).await
}
pub async fn consolidate_turns(
&self,
conv_id: &str,
turns: &[Turn],
now_ms: u64,
) -> Result<ConsolidationReport> {
self.consolidate_loaded(conv_id, turns, now_ms).await
}
pub async fn tick(
&self,
sink: &dyn ConversationSink,
conv_ids: &[&str],
now_ms: u64,
) -> Vec<Result<ConsolidationReport>> {
let mut out = Vec::with_capacity(conv_ids.len());
for id in conv_ids {
out.push(self.run_if_due(sink, id, now_ms).await);
}
out
}
pub fn last_fingerprint(&self, conv_id: &str) -> Option<String> {
let guard = self.state.lock().ok()?;
guard.get(conv_id).and_then(|s| s.last_fingerprint.clone())
}
async fn load_turns(
&self,
sink: &dyn ConversationSink,
conv_id: &str,
) -> Result<Vec<Turn>> {
let window = self.config.max_turns;
let via_history = sink.history(conv_id, window).await;
if !via_history.is_empty() {
return Ok(via_history);
}
match sink.load_history(conv_id).await {
Ok(mut all) => {
if window > 0 && all.len() > window {
all = all.split_off(all.len() - window);
}
Ok(all)
}
Err(e) => {
warn!(conv_id, error = %e, "consolidator: load_history failed");
Err(ClawftError::Provider {
message: format!("consolidator load_history({conv_id}): {e}"),
})
}
}
}
async fn consolidate_loaded(
&self,
conv_id: &str,
turns: &[Turn],
now_ms: u64,
) -> Result<ConsolidationReport> {
if turns.is_empty() {
self.record_state(conv_id, 0, now_ms, None);
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::Empty,
fingerprint: None,
facts_written: 0,
turns_read: 0,
});
}
let distilled = distill_turns(conv_id, turns);
let fingerprint = distilled.fingerprint.clone();
let turn_count = distilled.turn_count;
if distilled.facts.is_empty() {
self.record_state(conv_id, turn_count, now_ms, Some(fingerprint.clone()));
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::NoFacts,
fingerprint: Some(fingerprint),
facts_written: 0,
turns_read: turn_count,
});
}
if self.config.topic_merge {
let existing = self.store.read_long_term().await.unwrap_or_default();
let (updated, stats) = super::topics::merge_into_memory(&existing, &distilled.facts, conv_id);
self.record_state(conv_id, turn_count, now_ms, Some(fingerprint.clone()));
if !stats.changed() {
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::AlreadyPresent,
fingerprint: Some(fingerprint),
facts_written: 0,
turns_read: turn_count,
});
}
self.store.write_long_term(&updated).await?;
self.store.append_history(&distilled.history_entry).await?;
info!(
conv_id,
merged = stats.merged,
inserted = stats.inserted,
unchanged = stats.unchanged,
"consolidator: merged facts into the topic bank"
);
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::Written,
fingerprint: Some(fingerprint),
facts_written: stats.merged + stats.inserted,
turns_read: turn_count,
});
}
let existing = self.store.read_long_term().await.unwrap_or_default();
let marker = fingerprint_marker(&fingerprint);
if existing.contains(&marker) {
debug!(
conv_id,
fingerprint = %fingerprint,
"consolidator: fingerprint already present — skip write"
);
self.record_state(conv_id, turn_count, now_ms, Some(fingerprint.clone()));
return Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::AlreadyPresent,
fingerprint: Some(fingerprint),
facts_written: 0,
turns_read: turn_count,
});
}
self.store
.append_long_term(&distilled.memory_block)
.await?;
self.store
.append_history(&distilled.history_entry)
.await?;
info!(
conv_id,
facts = distilled.facts.len(),
turns = turn_count,
fingerprint = %fingerprint,
"consolidator: distilled turns into MEMORY.md"
);
let facts_written = distilled.facts.len();
self.record_state(conv_id, turn_count, now_ms, Some(fingerprint.clone()));
Ok(ConsolidationReport {
conv_id: conv_id.to_string(),
outcome: ConsolidationOutcome::Written,
fingerprint: Some(fingerprint),
facts_written,
turns_read: turn_count,
})
}
fn record_state(
&self,
conv_id: &str,
turn_count: usize,
now_ms: u64,
fingerprint: Option<String>,
) {
let mut guard = match self.state.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let entry = guard.entry(conv_id.to_string()).or_default();
entry.last_turn_count = turn_count;
entry.last_run_ms = Some(now_ms);
if fingerprint.is_some() {
entry.last_fingerprint = fingerprint;
}
}
}
pub fn distill_turns(conv_id: &str, turns: &[Turn]) -> DistilledMemory {
let fingerprint = fingerprint_turns(turns);
let facts = extract_facts(turns);
let turn_count = turns.len();
let memory_block = if facts.is_empty() {
String::new()
} else {
format_memory_block(conv_id, &fingerprint, turn_count, &facts)
};
let history_entry = format!(
"consolidated conv={} turns={} facts={} fp={}",
conv_id,
turn_count,
facts.len(),
&fingerprint[..8.min(fingerprint.len())]
);
DistilledMemory {
conv_id: conv_id.to_string(),
fingerprint,
turn_count,
facts,
memory_block,
history_entry,
}
}
fn fingerprint_turns(turns: &[Turn]) -> String {
let mut hasher = Sha256::new();
for t in turns {
hasher.update(t.turn_id.as_bytes());
hasher.update(b"\0");
hasher.update(t.role.as_bytes());
hasher.update(b"\0");
hasher.update(t.content.as_bytes());
hasher.update(b"\0");
hasher.update(t.ts_ms.to_le_bytes());
hasher.update(b"\n");
}
let digest = hasher.finalize();
hex_encode(&digest)
}
fn fingerprint_marker(fp: &str) -> String {
format!("<!-- consolidator:fp={fp} -->")
}
fn format_memory_block(
conv_id: &str,
fingerprint: &str,
turn_count: usize,
facts: &[String],
) -> String {
let mut out = String::new();
out.push_str(&fingerprint_marker(fingerprint));
out.push('\n');
out.push_str(&format!(
"### Distilled from conversation `{conv_id}` ({turn_count} turns)\n"
));
for fact in facts {
out.push_str("- ");
out.push_str(fact);
out.push('\n');
}
out.push_str("<!-- /consolidator -->");
out
}
fn extract_facts(turns: &[Turn]) -> Vec<String> {
let mut facts = Vec::new();
let mut seen = std::collections::HashSet::new();
for t in turns {
let role = t.role.to_ascii_lowercase();
if role == "tool" || role == "system" {
continue;
}
let content = normalize_whitespace(&t.content);
if content.is_empty() {
continue;
}
if role == "user" {
for line in content.lines() {
let line = normalize_whitespace(line);
if line.is_empty() {
continue;
}
if is_fact_like(&line) || is_preference_cue(&line) {
push_unique(&mut facts, &mut seen, &line);
}
}
if content.len() <= 200
&& !content.contains('?')
&& content.split_whitespace().count() >= 3
{
push_unique(&mut facts, &mut seen, &content);
}
} else if role == "assistant" {
if is_fact_like(&content) || is_preference_cue(&content) {
let snippet = first_chunk(&content, 240);
push_unique(&mut facts, &mut seen, &snippet);
}
}
if facts.len() >= MAX_FACTS {
break;
}
}
facts
}
fn push_unique(facts: &mut Vec<String>, seen: &mut std::collections::HashSet<String>, line: &str) {
if facts.len() >= MAX_FACTS {
return;
}
let key = line.to_ascii_lowercase();
if seen.insert(key) {
facts.push(line.to_string());
}
}
fn is_preference_cue(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
const CUES: &[&str] = &[
"i prefer",
"i'd rather",
"i would rather",
"please remember",
"remember that",
"from now on",
"going forward",
"my name is",
"i am ",
"i'm ",
"call me ",
"always ",
"never ",
"don't ",
"do not ",
"i work",
"i live",
"my timezone",
"my email",
"fact:",
"note:",
];
CUES.iter().any(|c| lower.contains(c))
}
fn is_fact_like(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
if lower.starts_with("fact:") || lower.starts_with("note:") {
return true;
}
if s.len() <= 180
&& (lower.contains(" is ") || lower.contains(" are ") || lower.contains(" = "))
&& !s.contains('?')
{
return true;
}
false
}
fn normalize_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn first_chunk(s: &str, max_chars: usize) -> String {
let trimmed = s.trim();
if let Some(idx) = trimmed.find(['.', '!', '\n']) {
let end = (idx + 1).min(trimmed.len());
let chunk = &trimmed[..end];
if chunk.chars().count() <= max_chars {
return normalize_whitespace(chunk);
}
}
let mut out = String::new();
for (i, ch) in trimmed.chars().enumerate() {
if i >= max_chars {
out.push('…');
break;
}
out.push(ch);
}
normalize_whitespace(&out)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0xf) as usize] as char);
}
s
}
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::sink::InMemorySink;
use clawft_platform::NativePlatform;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(prefix: &str) -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("clawft_consol_{prefix}_{pid}_{id}"))
}
fn test_store(dir: &std::path::Path) -> MemoryStore<NativePlatform> {
let platform = Arc::new(NativePlatform::new());
MemoryStore::with_paths(dir.join("MEMORY.md"), dir.join("HISTORY.md"), platform)
}
fn turn(id: &str, role: &str, content: &str, ts: u64) -> Turn {
Turn {
turn_id: id.into(),
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
ts_ms: ts,
voice_analysis: None,
audio: None,
}
}
fn sample_turns() -> Vec<Turn> {
vec![
turn("t1", "user", "My name is Ada and I prefer concise answers.", 1000),
turn("t2", "assistant", "Got it — I'll keep replies short.", 1001),
turn("t3", "user", "Please remember that the project codename is weftos.", 1002),
turn("t4", "tool", r#"{"ok":true}"#, 1003),
turn("t5", "assistant", "Noted: project codename is weftos.", 1004),
]
}
#[test]
fn distill_is_deterministic() {
let turns = sample_turns();
let a = distill_turns("conv-a", &turns);
let b = distill_turns("conv-a", &turns);
assert_eq!(a, b);
assert!(!a.fingerprint.is_empty());
assert_eq!(a.fingerprint.len(), 64); assert!(!a.facts.is_empty());
assert!(a.memory_block.contains(&a.fingerprint));
assert!(a.memory_block.contains("<!-- consolidator:fp="));
assert!(a.memory_block.contains("<!-- /consolidator -->"));
}
#[test]
fn distill_fingerprint_changes_with_content() {
let mut turns = sample_turns();
let fp1 = distill_turns("c", &turns).fingerprint;
turns[0].content = "My name is Grace.".into();
let fp2 = distill_turns("c", &turns).fingerprint;
assert_ne!(fp1, fp2);
}
#[test]
fn distill_extracts_preference_and_fact_cues() {
let d = distill_turns("c", &sample_turns());
let joined = d.facts.join(" | ").to_ascii_lowercase();
assert!(
joined.contains("ada") || joined.contains("concise") || joined.contains("weftos"),
"expected preference/fact cues in {joined:?}"
);
assert!(!joined.contains("{\"ok\":true}"));
}
#[test]
fn distill_empty_turns_yields_empty_facts() {
let d = distill_turns("c", &[]);
assert!(d.facts.is_empty());
assert!(d.memory_block.is_empty());
assert_eq!(d.fingerprint, fingerprint_turns(&[]));
}
#[test]
fn cadence_every_k_turns() {
let dir = temp_dir("cadence_k");
let store = test_store(&dir);
let c = MemoryConsolidator::new(store, ConsolidationConfig::every_k_turns(5));
assert!(c.is_due("c1", 3, 1_000));
c.record_state("c1", 3, 1_000, None);
assert!(!c.is_due("c1", 5, 1_001));
assert!(c.is_due("c1", 8, 1_002));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cadence_every_t_minutes() {
let dir = temp_dir("cadence_t");
let store = test_store(&dir);
let c = MemoryConsolidator::new(store, ConsolidationConfig::every_t_minutes(10));
assert!(c.is_due("c1", 1, 0));
c.record_state("c1", 1, 0, None);
assert!(!c.is_due("c1", 1, 5 * 60_000));
assert!(c.is_due("c1", 1, 10 * 60_000));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cadence_or_of_both_triggers() {
let dir = temp_dir("cadence_or");
let store = test_store(&dir);
let cfg = ConsolidationConfig::with_cadence(Some(10), Some(30));
let c = MemoryConsolidator::new(store, cfg);
c.record_state("c1", 0, 0, None);
assert!(c.is_due("c1", 10, 1_000));
c.record_state("c1", 10, 0, None);
assert!(c.is_due("c1", 10, 30 * 60_000));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disabled_config_is_never_due() {
let dir = temp_dir("disabled");
let store = test_store(&dir);
let mut cfg = ConsolidationConfig::every_k_turns(1);
cfg.enabled = false;
let c = MemoryConsolidator::new(store, cfg);
assert!(!c.is_due("c1", 100, 999_999));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn consolidate_writes_memory_and_history() {
let dir = temp_dir("write");
let store = test_store(&dir);
let consolidator = MemoryConsolidator::new(
store,
ConsolidationConfig::every_k_turns(1),
);
let sink = InMemorySink::new();
for t in sample_turns() {
sink.append_turn("conv-1", t).await.unwrap();
}
let report = consolidator
.consolidate(&sink, "conv-1", 5_000)
.await
.unwrap();
assert_eq!(report.outcome, ConsolidationOutcome::Written);
assert!(report.facts_written > 0);
assert_eq!(report.turns_read, 5);
let memory = consolidator.store().read_long_term().await.unwrap();
assert!(memory.contains("<!-- consolidator:fp="));
assert!(memory.contains("conv-1"));
let history = consolidator.store().read_history().await.unwrap();
assert!(history.contains("consolidated conv=conv-1"));
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn consolidate_is_idempotent_on_same_turns() {
let dir = temp_dir("idem");
let store = test_store(&dir);
let consolidator =
MemoryConsolidator::new(store, ConsolidationConfig::every_k_turns(1));
let turns = sample_turns();
let r1 = consolidator
.consolidate_turns("c", &turns, 1_000)
.await
.unwrap();
assert_eq!(r1.outcome, ConsolidationOutcome::Written);
let mem_after_first = consolidator.store().read_long_term().await.unwrap();
let d1 = distill_turns("c", &turns);
let d2 = distill_turns("c", &turns);
assert_eq!(d1.memory_block, d2.memory_block);
assert_eq!(d1.fingerprint, r1.fingerprint.as_deref().unwrap());
let r2 = consolidator
.consolidate_turns("c", &turns, 2_000)
.await
.unwrap();
assert_eq!(r2.outcome, ConsolidationOutcome::AlreadyPresent);
assert_eq!(r2.fingerprint, r1.fingerprint);
let mem_after_second = consolidator.store().read_long_term().await.unwrap();
assert_eq!(mem_after_first, mem_after_second);
let marker = fingerprint_marker(r1.fingerprint.as_deref().unwrap());
assert_eq!(mem_after_second.matches(&marker).count(), 1);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn run_if_due_respects_turn_cadence() {
let dir = temp_dir("due");
let store = test_store(&dir);
let consolidator =
MemoryConsolidator::new(store, ConsolidationConfig::every_k_turns(3));
let sink = InMemorySink::new();
sink.append_turn("c", turn("a", "user", "My name is Ada.", 1))
.await
.unwrap();
sink.append_turn("c", turn("b", "user", "I prefer dark mode.", 2))
.await
.unwrap();
let r1 = consolidator.run_if_due(&sink, "c", 100).await.unwrap();
assert_eq!(r1.outcome, ConsolidationOutcome::Written);
let r2 = consolidator.run_if_due(&sink, "c", 200).await.unwrap();
assert_eq!(r2.outcome, ConsolidationOutcome::SkippedNotDue);
for (i, text) in ["Please remember the timezone is UTC.", "fact: repo is clawft", "note: ship 0.8"]
.iter()
.enumerate()
{
sink.append_turn(
"c",
turn(&format!("n{i}"), "user", text, 10 + i as u64),
)
.await
.unwrap();
}
let r3 = consolidator.run_if_due(&sink, "c", 300).await.unwrap();
assert_eq!(r3.outcome, ConsolidationOutcome::Written);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn tick_processes_multiple_convs() {
let dir = temp_dir("tick");
let store = test_store(&dir);
let consolidator =
MemoryConsolidator::new(store, ConsolidationConfig::every_k_turns(1));
let sink = InMemorySink::new();
sink.append_turn("a", turn("1", "user", "My name is Alice.", 1))
.await
.unwrap();
sink.append_turn("b", turn("2", "user", "My name is Bob.", 2))
.await
.unwrap();
let reports = consolidator.tick(&sink, &["a", "b"], 1_000).await;
assert_eq!(reports.len(), 2);
assert!(reports.iter().all(|r| r.as_ref().unwrap().outcome == ConsolidationOutcome::Written));
let mem = consolidator.store().read_long_term().await.unwrap();
assert!(mem.contains("Alice") || mem.to_ascii_lowercase().contains("alice"));
assert!(mem.contains("Bob") || mem.to_ascii_lowercase().contains("bob"));
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn empty_conversation_reports_empty() {
let dir = temp_dir("empty");
let store = test_store(&dir);
let consolidator = MemoryConsolidator::with_defaults(store);
let sink = InMemorySink::new();
let r = consolidator.consolidate(&sink, "missing", 1).await.unwrap();
assert_eq!(r.outcome, ConsolidationOutcome::Empty);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn max_turns_window_is_honoured() {
let dir = temp_dir("window");
let store = test_store(&dir);
let mut cfg = ConsolidationConfig::every_k_turns(1);
cfg.max_turns = 2;
let consolidator = MemoryConsolidator::new(store, cfg);
let sink = InMemorySink::new();
for i in 0..5 {
sink.append_turn(
"c",
turn(
&format!("t{i}"),
"user",
&format!("Please remember fact number {i} is important."),
i as u64,
),
)
.await
.unwrap();
}
let r = consolidator.consolidate(&sink, "c", 1).await.unwrap();
assert_eq!(r.turns_read, 2);
let mem = consolidator.store().read_long_term().await.unwrap();
assert!(mem.contains("number 4") || mem.contains("number 3"));
assert!(!mem.contains("number 0"));
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn topic_merge_replaces_a_corrected_fact_and_is_idempotent() {
let dir = temp_dir("topics");
let consolidator =
MemoryConsolidator::new(test_store(&dir), ConsolidationConfig::default().with_topic_merge(true));
let c1 = vec![turn("a1", "user", "I am allergic to penicillin and I live in Denver.", 1)];
let c2 = vec![turn("b1", "user", "Correction: I am not allergic to penicillin.", 2)];
let r1 = consolidator.consolidate_turns("conv-1", &c1, 10).await.unwrap();
assert_eq!(r1.outcome, ConsolidationOutcome::Written);
let r2 = consolidator.consolidate_turns("conv-2", &c2, 20).await.unwrap();
assert_eq!(r2.outcome, ConsolidationOutcome::Written);
let md = consolidator.store().read_long_term().await.unwrap();
let (_, nodes) = super::super::topics::parse_bank(&md);
let allergy: Vec<_> = nodes.iter().filter(|n| n.text.contains("penicillin")).collect();
assert_eq!(allergy.len(), 1, "one allergy node, not two: {md}");
assert!(allergy[0].text.contains("not allergic"), "{md}");
assert!(!md.contains("consolidator:fp="), "topic mode does not append fingerprint blocks");
let again = consolidator.consolidate_turns("conv-2", &c2, 30).await.unwrap();
assert_eq!(again.outcome, ConsolidationOutcome::AlreadyPresent);
assert_eq!(consolidator.store().read_long_term().await.unwrap(), md);
let _ = std::fs::remove_dir_all(&dir);
}
}