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