use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use weaver_lang::{CompiledExpr, Registry};
use crate::host::WeaverHost;
use crate::lorebook::Lorebook;
use crate::ChatMessage;
#[derive(Debug, Clone)]
pub enum ActivationReason {
Constant,
Keyword { matched: Vec<String> },
Regex { pattern: String },
Triggered,
Sticky { remaining_turns: usize },
}
#[derive(Debug)]
pub struct ActivationResult {
pub entry_id: String,
pub reason: ActivationReason,
pub priority: i32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActivationState {
last_activated: HashMap<String, usize>,
sticky_remaining: HashMap<String, usize>,
current_turn: usize,
}
impl ActivationState {
pub fn new() -> Self {
Self::default()
}
pub fn advance_turn(&mut self) {
self.current_turn += 1;
self.sticky_remaining.retain(|_, turns| {
*turns = turns.saturating_sub(1);
*turns > 0
});
}
pub fn current_turn(&self) -> usize {
self.current_turn
}
pub fn record_activation(&mut self, entry_id: &str, sticky_turns: usize) {
self.last_activated
.insert(entry_id.to_string(), self.current_turn);
if sticky_turns > 0 {
self.sticky_remaining
.insert(entry_id.to_string(), sticky_turns + 1);
}
}
pub fn is_on_cooldown(&self, entry_id: &str, cooldown: usize) -> bool {
if cooldown == 0 {
return false;
}
self.last_activated
.get(entry_id)
.is_some_and(|last| self.current_turn - last < cooldown)
}
pub fn is_sticky(&self, entry_id: &str) -> Option<usize> {
self.sticky_remaining.get(entry_id).copied()
}
pub fn sticky_entries(&self) -> impl Iterator<Item = (&str, usize)> {
self.sticky_remaining
.iter()
.map(|(id, turns)| (id.as_str(), *turns))
}
}
pub struct ActivationEngine;
impl ActivationEngine {
pub fn scan(
lorebook: &Lorebook,
messages: &[ChatMessage],
host: &mut WeaverHost,
registry: &Registry,
state: &ActivationState,
) -> Vec<ActivationResult> {
let config = &lorebook.config;
let case_sensitive = config.case_sensitive_keywords;
let mut activated: Vec<ActivationResult> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut sticky_carry: HashMap<String, (usize, i32)> = HashMap::new();
for (entry_id, remaining) in state.sticky_entries() {
if let Some(entry) = lorebook.get_entry(entry_id) {
if entry.meta.enabled {
sticky_carry.insert(entry_id.to_string(), (remaining, entry.meta.priority));
}
}
}
for entry in lorebook.active_entries() {
let meta = &entry.meta;
if state.is_on_cooldown(&meta.id, meta.cooldown) {
continue;
}
if meta.constant {
if check_condition(&entry.condition, host, registry) {
seen.insert(meta.id.clone());
sticky_carry.remove(&meta.id);
activated.push(ActivationResult {
entry_id: meta.id.clone(),
reason: ActivationReason::Constant,
priority: meta.priority,
});
}
continue;
}
let scan_depth = meta.scan_depth.unwrap_or(config.default_scan_depth);
let scan_messages = if messages.len() > scan_depth {
&messages[messages.len() - scan_depth..]
} else {
messages
};
let corpus: String = scan_messages
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let corpus_normalized = if case_sensitive {
corpus.clone()
} else {
corpus.to_lowercase()
};
let keyword_match = check_keywords(&meta.keywords, &corpus_normalized, case_sensitive);
let regex_match = if keyword_match.is_none() {
check_regex_compiled(&entry.compiled_regex, &meta.regex, &corpus)
} else {
None
};
let reason = keyword_match.or(regex_match);
if let Some(reason) = reason {
if check_condition(&entry.condition, host, registry) {
seen.insert(meta.id.clone());
sticky_carry.remove(&meta.id);
activated.push(ActivationResult {
entry_id: meta.id.clone(),
reason,
priority: meta.priority,
});
}
}
}
for (id, (remaining, priority)) in sticky_carry {
if !seen.contains(&id) {
activated.push(ActivationResult {
entry_id: id,
reason: ActivationReason::Sticky {
remaining_turns: remaining,
},
priority,
});
}
}
activated.sort_by(|a, b| b.priority.cmp(&a.priority));
activated
}
pub fn filter_triggered(
lorebook: &Lorebook,
triggered_ids: &[String],
already_active: &[String],
host: &mut WeaverHost,
registry: &Registry,
state: &ActivationState,
) -> Vec<ActivationResult> {
let mut results = Vec::new();
for id in triggered_ids {
if already_active.contains(id) && state.is_sticky(id).is_none() {
continue;
}
if let Some(entry) = lorebook.get_entry(id) {
let meta = &entry.meta;
if !meta.enabled {
continue;
}
if state.is_on_cooldown(&meta.id, meta.cooldown) {
continue;
}
if check_condition(&entry.condition, host, registry) {
results.push(ActivationResult {
entry_id: meta.id.clone(),
reason: ActivationReason::Triggered,
priority: meta.priority,
});
}
}
}
results.sort_by(|a, b| b.priority.cmp(&a.priority));
results
}
}
fn check_condition(
condition: &Option<Arc<CompiledExpr>>,
host: &mut WeaverHost,
registry: &Registry,
) -> bool {
match condition {
None => true,
Some(expr) => match expr.evaluate(host, registry) {
Ok(val) => val.is_truthy(),
Err(_) => false,
},
}
}
fn check_keywords(
keywords: &[String],
corpus: &str,
case_sensitive: bool,
) -> Option<ActivationReason> {
if keywords.is_empty() {
return None;
}
let matched: Vec<String> = keywords
.iter()
.filter(|kw| {
let kw_normalized = if case_sensitive {
kw.to_string()
} else {
kw.to_lowercase()
};
corpus.contains(&kw_normalized)
})
.cloned()
.collect();
if matched.is_empty() {
None
} else {
Some(ActivationReason::Keyword { matched })
}
}
fn check_regex_compiled(
compiled: &[Regex],
raw_patterns: &[String],
corpus: &str,
) -> Option<ActivationReason> {
for (re, pattern) in compiled.iter().zip(raw_patterns.iter()) {
if re.is_match(corpus) {
return Some(ActivationReason::Regex {
pattern: pattern.clone(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_activation_state_cooldown() {
let mut state = ActivationState::new();
state.current_turn = 5;
state.record_activation("entry_a", 0);
state.current_turn = 6;
assert!(state.is_on_cooldown("entry_a", 3));
state.current_turn = 7;
assert!(state.is_on_cooldown("entry_a", 3));
state.current_turn = 8;
assert!(!state.is_on_cooldown("entry_a", 3));
assert!(!state.is_on_cooldown("entry_b", 3));
}
#[test]
fn test_activation_state_sticky() {
let mut state = ActivationState::new();
state.record_activation("entry_a", 3);
assert_eq!(state.is_sticky("entry_a"), Some(4));
state.advance_turn(); assert_eq!(state.is_sticky("entry_a"), Some(3));
state.advance_turn(); assert_eq!(state.is_sticky("entry_a"), Some(2));
state.advance_turn(); assert_eq!(state.is_sticky("entry_a"), Some(1));
state.advance_turn(); assert_eq!(state.is_sticky("entry_a"), None);
}
#[test]
fn test_sticky_refresh_resets_countdown() {
let mut state = ActivationState::new();
state.record_activation("entry_a", 2);
assert_eq!(state.is_sticky("entry_a"), Some(3));
state.advance_turn();
assert_eq!(state.is_sticky("entry_a"), Some(2));
state.advance_turn();
assert_eq!(state.is_sticky("entry_a"), Some(1));
state.record_activation("entry_a", 2);
assert_eq!(state.is_sticky("entry_a"), Some(3));
state.advance_turn();
assert_eq!(state.is_sticky("entry_a"), Some(2));
state.advance_turn();
assert_eq!(state.is_sticky("entry_a"), Some(1));
state.advance_turn();
assert_eq!(state.is_sticky("entry_a"), None);
}
#[test]
fn test_activation_state_advance_tracks_turn() {
let mut state = ActivationState::new();
assert_eq!(state.current_turn(), 0);
state.advance_turn();
assert_eq!(state.current_turn(), 1);
state.advance_turn();
assert_eq!(state.current_turn(), 2);
}
#[test]
fn test_activation_state_serialization() {
let mut state = ActivationState::new();
state.record_activation("entry_a", 3);
state.advance_turn();
let json = serde_json::to_string(&state).unwrap();
let restored: ActivationState = serde_json::from_str(&json).unwrap();
assert_eq!(restored.current_turn(), 1);
assert_eq!(restored.is_sticky("entry_a"), Some(3));
}
#[test]
fn test_condition_none_is_true() {
let mut host = crate::host::WeaverHost::from_lorebook_config(
&crate::lorebook::LorebookConfig::default(),
);
let registry = Registry::new();
assert!(check_condition(&None, &mut host, ®istry));
}
#[test]
fn test_condition_true_expression() {
let mut host = crate::host::WeaverHost::from_lorebook_config(
&crate::lorebook::LorebookConfig::default(),
);
host.set_host_variable("state", "level", weaver_lang::Value::Number(10.0));
let registry = Registry::new();
let expr = Arc::new(CompiledExpr::compile("{{state:level}} > 5").unwrap());
assert!(check_condition(&Some(expr), &mut host, ®istry));
}
#[test]
fn test_condition_false_expression() {
let mut host = crate::host::WeaverHost::from_lorebook_config(
&crate::lorebook::LorebookConfig::default(),
);
host.set_host_variable("state", "level", weaver_lang::Value::Number(3.0));
let registry = Registry::new();
let expr = Arc::new(CompiledExpr::compile("{{state:level}} > 5").unwrap());
assert!(!check_condition(&Some(expr), &mut host, ®istry));
}
#[test]
fn test_condition_error_returns_false() {
let mut host = crate::host::WeaverHost::from_lorebook_config(
&crate::lorebook::LorebookConfig::default(),
);
let registry = Registry::new();
let expr = Arc::new(CompiledExpr::compile("{{state:missing}} > 5").unwrap());
assert!(!check_condition(&Some(expr), &mut host, ®istry));
}
}