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::{DistilledMemory, Distiller, Fact, FactKind};
106pub use engine::{ContextEngine, SaveOptions, MATCH_ALL_QUERY};
107pub use entry::{kind, ContextEntry, ScoredEntry};
108pub use error::Error;
109pub use scrub::{scrub_secrets, ScrubConfig};
110pub use session::{group_entries_by_session, SessionGroup};
111pub use storage::{open_storage, SqliteSearcher, SqliteStorage};
112pub use traits::{ContextStorage, Result, Searcher};
113
114/// The documented entry point for `context-forge`.
115///
116/// `ContextForge` wires together a [`SqliteStorage`] backend, a
117/// [`SqliteSearcher`], and a [`ContextEngine`] behind a small,
118/// stable API surface. Advanced callers that need direct access to the
119/// underlying storage or searcher can construct those types directly and
120/// pass them to [`ContextEngine::new`] instead.
121pub struct ContextForge {
122 engine: ContextEngine,
123 scrub_config: ScrubConfig,
124}
125
126impl ContextForge {
127 /// Open (or create) the database at `config.db_path`, run any pending
128 /// migrations, and build the engine.
129 ///
130 /// # Errors
131 ///
132 /// Returns an error if the database cannot be opened, the connection
133 /// pool cannot be built, or migrations fail.
134 pub fn open(config: Config) -> Result<Self> {
135 let db_path = config.db_path.clone();
136 let max_entries = config.max_entries;
137 let scrub_config = config.scrub.clone();
138 let (storage, searcher) = open_storage(Path::new(&db_path), max_entries)?;
139 let engine = ContextEngine::new(Box::new(storage), Box::new(searcher), config);
140 Ok(Self {
141 engine,
142 scrub_config,
143 })
144 }
145
146 /// Save a new entry. Returns the generated entry ID.
147 ///
148 /// `kind` is a caller-defined classification (see [`mod@kind`] for
149 /// well-known values). Capacity enforcement (LRU eviction) is handled
150 /// atomically by the storage layer.
151 ///
152 /// Before persistence, `content` is passed through [`scrub_secrets`]
153 /// using this instance's [`ScrubConfig`] (see [`Config::scrub`]),
154 /// redacting common credential formats with `[REDACTED:<label>]`
155 /// placeholders. `opts.metadata` is stored verbatim and is **not**
156 /// scrubbed — see [`SaveOptions::metadata`].
157 ///
158 /// # Errors
159 ///
160 /// Returns an error if `content` is empty or if the underlying storage
161 /// write fails.
162 pub fn save(&self, content: &str, kind: &str, opts: &SaveOptions) -> Result<String> {
163 let scrubbed = scrub_secrets(content, &self.scrub_config);
164 self.engine.save_snapshot(&scrubbed, kind, opts)
165 }
166
167 /// Distill `transcript` into a summary and durable facts, then save
168 /// them as separate entries sharing `opts.scope` and
169 /// `opts.session_id`.
170 ///
171 /// `transcript` is passed through [`scrub_secrets`] **before** it is
172 /// sent to `distiller` (so secrets never reach a distillation
173 /// endpoint), and the summary/facts produced are scrubbed again before
174 /// persistence via the normal [`ContextForge::save`] path (defense in
175 /// depth).
176 ///
177 /// The summary is saved with `kind::SUMMARY`; each fact is saved with
178 /// `kind::FACT` and metadata `{"fact_kind": "<kind>", "source":
179 /// "distill"}`.
180 ///
181 /// The distilled output is bounded before any of it is saved: at most
182 /// [`MAX_FACTS`](crate::distill::MAX_FACTS) facts are kept, each fact's
183 /// text is truncated to at most
184 /// [`MAX_FACT_CHARS`](crate::distill::MAX_FACT_CHARS) characters, and the
185 /// summary is truncated to at most
186 /// [`MAX_SUMMARY_CHARS`](crate::distill::MAX_SUMMARY_CHARS) characters.
187 /// Excess facts and text beyond these limits are silently dropped or
188 /// truncated, since they are untrusted model-generated content.
189 ///
190 /// Returns the IDs of the saved entries: the summary's ID first,
191 /// followed by each fact's ID in order.
192 ///
193 /// # Errors
194 ///
195 /// Returns an error if distillation fails, or if any save fails.
196 pub fn distill_and_save(
197 &self,
198 transcript: &str,
199 distiller: &dyn Distiller,
200 opts: &SaveOptions,
201 ) -> Result<Vec<String>> {
202 let scrubbed_transcript = scrub_secrets(transcript, &self.scrub_config);
203 let memory = distiller.distill(&scrubbed_transcript)?;
204 let memory = crate::distill::cap_distilled_memory(memory);
205
206 let mut ids = Vec::with_capacity(1 + memory.facts.len());
207
208 let summary_id = self.save(&memory.summary, kind::SUMMARY, opts)?;
209 ids.push(summary_id);
210
211 for fact in &memory.facts {
212 let fact_kind_str = match fact.kind {
213 FactKind::Decision => "decision",
214 FactKind::Correction => "correction",
215 FactKind::Preference => "preference",
216 FactKind::State => "state",
217 };
218 let metadata = serde_json::json!({
219 "fact_kind": fact_kind_str,
220 "source": "distill",
221 });
222 let fact_opts = SaveOptions {
223 session_id: opts.session_id.clone(),
224 scope: opts.scope.clone(),
225 metadata: Some(metadata),
226 };
227 let fact_id = self.save(&fact.text, kind::FACT, &fact_opts)?;
228 ids.push(fact_id);
229 }
230
231 Ok(ids)
232 }
233
234 /// Assemble entries matching `query` that fit within `token_budget`.
235 ///
236 /// `scope = None` searches every entry regardless of scope (global
237 /// recall). `scope = Some(s)` restricts the search to entries whose
238 /// `scope` equals `s`.
239 ///
240 /// # Errors
241 ///
242 /// Returns an error if the search or recency-weighting step fails.
243 pub fn query(
244 &self,
245 query: &str,
246 scope: Option<&str>,
247 token_budget: usize,
248 ) -> Result<Vec<ContextEntry>> {
249 self.engine.assemble(query, scope, token_budget)
250 }
251
252 /// Delete a single entry by id. Returns `true` if an entry was removed.
253 ///
254 /// # Errors
255 ///
256 /// Returns an error if the underlying storage delete fails.
257 pub fn delete(&self, id: &str) -> Result<bool> {
258 self.engine.storage().delete(id)
259 }
260
261 /// Remove all entries within a given scope. Returns the number of
262 /// entries removed.
263 ///
264 /// # Errors
265 ///
266 /// Returns an error if the underlying storage delete fails.
267 pub fn clear_scope(&self, scope: &str) -> Result<usize> {
268 self.engine.storage().clear_scope(scope)
269 }
270
271 /// Remove all entries. Returns the number of entries removed.
272 ///
273 /// # Errors
274 ///
275 /// Returns an error if the underlying storage delete fails.
276 pub fn clear_all(&self) -> Result<usize> {
277 self.engine.storage().clear()
278 }
279
280 /// Return the total number of stored entries.
281 ///
282 /// # Errors
283 ///
284 /// Returns an error if the underlying storage count fails.
285 pub fn count(&self) -> Result<usize> {
286 self.engine.storage().count()
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use std::path::PathBuf;
294
295 #[test]
296 fn context_entry_json_roundtrip() {
297 let entry = ContextEntry {
298 id: "e1".into(),
299 content: "hello world".into(),
300 timestamp: 1_700_000_000,
301 kind: kind::MANUAL.to_owned(),
302 scope: None,
303 session_id: None,
304 token_count: Some(3),
305 metadata: None,
306 };
307 let json = serde_json::to_string(&entry).unwrap();
308 let back: ContextEntry = serde_json::from_str(&json).unwrap();
309 assert_eq!(back.id, "e1");
310 assert_eq!(back.token_count, Some(3));
311 }
312
313 #[test]
314 fn error_display_messages() {
315 let invalid = Error::InvalidEntry("empty content".into());
316 assert_eq!(invalid.to_string(), "invalid entry: empty content");
317
318 let migration = Error::Migration("schema mismatch".into());
319 assert_eq!(migration.to_string(), "migration error: schema mismatch");
320
321 let distill = Error::Distill("model unavailable".into());
322 assert_eq!(distill.to_string(), "distillation error: model unavailable");
323 }
324
325 #[test]
326 fn core_config_json_roundtrip() {
327 let cfg = Config {
328 max_entries: 1000,
329 token_budget: 8192,
330 db_path: PathBuf::from("/tmp/cf.db"),
331 eviction_policy: EvictionPolicy::Lru,
332 recency_half_life_secs: 259_200.0,
333 ..Config::default()
334 };
335 let json = serde_json::to_string(&cfg).unwrap();
336 let back: Config = serde_json::from_str(&json).unwrap();
337 assert_eq!(back.max_entries, 1000);
338 assert_eq!(back.eviction_policy, EvictionPolicy::Lru);
339 }
340
341 #[test]
342 fn trait_objects_are_object_safe() {
343 // This test verifies that the traits compile as trait objects.
344 fn _assert_storage(_s: Box<dyn ContextStorage>) {}
345 fn _assert_searcher(_s: Box<dyn Searcher>) {}
346 }
347
348 #[test]
349 fn kind_constants_are_distinct() {
350 assert_ne!(kind::MANUAL, kind::SNAPSHOT);
351 assert_ne!(kind::MANUAL, kind::SUMMARY);
352 assert_ne!(kind::MANUAL, kind::FACT);
353 assert_ne!(kind::SNAPSHOT, kind::SUMMARY);
354 assert_ne!(kind::SNAPSHOT, kind::FACT);
355 assert_ne!(kind::SUMMARY, kind::FACT);
356 }
357
358 #[test]
359 fn scored_entry_json_roundtrip() {
360 let scored = ScoredEntry {
361 entry: ContextEntry {
362 id: "s1".into(),
363 content: "search hit".into(),
364 timestamp: 1_700_000_001,
365 kind: kind::SUMMARY.to_owned(),
366 scope: None,
367 session_id: None,
368 token_count: None,
369 metadata: None,
370 },
371 score: 0.95,
372 };
373 let json = serde_json::to_string(&scored).unwrap();
374 let back: ScoredEntry = serde_json::from_str(&json).unwrap();
375 assert_eq!(back.entry.id, "s1");
376 assert!((back.score - 0.95).abs() < f64::EPSILON);
377 }
378
379 #[test]
380 fn context_forge_is_send_sync() {
381 fn assert_send_sync<T: Send + Sync>() {}
382 assert_send_sync::<ContextForge>();
383 }
384
385 #[test]
386 fn context_forge_open_save_query_roundtrip() {
387 let config = Config {
388 db_path: PathBuf::from(":memory:"),
389 ..Config::default()
390 };
391 let cf = ContextForge::open(config).unwrap();
392
393 let id = cf
394 .save("hello world", kind::MANUAL, &SaveOptions::default())
395 .unwrap();
396 assert!(!id.is_empty());
397 assert_eq!(cf.count().unwrap(), 1);
398
399 let hits = cf.query("hello", None, 1000).unwrap();
400 assert_eq!(hits.len(), 1);
401 assert_eq!(hits[0].id, id);
402
403 assert!(cf.delete(&id).unwrap());
404 assert_eq!(cf.count().unwrap(), 0);
405 }
406
407 #[test]
408 fn context_forge_clear_all() {
409 let config = Config {
410 db_path: PathBuf::from(":memory:"),
411 ..Config::default()
412 };
413 let cf = ContextForge::open(config).unwrap();
414
415 cf.save("a", kind::MANUAL, &SaveOptions::default()).unwrap();
416 cf.save("b", kind::MANUAL, &SaveOptions::default()).unwrap();
417
418 let cleared = cf.clear_all().unwrap();
419 assert_eq!(cleared, 2);
420 assert_eq!(cf.count().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 #[test]
457 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).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.distill_and_save(transcript, &distiller, &opts).unwrap();
474
475 // Summary ID first, then one ID per fact.
476 assert_eq!(ids.len(), 3);
477 for id in &ids {
478 assert!(!id.is_empty());
479 }
480
481 // The transcript reaching the distiller was scrubbed.
482 let seen = distiller.transcript.lock().unwrap().clone().unwrap();
483 assert!(seen.contains("[REDACTED:aws-key]"));
484 assert!(!seen.contains("AKIAABCDEFGHIJKLMNOP"));
485
486 // Summary saved with kind::SUMMARY.
487 let summary = cf
488 .query("rollback OR rollback OR roll", None, 10_000)
489 .unwrap();
490 let summary_entry = summary
491 .iter()
492 .find(|e| e.id == ids[0])
493 .expect("summary entry present");
494 assert_eq!(summary_entry.kind, kind::SUMMARY);
495 assert_eq!(summary_entry.scope.as_deref(), Some("project:test"));
496 assert_eq!(summary_entry.session_id.as_deref(), Some("sess-1"));
497
498 // Facts saved with kind::FACT and the right metadata.
499 let all = cf.query(MATCH_ALL_QUERY, None, 100_000).unwrap();
500 for fact_id in &ids[1..] {
501 let fact_entry = all
502 .iter()
503 .find(|e| &e.id == fact_id)
504 .expect("fact entry present");
505 assert_eq!(fact_entry.kind, kind::FACT);
506 let metadata = fact_entry.metadata.as_ref().expect("metadata present");
507 assert_eq!(metadata["source"], "distill");
508 assert!(metadata["fact_kind"].is_string());
509 }
510 }
511
512 /// A stub [`Distiller`] that returns a fixed number of facts, used to
513 /// verify that [`ContextForge::distill_and_save`] caps excess facts
514 /// before persisting them.
515 struct ManyFactsDistiller {
516 fact_count: usize,
517 }
518
519 impl Distiller for ManyFactsDistiller {
520 fn distill(&self, _transcript: &str) -> Result<DistilledMemory> {
521 let facts = (0..self.fact_count)
522 .map(|i| Fact {
523 kind: FactKind::State,
524 text: format!("fact number {i}"),
525 })
526 .collect();
527 Ok(DistilledMemory {
528 summary: "summary".to_owned(),
529 facts,
530 })
531 }
532 }
533
534 #[test]
535 fn distill_and_save_caps_excess_facts() {
536 let config = Config {
537 db_path: PathBuf::from(":memory:"),
538 ..Config::default()
539 };
540 let cf = ContextForge::open(config).unwrap();
541
542 let distiller = ManyFactsDistiller {
543 fact_count: crate::distill::MAX_FACTS + 20,
544 };
545
546 let ids = cf
547 .distill_and_save("transcript", &distiller, &SaveOptions::default())
548 .unwrap();
549
550 // Summary ID first, then one ID per capped fact.
551 assert_eq!(ids.len(), 1 + crate::distill::MAX_FACTS);
552 assert_eq!(cf.count().unwrap(), 1 + crate::distill::MAX_FACTS);
553 }
554}