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}