Skip to main content

context_weaver/
lib.rs

1//! # ContextWeaver
2//!
3//! A lorebook engine for LLM role-playing applications, built on
4//! [weaver-lang](../weaver_lang). ContextWeaver manages a collection of
5//! entries that are selectively activated based on conversation context
6//! and assembled into the final prompt sent to the model.
7//!
8//! ## Architecture
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────────────┐
12//! │  Host Application (LLM frontend)                    │
13//! │                                                     │
14//! │  Provides: chat history, character data, user prefs │
15//! │  Receives: assembled context blocks for the prompt  │
16//! └────────────────────────┬────────────────────────────┘
17//!                          │
18//! ┌────────────────────────▼────────────────────────────┐
19//! │  ContextWeaver                                      │
20//! │                                                     │
21//! │  ┌─────────────┐  ┌────────────┐  ┌─────────────┐   │
22//! │  │  Lorebook   │  │ Activation │  │  Assembler  │   │
23//! │  │  (entries)  │──│  Engine    │──│  (ordering, │   │
24//! │  │             │  │            │  │   budgeting)│   │
25//! │  └─────────────┘  └────────────┘  └──────┬──────┘   │
26//! │                                          │          │
27//! │  ┌───────────────────────────────────────▼──────┐   │
28//! │  │  WeaverHost (EvalContext impl)               │   │
29//! │  │  - namespace management                      │   │
30//! │  │  - read-only enforcement                     │   │
31//! │  │  - trigger collection (no output)            │   │
32//! │  │  - document → recursive entry evaluation     │   │
33//! │  └──────────────────────────────────────────────┘   │
34//! │                                                     │
35//! │  ┌──────────────────────────────────────────────┐   │
36//! │  │  Plugin Interface                            │   │
37//! │  │  - custom processors & commands              │   │
38//! │  │  - activation hooks                          │   │
39//! │  └──────────────────────────────────────────────┘   │
40//! └─────────────────────────────────────────────────────┘
41//!                          │
42//! ┌────────────────────────▼────────────────────────────┐
43//! │  weaver-lang (template evaluation)                  │
44//! └─────────────────────────────────────────────────────┘
45//! ```
46//!
47//! ## Quick start
48//!
49//! ```rust,ignore
50//! use context_weaver::{ContextWeaver, Lorebook, ChatMessage, Slot};
51//!
52//! // Load a lorebook from disk
53//! let book = Lorebook::load_from_directory("./my_character/lorebook")?;
54//!
55//! // Configure the engine
56//! let mut weaver = ContextWeaver::new(book);
57//! weaver.set_variable("char", "name", "Aria");
58//! weaver.set_variable("char", "class", "Mage");
59//! weaver.set_variable("user", "name", "Player");
60//!
61//! // Provide conversation context
62//! let messages = vec![
63//!     ChatMessage::user("I walk into the dark forest"),
64//!     ChatMessage::assistant("The trees close in around you..."),
65//! ];
66//!
67//! // Assemble activated entries into context blocks
68//! let blocks = weaver.assemble(&messages)?;
69//! for block in &blocks {
70//!     println!("[{}] {}", block.slot, block.content);
71//! }
72//! ```
73
74pub mod activation;
75pub mod assembler;
76pub mod entry;
77pub mod host;
78pub mod lifecycle;
79pub mod lorebook;
80pub mod plugin;
81#[cfg(feature = "stdlib")]
82pub mod stdlib;
83
84pub use activation::{ActivationEngine, ActivationReason, ActivationResult, ActivationState};
85pub use assembler::{
86    AssembledBlock, ContextAssembler, GuesstimationTokenizer, Slot, TokenBudget, Tokenizer,
87};
88pub use entry::{Entry, EntryMeta};
89pub use host::{NamespaceAccess, NamespaceConfig, WeaverHost};
90pub use lifecycle::{
91    FnLifecycle, HookError, LifecyclePlugin, PostActivationCtx, PostAssembleCtx, PostEvaluateCtx,
92    PreActivationCtx, PreEvaluateCtx, TriggerCtx, TurnAdvanceCtx,
93};
94pub use lorebook::{Lorebook, LorebookConfig};
95pub use plugin::Plugin;
96
97use std::collections::{HashMap, HashSet};
98use std::sync::Arc;
99
100use weaver_lang::registry::{CommandSignature, ParamDef, WeaverCommand};
101use weaver_lang::{CompiledTemplate, EvalContext, EvalError, Registry, Value};
102
103// ── Top-level engine ────────────────────────────────────────────────────
104
105/// The main entry point for ContextWeaver.
106///
107/// Owns a [`Lorebook`], manages the [`Registry`] and [`WeaverHost`],
108/// and orchestrates the activation → evaluation → assembly pipeline.
109pub struct ContextWeaver {
110    lorebook: Lorebook,
111    registry: Registry,
112    host: WeaverHost,
113    activation_state: ActivationState,
114    config: EngineConfig,
115    /// The tokenizer used for budget estimation.
116    tokenizer: Box<dyn Tokenizer>,
117    /// Which slots are available in the host's ContextDefinition template.
118    /// Entries targeting unavailable slots (with no matching fallback) are dropped.
119    available_slots: HashSet<Slot>,
120    /// Lifecycle plugins registered to hook into the assembly pipeline.
121    /// Fired in registration order; see the [`lifecycle`] module for details.
122    lifecycle_plugins: Vec<Box<dyn LifecyclePlugin>>,
123}
124
125/// A chat message provided by the host application for activation scanning.
126#[derive(Debug, Clone)]
127pub struct ChatMessage {
128    pub role: ChatRole,
129    pub content: String,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ChatRole {
134    User,
135    Assistant,
136    System,
137}
138
139impl ChatMessage {
140    pub fn user(content: impl Into<String>) -> Self {
141        Self {
142            role: ChatRole::User,
143            content: content.into(),
144        }
145    }
146
147    pub fn assistant(content: impl Into<String>) -> Self {
148        Self {
149            role: ChatRole::Assistant,
150            content: content.into(),
151        }
152    }
153
154    pub fn system(content: impl Into<String>) -> Self {
155        Self {
156            role: ChatRole::System,
157            content: content.into(),
158        }
159    }
160}
161
162/// Top-level engine configuration.
163#[derive(Debug, Clone)]
164pub struct EngineConfig {
165    /// Maximum recursion depth for document chains.
166    pub max_recursion_depth: usize,
167    /// Maximum number of entries that can activate per assembly pass.
168    pub max_active_entries: usize,
169    /// Number of trigger-resolution passes (trigger output activating
170    /// further entries).
171    pub max_trigger_passes: usize,
172    /// Whether to use lenient evaluation (pass through errors as raw syntax).
173    pub lenient: bool,
174}
175
176impl Default for EngineConfig {
177    fn default() -> Self {
178        Self {
179            max_recursion_depth: 10,
180            max_active_entries: 100,
181            max_trigger_passes: 3,
182            lenient: false,
183        }
184    }
185}
186
187impl ContextWeaver {
188    pub fn new(lorebook: Lorebook) -> Self {
189        let mut host = WeaverHost::from_lorebook_config(&lorebook.config);
190        let mut registry = Registry::new();
191
192        // Register built-in commands and processors
193        register_builtins(&mut registry);
194
195        // Set max recursion depth from default config
196        host.set_max_recursion_depth(EngineConfig::default().max_recursion_depth);
197
198        Self {
199            lorebook,
200            registry,
201            host,
202            activation_state: ActivationState::new(),
203            config: EngineConfig::default(),
204            tokenizer: Box::new(GuesstimationTokenizer),
205            available_slots: Slot::standard_slots().into_iter().collect(),
206            lifecycle_plugins: Vec::new(),
207        }
208    }
209
210    pub fn with_config(mut self, config: EngineConfig) -> Self {
211        self.host
212            .set_max_recursion_depth(config.max_recursion_depth);
213        self.config = config;
214        self
215    }
216
217    /// Set a custom tokenizer for accurate token budget enforcement.
218    ///
219    /// The default is [`GuesstimationTokenizer`] which estimates ~4 chars
220    /// per token. For production use, provide a tokenizer that matches
221    /// your target model (tiktoken, sentencepiece, etc.).
222    pub fn set_tokenizer(&mut self, tokenizer: Box<dyn Tokenizer>) {
223        self.tokenizer = tokenizer;
224    }
225
226    /// Set which slots are available in the host's ContextDefinition.
227    ///
228    /// Entries targeting slots not in this set (and with no matching
229    /// fallback) will be silently dropped during assembly. By default,
230    /// all standard slots are available.
231    pub fn set_available_slots(&mut self, slots: impl IntoIterator<Item = Slot>) {
232        self.available_slots = slots.into_iter().collect();
233    }
234
235    /// Set a variable in a host-provided namespace.
236    ///
237    /// This is how the host application feeds data into the lorebook:
238    /// character attributes, user preferences, world state, etc.
239    pub fn set_variable(&mut self, scope: &str, name: &str, value: impl Into<Value>) {
240        self.host.set_host_variable(scope, name, value.into());
241    }
242
243    /// Register a plugin, adding its processors and commands to the registry.
244    pub fn register_plugin(&mut self, plugin: impl Plugin) -> Result<(), plugin::PluginError> {
245        plugin.register(&mut self.registry);
246        plugin.init()
247    }
248
249    pub fn register_lifecycle<P: LifecyclePlugin + 'static>(&mut self, plugin: P) {
250        self.lifecycle_plugins.push(Box::new(plugin));
251    }
252
253    /// Access the activation state (for serialization / inspection).
254    pub fn activation_state(&self) -> &ActivationState {
255        &self.activation_state
256    }
257
258    /// Restore activation state (e.g. from a save file).
259    pub fn restore_activation_state(&mut self, state: ActivationState) {
260        self.activation_state = state;
261    }
262
263    /// Access the host's persistent state (for serialization).
264    pub fn persistent_state(&self) -> &HashMap<String, Value> {
265        self.host.persistent_state()
266    }
267
268    /// Restore persistent state (e.g. from a save file).
269    pub fn restore_persistent_state(&mut self, state: HashMap<String, Value>) {
270        self.host.restore_persistent_state(state);
271    }
272
273    /// Advance the turn counter. Call this once per conversation turn,
274    /// before `assemble`. Decrements sticky counters and clears
275    /// transient variables.
276    pub fn advance_turn(&mut self) -> Result<(), ContextWeaverError> {
277        self.activation_state.advance_turn();
278        self.host.clear_transient();
279
280        // ── Lifecycle: on_turn_advance ──────────────────────────────
281        let state = &mut self.activation_state;
282        for plugin in &mut self.lifecycle_plugins {
283            let plugin_name = plugin.name().to_string();
284            let mut ctx = TurnAdvanceCtx { state };
285            plugin
286                .on_turn_advance(&mut ctx)
287                .map_err(|e| ContextWeaverError::PluginHook {
288                    plugin: plugin_name,
289                    hook: "on_turn_advance",
290                    source: e,
291                })?;
292        }
293
294        Ok(())
295    }
296
297    /// Run the full pipeline: activate → evaluate → assemble.
298    ///
299    /// Returns ordered context blocks ready for prompt insertion.
300    pub fn assemble(
301        &mut self,
302        messages: &[ChatMessage],
303    ) -> Result<Vec<AssembledBlock>, ContextWeaverError> {
304        // Clone messages so pre_activation hooks can mutate.
305        let mut messages_owned: Vec<ChatMessage> = messages.to_vec();
306        let turn = self.activation_state.current_turn();
307
308        // ── Lifecycle: pre_activation ───────────────────────────────
309        for plugin in &mut self.lifecycle_plugins {
310            let plugin_name = plugin.name().to_string();
311            let mut ctx = PreActivationCtx {
312                messages: &mut messages_owned,
313                turn,
314            };
315            plugin
316                .pre_activation(&mut ctx)
317                .map_err(|e| ContextWeaverError::PluginHook {
318                    plugin: plugin_name,
319                    hook: "pre_activation",
320                    source: e,
321                })?;
322        }
323
324        // ── Prepare host for evaluation ─────────────────────────────
325        let entry_templates = self.build_template_map();
326        self.host.set_entry_templates(entry_templates);
327
328        // ── Phase 1: Activation scan ────────────────────────────────
329        let mut results = ActivationEngine::scan(
330            &self.lorebook,
331            messages,
332            &mut self.host,
333            &self.registry,
334            &self.activation_state,
335        );
336
337        // ── Lifecycle: post_activation ──────────────────────────────
338        {
339            let lifecycle_plugins = &mut self.lifecycle_plugins;
340            let lorebook = &self.lorebook;
341            for plugin in lifecycle_plugins {
342                let plugin_name = plugin.name().to_string();
343                let mut ctx = PostActivationCtx {
344                    results: &mut results,
345                    lorebook,
346                    turn,
347                };
348                plugin
349                    .post_activation(&mut ctx)
350                    .map_err(|e| ContextWeaverError::PluginHook {
351                        plugin: plugin_name,
352                        hook: "post_activation",
353                        source: e,
354                    })?;
355            }
356        }
357
358        // Enforce max active entries
359        if results.len() > self.config.max_active_entries {
360            results.truncate(self.config.max_active_entries);
361        }
362
363        let mut active_ids: Vec<String> = results.iter().map(|r| r.entry_id.clone()).collect();
364
365        // Tell the host which entries are active (for trigger dedup and
366        // the is_active command via the _active namespace)
367        self.host
368            .set_active_entries(active_ids.iter().cloned().collect());
369
370        // ── Phase 2: Evaluate + trigger resolution passes ───────────
371        //
372        // Each pass evaluates entries and captures both their output AND
373        // any trigger side effects. Already-evaluated entries are NOT
374        // re-evaluated — their cached output is reused. This prevents
375        // side effects (inc_var, set_var, push_var) from running twice.
376        //
377        // Each pass:
378        //   1. Evaluate un-evaluated entries, caching output
379        //   2. Drain triggered IDs from host
380        //   3. Filter through cooldown/conditions
381        //   4. Add newly activated entries to the list, repeat
382        let mut evaluated_cache: HashMap<String, EvaluatedEntry> = HashMap::new();
383
384        // Evaluate the initial batch and cache results
385        for entry in self.evaluate_entries(&active_ids)? {
386            evaluated_cache.insert(entry.id.clone(), entry);
387        }
388
389        for pass_number in 0..self.config.max_trigger_passes {
390            // Drain trigger activations collected during evaluation
391            let mut triggered = self.host.drain_triggered_entries();
392            if triggered.is_empty() {
393                break;
394            }
395
396            // ── Lifecycle: on_trigger_fired ─────────────────────────
397            for plugin in &mut self.lifecycle_plugins {
398                let plugin_name = plugin.name().to_string();
399                let mut ctx = TriggerCtx {
400                    triggered_ids: &mut triggered,
401                    pass_number,
402                };
403                plugin
404                    .on_trigger_fired(&mut ctx)
405                    .map_err(|e| ContextWeaverError::PluginHook {
406                        plugin: plugin_name,
407                        hook: "on_trigger_fired",
408                        source: e,
409                    })?;
410            }
411
412            // Filter through activation rules
413            let new_results = ActivationEngine::filter_triggered(
414                &self.lorebook,
415                &triggered,
416                &active_ids,
417                &mut self.host,
418                &self.registry,
419                &self.activation_state,
420            );
421
422            if new_results.is_empty() {
423                break;
424            }
425
426            // Collect truly new entry IDs (skip entries already in
427            // active_ids — they may be sticky refreshes that don't need
428            // re-evaluation or duplicate list entries)
429            let new_ids: Vec<String> = new_results
430                .iter()
431                .map(|r| r.entry_id.clone())
432                .filter(|id| !active_ids.contains(id))
433                .collect();
434
435            for id in &new_ids {
436                active_ids.push(id.clone());
437            }
438            results.extend(new_results);
439
440            // Update host's active set
441            self.host
442                .set_active_entries(active_ids.iter().cloned().collect());
443
444            // Evaluate ONLY the truly new entries (not sticky refreshes)
445            if !new_ids.is_empty() {
446                for entry in self.evaluate_entries(&new_ids)? {
447                    evaluated_cache.insert(entry.id.clone(), entry);
448                }
449            }
450
451            if active_ids.len() > self.config.max_active_entries {
452                active_ids.truncate(self.config.max_active_entries);
453                break;
454            }
455        }
456
457        // ── Phase 3: Collect evaluated entries in activation order ───
458        let evaluated: Vec<EvaluatedEntry> = active_ids
459            .iter()
460            .filter_map(|id| evaluated_cache.remove(id))
461            .collect();
462
463        // ── Phase 4: Record activations ─────────────────────────────
464        //
465        // Only record FRESH activations (keyword, regex, constant,
466        // triggered) — not sticky carry-forwards. This ensures that
467        // carry-forwards don't reset the sticky countdown, while fresh
468        // re-activations (keyword re-match, trigger refresh) DO reset it.
469        //
470        // When an entry appears multiple times in `results` (e.g. once
471        // as Sticky carry-forward, once as Triggered refresh), the
472        // non-Sticky entry takes precedence here because we iterate
473        // all results and the last `record_activation` call wins.
474        for result in &results {
475            if matches!(result.reason, ActivationReason::Sticky { .. }) {
476                continue;
477            }
478            if let Some(entry) = self.lorebook.get_entry(&result.entry_id) {
479                self.activation_state
480                    .record_activation(&result.entry_id, entry.meta.sticky_turns);
481            }
482        }
483
484        // ── Phase 5: Assemble ───────────────────────────────────────
485        let mut blocks = ContextAssembler::assemble(
486            evaluated,
487            &self.lorebook.config,
488            &*self.tokenizer,
489            &self.available_slots,
490        );
491
492        // ── Lifecycle: post_assemble ────────────────────────────────
493        {
494            let lifecycle_plugins = &mut self.lifecycle_plugins;
495            let lorebook = &self.lorebook;
496            for plugin in lifecycle_plugins {
497                let plugin_name = plugin.name().to_string();
498                let mut ctx = PostAssembleCtx {
499                    blocks: &mut blocks,
500                    lorebook,
501                };
502                plugin
503                    .post_assemble(&mut ctx)
504                    .map_err(|e| ContextWeaverError::PluginHook {
505                        plugin: plugin_name,
506                        hook: "post_assemble",
507                        source: e,
508                    })?;
509            }
510        }
511
512        Ok(blocks)
513    }
514
515    /// Build the entry ID → Arc<CompiledTemplate> map for the host.
516    fn build_template_map(&self) -> HashMap<String, Arc<CompiledTemplate>> {
517        self.lorebook
518            .entries_in_order()
519            .map(|e| (e.meta.id.clone(), e.compiled.clone()))
520            .collect()
521    }
522
523    /// Evaluate all active entries and collect their output.
524    fn evaluate_entries(
525        &mut self,
526        entry_ids: &[String],
527    ) -> Result<Vec<EvaluatedEntry>, ContextWeaverError> {
528        let mut results = Vec::new();
529
530        for id in entry_ids {
531            if let Some(entry) = self.lorebook.get_entry(id).cloned() {
532                if let Some(content) = self.evaluate_single_entry(&entry)? {
533                    results.push(EvaluatedEntry {
534                        id: id.clone(),
535                        meta: entry.meta.clone(),
536                        content,
537                    });
538                }
539            }
540        }
541
542        Ok(results)
543    }
544
545    /// Evaluate a single entry's template against the current host state.
546    /// Returns `Ok(None)` if a `pre_evaluate` hook set `skip = true`.
547    fn evaluate_single_entry(
548        &mut self,
549        entry: &Entry,
550    ) -> Result<Option<String>, ContextWeaverError> {
551        // ── Lifecycle: pre_evaluate ─────────────────────────────────
552        let mut skip = false;
553        for plugin in &mut self.lifecycle_plugins {
554            let plugin_name = plugin.name().to_string();
555            let mut ctx = PreEvaluateCtx {
556                entry,
557                skip: &mut skip,
558            };
559            plugin
560                .pre_evaluate(&mut ctx)
561                .map_err(|e| ContextWeaverError::PluginHook {
562                    plugin: plugin_name,
563                    hook: "pre_evaluate",
564                    source: e,
565                })?;
566        }
567        if skip {
568            return Ok(None);
569        }
570
571        // ── Evaluate ────────────────────────────────────────────────
572        self.host.begin_entry(&entry.meta.id);
573
574        let opts = weaver_lang::EvalOptions::new()
575            .max_node_evaluations(50_000)
576            .max_iterations(10_000)
577            .lenient(self.config.lenient);
578
579        let result = weaver_lang::evaluate_with_options(
580            entry.compiled.ast(),
581            &mut self.host,
582            &self.registry,
583            opts,
584        );
585
586        self.host.end_entry();
587
588        let mut content = result.map_err(|e| ContextWeaverError::Eval {
589            entry_id: entry.meta.id.clone(),
590            source: e,
591        })?;
592
593        // ── Lifecycle: post_evaluate ────────────────────────────────
594        for plugin in &mut self.lifecycle_plugins {
595            let plugin_name = plugin.name().to_string();
596            let mut ctx = PostEvaluateCtx {
597                entry,
598                content: &mut content,
599            };
600            plugin
601                .post_evaluate(&mut ctx)
602                .map_err(|e| ContextWeaverError::PluginHook {
603                    plugin: plugin_name,
604                    hook: "post_evaluate",
605                    source: e,
606                })?;
607        }
608
609        Ok(Some(content))
610    }
611}
612
613/// An entry that has been evaluated to its final string content.
614pub struct EvaluatedEntry {
615    pub id: String,
616    pub meta: EntryMeta,
617    pub content: String,
618}
619
620// ── Errors ──────────────────────────────────────────────────────────────
621
622#[derive(Debug)]
623pub enum ContextWeaverError {
624    /// Failed to parse an entry's frontmatter.
625    MetaParse { entry_path: String, message: String },
626    /// Failed to parse an entry's weaver-lang body.
627    TemplateParse {
628        entry_id: String,
629        errors: Vec<weaver_lang::ParseError>,
630    },
631    /// Failed during template evaluation.
632    Eval {
633        entry_id: String,
634        source: weaver_lang::EvalError,
635    },
636    /// A document reference hit the recursion limit.
637    RecursionLimit { entry_id: String, depth: usize },
638    /// I/O error loading lorebook files.
639    Io(std::io::Error),
640    PluginHook {
641        plugin: String,
642        hook: &'static str,
643        source: HookError,
644    },
645}
646
647impl std::fmt::Display for ContextWeaverError {
648    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649        match self {
650            Self::MetaParse {
651                entry_path,
652                message,
653            } => {
654                write!(f, "metadata parse error in {entry_path}: {message}")
655            }
656            Self::TemplateParse { entry_id, errors } => {
657                write!(f, "template parse error in entry '{entry_id}':")?;
658                for e in errors {
659                    write!(f, "\n  {e}")?;
660                }
661                Ok(())
662            }
663            Self::Eval { entry_id, source } => {
664                write!(f, "evaluation error in entry '{entry_id}': {source}")
665            }
666            Self::RecursionLimit { entry_id, depth } => {
667                write!(f, "recursion limit ({depth}) hit from entry '{entry_id}'")
668            }
669            Self::Io(e) => write!(f, "I/O error: {e}"),
670            Self::PluginHook {
671                plugin,
672                hook,
673                source,
674            } => {
675                write!(f, "lifecycle plugin '{plugin}' failed in {hook}: {source}")
676            }
677        }
678    }
679}
680
681impl std::error::Error for ContextWeaverError {}
682
683impl From<std::io::Error> for ContextWeaverError {
684    fn from(e: std::io::Error) -> Self {
685        Self::Io(e)
686    }
687}
688
689// ── Built-in commands & processors ──────────────────────────────────────
690
691fn register_builtins(registry: &mut Registry) {
692    // When the stdlib feature is enabled, register the full standard library.
693    // Otherwise, register minimal placeholders.
694    #[cfg(feature = "stdlib")]
695    {
696        stdlib::register(registry);
697    }
698
699    #[cfg(not(feature = "stdlib"))]
700    {
701        // Minimal built-in set when stdlib is disabled.
702        registry.register_processor(weaver_lang::ClosureProcessor::new(
703            "text",
704            "upper",
705            |props| {
706                let text = props.get("text").and_then(|v| v.as_string()).unwrap_or("");
707                Ok(Value::String(text.to_uppercase()))
708            },
709        ));
710        registry.register_processor(weaver_lang::ClosureProcessor::new(
711            "text",
712            "lower",
713            |props| {
714                let text = props.get("text").and_then(|v| v.as_string()).unwrap_or("");
715                Ok(Value::String(text.to_lowercase()))
716            },
717        ));
718    }
719
720    // $[is_active("entry_id")] — check if an entry is currently active.
721    // Uses the _active namespace populated by WeaverHost::set_active_entries.
722    registry.register_command(IsActiveCommand);
723}
724
725// ── is_active command ──────────────────────────────────────────────────
726
727/// `$[is_active("entry_id")]` — check if a lorebook entry is active
728/// in the current evaluation pass.
729///
730/// Returns `true` if the entry ID is in the active set, `false`
731/// otherwise. Works by reading the `_active` namespace which the
732/// engine populates before evaluation.
733struct IsActiveCommand;
734
735impl WeaverCommand for IsActiveCommand {
736    fn call(
737        &self,
738        args: Vec<Value>,
739        ctx: &mut dyn EvalContext,
740        _registry: &Registry,
741    ) -> Result<Option<Value>, EvalError> {
742        let id = args.first().and_then(|v| v.as_string()).ok_or_else(|| {
743            EvalError::type_error("string", args.first().map_or("none", |v| v.type_name()))
744        })?;
745
746        let is_active = ctx
747            .resolve_variable("_active", id)?
748            .is_some_and(|v| v.is_truthy());
749
750        Ok(Some(Value::Bool(is_active)))
751    }
752
753    fn signature(&self) -> CommandSignature {
754        CommandSignature {
755            name: "is_active".to_string(),
756            params: vec![ParamDef {
757                name: "entry_id".to_string(),
758                expected_type: Some(weaver_lang::registry::ValueType::String),
759                required: true,
760            }],
761        }
762    }
763}