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/// v2.6 #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    /// v2.6 #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    /// v2.6: entries routed into the review queue instead of the retrieval
227    /// pool. See [`quarantine_memories`].
228    pub quarantined: usize,
229}
230
231thread_local! {
232    /// v2.6 #4: provenance source stamped onto memories written during a pack
233    /// install (e.g. `{source:"pack", pack_name, pack_version}`). When unset,
234    /// `add_memory` uses its default `manual_cli` provenance. RAII-scoped by
235    /// [`ImportProvenanceScope`] so it never leaks past the import.
236    static IMPORT_PROVENANCE: std::cell::RefCell<Option<serde_json::Value>> =
237        const { std::cell::RefCell::new(None) };
238}
239
240pub(crate) struct ImportProvenanceScope;
241impl ImportProvenanceScope {
242    pub(crate) fn new(v: serde_json::Value) -> Self {
243        IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = Some(v));
244        ImportProvenanceScope
245    }
246}
247impl Drop for ImportProvenanceScope {
248    fn drop(&mut self) {
249        IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = None);
250    }
251}
252
253/// Build a memory's `provenance_snapshot`. Uses the thread-local pack source
254/// (set during a pack install) when present, else the default `manual_cli`.
255pub(crate) fn build_provenance(run_id: RunId, text: &str) -> serde_json::Value {
256    IMPORT_PROVENANCE.with(|c| {
257        if let Some(src) = c.borrow().as_ref() {
258            let mut v = src.clone();
259            if let Some(obj) = v.as_object_mut() {
260                obj.insert("run_id".into(), serde_json::json!(run_id.to_string()));
261                obj.insert("text".into(), serde_json::json!(text));
262            }
263            v
264        } else {
265            serde_json::json!({
266                "source": "manual_cli",
267                "run_id": run_id.to_string(),
268                "text": text,
269            })
270        }
271    })
272}
273
274/// Export active memories as a vec of portable records.
275///
276/// `scope` and `kind` are optional filters; `None` means "all".
277/// `redact` strips the trailing `(context: …)` segment from each text.
278/// `redact_tags` additionally strips the leading `[tags: …]` prefix.
279/// Aggregate security-scrub findings across an export (no credentials / PII may
280/// ship in a shareable pack). `kinds` maps each redaction kind to its count.
281#[derive(Debug, Clone, Default, serde::Serialize)]
282pub struct ScrubReport {
283    pub total: usize,
284    pub kinds: std::collections::BTreeMap<String, usize>,
285}
286
287impl ScrubReport {
288    pub fn is_clean(&self) -> bool {
289        self.total == 0
290    }
291    /// One-liner like `"scrubbed 4: email×2, anthropic_oauth×1, ssn×1"`.
292    pub fn summary(&self) -> String {
293        if self.total == 0 {
294            return "no credentials or PII found".to_string();
295        }
296        let parts: Vec<String> = self.kinds.iter().map(|(k, n)| format!("{k}×{n}")).collect();
297        format!("scrubbed {}: {}", self.total, parts.join(", "))
298    }
299}
300
301pub fn export_memories(
302    start: &Path,
303    scope: Option<MemoryScope>,
304    kind: Option<MemoryKind>,
305    redact: bool,
306    redact_tags: bool,
307) -> KimetsuResult<(Vec<MemoryExport>, ScrubReport)> {
308    // Build the SQL dynamically based on the optional filters, including
309    // `created_at` so the JSON record carries the origin timestamp.
310    let (sql, params_vec): (&str, Vec<String>) = match (scope.as_ref(), kind.as_ref()) {
311        (Some(s), Some(k)) => (
312            "SELECT scope, kind, text, confidence, created_at
313             FROM memories
314             WHERE invalidated_at IS NULL
315               AND superseded_by IS NULL
316               AND lower(scope) = lower(?1)
317               AND lower(kind)  = lower(?2)
318             ORDER BY created_at DESC",
319            vec![s.to_string(), k.to_string()],
320        ),
321        (Some(s), None) => (
322            "SELECT scope, kind, text, confidence, created_at
323             FROM memories
324             WHERE invalidated_at IS NULL
325               AND superseded_by IS NULL
326               AND lower(scope) = lower(?1)
327             ORDER BY created_at DESC",
328            vec![s.to_string()],
329        ),
330        (None, Some(k)) => (
331            "SELECT scope, kind, text, confidence, created_at
332             FROM memories
333             WHERE invalidated_at IS NULL
334               AND superseded_by IS NULL
335               AND lower(kind) = lower(?1)
336             ORDER BY created_at DESC",
337            vec![k.to_string()],
338        ),
339        (None, None) => (
340            "SELECT scope, kind, text, confidence, created_at
341             FROM memories
342             WHERE invalidated_at IS NULL
343               AND superseded_by IS NULL
344             ORDER BY created_at DESC",
345            vec![],
346        ),
347    };
348
349    // Project-level memories only (user brain memories live in a separate DB;
350    // callers wanting the user brain should call with scope=GlobalUser on the
351    // user-brain path, or simply use list_memories which merges both).
352    let (_paths, _config, conn) = load_project(start)?;
353
354    let mut stmt = conn.prepare(sql)?;
355    let refs: Vec<&dyn rusqlite::ToSql> = params_vec
356        .iter()
357        .map(|s| s as &dyn rusqlite::ToSql)
358        .collect();
359    let rows = stmt.query_map(refs.as_slice(), |row| {
360        Ok(MemoryExport {
361            scope: row.get(0)?,
362            kind: row.get(1)?,
363            text: row.get(2)?,
364            confidence: row.get::<_, f64>(3)? as f32,
365            created_at: row.get(4)?,
366        })
367    })?;
368
369    // Security scrub (v2.6 #4): every exported memory passes through the
370    // credential + PII scrubber so a shareable pack can never ship secrets or
371    // personal data. The scrub is on the EXPORT COPY only — the source DB is
372    // untouched. Findings are tallied for the caller to report (and --strict).
373    let mut out = Vec::new();
374    let mut report = ScrubReport::default();
375    for row in rows {
376        let mut entry = apply_export_redaction(row?, redact, redact_tags);
377        let scrubbed = crate::redact::scrub_for_export(&entry.text);
378        for m in &scrubbed.matches {
379            *report.kinds.entry(m.kind.to_string()).or_insert(0) += 1;
380            report.total += 1;
381        }
382        entry.text = scrubbed.text;
383        out.push(entry);
384    }
385    Ok((out, report))
386}
387
388/// Import a slice of [`MemoryExport`] records into the brain at `start`.
389///
390/// For each entry:
391/// - Parse scope + kind from the string fields (with optional `scope_override`).
392/// - Call `add_memory`, which dedups by normalized text. Dedup is detected by
393///   comparing the set of active memory IDs in the project DB before vs after
394///   each `add_memory` call — if the returned ID was already in the DB at
395///   the start of this import batch, it counts as deduped.
396/// - Malformed entries (bad scope/kind string) are skipped with a warning;
397///   they do NOT abort the whole import.
398///
399/// Returns an [`ImportSummary`] with `imported` (new rows) and `deduped`
400/// (entries that collapsed to an existing row or were skipped).
401pub fn import_memories(
402    start: &Path,
403    entries: &[MemoryExport],
404    scope_override: Option<MemoryScope>,
405) -> KimetsuResult<ImportSummary> {
406    let mut summary = ImportSummary::default();
407
408    // Snapshot all active memory IDs before we start importing.  Any ID
409    // returned by add_memory that is already in this set is a dedup.
410    let pre_existing_ids: std::collections::HashSet<String> = {
411        // Open a read-only connection just for the snapshot; avoid holding it
412        // across the write calls (each add_memory opens its own connection).
413        match load_project_readonly(start) {
414            Ok((_paths, _config, conn)) => {
415                let mut stmt = conn
416                    .prepare("SELECT memory_id FROM memories WHERE invalidated_at IS NULL")
417                    .unwrap_or_else(|_| conn.prepare("SELECT memory_id FROM memories").unwrap());
418                stmt.query_map([], |row| row.get::<_, String>(0))
419                    .map(|rows| rows.filter_map(|r| r.ok()).collect())
420                    .unwrap_or_default()
421            }
422            Err(_) => std::collections::HashSet::new(),
423        }
424    };
425
426    // Also track IDs minted during THIS batch so we can detect within-batch
427    // duplicates (e.g. two identical entries in the import file).
428    let mut this_batch_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
429
430    for entry in entries {
431        // Resolve scope: prefer override, then parse from the entry.
432        let scope = if let Some(ref ov) = scope_override {
433            *ov
434        } else {
435            match entry.scope.parse::<MemoryScope>() {
436                Ok(s) => s,
437                Err(_) => {
438                    eprintln!(
439                        "kimetsu-brain import: skipping entry with unknown scope `{}`",
440                        entry.scope
441                    );
442                    summary.deduped += 1;
443                    continue;
444                }
445            }
446        };
447
448        // Resolve kind.
449        let kind = match entry.kind.parse::<MemoryKind>() {
450            Ok(k) => k,
451            Err(_) => {
452                eprintln!(
453                    "kimetsu-brain import: skipping entry with unknown kind `{}`",
454                    entry.kind
455                );
456                summary.deduped += 1;
457                continue;
458            }
459        };
460
461        match add_memory(start, scope, kind, &entry.text) {
462            Ok(id) => {
463                // Dedup if the ID was present before this import started OR
464                // was already seen in this batch (within-batch duplicates).
465                if pre_existing_ids.contains(&id) || !this_batch_ids.insert(id) {
466                    summary.deduped += 1;
467                } else {
468                    summary.imported += 1;
469                }
470            }
471            Err(e) => {
472                eprintln!("kimetsu-brain import: failed to add memory: {e}");
473                summary.deduped += 1;
474            }
475        }
476    }
477
478    Ok(summary)
479}
480
481/// v2.6: route a pack's entries into the review queue instead of the brain.
482///
483/// [`crate::trust`] scores a memory's origin and folds it into the broker
484/// score, and says outright what that does not do: a weight makes a poisoned
485/// pack rank lower, it does not stop it influencing anything. Memory poisoning
486/// (OWASP ASI06) is worth stopping rather than discounting precisely because it
487/// persists — MINJA (arXiv 2601.05504) reports >95% success against
488/// memory-backed agents, and unlike prompt injection the effect does not end
489/// with the session.
490///
491/// So a quarantined import writes `memory.proposed` events rather than
492/// memories. Nothing enters retrieval until a human accepts it through the
493/// review queue that already exists (`brain memory proposals`, `--accept`,
494/// `--reject`) — no new surface to learn, and no new table.
495///
496/// The plan called for releasing quarantine on a local citation instead of a
497/// human decision. That is not implementable as stated: a memory outside the
498/// retrieval pool can never be cited, so the release condition can never fire.
499/// A human decision is the smallest thing that actually gates.
500///
501/// Entries whose normalized text already matches an active memory are counted
502/// as deduped rather than proposed. Without that, re-importing a pack you
503/// already trust would fill the review queue with copies of your own memories,
504/// and a review queue nobody can face is not a safety mechanism.
505pub fn quarantine_memories(
506    start: &Path,
507    entries: &[MemoryExport],
508    scope_override: Option<MemoryScope>,
509    pack: Option<&PackRef>,
510) -> KimetsuResult<ImportSummary> {
511    let mut summary = ImportSummary::default();
512
513    let existing: std::collections::HashSet<String> = match load_project_readonly(start) {
514        Ok((_paths, _config, conn)) => conn
515            .prepare("SELECT normalized_text FROM memories WHERE invalidated_at IS NULL")
516            .and_then(|mut stmt| {
517                stmt.query_map([], |row| row.get::<_, String>(0))
518                    .map(|rows| rows.filter_map(|r| r.ok()).collect())
519            })
520            .unwrap_or_default(),
521        Err(_) => std::collections::HashSet::new(),
522    };
523
524    let origin = match pack {
525        Some(p) => format!(
526            "pack {}@{}",
527            p.name.as_deref().unwrap_or("unknown"),
528            p.version.as_deref().unwrap_or("?")
529        ),
530        None => "an import".to_string(),
531    };
532    let rationale = format!(
533        "Quarantined on import from {origin}. Imported memories are held for \
534         review rather than entering retrieval, because a poisoned memory \
535         persists across every future session. Accept only what you would have \
536         written yourself."
537    );
538
539    let mut seen_in_batch = std::collections::HashSet::new();
540    for entry in entries {
541        let scope = match scope_override {
542            Some(ov) => ov,
543            None => match entry.scope.parse::<MemoryScope>() {
544                Ok(s) => s,
545                Err(_) => {
546                    eprintln!(
547                        "kimetsu-brain import: skipping entry with unknown scope `{}`",
548                        entry.scope
549                    );
550                    summary.deduped += 1;
551                    continue;
552                }
553            },
554        };
555        let kind = match entry.kind.parse::<MemoryKind>() {
556            Ok(k) => k,
557            Err(_) => {
558                eprintln!(
559                    "kimetsu-brain import: skipping entry with unknown kind `{}`",
560                    entry.kind
561                );
562                summary.deduped += 1;
563                continue;
564            }
565        };
566
567        let normalized = kimetsu_core::memory::normalize_memory_text(&entry.text);
568        if existing.contains(&normalized) || !seen_in_batch.insert(normalized) {
569            summary.deduped += 1;
570            continue;
571        }
572
573        // Confidence is the pack author's claim about their own content, which
574        // is exactly what quarantine declines to take at face value. It is
575        // carried through so the reviewer sees what was asserted.
576        match crate::project::propose_memory(
577            start,
578            scope,
579            kind,
580            &entry.text,
581            entry.confidence,
582            &rationale,
583        ) {
584            Ok(_) => summary.quarantined += 1,
585            Err(e) => {
586                eprintln!("kimetsu-brain import: failed to quarantine memory: {e}");
587                summary.deduped += 1;
588            }
589        }
590    }
591    Ok(summary)
592}
593
594/// v2.6 #4: install a pack's memories. `merge` adds additively (dedup against
595/// existing). `replace` first invalidates active memories in the pack's scope(s)
596/// — REVERSIBLE (events kept; rows marked invalidated) — then loads the pack.
597/// Each installed memory is stamped with the `pack` provenance.
598///
599/// v2.6: when `quarantine` is set, entries go to the review queue instead of
600/// the retrieval pool — see [`quarantine_memories`]. `replace` and `quarantine`
601/// are mutually exclusive by construction at the CLI, since superseding what
602/// you have in favour of content you have not reviewed is the worst of both.
603pub fn import_pack(
604    start: &Path,
605    entries: &[MemoryExport],
606    scope_override: Option<MemoryScope>,
607    replace: bool,
608    pack: Option<&PackRef>,
609    quarantine: bool,
610) -> KimetsuResult<ImportSummary> {
611    let mut superseded = 0usize;
612    if replace {
613        let scopes = pack_target_scopes(entries, scope_override);
614        let reason = match pack {
615            Some(p) => format!(
616                "replaced_by_pack:{}@{}",
617                p.name.as_deref().unwrap_or("unknown"),
618                p.version.as_deref().unwrap_or("?")
619            ),
620            None => "replaced_by_import".to_string(),
621        };
622        for id in active_memory_ids_in_scopes(start, &scopes)? {
623            invalidate_memory(start, &id, Some(&reason))?;
624            superseded += 1;
625        }
626    }
627
628    // Defensive scrub: never INGEST a credential/PII from a pack, even if the
629    // author bypassed export-time scrubbing. (Export already scrubs; this is
630    // belt-and-suspenders on the receiving side.)
631    let scrubbed: Vec<MemoryExport> = entries
632        .iter()
633        .map(|e| {
634            let mut e = e.clone();
635            e.text = crate::redact::scrub_for_export(&e.text).text;
636            e
637        })
638        .collect();
639
640    // Stamp pack provenance on each installed memory for the duration of the load.
641    let _prov = pack.map(|p| {
642        ImportProvenanceScope::new(serde_json::json!({
643            "source": "pack",
644            "pack_name": p.name,
645            "pack_version": p.version,
646        }))
647    });
648    let mut summary = if quarantine {
649        quarantine_memories(start, &scrubbed, scope_override, pack)?
650    } else {
651        import_memories(start, &scrubbed, scope_override)?
652    };
653    summary.superseded = superseded;
654    Ok(summary)
655}
656
657/// Distinct scopes a pack will write to (override wins; else parsed per entry).
658fn pack_target_scopes(
659    entries: &[MemoryExport],
660    scope_override: Option<MemoryScope>,
661) -> Vec<MemoryScope> {
662    if let Some(ov) = scope_override {
663        return vec![ov];
664    }
665    let mut seen = std::collections::HashSet::new();
666    let mut out = Vec::new();
667    for e in entries {
668        if let Ok(s) = e.scope.parse::<MemoryScope>() {
669            if seen.insert(s.to_string()) {
670                out.push(s);
671            }
672        }
673    }
674    out
675}
676
677/// Active (non-invalidated, non-superseded) memory ids in the given scopes.
678fn active_memory_ids_in_scopes(start: &Path, scopes: &[MemoryScope]) -> KimetsuResult<Vec<String>> {
679    if scopes.is_empty() {
680        return Ok(Vec::new());
681    }
682    let (_p, _c, conn) = load_project_readonly(start)?;
683    let mut ids = Vec::new();
684    for sc in scopes {
685        let mut stmt = conn.prepare(
686            "SELECT memory_id FROM memories
687             WHERE scope = ?1 AND invalidated_at IS NULL AND superseded_by IS NULL",
688        )?;
689        let rows = stmt.query_map(params![sc.to_string()], |r| r.get::<_, String>(0))?;
690        for r in rows {
691            ids.push(r?);
692        }
693    }
694    Ok(ids)
695}