Skip to main content

context_forge/
lib.rs

1//! `context-forge` — a local-first persistent memory library for LLM applications.
2//!
3//! This crate provides turso + Tantivy BM25 retrieval, recency-decay scoring, and
4//! token-budget-aware context assembly with no network calls. It is intended to
5//! be embedded in larger applications (CLI tools, bots, agent runtimes) that need
6//! durable, searchable memory.
7//!
8//! # Quick start
9//!
10//! ```no_run
11//! use context_forge::{kind, ContextForge, Config, SaveOptions};
12//! use std::path::PathBuf;
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), context_forge::Error> {
16//!     let mut config = Config::default();
17//!     config.db_path = PathBuf::from(":memory:");
18//!
19//!     let cf = ContextForge::open(config).await?;
20//!     cf.save("the deploy failure was caused by a missing env var", kind::SNAPSHOT, &SaveOptions::default()).await?;
21//!
22//!     let hits = cf.query("deploy failure", None, 2048).await?;
23//!     assert_eq!(hits.len(), 1);
24//!     Ok(())
25//! }
26//! ```
27//!
28//! # Security
29//!
30//! **Retrieved entries are untrusted text.** Content persisted from past
31//! conversations may contain adversarial instructions (stored prompt
32//! injection) — whatever was saved into the store, including text that
33//! originated from another user or from a tool's output, comes back out
34//! verbatim on [`ContextForge::query`] (aside from save-time secret
35//! scrubbing, see below).
36//!
37//! Callers **MUST** present retrieved memory to models as quoted data
38//! (e.g. inside a fenced or otherwise clearly delimited context block
39//! labeled as history), **never** as system-level instructions, and
40//! **MUST NOT** execute or evaluate anything found in it.
41//!
42//! ## Save-time secret scrubbing
43//!
44//! [`ContextForge::save`] applies [`scrub_secrets`] to `content` before it
45//! is persisted, using the [`ScrubConfig`] supplied in [`Config::scrub`].
46//! This redacts common credential formats (cloud provider keys, API
47//! tokens, private key blocks, JWTs, bearer tokens) with
48//! `[REDACTED:<label>]` placeholders so they never reach the database or
49//! the search index. Scrubbing is **on by default** and can be disabled
50//! via `Config { scrub: ScrubConfig { enabled: false }, .. }` — this is an
51//! explicit, non-silent opt-out.
52//!
53//! Note that [`SaveOptions::metadata`] is **not** scrubbed (see its docs).
54
55#![warn(clippy::pedantic)]
56#![warn(missing_docs)]
57
58/// Engine and scrub configuration types (`Config`, `EvictionPolicy`, `ScrubConfig`).
59pub mod config;
60/// Local-LLM distillation trait and the optional `distill-http` implementation.
61pub mod distill;
62/// `ContextEngine`: search, recency decay, and token-budget assembly.
63pub mod engine;
64/// `ContextEntry`, `ScoredEntry`, and the `kind` constants module.
65pub mod entry;
66/// The crate's `Error` type.
67pub mod error;
68/// Save-time secret scrubbing (`scrub_secrets`, `ScrubConfig`).
69pub mod scrub;
70/// Session grouping helpers (`group_entries_by_session`, `SessionGroup`).
71pub mod session;
72/// Turso-backed storage and search implementations.
73pub mod storage;
74/// `ContextStorage` and `Searcher` traits, and the crate's `Result` alias.
75pub mod traits;
76
77/// Importance-detection pipeline (tokenizer, lexicon, scoring). Pure
78/// computation, no I/O. Enabled by the `analysis` feature (default).
79#[cfg(feature = "analysis")]
80pub mod analysis;
81
82#[cfg(feature = "parallel")]
83pub use analysis::with_thread_cap;
84
85use std::path::Path;
86
87// Re-export primary types at crate root for convenience.
88pub use config::{Config, EvictionPolicy};
89pub use distill::{
90    merge_distilled, split_on_budget, ChunkingDistiller, DistilledMemory, Distiller, Fact,
91    FactKind, ReduceStrategy,
92};
93pub use engine::{ContextEngine, SaveOptions, MATCH_ALL_QUERY};
94pub use entry::{kind, ContextEntry, ScoredEntry};
95pub use error::Error;
96pub use scrub::{scrub_secrets, ScrubConfig};
97pub use session::{group_entries_by_session, SessionGroup};
98pub use storage::{open_storage, TursoSearcher, TursoStorage};
99pub use traits::{ContextStorage, Result, Searcher};
100
101/// The documented entry point for `context-forge`.
102///
103/// `ContextForge` wires together a [`TursoStorage`] backend, a
104/// [`TursoSearcher`], and a [`ContextEngine`] behind a small,
105/// stable API surface. Advanced callers that need direct access to the
106/// underlying storage or searcher can construct those types directly and
107/// pass them to [`ContextEngine::new`] instead.
108pub struct ContextForge {
109    engine: ContextEngine,
110    scrub_config: ScrubConfig,
111}
112
113impl ContextForge {
114    /// Open (or create) the database at `config.db_path`, run any pending
115    /// migrations, and build the engine.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the database cannot be opened or migrations fail.
120    pub async fn open(config: Config) -> Result<Self> {
121        let db_path = config.db_path.clone();
122        let max_entries = config.max_entries;
123        let scrub_config = config.scrub.clone();
124        let (storage, searcher) = open_storage(Path::new(&db_path), max_entries).await?;
125        let engine = ContextEngine::new(Box::new(storage), Box::new(searcher), config);
126        Ok(Self {
127            engine,
128            scrub_config,
129        })
130    }
131
132    /// Save a new entry. Returns the generated entry ID.
133    ///
134    /// `kind` is a caller-defined classification (see [`mod@kind`] for
135    /// well-known values). Capacity enforcement (LRU eviction) is handled
136    /// atomically by the storage layer.
137    ///
138    /// Before persistence, `content` is passed through [`scrub_secrets`]
139    /// using this instance's [`ScrubConfig`] (see [`Config::scrub`]),
140    /// redacting common credential formats with `[REDACTED:<label>]`
141    /// placeholders. `opts.metadata` is stored verbatim and is **not**
142    /// scrubbed — see [`SaveOptions::metadata`].
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if `content` is empty or if the underlying storage
147    /// write fails.
148    pub async fn save(&self, content: &str, kind: &str, opts: &SaveOptions) -> Result<String> {
149        let scrubbed = scrub_secrets(content, &self.scrub_config);
150        self.engine.save_snapshot(&scrubbed, kind, opts).await
151    }
152
153    /// Distill `transcript` into a summary and durable facts, then save
154    /// them as separate entries sharing `opts.scope` and
155    /// `opts.session_id`.
156    ///
157    /// `transcript` is passed through [`scrub_secrets`] **before** it is
158    /// sent to `distiller` (so secrets never reach a distillation
159    /// endpoint), and the summary/facts produced are scrubbed again before
160    /// persistence via the normal [`ContextForge::save`] path (defense in
161    /// depth).
162    ///
163    /// The summary is saved with `kind::SUMMARY`; each fact is saved with
164    /// `kind::FACT` and metadata `{"fact_kind": "<kind>", "source":
165    /// "distill"}`.
166    ///
167    /// The distilled output is bounded before any of it is saved: at most
168    /// [`MAX_FACTS`](crate::distill::MAX_FACTS) facts are kept, each fact's
169    /// text is truncated to at most
170    /// [`MAX_FACT_CHARS`](crate::distill::MAX_FACT_CHARS) characters, and the
171    /// summary is truncated to at most
172    /// [`MAX_SUMMARY_CHARS`](crate::distill::MAX_SUMMARY_CHARS) characters.
173    /// Excess facts and text beyond these limits are silently dropped or
174    /// truncated, since they are untrusted model-generated content.
175    ///
176    /// Returns the IDs of the saved entries: the summary's ID first,
177    /// followed by each fact's ID in order.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if distillation fails, or if any save fails.
182    pub async fn distill_and_save(
183        &self,
184        transcript: &str,
185        distiller: &dyn Distiller,
186        opts: &SaveOptions,
187    ) -> Result<Vec<String>> {
188        let scrubbed_transcript = scrub_secrets(transcript, &self.scrub_config);
189        let memory = tokio::task::block_in_place(|| distiller.distill(&scrubbed_transcript))?;
190        let memory = crate::distill::cap_distilled_memory(memory);
191
192        let mut ids = Vec::with_capacity(1 + memory.facts.len());
193
194        let summary_id = self.save(&memory.summary, kind::SUMMARY, opts).await?;
195        ids.push(summary_id);
196
197        for fact in &memory.facts {
198            let fact_kind_str = match fact.kind {
199                FactKind::Decision => "decision",
200                FactKind::Correction => "correction",
201                FactKind::Preference => "preference",
202                FactKind::State => "state",
203            };
204            let metadata = serde_json::json!({
205                "fact_kind": fact_kind_str,
206                "source": "distill",
207            });
208            let fact_opts = SaveOptions {
209                session_id: opts.session_id.clone(),
210                scope: opts.scope.clone(),
211                metadata: Some(metadata),
212            };
213            let fact_id = self.save(&fact.text, kind::FACT, &fact_opts).await?;
214            ids.push(fact_id);
215        }
216
217        Ok(ids)
218    }
219
220    /// Assemble entries matching `query` that fit within `token_budget`.
221    ///
222    /// `scope = None` searches every entry regardless of scope (global
223    /// recall). `scope = Some(s)` restricts the search to entries whose
224    /// `scope` equals `s`.
225    ///
226    /// `query` is treated as natural-language text: it is split into
227    /// alphanumeric terms which are OR-matched and ranked by bm25 relevance.
228    /// FTS5 operator syntax (`AND`, `OR`, `NEAR`, prefix `*`, quoted phrases,
229    /// column filters, etc.) is **not** interpreted — operator characters are
230    /// treated as term separators, so arbitrary user text never produces a
231    /// query syntax error. A query with no alphanumeric terms (empty or
232    /// punctuation-only) returns an empty result set rather than an error.
233    /// The special value [`MATCH_ALL_QUERY`] (`"*"`) matches every entry.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the search or recency-weighting step fails.
238    pub async fn query(
239        &self,
240        query: &str,
241        scope: Option<&str>,
242        token_budget: usize,
243    ) -> Result<Vec<ContextEntry>> {
244        self.engine.assemble(query, scope, token_budget).await
245    }
246
247    /// Delete a single entry by id. Returns `true` if an entry was removed.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the underlying storage delete fails.
252    pub async fn delete(&self, id: &str) -> Result<bool> {
253        self.engine.storage().delete(id).await
254    }
255
256    /// Remove all entries within a given scope. Returns the number of
257    /// entries removed.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the underlying storage delete fails.
262    pub async fn clear_scope(&self, scope: &str) -> Result<usize> {
263        self.engine.storage().clear_scope(scope).await
264    }
265
266    /// Remove all entries. Returns the number of entries removed.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the underlying storage delete fails.
271    pub async fn clear_all(&self) -> Result<usize> {
272        self.engine.storage().clear().await
273    }
274
275    /// Return the total number of stored entries.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the underlying storage count fails.
280    pub async fn count(&self) -> Result<usize> {
281        self.engine.storage().count().await
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::path::PathBuf;
289
290    #[test]
291    fn context_entry_json_roundtrip() {
292        let entry = ContextEntry {
293            id: "e1".into(),
294            content: "hello world".into(),
295            timestamp: 1_700_000_000,
296            kind: kind::MANUAL.to_owned(),
297            scope: None,
298            session_id: None,
299            token_count: Some(3),
300            metadata: None,
301        };
302        let json = serde_json::to_string(&entry).unwrap();
303        let back: ContextEntry = serde_json::from_str(&json).unwrap();
304        assert_eq!(back.id, "e1");
305        assert_eq!(back.token_count, Some(3));
306    }
307
308    #[test]
309    fn error_display_messages() {
310        let invalid = Error::InvalidEntry("empty content".into());
311        assert_eq!(invalid.to_string(), "invalid entry: empty content");
312
313        let migration = Error::Migration("schema mismatch".into());
314        assert_eq!(migration.to_string(), "migration error: schema mismatch");
315
316        let distill = Error::Distill("model unavailable".into());
317        assert_eq!(distill.to_string(), "distillation error: model unavailable");
318    }
319
320    #[test]
321    fn core_config_json_roundtrip() {
322        let cfg = Config {
323            max_entries: 1000,
324            token_budget: 8192,
325            db_path: PathBuf::from("/tmp/cf.db"),
326            eviction_policy: EvictionPolicy::Lru,
327            recency_half_life_secs: 259_200.0,
328            ..Config::default()
329        };
330        let json = serde_json::to_string(&cfg).unwrap();
331        let back: Config = serde_json::from_str(&json).unwrap();
332        assert_eq!(back.max_entries, 1000);
333        assert_eq!(back.eviction_policy, EvictionPolicy::Lru);
334    }
335
336    #[test]
337    fn trait_objects_are_object_safe() {
338        // This test verifies that the traits compile as trait objects.
339        fn _assert_storage(_s: Box<dyn ContextStorage>) {}
340        fn _assert_searcher(_s: Box<dyn Searcher>) {}
341    }
342
343    #[test]
344    fn kind_constants_are_distinct() {
345        assert_ne!(kind::MANUAL, kind::SNAPSHOT);
346        assert_ne!(kind::MANUAL, kind::SUMMARY);
347        assert_ne!(kind::MANUAL, kind::FACT);
348        assert_ne!(kind::SNAPSHOT, kind::SUMMARY);
349        assert_ne!(kind::SNAPSHOT, kind::FACT);
350        assert_ne!(kind::SUMMARY, kind::FACT);
351    }
352
353    #[test]
354    fn scored_entry_json_roundtrip() {
355        let scored = ScoredEntry {
356            entry: ContextEntry {
357                id: "s1".into(),
358                content: "search hit".into(),
359                timestamp: 1_700_000_001,
360                kind: kind::SUMMARY.to_owned(),
361                scope: None,
362                session_id: None,
363                token_count: None,
364                metadata: None,
365            },
366            score: 0.95,
367        };
368        let json = serde_json::to_string(&scored).unwrap();
369        let back: ScoredEntry = serde_json::from_str(&json).unwrap();
370        assert_eq!(back.entry.id, "s1");
371        assert!((back.score - 0.95).abs() < f64::EPSILON);
372    }
373
374    #[test]
375    fn context_forge_is_send_sync() {
376        fn assert_send_sync<T: Send + Sync>() {}
377        assert_send_sync::<ContextForge>();
378    }
379
380    #[tokio::test]
381    async fn context_forge_open_save_query_roundtrip() {
382        let config = Config {
383            db_path: PathBuf::from(":memory:"),
384            ..Config::default()
385        };
386        let cf = ContextForge::open(config).await.unwrap();
387
388        let id = cf
389            .save("hello world", kind::MANUAL, &SaveOptions::default())
390            .await
391            .unwrap();
392        assert!(!id.is_empty());
393        assert_eq!(cf.count().await.unwrap(), 1);
394
395        let hits = cf.query("hello", None, 1000).await.unwrap();
396        assert_eq!(hits.len(), 1);
397        assert_eq!(hits[0].id, id);
398
399        assert!(cf.delete(&id).await.unwrap());
400        assert_eq!(cf.count().await.unwrap(), 0);
401    }
402
403    #[tokio::test]
404    async fn context_forge_clear_all() {
405        let config = Config {
406            db_path: PathBuf::from(":memory:"),
407            ..Config::default()
408        };
409        let cf = ContextForge::open(config).await.unwrap();
410
411        cf.save("a", kind::MANUAL, &SaveOptions::default())
412            .await
413            .unwrap();
414        cf.save("b", kind::MANUAL, &SaveOptions::default())
415            .await
416            .unwrap();
417
418        let cleared = cf.clear_all().await.unwrap();
419        assert_eq!(cleared, 2);
420        assert_eq!(cf.count().await.unwrap(), 0);
421    }
422
423    /// A stub [`Distiller`] for tests that records the transcript it was
424    /// called with and returns a fixed [`DistilledMemory`].
425    struct StubDistiller {
426        transcript: std::sync::Mutex<Option<String>>,
427    }
428
429    impl StubDistiller {
430        fn new() -> Self {
431            Self {
432                transcript: std::sync::Mutex::new(None),
433            }
434        }
435    }
436
437    impl Distiller for StubDistiller {
438        fn distill(&self, transcript: &str) -> Result<DistilledMemory> {
439            *self.transcript.lock().unwrap() = Some(transcript.to_owned());
440            Ok(DistilledMemory {
441                summary: "User decided to roll back the deploy.".to_owned(),
442                facts: vec![
443                    Fact {
444                        kind: FactKind::Decision,
445                        text: "We decided to roll back the deploy.".to_owned(),
446                    },
447                    Fact {
448                        kind: FactKind::Preference,
449                        text: "The user prefers terse commit messages.".to_owned(),
450                    },
451                ],
452            })
453        }
454    }
455
456    #[tokio::test(flavor = "multi_thread")]
457    async fn distill_and_save_scrubs_saves_and_returns_ids() {
458        let config = Config {
459            db_path: PathBuf::from(":memory:"),
460            ..Config::default()
461        };
462        let cf = ContextForge::open(config).await.unwrap();
463
464        let distiller = StubDistiller::new();
465        let transcript = "Here is a secret key=AKIAABCDEFGHIJKLMNOP end of transcript";
466
467        let opts = SaveOptions {
468            session_id: Some("sess-1".to_owned()),
469            scope: Some("project:test".to_owned()),
470            metadata: None,
471        };
472
473        let ids = cf
474            .distill_and_save(transcript, &distiller, &opts)
475            .await
476            .unwrap();
477
478        // Summary ID first, then one ID per fact.
479        assert_eq!(ids.len(), 3);
480        for id in &ids {
481            assert!(!id.is_empty());
482        }
483
484        // The transcript reaching the distiller was scrubbed.
485        let seen = distiller.transcript.lock().unwrap().clone().unwrap();
486        assert!(seen.contains("[REDACTED:aws-key]"));
487        assert!(!seen.contains("AKIAABCDEFGHIJKLMNOP"));
488
489        // Summary saved with kind::SUMMARY.
490        let summary = cf
491            .query("rollback OR rollback OR roll", None, 10_000)
492            .await
493            .unwrap();
494        let summary_entry = summary
495            .iter()
496            .find(|e| e.id == ids[0])
497            .expect("summary entry present");
498        assert_eq!(summary_entry.kind, kind::SUMMARY);
499        assert_eq!(summary_entry.scope.as_deref(), Some("project:test"));
500        assert_eq!(summary_entry.session_id.as_deref(), Some("sess-1"));
501
502        // Facts saved with kind::FACT and the right metadata.
503        let all = cf.query(MATCH_ALL_QUERY, None, 100_000).await.unwrap();
504        for fact_id in &ids[1..] {
505            let fact_entry = all
506                .iter()
507                .find(|e| &e.id == fact_id)
508                .expect("fact entry present");
509            assert_eq!(fact_entry.kind, kind::FACT);
510            let metadata = fact_entry.metadata.as_ref().expect("metadata present");
511            assert_eq!(metadata["source"], "distill");
512            assert!(metadata["fact_kind"].is_string());
513        }
514    }
515
516    /// A stub [`Distiller`] that returns a fixed number of facts, used to
517    /// verify that [`ContextForge::distill_and_save`] caps excess facts
518    /// before persisting them.
519    struct ManyFactsDistiller {
520        fact_count: usize,
521    }
522
523    impl Distiller for ManyFactsDistiller {
524        fn distill(&self, _transcript: &str) -> Result<DistilledMemory> {
525            let facts = (0..self.fact_count)
526                .map(|i| Fact {
527                    kind: FactKind::State,
528                    text: format!("fact number {i}"),
529                })
530                .collect();
531            Ok(DistilledMemory {
532                summary: "summary".to_owned(),
533                facts,
534            })
535        }
536    }
537
538    #[tokio::test(flavor = "multi_thread")]
539    async fn distill_and_save_caps_excess_facts() {
540        let config = Config {
541            db_path: PathBuf::from(":memory:"),
542            ..Config::default()
543        };
544        let cf = ContextForge::open(config).await.unwrap();
545
546        let distiller = ManyFactsDistiller {
547            fact_count: crate::distill::MAX_FACTS + 20,
548        };
549
550        let ids = cf
551            .distill_and_save("transcript", &distiller, &SaveOptions::default())
552            .await
553            .unwrap();
554
555        // Summary ID first, then one ID per capped fact.
556        assert_eq!(ids.len(), 1 + crate::distill::MAX_FACTS);
557        assert_eq!(cf.count().await.unwrap(), 1 + crate::distill::MAX_FACTS);
558    }
559}