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}