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}