Skip to main content

kimetsu_brain/
packs.rs

1//! Portable brain packs: security-scrubbed export, merge/replace import,
2//! and the gzip'd pack envelope. Split out of `project.rs` (v2.5.1); the
3//! public API is unchanged — everything here is re-exported by [`crate::project`].
4
5use std::path::Path;
6
7use kimetsu_core::KimetsuResult;
8use kimetsu_core::ids::RunId;
9use kimetsu_core::memory::{MemoryKind, MemoryScope};
10use rusqlite::params;
11
12use crate::project::{add_memory, invalidate_memory, load_project, load_project_readonly};
13
14// ── Q5: portable memory export / import ──────────────────────────────────────
15
16/// A single memory in the portable JSON exchange format.
17///
18/// Carries only the fields needed to reconstruct the memory in another brain —
19/// instance-specific data (`memory_id`, `usefulness_score`, `use_count`) is
20/// intentionally excluded so importing always creates a fresh row with clean
21/// stats.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MemoryExport {
24    pub text: String,
25    pub scope: String,
26    pub kind: String,
27    pub confidence: f32,
28    pub created_at: Option<String>,
29}
30
31/// v3.0 #4: a shareable brain PACK — a self-describing envelope (manifest +
32/// memories) for distribution via the marketplace. Serialized to JSON then
33/// gzip-compressed by the CLI. A bare `Vec<MemoryExport>` (the pre-pack export
34/// format) also imports, for back-compat — see [`parse_pack_or_array`].
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct Pack {
37    /// Pack format version (currently 1).
38    pub kimetsu_pack: u32,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub name: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub version: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub description: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub exported_at: Option<String>,
47    #[serde(default)]
48    pub memory_count: usize,
49    pub memories: Vec<MemoryExport>,
50}
51
52/// Identity of an installed pack, stamped into each imported memory's provenance
53/// so it can later be listed / updated / uninstalled.
54#[derive(Debug, Clone, Default)]
55pub struct PackRef {
56    pub name: Option<String>,
57    pub version: Option<String>,
58}
59
60/// Parse a pack file body: a [`Pack`] envelope OR a bare `Vec<MemoryExport>`
61/// (back-compat with pre-pack exports). Returns the manifest [`PackRef`] (empty
62/// for a bare array) and the memory entries.
63pub fn parse_pack_or_array(json: &str) -> KimetsuResult<(PackRef, Vec<MemoryExport>)> {
64    // A Pack is a JSON object with `kimetsu_pack` + `memories`; a bare array is
65    // a JSON array. Try the envelope first; fall back to the array.
66    if let Ok(pack) = serde_json::from_str::<Pack>(json) {
67        return Ok((
68            PackRef {
69                name: pack.name,
70                version: pack.version,
71            },
72            pack.memories,
73        ));
74    }
75    let entries: Vec<MemoryExport> = serde_json::from_str(json)
76        .map_err(|e| format!("pack: not a Pack envelope or a memory array: {e}"))?;
77    Ok((PackRef::default(), entries))
78}
79
80/// Strip the trailing `(context: …)` segment from a memory text produced by
81/// the distiller / `brain record` workflow, leaving only the lesson body.
82///
83/// Matches the literal pattern ` (context: <anything>)` at the very end of
84/// the trimmed string. The match is case-sensitive to avoid false positives.
85///
86/// Returns the original `text` unchanged when:
87///   - the pattern is absent, or
88///   - stripping would leave an empty or whitespace-only string (safety
89///     fallback: a blank lesson is worse than a slightly noisy one).
90///
91/// # Examples
92/// ```
93/// # use kimetsu_brain::project::redact_context_suffix;
94/// assert_eq!(
95///     redact_context_suffix("always use --locked (context: cargo build)"),
96///     "always use --locked"
97/// );
98/// assert_eq!(
99///     redact_context_suffix("bare lesson"),
100///     "bare lesson"
101/// );
102/// ```
103pub fn redact_context_suffix(text: &str) -> &str {
104    let trimmed = text.trim_end();
105    // Pattern: " (context: …)" where the parenthesised segment is at the end.
106    // Walk backwards to find the matching open-paren for a ` (context: ` prefix.
107    if let Some(pos) = find_trailing_context_paren(trimmed) {
108        let candidate = trimmed[..pos].trim_end();
109        if !candidate.is_empty() {
110            return candidate;
111        }
112    }
113    text
114}
115
116/// Strip the leading `[tags: …]` prefix from a memory text, leaving only the
117/// lesson body (and any trailing context segment unless that is separately
118/// stripped by [`redact_context_suffix`]).
119///
120/// Matches `[tags: …] ` at the very start of the trimmed string.
121/// Returns the original `text` when:
122///   - the pattern is absent, or
123///   - stripping would leave an empty or whitespace-only string.
124///
125/// # Examples
126/// ```
127/// # use kimetsu_brain::project::redact_tags_prefix;
128/// assert_eq!(
129///     redact_tags_prefix("[tags: rust, cargo] always use --locked"),
130///     "always use --locked"
131/// );
132/// assert_eq!(
133///     redact_tags_prefix("no tags here"),
134///     "no tags here"
135/// );
136/// ```
137pub fn redact_tags_prefix(text: &str) -> &str {
138    let trimmed = text.trim_start();
139    if let Some(rest) = trimmed.strip_prefix("[tags: ") {
140        if let Some(close) = rest.find(']') {
141            let after = rest[close + 1..].trim_start();
142            if !after.is_empty() {
143                return after;
144            }
145        }
146    }
147    text
148}
149
150/// Apply export-time redaction to a single `MemoryExport`'s text field
151/// according to the requested flags. Returns a new `MemoryExport` with the
152/// text replaced (or the original when no patterns match and the safety
153/// fallback applies).
154///
155/// The two-step order matters: strip tags first, then context, so that a
156/// memory like `[tags: rust] lesson body (context: foo)` becomes
157/// `lesson body` when both flags are active.
158pub fn apply_export_redaction(
159    entry: MemoryExport,
160    redact: bool,
161    redact_tags: bool,
162) -> MemoryExport {
163    if !redact && !redact_tags {
164        return entry;
165    }
166    let mut text: &str = &entry.text;
167    // Temporary storage so we can chain borrows without lifetime woes.
168    let after_tags: String;
169    let after_ctx: String;
170    if redact_tags {
171        let stripped = redact_tags_prefix(text);
172        after_tags = stripped.to_string();
173        text = &after_tags;
174    }
175    if redact {
176        let stripped = redact_context_suffix(text);
177        after_ctx = stripped.to_string();
178        text = &after_ctx;
179    }
180    MemoryExport {
181        text: text.to_string(),
182        ..entry
183    }
184}
185
186// Helper: find the byte offset of the opening ` (context: ` run that closes
187// at the very end of `s` (which must already be trimmed of trailing
188// whitespace). Returns `None` when no such suffix is present.
189fn find_trailing_context_paren(s: &str) -> Option<usize> {
190    // We look for a closing `)` at the end, then walk left to find ` (context: `.
191    if !s.ends_with(')') {
192        return None;
193    }
194    // The minimum suffix is ` (context: x)` — 13 chars.
195    let bytes = s.as_bytes();
196    // Find the matching open paren by scanning backwards from the terminal `)`.
197    let close = s.len() - 1;
198    // We need at least " (context: " before the close paren, so start scanning
199    // no further than close - len(" (context: ") = close - 11.
200    // Use a simple prefix search scanning from the right.
201    let prefix = b" (context: ";
202    for start in (0..close).rev() {
203        if start + prefix.len() > close {
204            continue;
205        }
206        if &bytes[start..start + prefix.len()] == prefix {
207            // Found the open sequence; the segment is s[start..=close].
208            return Some(start);
209        }
210    }
211    None
212}
213
214/// Summary returned by [`import_memories`] / [`import_pack`].
215#[derive(Debug, Clone, Default)]
216pub struct ImportSummary {
217    /// Memories that were actually written (new rows).
218    pub imported: usize,
219    /// Entries that were skipped because an identical memory already existed
220    /// (detected by `add_memory`'s normalized-text dedup) or because the
221    /// scope/kind was malformed.
222    pub deduped: usize,
223    /// v3.0 #4: memories superseded by a `replace`-mode pack install (existing
224    /// active memories in the pack's scope(s), invalidated before the load).
225    pub superseded: usize,
226}
227
228thread_local! {
229    /// v3.0 #4: provenance source stamped onto memories written during a pack
230    /// install (e.g. `{source:"pack", pack_name, pack_version}`). When unset,
231    /// `add_memory` uses its default `manual_cli` provenance. RAII-scoped by
232    /// [`ImportProvenanceScope`] so it never leaks past the import.
233    static IMPORT_PROVENANCE: std::cell::RefCell<Option<serde_json::Value>> =
234        const { std::cell::RefCell::new(None) };
235}
236
237pub(crate) struct ImportProvenanceScope;
238impl ImportProvenanceScope {
239    pub(crate) fn new(v: serde_json::Value) -> Self {
240        IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = Some(v));
241        ImportProvenanceScope
242    }
243}
244impl Drop for ImportProvenanceScope {
245    fn drop(&mut self) {
246        IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = None);
247    }
248}
249
250/// Build a memory's `provenance_snapshot`. Uses the thread-local pack source
251/// (set during a pack install) when present, else the default `manual_cli`.
252pub(crate) fn build_provenance(run_id: RunId, text: &str) -> serde_json::Value {
253    IMPORT_PROVENANCE.with(|c| {
254        if let Some(src) = c.borrow().as_ref() {
255            let mut v = src.clone();
256            if let Some(obj) = v.as_object_mut() {
257                obj.insert("run_id".into(), serde_json::json!(run_id.to_string()));
258                obj.insert("text".into(), serde_json::json!(text));
259            }
260            v
261        } else {
262            serde_json::json!({
263                "source": "manual_cli",
264                "run_id": run_id.to_string(),
265                "text": text,
266            })
267        }
268    })
269}
270
271/// Export active memories as a vec of portable records.
272///
273/// `scope` and `kind` are optional filters; `None` means "all".
274/// `redact` strips the trailing `(context: …)` segment from each text.
275/// `redact_tags` additionally strips the leading `[tags: …]` prefix.
276/// Aggregate security-scrub findings across an export (no credentials / PII may
277/// ship in a shareable pack). `kinds` maps each redaction kind to its count.
278#[derive(Debug, Clone, Default, serde::Serialize)]
279pub struct ScrubReport {
280    pub total: usize,
281    pub kinds: std::collections::BTreeMap<String, usize>,
282}
283
284impl ScrubReport {
285    pub fn is_clean(&self) -> bool {
286        self.total == 0
287    }
288    /// One-liner like `"scrubbed 4: email×2, anthropic_oauth×1, ssn×1"`.
289    pub fn summary(&self) -> String {
290        if self.total == 0 {
291            return "no credentials or PII found".to_string();
292        }
293        let parts: Vec<String> = self.kinds.iter().map(|(k, n)| format!("{k}×{n}")).collect();
294        format!("scrubbed {}: {}", self.total, parts.join(", "))
295    }
296}
297
298pub fn export_memories(
299    start: &Path,
300    scope: Option<MemoryScope>,
301    kind: Option<MemoryKind>,
302    redact: bool,
303    redact_tags: bool,
304) -> KimetsuResult<(Vec<MemoryExport>, ScrubReport)> {
305    // Build the SQL dynamically based on the optional filters, including
306    // `created_at` so the JSON record carries the origin timestamp.
307    let (sql, params_vec): (&str, Vec<String>) = match (scope.as_ref(), kind.as_ref()) {
308        (Some(s), Some(k)) => (
309            "SELECT scope, kind, text, confidence, created_at
310             FROM memories
311             WHERE invalidated_at IS NULL
312               AND superseded_by IS NULL
313               AND lower(scope) = lower(?1)
314               AND lower(kind)  = lower(?2)
315             ORDER BY created_at DESC",
316            vec![s.to_string(), k.to_string()],
317        ),
318        (Some(s), None) => (
319            "SELECT scope, kind, text, confidence, created_at
320             FROM memories
321             WHERE invalidated_at IS NULL
322               AND superseded_by IS NULL
323               AND lower(scope) = lower(?1)
324             ORDER BY created_at DESC",
325            vec![s.to_string()],
326        ),
327        (None, Some(k)) => (
328            "SELECT scope, kind, text, confidence, created_at
329             FROM memories
330             WHERE invalidated_at IS NULL
331               AND superseded_by IS NULL
332               AND lower(kind) = lower(?1)
333             ORDER BY created_at DESC",
334            vec![k.to_string()],
335        ),
336        (None, None) => (
337            "SELECT scope, kind, text, confidence, created_at
338             FROM memories
339             WHERE invalidated_at IS NULL
340               AND superseded_by IS NULL
341             ORDER BY created_at DESC",
342            vec![],
343        ),
344    };
345
346    // Project-level memories only (user brain memories live in a separate DB;
347    // callers wanting the user brain should call with scope=GlobalUser on the
348    // user-brain path, or simply use list_memories which merges both).
349    let (_paths, _config, conn) = load_project(start)?;
350
351    let mut stmt = conn.prepare(sql)?;
352    let refs: Vec<&dyn rusqlite::ToSql> = params_vec
353        .iter()
354        .map(|s| s as &dyn rusqlite::ToSql)
355        .collect();
356    let rows = stmt.query_map(refs.as_slice(), |row| {
357        Ok(MemoryExport {
358            scope: row.get(0)?,
359            kind: row.get(1)?,
360            text: row.get(2)?,
361            confidence: row.get::<_, f64>(3)? as f32,
362            created_at: row.get(4)?,
363        })
364    })?;
365
366    // Security scrub (v3.0 #4): every exported memory passes through the
367    // credential + PII scrubber so a shareable pack can never ship secrets or
368    // personal data. The scrub is on the EXPORT COPY only — the source DB is
369    // untouched. Findings are tallied for the caller to report (and --strict).
370    let mut out = Vec::new();
371    let mut report = ScrubReport::default();
372    for row in rows {
373        let mut entry = apply_export_redaction(row?, redact, redact_tags);
374        let scrubbed = crate::redact::scrub_for_export(&entry.text);
375        for m in &scrubbed.matches {
376            *report.kinds.entry(m.kind.to_string()).or_insert(0) += 1;
377            report.total += 1;
378        }
379        entry.text = scrubbed.text;
380        out.push(entry);
381    }
382    Ok((out, report))
383}
384
385/// Import a slice of [`MemoryExport`] records into the brain at `start`.
386///
387/// For each entry:
388/// - Parse scope + kind from the string fields (with optional `scope_override`).
389/// - Call `add_memory`, which dedups by normalized text. Dedup is detected by
390///   comparing the set of active memory IDs in the project DB before vs after
391///   each `add_memory` call — if the returned ID was already in the DB at
392///   the start of this import batch, it counts as deduped.
393/// - Malformed entries (bad scope/kind string) are skipped with a warning;
394///   they do NOT abort the whole import.
395///
396/// Returns an [`ImportSummary`] with `imported` (new rows) and `deduped`
397/// (entries that collapsed to an existing row or were skipped).
398pub fn import_memories(
399    start: &Path,
400    entries: &[MemoryExport],
401    scope_override: Option<MemoryScope>,
402) -> KimetsuResult<ImportSummary> {
403    let mut summary = ImportSummary::default();
404
405    // Snapshot all active memory IDs before we start importing.  Any ID
406    // returned by add_memory that is already in this set is a dedup.
407    let pre_existing_ids: std::collections::HashSet<String> = {
408        // Open a read-only connection just for the snapshot; avoid holding it
409        // across the write calls (each add_memory opens its own connection).
410        match load_project_readonly(start) {
411            Ok((_paths, _config, conn)) => {
412                let mut stmt = conn
413                    .prepare("SELECT memory_id FROM memories WHERE invalidated_at IS NULL")
414                    .unwrap_or_else(|_| conn.prepare("SELECT memory_id FROM memories").unwrap());
415                stmt.query_map([], |row| row.get::<_, String>(0))
416                    .map(|rows| rows.filter_map(|r| r.ok()).collect())
417                    .unwrap_or_default()
418            }
419            Err(_) => std::collections::HashSet::new(),
420        }
421    };
422
423    // Also track IDs minted during THIS batch so we can detect within-batch
424    // duplicates (e.g. two identical entries in the import file).
425    let mut this_batch_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
426
427    for entry in entries {
428        // Resolve scope: prefer override, then parse from the entry.
429        let scope = if let Some(ref ov) = scope_override {
430            *ov
431        } else {
432            match entry.scope.parse::<MemoryScope>() {
433                Ok(s) => s,
434                Err(_) => {
435                    eprintln!(
436                        "kimetsu-brain import: skipping entry with unknown scope `{}`",
437                        entry.scope
438                    );
439                    summary.deduped += 1;
440                    continue;
441                }
442            }
443        };
444
445        // Resolve kind.
446        let kind = match entry.kind.parse::<MemoryKind>() {
447            Ok(k) => k,
448            Err(_) => {
449                eprintln!(
450                    "kimetsu-brain import: skipping entry with unknown kind `{}`",
451                    entry.kind
452                );
453                summary.deduped += 1;
454                continue;
455            }
456        };
457
458        match add_memory(start, scope, kind, &entry.text) {
459            Ok(id) => {
460                // Dedup if the ID was present before this import started OR
461                // was already seen in this batch (within-batch duplicates).
462                if pre_existing_ids.contains(&id) || !this_batch_ids.insert(id) {
463                    summary.deduped += 1;
464                } else {
465                    summary.imported += 1;
466                }
467            }
468            Err(e) => {
469                eprintln!("kimetsu-brain import: failed to add memory: {e}");
470                summary.deduped += 1;
471            }
472        }
473    }
474
475    Ok(summary)
476}
477
478/// v3.0 #4: install a pack's memories. `merge` adds additively (dedup against
479/// existing). `replace` first invalidates active memories in the pack's scope(s)
480/// — REVERSIBLE (events kept; rows marked invalidated) — then loads the pack.
481/// Each installed memory is stamped with the `pack` provenance.
482pub fn import_pack(
483    start: &Path,
484    entries: &[MemoryExport],
485    scope_override: Option<MemoryScope>,
486    replace: bool,
487    pack: Option<&PackRef>,
488) -> KimetsuResult<ImportSummary> {
489    let mut superseded = 0usize;
490    if replace {
491        let scopes = pack_target_scopes(entries, scope_override);
492        let reason = match pack {
493            Some(p) => format!(
494                "replaced_by_pack:{}@{}",
495                p.name.as_deref().unwrap_or("unknown"),
496                p.version.as_deref().unwrap_or("?")
497            ),
498            None => "replaced_by_import".to_string(),
499        };
500        for id in active_memory_ids_in_scopes(start, &scopes)? {
501            invalidate_memory(start, &id, Some(&reason))?;
502            superseded += 1;
503        }
504    }
505
506    // Defensive scrub: never INGEST a credential/PII from a pack, even if the
507    // author bypassed export-time scrubbing. (Export already scrubs; this is
508    // belt-and-suspenders on the receiving side.)
509    let scrubbed: Vec<MemoryExport> = entries
510        .iter()
511        .map(|e| {
512            let mut e = e.clone();
513            e.text = crate::redact::scrub_for_export(&e.text).text;
514            e
515        })
516        .collect();
517
518    // Stamp pack provenance on each installed memory for the duration of the load.
519    let _prov = pack.map(|p| {
520        ImportProvenanceScope::new(serde_json::json!({
521            "source": "pack",
522            "pack_name": p.name,
523            "pack_version": p.version,
524        }))
525    });
526    let mut summary = import_memories(start, &scrubbed, scope_override)?;
527    summary.superseded = superseded;
528    Ok(summary)
529}
530
531/// Distinct scopes a pack will write to (override wins; else parsed per entry).
532fn pack_target_scopes(
533    entries: &[MemoryExport],
534    scope_override: Option<MemoryScope>,
535) -> Vec<MemoryScope> {
536    if let Some(ov) = scope_override {
537        return vec![ov];
538    }
539    let mut seen = std::collections::HashSet::new();
540    let mut out = Vec::new();
541    for e in entries {
542        if let Ok(s) = e.scope.parse::<MemoryScope>() {
543            if seen.insert(s.to_string()) {
544                out.push(s);
545            }
546        }
547    }
548    out
549}
550
551/// Active (non-invalidated, non-superseded) memory ids in the given scopes.
552fn active_memory_ids_in_scopes(start: &Path, scopes: &[MemoryScope]) -> KimetsuResult<Vec<String>> {
553    if scopes.is_empty() {
554        return Ok(Vec::new());
555    }
556    let (_p, _c, conn) = load_project_readonly(start)?;
557    let mut ids = Vec::new();
558    for sc in scopes {
559        let mut stmt = conn.prepare(
560            "SELECT memory_id FROM memories
561             WHERE scope = ?1 AND invalidated_at IS NULL AND superseded_by IS NULL",
562        )?;
563        let rows = stmt.query_map(params![sc.to_string()], |r| r.get::<_, String>(0))?;
564        for r in rows {
565            ids.push(r?);
566        }
567    }
568    Ok(ids)
569}