Skip to main content

kimetsu_brain/
project.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kimetsu_core::config::ProjectConfig;
5use kimetsu_core::env_file::resolve_env_value;
6use kimetsu_core::event::Event;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::memory::{MemoryKind, MemoryScope, normalize_memory_text};
9use kimetsu_core::paths::{ProjectPaths, default_project_id};
10use kimetsu_core::{KIMETSU_CONFIG_VERSION, KimetsuResult};
11use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
12use ulid::Ulid;
13
14use crate::benchmark;
15use crate::conflict;
16use crate::context::{self, ContextBundle, ContextRequest};
17use crate::embeddings;
18use crate::ingest::{self, RepoIngestSummary};
19use crate::lock::ProjectLock;
20use crate::projector;
21use crate::redact;
22use crate::schema;
23use crate::trace::{self, TraceWriter};
24use crate::user_brain;
25
26// ---------------------------------------------------------------------------
27// Flagship 2 / Story 2.1: rule-based initial importance estimator
28// ---------------------------------------------------------------------------
29
30/// Scan the corpus for the highest cosine similarity to `query_vec`.
31/// Returns 0.0 when there are no embeddings or any error occurs.
32fn max_corpus_cosine(conn: &Connection, query_vec: &[f32]) -> f32 {
33    let mut stmt = match conn.prepare(
34        "SELECT embedding FROM memories
35         WHERE invalidated_at IS NULL
36           AND superseded_by IS NULL
37           AND embedding IS NOT NULL
38         ORDER BY created_at DESC
39         LIMIT 200",
40    ) {
41        Ok(s) => s,
42        Err(_) => return 0.0,
43    };
44    let rows = match stmt.query_map([], |row| row.get::<_, Vec<u8>>(0)) {
45        Ok(r) => r,
46        Err(_) => return 0.0,
47    };
48    let mut max_cos: f32 = 0.0;
49    for row in rows.flatten() {
50        if let Ok(vec) = embeddings::decode_embedding(&row, None) {
51            if vec.len() == query_vec.len() {
52                let cos = cosine_sim(query_vec, &vec);
53                if cos > max_cos {
54                    max_cos = cos;
55                }
56            }
57        }
58    }
59    max_cos
60}
61
62fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
63    if a.len() != b.len() || a.is_empty() {
64        return 0.0;
65    }
66    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
67    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
68    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
69    if na < f32::EPSILON || nb < f32::EPSILON {
70        return 0.0;
71    }
72    (dot / (na * nb)).clamp(-1.0, 1.0)
73}
74
75#[derive(Debug, Clone)]
76pub struct InitSummary {
77    pub project_id: String,
78    pub repo_root: PathBuf,
79    pub kimetsu_dir: PathBuf,
80    pub brain_db: PathBuf,
81    pub model: String,
82    pub api_key_env: String,
83    pub api_key_present: bool,
84    pub wrote_project_toml: bool,
85}
86
87#[derive(Debug, Clone)]
88pub struct RunSummary {
89    pub run_id: String,
90    pub task: String,
91    pub started_at: String,
92    pub terminal_kind: Option<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct MemoryRow {
97    pub memory_id: String,
98    pub scope: String,
99    pub kind: String,
100    pub text: String,
101    pub confidence: f32,
102    pub use_count: u32,
103    /// MP-4a: running net outcome score. +1 for each run.finished that
104    /// surfaced this memory; -1 for each run.failed (excluding Gate
105    /// failures). Use_count tracks all updates, useful as a small-sample
106    /// guard before letting the score bias retrieval.
107    pub usefulness_score: f32,
108}
109
110/// v0.8: a full-text search hit over memory text, returned by
111/// [`search_memories`] and the `kimetsu_brain_memory_search` MCP tool.
112/// `rank` is the BM25-derived relevance (higher = more relevant).
113#[derive(Debug, Clone)]
114pub struct MemorySearchHit {
115    pub memory_id: String,
116    pub scope: String,
117    pub kind: String,
118    pub text: String,
119    pub rank: f32,
120}
121
122#[derive(Debug, Clone)]
123pub struct ProposalRow {
124    pub proposal_id: String,
125    pub run_id: String,
126    pub scope: String,
127    pub kind: String,
128    pub text: String,
129    pub rationale: String,
130    pub proposed_confidence: f32,
131    pub status: String,
132    pub decided_reason: Option<String>,
133}
134
135#[derive(Debug, Clone, Default)]
136pub struct ProposalFilter {
137    pub scope: Option<String>,
138    pub kind: Option<String>,
139    pub from_run: Option<String>,
140    pub min_confidence: Option<f32>,
141    pub status: Option<String>,
142    pub limit: u32,
143    /// v0.8: row offset for paginated navigation from the MCP surface.
144    /// 0 = first page (prior behaviour).
145    pub offset: u32,
146}
147
148#[derive(Debug, Clone, Default)]
149pub struct AcceptOverrides {
150    pub scope: Option<String>,
151    pub confidence: Option<f32>,
152}
153
154#[derive(Debug, Clone)]
155pub struct RecordedBenchmarkOutcome {
156    pub memory_id: String,
157    pub task_slug: Option<String>,
158    pub kind: MemoryKind,
159    pub text: String,
160    pub proposal_id: Option<String>,
161    pub proposal_text: Option<String>,
162}
163
164// v0.5.1: blame surface — per-run memory attribution. Both the CLI
165// (`kimetsu brain memory blame <run-id>`) and the MCP tool
166// (`kimetsu_brain_memory_blame`) consume `BlameReport`.
167
168#[derive(Debug, Clone, serde::Serialize)]
169pub struct BlameReport {
170    pub run_id: String,
171    /// Terminal outcome of the run: "success" (run.finished),
172    /// "failed" (run.failed), "aborted" (run.aborted), or "unknown"
173    /// (no terminal event found yet).
174    pub outcome: String,
175    /// Failure category when outcome is "failed" (e.g. "Gate",
176    /// "Implementation"). None otherwise.
177    pub failure_category: Option<String>,
178    /// Memories the model explicitly cited via `cite_memory`,
179    /// ordered by turn.
180    pub cited: Vec<CitedMemory>,
181    /// Memories that were retrieved into the run's context but
182    /// never cited. They got the weak ±0.1 signal instead of ±1.0.
183    pub silent_passengers: Vec<SilentMemory>,
184}
185
186#[derive(Debug, Clone, serde::Serialize)]
187pub struct CitedMemory {
188    pub memory_id: String,
189    pub turn: i64,
190    pub rationale: Option<String>,
191    pub cited_at: String,
192    /// Truncated memory text for human-readable output.
193    pub text_preview: String,
194    pub scope: String,
195    pub kind: String,
196}
197
198#[derive(Debug, Clone, serde::Serialize)]
199pub struct SilentMemory {
200    pub memory_id: String,
201    pub text_preview: String,
202    pub scope: String,
203    pub kind: String,
204}
205
206pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
207    let paths = ProjectPaths::discover(start)?;
208    paths.validate_state_dir()?;
209    // Create only the `.kimetsu/` dir itself (needed before writing
210    // project.toml / brain.db). The `runs/` dir is created lazily by the
211    // agent pipeline's TraceWriter — memory writes no longer produce run
212    // dirs (W1.4), so a brain-only install never grows a `runs/` tree.
213    fs::create_dir_all(&paths.kimetsu_dir)?;
214
215    let project_id = default_project_id(&paths.repo_root);
216    let config = ProjectConfig::default_for_project(project_id);
217    let wrote_project_toml = if force || !paths.project_toml.exists() {
218        fs::write(&paths.project_toml, config.to_toml()?)?;
219        true
220    } else {
221        false
222    };
223
224    let config = load_config(&paths)?;
225    let conn = Connection::open(&paths.brain_db)?;
226    schema::initialize(&conn)?;
227
228    let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
229
230    Ok(InitSummary {
231        project_id: config.kimetsu.project_id,
232        repo_root: paths.repo_root,
233        kimetsu_dir: paths.kimetsu_dir,
234        brain_db: paths.brain_db,
235        model: format!("{}/{}", config.model.provider, config.model.model),
236        api_key_env: config.model.api_key_env,
237        api_key_present,
238        wrote_project_toml,
239    })
240}
241
242pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
243    let paths = ProjectPaths::discover(start)?;
244    paths.validate_state_dir()?;
245    let config = load_config(&paths)?;
246    if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
247        return Err(format!(
248            "project.toml schema version {} does not match expected {}",
249            config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
250        )
251        .into());
252    }
253
254    let conn = Connection::open(&paths.brain_db)?;
255    schema::initialize(&conn)?;
256    Ok((paths, config, conn))
257}
258
259/// No-git variant of [`init_project`]: uses [`ProjectPaths::at_root`]
260/// directly so discovery never shells out to git or climbs to a parent repo.
261/// Intended for the remote HTTP MCP server which manages brains at an
262/// explicit root directory per repo-id.
263pub fn init_project_at_root(root: &Path, force: bool) -> KimetsuResult<InitSummary> {
264    let paths = ProjectPaths::at_root(root);
265    paths.validate_state_dir()?;
266    fs::create_dir_all(&paths.kimetsu_dir)?;
267
268    let project_id = default_project_id(&paths.repo_root);
269    let config = ProjectConfig::default_for_project(project_id);
270    let wrote_project_toml = if force || !paths.project_toml.exists() {
271        fs::write(&paths.project_toml, config.to_toml()?)?;
272        true
273    } else {
274        false
275    };
276
277    let config = load_config(&paths)?;
278    let conn = Connection::open(&paths.brain_db)?;
279    schema::initialize(&conn)?;
280
281    let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
282
283    Ok(InitSummary {
284        project_id: config.kimetsu.project_id,
285        repo_root: paths.repo_root,
286        kimetsu_dir: paths.kimetsu_dir,
287        brain_db: paths.brain_db,
288        model: format!("{}/{}", config.model.provider, config.model.model),
289        api_key_env: config.model.api_key_env,
290        api_key_present,
291        wrote_project_toml,
292    })
293}
294
295/// No-git variant of [`load_project`]: uses [`ProjectPaths::at_root`]
296/// directly so discovery never shells out to git or climbs to a parent repo.
297pub fn load_project_at_root(
298    root: &Path,
299) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
300    let paths = ProjectPaths::at_root(root);
301    paths.validate_state_dir()?;
302    let config = load_config(&paths)?;
303    if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
304        return Err(format!(
305            "project.toml schema version {} does not match expected {}",
306            config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
307        )
308        .into());
309    }
310
311    let conn = Connection::open(&paths.brain_db)?;
312    schema::initialize(&conn)?;
313    Ok((paths, config, conn))
314}
315
316/// No-git variant of [`load_project_readonly`]: uses [`ProjectPaths::at_root`]
317/// directly so discovery never shells out to git or climbs to a parent repo.
318pub fn load_project_readonly_at_root(
319    root: &Path,
320) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
321    let paths = ProjectPaths::at_root(root);
322    paths.validate_state_dir()?;
323    let config = load_config(&paths)?;
324    if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
325        return Err(format!(
326            "project.toml schema version {} does not match expected {}",
327            config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
328        )
329        .into());
330    }
331
332    let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
333    schema::validate(&conn)?;
334    Ok((paths, config, conn))
335}
336
337/// Return the brain.db schema version for the project rooted at `start`.
338///
339/// Opens via `load_project` (which migrates on the way through), so by the
340/// time this returns the DB is at the current target version.
341pub fn schema_version(start: &Path) -> KimetsuResult<i64> {
342    let (_, _, conn) = load_project(start)?;
343    crate::migrate::current_version(&conn)
344}
345
346pub fn load_project_readonly(
347    start: &Path,
348) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
349    let paths = ProjectPaths::discover(start)?;
350    paths.validate_state_dir()?;
351    let config = load_config(&paths)?;
352    if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
353        return Err(format!(
354            "project.toml schema version {} does not match expected {}",
355            config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
356        )
357        .into());
358    }
359
360    let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
361    schema::validate(&conn)?;
362    Ok((paths, config, conn))
363}
364
365pub struct BrainSession {
366    paths: ProjectPaths,
367    config: ProjectConfig,
368    conn: Connection,
369    /// v0.4.1: user-scope brain at `~/.kimetsu/brain.db`. Opened
370    /// lazily during session construction; `None` when the user
371    /// brain is disabled (`KIMETSU_USER_BRAIN=0`), no home dir is
372    /// resolvable, or — for the read-only constructor — the file
373    /// hasn't been created yet. Retrieval merges memories from this
374    /// connection alongside the project DB; repo files and manifests
375    /// stay project-only.
376    user_conn: Option<Connection>,
377    repo_root: String,
378}
379
380impl BrainSession {
381    pub fn open(start: &Path) -> KimetsuResult<Self> {
382        let (paths, config, conn) = load_project(start)?;
383        // Read/write user brain — created on demand so a v0.4 binary
384        // running on a v0.3 home dir provisions the file the first
385        // time the user actually writes a GlobalUser memory.
386        // W3.3: honor config.kimetsu.use_user_brain with env override.
387        let user_conn = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?;
388        Self::from_parts(paths, config, conn, user_conn)
389    }
390
391    pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
392        let (paths, config, conn) = load_project_readonly(start)?;
393        // Read-only path skips file creation — if the user brain
394        // doesn't exist yet we just retrieve from the project DB
395        // alone, no surprise file under $HOME.
396        // W3.3: honor config.kimetsu.use_user_brain with env override.
397        let user_conn =
398            user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
399        Self::from_parts(paths, config, conn, user_conn)
400    }
401
402    fn from_parts(
403        paths: ProjectPaths,
404        config: ProjectConfig,
405        conn: Connection,
406        user_conn: Option<Connection>,
407    ) -> KimetsuResult<Self> {
408        let repo_root = paths
409            .repo_root
410            .canonicalize()?
411            .to_string_lossy()
412            .to_string();
413        Ok(Self {
414            paths,
415            config,
416            conn,
417            user_conn,
418            repo_root,
419        })
420    }
421
422    pub fn retrieve_context(
423        &self,
424        stage: &str,
425        query: &str,
426        budget_tokens: u32,
427    ) -> KimetsuResult<ContextBundle> {
428        self.retrieve_context_with_request(ContextRequest {
429            stage: stage.to_string(),
430            query: query.to_string(),
431            budget_tokens,
432            ..Default::default()
433        })
434    }
435
436    /// v0.6: full-request variant used by `kimetsu_brain_context` MCP tool
437    /// and `retrieve_context_readonly_with_request` to expose `tags`,
438    /// `min_score`, `max_capsules`, and `prefer_roles`.
439    ///
440    /// W3.1: routes through `open_embedder_for` so the persistent
441    /// `[embedder] enabled = false` config field truly disables the
442    /// cosine path (FTS-only retrieval) without relying on the env var.
443    pub fn retrieve_context_with_request(
444        &self,
445        mut request: ContextRequest,
446    ) -> KimetsuResult<ContextBundle> {
447        // v1.0.0: drive the lexical + semantic relevance floors from config
448        // unless the caller set its own (non-zero) values.
449        if request.min_lexical_coverage == 0.0 {
450            request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
451        }
452        if request.min_semantic_score == 0.0 {
453            request.min_semantic_score = self.resolved_min_semantic_score();
454        }
455        // v2.5: whole-retrieval abstention floor (top direct candidate's score).
456        // The gate in context.rs returns an empty bundle when top_score is below
457        // this, so a weak retrieval makes the reader abstain. 0.0 = off.
458        if request.min_score == 0.0 {
459            request.min_score = self.config.broker.abstain_min_score;
460        }
461        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
462        let backend = crate::backend::backend_for(&self.config.storage.backend);
463        context::retrieve_context_with_embedder_and_backend(
464            &self.conn,
465            &self.repo_root,
466            &self.config.broker.weights,
467            request,
468            &extras,
469            embeddings::open_embedder_for(self.config.embedder.enabled),
470            backend.as_ref(),
471        )
472    }
473
474    /// v1.0.0: resolve the semantic floor for this session's embedder. The
475    /// config default is the AUTO sentinel (-1.0): cosine scales are
476    /// MODEL-DEPENDENT — 0.35 suits bge-family distributions, but the remote
477    /// benchmark showed the same floor killing relevant jina-v2 results
478    /// outright (MRR 0.90 → 0.77, recall@2 == recall@4) — so auto applies
479    /// the bge-calibrated floor only to bge models and disables it
480    /// elsewhere (jina-v2's own precision keeps noise low without it).
481    /// Explicit non-negative config values are used as-is for any model.
482    fn resolved_min_semantic_score(&self) -> f32 {
483        let configured = self.config.broker.min_semantic_score;
484        if configured >= 0.0 {
485            return configured;
486        }
487        let model = embeddings::resolve_embedder_id(Some(self.config.embedder.model.as_str()));
488        if model.starts_with("bge") { 0.35 } else { 0.0 }
489    }
490
491    /// v0.8: proactive (mid-work) retrieval. Pins [`NoopEmbedder`] so
492    /// it stays lexical-FTS-only — NO embedding model is loaded even in
493    /// `--features embeddings` builds, keeping the per-tool-call hook
494    /// cheap. `request.kinds` should restrict to actionable kinds; the
495    /// caller sets a high `min_score` and `max_capsules: 1` so recall is
496    /// rare and confident (the human-brain "it comes to you" model).
497    pub fn retrieve_proactive(&self, mut request: ContextRequest) -> KimetsuResult<ContextBundle> {
498        // v1.0.0: the lexical floor applies here too — proactive recall is
499        // FTS-only, so without it an off-topic memory sharing a ubiquitous
500        // token with the command line (e.g. "config") can take the single
501        // proactive slot.
502        if request.min_lexical_coverage == 0.0 {
503            request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
504        }
505        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
506        let backend = crate::backend::backend_for(&self.config.storage.backend);
507        context::retrieve_context_with_embedder_and_backend(
508            &self.conn,
509            &self.repo_root,
510            &self.config.broker.weights,
511            request,
512            &extras,
513            &embeddings::NoopEmbedder,
514            backend.as_ref(),
515        )
516    }
517
518    /// v1.0.0: lexical (FTS-only) retrieval honoring the full
519    /// [`ContextRequest`]. Like [`Self::retrieve_context_with_request`]
520    /// but pins [`NoopEmbedder`] so NO embedding model is loaded even in
521    /// `--features embeddings` builds. The `UserPromptSubmit` context-hook
522    /// uses this: it runs in a throwaway per-prompt process that cannot
523    /// reuse the long-lived MCP server's warm model cache, so a cold ONNX
524    /// load there can blow the host's 30s hook timeout. Semantic ANN
525    /// recall stays with the warm MCP `kimetsu_brain_context` tool.
526    pub fn retrieve_context_lexical(
527        &self,
528        mut request: ContextRequest,
529    ) -> KimetsuResult<ContextBundle> {
530        // v1.0.0: the hook path is FTS-only, so this lexical floor (driven
531        // from config unless the caller overrode it) is the *only* relevance
532        // gate protecting it — the cosine-based `min_semantic_score` is inert
533        // here.
534        if request.min_lexical_coverage == 0.0 {
535            request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
536        }
537        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
538        let backend = crate::backend::backend_for(&self.config.storage.backend);
539        context::retrieve_context_with_embedder_and_backend(
540            &self.conn,
541            &self.repo_root,
542            &self.config.broker.weights,
543            request,
544            &extras,
545            &embeddings::NoopEmbedder,
546            backend.as_ref(),
547        )
548    }
549
550    /// v1.0.0: read-only retrieval honoring the full [`ContextRequest`] but
551    /// with a caller-supplied embedder. The warm embedder daemon uses this
552    /// to run cosine/ANN retrieval with ONE long-lived model instead of
553    /// opening a fresh embedder per request. The lexical-coverage floor is
554    /// applied here too (driven from config unless the caller overrode it).
555    pub fn retrieve_context_with_injected_embedder(
556        &self,
557        mut request: ContextRequest,
558        embedder: &dyn embeddings::Embedder,
559    ) -> KimetsuResult<ContextBundle> {
560        if request.min_lexical_coverage == 0.0 {
561            request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
562        }
563        // v1.0.0: semantic floor from config too — this is the daemon's path,
564        // where a real query embedding makes the cosine floor effective.
565        if request.min_semantic_score == 0.0 {
566            request.min_semantic_score = self.resolved_min_semantic_score();
567        }
568        // v2.5: whole-retrieval abstention floor (top direct candidate's score).
569        // The gate in context.rs returns an empty bundle when top_score is below
570        // this, so a weak retrieval makes the reader abstain. 0.0 = off.
571        if request.min_score == 0.0 {
572            request.min_score = self.config.broker.abstain_min_score;
573        }
574        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
575        let backend = crate::backend::backend_for(&self.config.storage.backend);
576        context::retrieve_context_with_embedder_and_backend(
577            &self.conn,
578            &self.repo_root,
579            &self.config.broker.weights,
580            request,
581            &extras,
582            embedder,
583            backend.as_ref(),
584        )
585    }
586
587    pub fn repo_root(&self) -> &Path {
588        &self.paths.repo_root
589    }
590
591    /// v0.4.1: expose the user-brain connection so callers (e.g.
592    /// `kimetsu brain status`) can report counts/paths without
593    /// re-opening the file. Returns None when the user brain is
594    /// disabled or unresolvable.
595    pub fn user_conn(&self) -> Option<&Connection> {
596        self.user_conn.as_ref()
597    }
598}
599
600pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
601    let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
602        format!(
603            "failed to read {}; run `kimetsu init` first: {err}",
604            paths.project_toml.display()
605        )
606    })?;
607    let mut config = ProjectConfig::from_toml(&content)?;
608    // Resolve the [retrieval] level preset into [embedder].enabled +
609    // [embedder].reranker BEFORE returning, so every retrieval consumer
610    // (the config.embedder.enabled sites + the daemon reranker resolution)
611    // sees the resolved values automatically. "custom" (the default) is a
612    // no-op, so configs without [retrieval] are byte-identical in behaviour.
613    config.apply_retrieval_level();
614    Ok(config)
615}
616
617/// D2: Parse a project config from raw TOML text. Used by `config edit`
618/// to validate the file the user just saved before confirming success.
619pub fn load_config_from_text(toml: &str) -> KimetsuResult<ProjectConfig> {
620    ProjectConfig::from_toml(toml)
621}
622
623pub fn config_text(start: &Path) -> KimetsuResult<String> {
624    let paths = ProjectPaths::discover(start)?;
625    Ok(fs::read_to_string(paths.project_toml)?)
626}
627
628pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
629    let (_paths, _config, conn) = load_project(start)?;
630    let mut stmt = conn.prepare(
631        "
632        SELECT run_id, task, started_at, terminal_kind
633        FROM runs
634        ORDER BY started_at DESC
635        LIMIT 100
636        ",
637    )?;
638
639    let rows = stmt.query_map([], |row| {
640        Ok(RunSummary {
641            run_id: row.get(0)?,
642            task: row.get(1)?,
643            started_at: row.get(2)?,
644            terminal_kind: row.get(3)?,
645        })
646    })?;
647
648    let mut runs = Vec::new();
649    for row in rows {
650        runs.push(row?);
651    }
652    Ok(runs)
653}
654
655pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
656    let (_paths, _config, conn) = load_project(start)?;
657    let mut stmt = conn.prepare(
658        "
659        SELECT run_id, task, started_at, terminal_kind
660        FROM runs
661        WHERE run_id = ?1
662        ",
663    )?;
664
665    let mut rows = stmt.query(params![run_id])?;
666    if let Some(row) = rows.next()? {
667        Ok(Some(RunSummary {
668            run_id: row.get(0)?,
669            task: row.get(1)?,
670            started_at: row.get(2)?,
671            terminal_kind: row.get(3)?,
672        }))
673    } else {
674        Ok(None)
675    }
676}
677
678/// One entry in a [`add_memories_batch`] call.
679///
680/// `text` is required; all other fields are optional and fall back to
681/// the defaults documented on each field.
682#[derive(Debug, Clone)]
683pub struct BatchMemoryEntry {
684    /// The memory text to store.
685    pub text: String,
686    /// Scope to store under.  Defaults to `MemoryScope::Project`.
687    pub scope: MemoryScope,
688    /// Memory kind.  Defaults to `MemoryKind::Fact`.
689    pub kind: MemoryKind,
690    /// Flagship 1 / temporal: optional RFC 3339 valid-from bound.
691    /// `None` leaves the column NULL (valid forever from creation).
692    pub valid_from: Option<String>,
693    /// Flagship 1 / temporal: optional RFC 3339 valid-to bound.
694    /// `None` leaves the column NULL (no expiry).
695    pub valid_to: Option<String>,
696}
697
698/// Initial confidence for a directly-added memory (`memory add` / `add-batch`).
699///
700/// Story 2.4 follow-up: a freshly written memory is asserted but UNPROVEN, so it
701/// must not start at the 1.0 ceiling. The outcome-update path nudges confidence
702/// toward 1.0 on citation (asymptoting to the 0.99 clamp) and toward 0.0 on
703/// regret, so a default below the clamp leaves headroom for a proven memory to
704/// outrank a never-evaluated one — instead of every fresh memory pinning at the
705/// top. All directly-added memories share this value, so retrieval ranking and
706/// contradiction resolution (which compare confidence) are unchanged for the
707/// uniform case; the value only matters once outcomes differentiate memories.
708const DIRECT_ADD_CONFIDENCE: f32 = 0.85;
709
710pub fn add_memory(
711    start: &Path,
712    scope: MemoryScope,
713    kind: MemoryKind,
714    text: &str,
715) -> KimetsuResult<String> {
716    // v0.4.5: redact secrets at the ingest boundary. The redaction
717    // pipeline catches Anthropic/OpenAI/GitHub/AWS/Slack/Google
718    // credentials, JWTs, PEM blocks, and generic `api_key=...` /
719    // `bearer ...` / `token: ...` assignments. A leak that lands in
720    // brain.db is durable, replicated across user / project scopes,
721    // and shows up in every retrieval — better to false-positive on
722    // a config string than to leak a real key.
723    //
724    // On a hit we replace the bytes with `[REDACTED:<kind>]` and
725    // print a one-liner to stderr so the operator notices. We do
726    // NOT fail the write: keeping the user memorable (the rest of
727    // the text) is more useful than rejecting outright.
728    let redaction = redact::redact_secrets(text);
729    if redaction.was_redacted() {
730        eprintln!("kimetsu-brain: {}", redaction.summary());
731    }
732    let text = redaction.text.as_str();
733
734    // v0.4.1: GlobalUser memories route to `~/.kimetsu/brain.db` when
735    // the user brain is enabled. The user-brain write path is
736    // intentionally simpler (no run rows, no trace events, no project
737    // lock) because there's no project to attribute them to.
738    //
739    // If the user brain is disabled (KIMETSU_USER_BRAIN=0 or
740    // config.kimetsu.use_user_brain=false) OR unreachable (no $HOME),
741    // fall through to the project DB so backward compat is preserved —
742    // existing scripts that wrote GlobalUser memories into the project
743    // keep working.
744    //
745    // P0 fix: this short-circuit MUST run BEFORE `load_project` so
746    // that GlobalUser writes work from ANY `start` directory — including
747    // dirs that are not kimetsu projects (e.g. the global distiller's
748    // temp/user dir). W3.3's `use_user_brain` toggle is still honored
749    // best-effort: if `start` IS a project we read its config; if not
750    // (or if the read fails) we default to enabled (nothing to opt out of).
751    if scope == MemoryScope::GlobalUser {
752        let use_user_brain = ProjectPaths::discover(start)
753            .ok()
754            .and_then(|paths| load_config(&paths).ok())
755            .map(|cfg| cfg.kimetsu.use_user_brain)
756            .unwrap_or(true);
757        if let Some(user_conn) = user_brain::open_user_brain_for_config(use_user_brain)? {
758            return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
759        }
760        // User brain disabled/unreachable → fall through to the project DB
761        // (which DOES require a valid project — same pre-P0 behavior for
762        // the disabled/fallback path).
763    }
764
765    let (paths, config, conn) = load_project(start)?;
766    let run_id = RunId::new();
767    let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
768
769    let embedder = embeddings::open_embedder_for(config.embedder.enabled);
770    add_memory_inner(
771        &conn, &paths, &config, scope, kind, text, None, None, embedder,
772    )
773}
774
775/// Per-entry core shared by [`add_memory`] and [`add_memories_batch`].
776///
777/// Takes an already-open connection + loaded config + resolved embedder so
778/// neither the project nor the embedder is re-initialized per call.
779/// The single-add path acquires the project lock once before calling this;
780/// the batch path acquires it once for the whole batch.
781///
782/// Returns the `memory_id` of the written (or deduped) memory.
783#[allow(clippy::too_many_arguments)]
784fn add_memory_inner(
785    conn: &Connection,
786    paths: &ProjectPaths,
787    config: &kimetsu_core::config::ProjectConfig,
788    scope: MemoryScope,
789    kind: MemoryKind,
790    text: &str,
791    valid_from: Option<&str>,
792    valid_to: Option<&str>,
793    embedder: &dyn embeddings::Embedder,
794) -> KimetsuResult<String> {
795    let run_id = RunId::new();
796    let memory_id = Ulid::new().to_string();
797    let normalized = normalize_memory_text(text);
798
799    // MP-17 #14: dedup. If an ACTIVE memory with the same scope + kind +
800    // normalized text already exists, return its ID without writing a
801    // duplicate. The scope/kind tuple keeps task-specific duplicates
802    // separate from global ones; the normalized form makes minor
803    // whitespace / punctuation differences collapse to the same row.
804    let existing: Option<String> = conn
805        .query_row(
806            "
807            SELECT memory_id FROM memories
808            WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
809              AND invalidated_at IS NULL
810              AND superseded_by IS NULL
811            LIMIT 1
812            ",
813            rusqlite::params![scope.to_string(), kind.to_string(), normalized],
814            |row| row.get::<_, String>(0),
815        )
816        .optional()?;
817    if let Some(existing_id) = existing {
818        return Ok(existing_id);
819    }
820
821    // Flagship 2 / Story 2.1: compute kind-weight portion of initial
822    // usefulness BEFORE writing the event so the value is in the event
823    // payload (rebuild-safe).  Rarity bonus (requires embedding) is applied
824    // as a follow-up UPDATE after embed_and_persist — not in the event, so it
825    // degrades to 0 on rebuild, but that is acceptable for a bootstrap seed.
826    let importance_enabled = config.ingestion.initial_importance_scoring;
827    let initial_kind_weight = if importance_enabled {
828        match &kind {
829            MemoryKind::FailurePattern => 0.3_f32,
830            MemoryKind::Command => 0.2,
831            MemoryKind::Convention => 0.15,
832            MemoryKind::Fact => 0.1,
833            MemoryKind::Preference => 0.05,
834        }
835    } else {
836        0.0
837    };
838
839    let started = Event::new(
840        run_id,
841        "run.started",
842        serde_json::json!({
843            "mode": "admin",
844            "task": "memory add",
845            "project_id": config.kimetsu.project_id,
846            "repo_root": paths.repo_root.to_string_lossy(),
847            "model": null,
848            "platform": std::env::consts::OS,
849            "kimetsu_version": env!("CARGO_PKG_VERSION"),
850            "config_hash": config_hash(&paths.project_toml)?,
851        }),
852    );
853    let accepted = Event::new(
854        run_id,
855        "memory.accepted",
856        serde_json::json!({
857            "proposal_id": null,
858            "memory_id": memory_id,
859            "scope": scope.to_string(),
860            "kind": kind.to_string(),
861            "text": text,
862            "normalized_text": normalized,
863            "confidence": DIRECT_ADD_CONFIDENCE,
864            "initial_usefulness": initial_kind_weight,
865            "provenance_snapshot": build_provenance(run_id, text),
866        }),
867    );
868
869    let finished = Event::new(
870        run_id,
871        "run.finished",
872        serde_json::json!({
873            "status": "success",
874            "final_report_path": null,
875            "total_cost_usd": 0.0,
876            "total_tool_calls": 0,
877        }),
878    );
879
880    projector::apply_events(conn, &[started, accepted, finished])?;
881
882    // Flagship 1 / temporal: stamp valid_from / valid_to when requested.
883    // This is event-sourced (rebuild-safe) via mark_memory_temporal.
884    if valid_from.is_some() || valid_to.is_some() {
885        projector::mark_memory_temporal(conn, &memory_id, valid_from, valid_to)?;
886    }
887
888    // v0.4.2: post-projection embedding write. v0.4.3 wired the
889    // default embedder behind a feature flag — see
890    // `embeddings::open_default_embedder`. Default build: NoopEmbedder
891    // (column stays NULL, FTS only). `--features embeddings` build:
892    // fastembed-rs BGE-small by default, configurable via
893    // KIMETSU_BRAIN_EMBEDDER. The embedder is cached in a
894    // process-static OnceLock so we only pay model-load cost once.
895    // W3.1: route through open_embedder_for so `[embedder] enabled = false`
896    // in project.toml durably disables vector writes (FTS-only).
897    //
898    // embed_and_persist returns the computed vector so we can reuse it for
899    // conflict detection without re-embedding (Fix 4c — halves embedding cost).
900    let embedding_vec = embeddings::embed_and_persist(conn, &memory_id, text, embedder)?;
901
902    // Flagship 2 / Story 2.1: apply rarity bonus (requires embedding).
903    // The kind-weight was already stored in the event; now compute the rarity
904    // bonus (if embedder is active and we got a vector) and UPDATE the row.
905    // This is NOT rebuild-safe (rarity depends on the corpus snapshot at write
906    // time), which is acceptable: on rebuild, the kind-weight from the event
907    // is used and the rarity bonus is 0.
908    if importance_enabled && !embedder.is_noop() {
909        if let Some(vec) = embedding_vec.as_deref() {
910            let rarity_bonus = {
911                let max_cos = max_corpus_cosine(conn, vec);
912                if max_cos < 0.5 { 0.1_f32 } else { 0.0 }
913            };
914            if rarity_bonus > 0.0 {
915                let full_score = (initial_kind_weight + rarity_bonus).min(0.5);
916                conn.execute(
917                    "UPDATE memories SET usefulness_score = ?2 WHERE memory_id = ?1",
918                    rusqlite::params![memory_id, full_score],
919                )
920                .ok(); // best-effort
921            }
922        }
923    }
924
925    // v0.5.2 / v1.0: conflict detection at ingest. Scans for high-cosine,
926    // different-text neighbors in the same scope and logs each pair
927    // to `memory_conflicts` for operator review via
928    // `kimetsu brain memory conflicts`. Best-effort: NoopEmbedder
929    // (lean build) returns 0 hits; embedder failures degrade to a
930    // stderr line, never to a failed insert.
931    //
932    // v1.0: honor the [ingestion] detect_conflicts config field and the
933    // KIMETSU_DETECT_CONFLICTS env override so bulk-seeding can skip the
934    // O(N²) conflict scan.
935    //
936    // v2.5 Pass B (Story 1.3): when resolve_conflicts is also enabled, run
937    // auto-resolution: clear winners (confidence×recency gap ≥ 0.15) have
938    // the loser's valid_to stamped; near-ties go to the queue.
939    if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
940        // Fetch the created_at timestamp of the newly-written memory for
941        // scoring (needed by resolve_conflicts).  We read it back from the DB
942        // because the event timestamp is the canonical value.
943        let new_created_at: String = conn
944            .query_row(
945                "SELECT created_at FROM memories WHERE memory_id = ?1",
946                rusqlite::params![memory_id],
947                |row| row.get(0),
948            )
949            .unwrap_or_else(|_| {
950                // Fallback: use "now" so recency scoring is still valid.
951                time::OffsetDateTime::now_utc()
952                    .format(&time::format_description::well_known::Rfc3339)
953                    .unwrap_or_default()
954            });
955
956        if conflict::resolve_conflicts_enabled(config.ingestion.resolve_conflicts) {
957            // Pass B: detect + auto-resolve.
958            let (auto_resolved, queued) = conflict::detect_record_and_resolve_with_vec(
959                conn,
960                &memory_id,
961                &scope,
962                &kind.to_string(),
963                text,
964                embedding_vec.as_deref(),
965                embedder,
966                DIRECT_ADD_CONFIDENCE, // matches the memory.accepted event above
967                &new_created_at,
968            );
969            if auto_resolved > 0 {
970                eprintln!(
971                    "kimetsu-brain: memory {memory_id} auto-resolved {auto_resolved} contradiction{} (loser valid_to stamped)",
972                    if auto_resolved == 1 { "" } else { "s" }
973                );
974            }
975            if queued > 0 {
976                eprintln!(
977                    "kimetsu-brain: memory {memory_id} has {queued} near-tie conflict{} queued for review (run `kimetsu brain memory conflicts`)",
978                    if queued == 1 { "" } else { "s" }
979                );
980            }
981        } else {
982            // Detect-only (Pass A / disabled-resolution) path.
983            let conflicts = conflict::detect_and_record_with_vec(
984                conn,
985                &memory_id,
986                &scope,
987                &kind.to_string(),
988                text,
989                embedding_vec.as_deref(),
990                embedder,
991            );
992            if conflicts > 0 {
993                eprintln!(
994                    "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
995                    if conflicts == 1 { "y" } else { "ies" }
996                );
997            }
998        }
999    }
1000
1001    Ok(memory_id)
1002}
1003
1004/// Add many memories in one process: the project is opened and the embedder
1005/// is initialized ONCE, then every entry is processed by [`add_memory_inner`].
1006///
1007/// This is the efficient ingest path for benchmarks (LongMemEval etc.) and
1008/// bulk imports: the per-call overhead of `load_project` + embedder init is
1009/// paid exactly once regardless of how many entries are in `entries`.
1010///
1011/// # Behaviour
1012/// * Entries whose `scope` is `GlobalUser` are silently routed to the user
1013///   brain (when enabled), exactly as the single-add path does.
1014/// * Dedup, redaction, conflict detection, rarity scoring, and temporal
1015///   stamping all apply per-entry — identical to the single-add path.
1016/// * Returns `Vec<String>` of memory IDs in the same order as `entries`.
1017///   Deduped entries return the existing memory ID (not an error).
1018///
1019/// # Errors
1020/// The function opens the project once; if `load_project` fails the error is
1021/// returned before any entries are processed. Per-entry failures propagate
1022/// immediately (fail-fast), leaving already-written entries in the DB.
1023pub fn add_memories_batch(
1024    start: &Path,
1025    entries: Vec<BatchMemoryEntry>,
1026) -> KimetsuResult<Vec<String>> {
1027    if entries.is_empty() {
1028        return Ok(vec![]);
1029    }
1030
1031    // Determine user-brain config (needed for GlobalUser routing) without
1032    // requiring a valid project — same best-effort approach as single-add.
1033    let use_user_brain = ProjectPaths::discover(start)
1034        .ok()
1035        .and_then(|paths| load_config(&paths).ok())
1036        .map(|cfg| cfg.kimetsu.use_user_brain)
1037        .unwrap_or(true);
1038
1039    // Open user brain once (if available) so GlobalUser entries share it.
1040    let user_conn_opt = user_brain::open_user_brain_for_config(use_user_brain)?;
1041
1042    // Check whether any non-GlobalUser entries exist; only open the project
1043    // if needed (avoids failing on user-only batches in non-project dirs).
1044    let has_project_entries = entries.iter().any(|e| e.scope != MemoryScope::GlobalUser);
1045
1046    // Open project + embedder ONCE for all project-scoped entries.
1047    let project_state: Option<(
1048        ProjectPaths,
1049        kimetsu_core::config::ProjectConfig,
1050        Connection,
1051    )> = if has_project_entries {
1052        let state = load_project(start)?;
1053        Some(state)
1054    } else {
1055        None
1056    };
1057
1058    // Acquire project lock once for the whole batch (if we have a project).
1059    let run_id_for_lock = RunId::new();
1060    let _lock = if let Some((ref paths, _, _)) = project_state {
1061        Some(ProjectLock::acquire(
1062            paths,
1063            "brain memory add-batch",
1064            Some(run_id_for_lock),
1065        )?)
1066    } else {
1067        None
1068    };
1069
1070    // Resolve embedder once — the key perf benefit: model loaded once,
1071    // not once per entry.
1072    let embedder: &dyn embeddings::Embedder = if let Some((_, ref config, _)) = project_state {
1073        embeddings::open_embedder_for(config.embedder.enabled)
1074    } else {
1075        &embeddings::NoopEmbedder
1076    };
1077
1078    let mut ids = Vec::with_capacity(entries.len());
1079
1080    for entry in entries {
1081        // Redact at the ingest boundary (same as single-add).
1082        let redaction = redact::redact_secrets(&entry.text);
1083        if redaction.was_redacted() {
1084            eprintln!("kimetsu-brain: {}", redaction.summary());
1085        }
1086        let text = redaction.text.as_str();
1087
1088        if entry.scope == MemoryScope::GlobalUser {
1089            // Route to user brain when available; otherwise fall through to
1090            // project DB — same behaviour as the single-add path.
1091            if let Some(ref uc) = user_conn_opt {
1092                let id = user_brain::add_user_memory(uc, entry.kind, text, 1.0)?;
1093                ids.push(id);
1094                continue;
1095            }
1096            // Fall through: user brain disabled/unreachable, write to project.
1097        }
1098
1099        let (paths, config, conn) = project_state
1100            .as_ref()
1101            .expect("project must be open when non-GlobalUser entries are present");
1102
1103        let id = add_memory_inner(
1104            conn,
1105            paths,
1106            config,
1107            entry.scope,
1108            entry.kind,
1109            text,
1110            entry.valid_from.as_deref(),
1111            entry.valid_to.as_deref(),
1112            embedder,
1113        )?;
1114        ids.push(id);
1115    }
1116
1117    Ok(ids)
1118}
1119
1120/// v0.6: write a `memory.proposed` event (pending proposal) without
1121/// accepting it immediately. Used by `kimetsu_brain_record` when confidence
1122/// is low and the lesson needs human review before entering the retrieval pool.
1123/// Returns the `proposal_id`.
1124pub fn propose_memory(
1125    start: &Path,
1126    scope: MemoryScope,
1127    kind: MemoryKind,
1128    text: &str,
1129    confidence: f32,
1130    rationale: &str,
1131) -> KimetsuResult<String> {
1132    let redaction = redact::redact_secrets(text);
1133    if redaction.was_redacted() {
1134        eprintln!("kimetsu-brain: {}", redaction.summary());
1135    }
1136    let text = redaction.text.as_str();
1137    let rationale_redaction = redact::redact_secrets(rationale);
1138    if rationale_redaction.was_redacted() {
1139        eprintln!("kimetsu-brain: {}", rationale_redaction.summary());
1140    }
1141    let rationale = rationale_redaction.text.as_str();
1142    let (paths, config, conn) = load_project(start)?;
1143    let run_id = RunId::new();
1144    let _lock = ProjectLock::acquire(&paths, "memory propose", Some(run_id))?;
1145    let proposal_id = Ulid::new().to_string();
1146
1147    let started = admin_started_event(&paths, &config, run_id, "memory propose")?;
1148
1149    let proposed = Event::new(
1150        run_id,
1151        "memory.proposed",
1152        serde_json::json!({
1153            "proposal_id": proposal_id,
1154            "scope": scope.to_string(),
1155            "kind": kind.to_string(),
1156            "text": text,
1157            "rationale": rationale,
1158            "proposed_confidence": confidence.clamp(0.0, 1.0),
1159            "source_event_ids": [],
1160        }),
1161    );
1162
1163    let finished = admin_finished_event(run_id);
1164
1165    projector::apply_events(&conn, &[started, proposed, finished])?;
1166    Ok(proposal_id)
1167}
1168
1169/// v0.7: outcome of a `propose_or_merge_memory` call.
1170#[derive(Debug)]
1171pub enum ProposeResult {
1172    Added(String),     // memory_id — new memory, directly accepted
1173    Proposed(String),  // proposal_id — pending for review (low confidence)
1174    Merged(String),    // memory_id of the existing memory that was updated
1175    Duplicate(String), // memory_id of the identical existing memory
1176}
1177
1178/// v0.7: capture a lesson, automatically deduplicating against the existing brain.
1179///
1180/// Decision tree:
1181/// 1. Exact normalized-text match → `Duplicate` (no write).
1182/// 2. Cosine similarity ≥ 0.85 with an existing memory → `Merged` (append & re-embed).
1183/// 3. confidence ≥ 0.7 and no close match → `Added` (direct acceptance).
1184/// 4. confidence < 0.7 → `Proposed` (pending for human review).
1185///
1186/// Step 2 only fires when the embedder is active (bge-small or similar). In lean builds
1187/// the cosine scan returns nothing and the function falls through to step 3/4.
1188pub fn propose_or_merge_memory(
1189    start: &Path,
1190    scope: MemoryScope,
1191    kind: MemoryKind,
1192    text: &str,
1193    confidence: f32,
1194    rationale: &str,
1195) -> KimetsuResult<ProposeResult> {
1196    let redaction = redact::redact_secrets(text);
1197    if redaction.was_redacted() {
1198        eprintln!("kimetsu-brain: {}", redaction.summary());
1199    }
1200    let text = redaction.text.as_str();
1201
1202    // Step 1: exact normalized-text dedup (same as add_memory).
1203    // W3.1: load config here so Step 2 can use open_embedder_for.
1204    let (_, config, _) = {
1205        let (paths, config, ro_conn) = load_project_readonly(start)?;
1206        let normalized = normalize_memory_text(text);
1207        let existing: Option<String> = ro_conn
1208            .query_row(
1209                "SELECT memory_id FROM memories
1210                 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
1211                   AND invalidated_at IS NULL
1212                   AND superseded_by IS NULL
1213                 LIMIT 1",
1214                rusqlite::params![scope.to_string(), kind.to_string(), normalized],
1215                |row| row.get::<_, String>(0),
1216            )
1217            .optional()?;
1218        if let Some(id) = existing {
1219            return Ok(ProposeResult::Duplicate(id));
1220        }
1221        (paths, config, ro_conn)
1222    };
1223
1224    // Step 2: semantic dedup — look for a high-cosine existing memory.
1225    // W3.1: route through open_embedder_for so `[embedder] enabled = false`
1226    // skips cosine dedup (NoopEmbedder → find_potential_conflicts returns 0).
1227    // v1.0: honor the [ingestion] detect_conflicts off-switch so bulk-seeding
1228    // skips the cosine scan (find_potential_conflicts returns empty → no merge).
1229    let embedder = embeddings::open_embedder_for(config.embedder.enabled);
1230    {
1231        let (_, _, ro_conn) = load_project_readonly(start)?;
1232        let conflicts = if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
1233            conflict::find_potential_conflicts(&ro_conn, &scope, text, embedder, 1, 0.85)?
1234        } else {
1235            Vec::new()
1236        };
1237        if let Some(hit) = conflicts.into_iter().next() {
1238            // Append the new lesson to the existing memory and re-embed it.
1239            let (paths, _config, conn) = load_project(start)?;
1240            let run_id = RunId::new();
1241            let _lock = ProjectLock::acquire(&paths, "memory merge", Some(run_id))?;
1242            let merged_text = format!("{}\n\nAlso: {text}", hit.existing_text);
1243            let new_normalized = normalize_memory_text(&merged_text);
1244            conn.execute(
1245                "UPDATE memories
1246                 SET text = ?1, normalized_text = ?2, use_count = use_count + 1
1247                 WHERE memory_id = ?3",
1248                rusqlite::params![merged_text, new_normalized, hit.existing_memory_id],
1249            )?;
1250            // Return value not needed — no conflict scan after a merge.
1251            embeddings::embed_and_persist(&conn, &hit.existing_memory_id, &merged_text, embedder)?;
1252            return Ok(ProposeResult::Merged(hit.existing_memory_id));
1253        }
1254    }
1255
1256    // Step 3/4: no close match found — accept or propose based on confidence.
1257    if confidence >= 0.7 {
1258        let memory_id = add_memory(start, scope, kind, text)?;
1259        Ok(ProposeResult::Added(memory_id))
1260    } else {
1261        let proposal_id = propose_memory(start, scope, kind, text, confidence, rationale)?;
1262        Ok(ProposeResult::Proposed(proposal_id))
1263    }
1264}
1265
1266/// v0.8: pagination + scope filter for `list_memories_with`, surfaced
1267/// by the `kimetsu_brain_memory_list` MCP tool so an agent can page
1268/// through the corpus from inside Claude/Codex.
1269#[derive(Debug, Clone)]
1270pub struct ListOptions {
1271    /// Max project rows to return. 0 → 100 (the prior default).
1272    pub limit: u32,
1273    /// Project-row offset (for paging). 0 → first page.
1274    pub offset: u32,
1275    /// Optional scope filter (global_user / project / repo / run).
1276    pub scope: Option<String>,
1277}
1278
1279impl Default for ListOptions {
1280    fn default() -> Self {
1281        Self {
1282            limit: 100,
1283            offset: 0,
1284            scope: None,
1285        }
1286    }
1287}
1288
1289pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
1290    list_memories_with(start, ListOptions::default())
1291}
1292
1293/// v0.8: paginated/scoped memory listing. The project page is bounded
1294/// by `limit`/`offset`; the user brain's portable rows are appended
1295/// only on the first page (`offset == 0`) so they appear exactly once
1296/// during navigation rather than on every page.
1297pub fn list_memories_with(start: &Path, opts: ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1298    let (_paths, config, conn) = load_project(start)?;
1299    let mut memories = list_memories_from_conn(&conn, &opts)?;
1300    // W3.3: honor config.kimetsu.use_user_brain with env override.
1301    if opts.offset == 0
1302        && let Some(user_conn) =
1303            user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1304    {
1305        memories.extend(user_brain::list_user_memories(&user_conn)?);
1306    }
1307    Ok(memories)
1308}
1309
1310/// v0.5.1: per-run memory attribution. Walks `memory_citations`,
1311/// the run's `context.injected` events, and (when present) the
1312/// terminal run.finished/failed/aborted event to produce a
1313/// `BlameReport` that surfaces which memories the model actually
1314/// reasoned with vs which were silent passengers.
1315///
1316/// Lookups across user + project brains are merged so a cited
1317/// user-scope memory shows its text even when the run lived in a
1318/// project brain.
1319pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
1320    let (_paths, config, conn) = load_project(start)?;
1321    // W3.3: honor config.kimetsu.use_user_brain with env override.
1322    let user_conn = user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
1323
1324    // 1. Terminal outcome.
1325    let (outcome, failure_category) = run_outcome(&conn, run_id)?;
1326
1327    // 2. Cited memories — ordered by turn.
1328    let cited_rows: Vec<(String, i64, Option<String>, String)> = {
1329        let mut stmt = conn.prepare(
1330            "
1331            SELECT memory_id, turn, rationale, cited_at
1332            FROM memory_citations
1333            WHERE run_id = ?1
1334            ORDER BY turn ASC, cited_at ASC
1335            ",
1336        )?;
1337        let rows = stmt.query_map(rusqlite::params![run_id], |row| {
1338            Ok((
1339                row.get::<_, String>(0)?,
1340                row.get::<_, i64>(1)?,
1341                row.get::<_, Option<String>>(2)?,
1342                row.get::<_, String>(3)?,
1343            ))
1344        })?;
1345        let mut out = Vec::new();
1346        for row in rows {
1347            out.push(row?);
1348        }
1349        out
1350    };
1351
1352    let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
1353    let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1354    for (memory_id, turn, rationale, cited_at) in cited_rows {
1355        cited_set.insert(memory_id.clone());
1356        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
1357        cited.push(CitedMemory {
1358            memory_id,
1359            turn,
1360            rationale,
1361            cited_at,
1362            text_preview: text_preview(&text, 120),
1363            scope,
1364            kind,
1365        });
1366    }
1367
1368    // 3. Silent passengers — retrieved but not cited.
1369    let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
1370    let mut silent: Vec<SilentMemory> = Vec::new();
1371    for memory_id in retrieved_ids {
1372        if cited_set.contains(&memory_id) {
1373            continue;
1374        }
1375        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
1376        silent.push(SilentMemory {
1377            memory_id,
1378            text_preview: text_preview(&text, 120),
1379            scope,
1380            kind,
1381        });
1382    }
1383
1384    Ok(BlameReport {
1385        run_id: run_id.to_string(),
1386        outcome,
1387        failure_category,
1388        cited,
1389        silent_passengers: silent,
1390    })
1391}
1392
1393fn run_outcome(conn: &Connection, run_id: &str) -> KimetsuResult<(String, Option<String>)> {
1394    // Pull the most recent terminal event for the run, if any.
1395    let row: Option<(String, String)> = conn
1396        .query_row(
1397            "
1398            SELECT kind, payload_json
1399            FROM events
1400            WHERE run_id = ?1
1401              AND kind IN ('run.finished', 'run.failed', 'run.aborted')
1402            ORDER BY ts DESC
1403            LIMIT 1
1404            ",
1405            rusqlite::params![run_id],
1406            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
1407        )
1408        .optional()?;
1409    Ok(match row {
1410        Some((kind, payload_json)) => {
1411            let outcome = match kind.as_str() {
1412                "run.finished" => "success".to_string(),
1413                "run.failed" => "failed".to_string(),
1414                "run.aborted" => "aborted".to_string(),
1415                other => other.to_string(),
1416            };
1417            let category = if kind == "run.failed" {
1418                serde_json::from_str::<serde_json::Value>(&payload_json)
1419                    .ok()
1420                    .and_then(|v| {
1421                        v.get("category")
1422                            .and_then(|c| c.as_str())
1423                            .map(str::to_string)
1424                    })
1425            } else {
1426                None
1427            };
1428            (outcome, category)
1429        }
1430        None => ("unknown".to_string(), None),
1431    })
1432}
1433
1434fn collect_injected_memory_ids_for_blame(
1435    conn: &Connection,
1436    run_id: &str,
1437) -> KimetsuResult<Vec<String>> {
1438    let mut stmt = conn.prepare(
1439        "
1440        SELECT payload_json
1441        FROM events
1442        WHERE run_id = ?1 AND kind = 'context.injected'
1443        ",
1444    )?;
1445    let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
1446    let mut seen = std::collections::BTreeSet::new();
1447    for row in rows {
1448        let payload_json = row?;
1449        let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
1450        if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
1451            for id in ids {
1452                if let Some(s) = id.as_str()
1453                    && !s.is_empty()
1454                {
1455                    seen.insert(s.to_string());
1456                }
1457            }
1458        }
1459    }
1460    Ok(seen.into_iter().collect())
1461}
1462
1463/// Look up a memory's (text, scope, kind) across the project conn
1464/// and the optional user-brain conn. Returns
1465/// ("<unknown — deleted?>", "", "") when the row isn't found in
1466/// either DB (e.g. invalidated + GC'd, or a typo'd memory_id in
1467/// the citation).
1468fn resolve_memory(
1469    project_conn: &Connection,
1470    user_conn: Option<&Connection>,
1471    memory_id: &str,
1472) -> (String, String, String) {
1473    let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
1474    let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
1475        conn.query_row(q, rusqlite::params![memory_id], |row| {
1476            Ok((
1477                row.get::<_, String>(0)?,
1478                row.get::<_, String>(1)?,
1479                row.get::<_, String>(2)?,
1480            ))
1481        })
1482        .optional()
1483        .ok()
1484        .flatten()
1485    };
1486    try_conn(project_conn)
1487        .or_else(|| user_conn.and_then(try_conn))
1488        .unwrap_or_else(|| {
1489            (
1490                "<unknown — deleted or invalid memory_id>".to_string(),
1491                String::new(),
1492                String::new(),
1493            )
1494        })
1495}
1496
1497fn text_preview(text: &str, max_chars: usize) -> String {
1498    let trimmed = text.trim();
1499    if trimmed.chars().count() <= max_chars {
1500        trimmed.to_string()
1501    } else {
1502        let head: String = trimmed.chars().take(max_chars).collect();
1503        format!("{head}…")
1504    }
1505}
1506
1507fn list_memories_from_conn(conn: &Connection, opts: &ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1508    // S4.4 list-asymmetry fix: apply the same active-only filters that
1509    // `list_user_memories` uses (invalidated_at IS NULL AND superseded_by IS
1510    // NULL) so that `memory list` on a project brain behaves symmetrically
1511    // with the user-brain listing — both surfaces show only memories that
1512    // retrieval would actually return.  Invalidated or superseded memories are
1513    // still inspectable via the raw DB or the event log.
1514    let limit = if opts.limit == 0 { 100 } else { opts.limit } as i64;
1515    let offset = opts.offset as i64;
1516
1517    let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
1518        (
1519            "
1520            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1521            FROM memories
1522            WHERE invalidated_at IS NULL
1523              AND superseded_by IS NULL
1524              AND lower(scope) = lower(?1)
1525            ORDER BY created_at DESC
1526            LIMIT ?2 OFFSET ?3
1527            ",
1528            Some(scope.to_string()),
1529        )
1530    } else {
1531        (
1532            "
1533            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1534            FROM memories
1535            WHERE invalidated_at IS NULL
1536              AND superseded_by IS NULL
1537            ORDER BY created_at DESC
1538            LIMIT ?1 OFFSET ?2
1539            ",
1540            None,
1541        )
1542    };
1543
1544    let mut stmt = conn.prepare(sql)?;
1545    let rows = if let Some(scope) = scope_param {
1546        stmt.query_map(params![scope, limit, offset], map_memory_row)?
1547            .collect::<Result<Vec<_>, _>>()?
1548    } else {
1549        stmt.query_map(params![limit, offset], map_memory_row)?
1550            .collect::<Result<Vec<_>, _>>()?
1551    };
1552    Ok(rows)
1553}
1554
1555/// MP-6: ranked list of memories sorted by the same usefulness ratio the
1556/// broker uses for retrieval scoring (`usefulness_score / use_count`).
1557/// Filters out invalidated rows and any memory with `use_count < min_uses`
1558/// (the small-sample guard; default 3 matches the broker's
1559/// SMALL_SAMPLE_THRESHOLD). Optional scope filter narrows to a single
1560/// memory class. Lets the user see which memories are actually doing
1561/// work so they can prune the rest with `memory prune`.
1562#[derive(Debug, Clone, Default)]
1563pub struct TopOptions {
1564    pub scope: Option<String>,
1565    pub min_uses: u32,
1566    pub limit: u32,
1567}
1568
1569pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
1570    let (_paths, _config, conn) = load_project(start)?;
1571    let min_uses = opts.min_uses.max(1) as i64;
1572    let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
1573
1574    let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
1575        (
1576            "
1577            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1578            FROM memories
1579            WHERE invalidated_at IS NULL
1580              AND superseded_by IS NULL
1581              AND use_count >= ?1
1582              AND lower(scope) = lower(?2)
1583            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
1584            LIMIT ?3
1585            ",
1586            Some(scope.to_string()),
1587        )
1588    } else {
1589        (
1590            "
1591            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1592            FROM memories
1593            WHERE invalidated_at IS NULL
1594              AND superseded_by IS NULL
1595              AND use_count >= ?1
1596            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
1597            LIMIT ?2
1598            ",
1599            None,
1600        )
1601    };
1602
1603    let mut stmt = conn.prepare(sql)?;
1604    let mut rows = if let Some(scope) = scope_param {
1605        stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
1606            .collect::<Result<Vec<_>, _>>()?
1607    } else {
1608        stmt.query_map(params![min_uses, limit], map_memory_row)?
1609            .collect::<Result<Vec<_>, _>>()?
1610    };
1611
1612    // SQLite's NaN-from-zero protection: a freshly-created memory with
1613    // use_count=0 would division-zero, but the WHERE clause guards
1614    // min_uses >= 1, so we never see a NaN here. Sort is a defensive
1615    // tie-breaker only.
1616    rows.sort_by(|a, b| {
1617        let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
1618        let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
1619        rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
1620    });
1621    Ok(rows)
1622}
1623
1624fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
1625    Ok(MemoryRow {
1626        memory_id: row.get(0)?,
1627        scope: row.get(1)?,
1628        kind: row.get(2)?,
1629        text: row.get(3)?,
1630        confidence: row.get(4)?,
1631        use_count: row.get(5)?,
1632        usefulness_score: row.get::<_, f64>(6)? as f32,
1633    })
1634}
1635
1636/// MP-6: bulk prune of memories whose outcome-attribution data says they
1637/// are net-negative. Selection rules:
1638///   use_count >= min_uses
1639///   usefulness_score / use_count <= max_ratio
1640///   invalidated_at IS NULL
1641///   scope filter optional
1642///
1643/// `apply = false` is the default at the CLI layer so the user sees
1644/// what would be touched before any writes. `apply = true` invalidates
1645/// each match via the existing `invalidate_memory` path so every
1646/// removal still emits a canonical `memory.invalidated` event.
1647#[derive(Debug, Clone)]
1648pub struct PruneOptions {
1649    pub scope: Option<String>,
1650    pub min_uses: u32,
1651    pub max_ratio: f32,
1652    pub apply: bool,
1653}
1654
1655impl Default for PruneOptions {
1656    fn default() -> Self {
1657        Self {
1658            scope: None,
1659            min_uses: 3,
1660            max_ratio: -0.2,
1661            apply: false,
1662        }
1663    }
1664}
1665
1666#[derive(Debug, Clone)]
1667pub struct PruneCandidate {
1668    pub memory_id: String,
1669    pub scope: String,
1670    pub kind: String,
1671    pub use_count: u32,
1672    pub usefulness_score: f32,
1673    pub text: String,
1674}
1675
1676#[derive(Debug, Clone, Default)]
1677pub struct PruneSummary {
1678    pub candidates: Vec<PruneCandidate>,
1679    pub invalidated: u32,
1680    pub failed: u32,
1681}
1682
1683pub fn prune_low_usefulness(start: &Path, opts: PruneOptions) -> KimetsuResult<PruneSummary> {
1684    let min_uses = opts.min_uses.max(1) as i64;
1685
1686    let candidates = {
1687        let (_paths, _config, conn) = load_project(start)?;
1688        let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref()
1689        {
1690            (
1691                "
1692                SELECT memory_id, scope, kind, text, use_count, usefulness_score
1693                FROM memories
1694                WHERE invalidated_at IS NULL
1695                  AND superseded_by IS NULL
1696                  AND use_count >= ?1
1697                  AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
1698                  AND lower(scope) = lower(?3)
1699                ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
1700                ",
1701                Some(scope.to_string()),
1702            )
1703        } else {
1704            (
1705                "
1706                SELECT memory_id, scope, kind, text, use_count, usefulness_score
1707                FROM memories
1708                WHERE invalidated_at IS NULL
1709                  AND superseded_by IS NULL
1710                  AND use_count >= ?1
1711                  AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
1712                ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
1713                ",
1714                None,
1715            )
1716        };
1717        let mut stmt = conn.prepare(sql)?;
1718        let max_ratio = opts.max_ratio as f64;
1719        let mut found: Vec<PruneCandidate> = if let Some(scope) = scope_param {
1720            stmt.query_map(params![min_uses, max_ratio, scope], |row| {
1721                Ok(PruneCandidate {
1722                    memory_id: row.get(0)?,
1723                    scope: row.get(1)?,
1724                    kind: row.get(2)?,
1725                    text: row.get(3)?,
1726                    use_count: row.get(4)?,
1727                    usefulness_score: row.get::<_, f64>(5)? as f32,
1728                })
1729            })?
1730            .collect::<Result<Vec<_>, _>>()?
1731        } else {
1732            stmt.query_map(params![min_uses, max_ratio], |row| {
1733                Ok(PruneCandidate {
1734                    memory_id: row.get(0)?,
1735                    scope: row.get(1)?,
1736                    kind: row.get(2)?,
1737                    text: row.get(3)?,
1738                    use_count: row.get(4)?,
1739                    usefulness_score: row.get::<_, f64>(5)? as f32,
1740                })
1741            })?
1742            .collect::<Result<Vec<_>, _>>()?
1743        };
1744        // Stable tie-break: lowest ratio first, then highest use_count
1745        // first (penalize the long-running underperformers).
1746        found.sort_by(|a, b| {
1747            let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
1748            let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
1749            ra.partial_cmp(&rb)
1750                .unwrap_or(std::cmp::Ordering::Equal)
1751                .then_with(|| b.use_count.cmp(&a.use_count))
1752        });
1753        found
1754    };
1755
1756    let mut summary = PruneSummary {
1757        candidates: candidates.clone(),
1758        invalidated: 0,
1759        failed: 0,
1760    };
1761    if !opts.apply {
1762        return Ok(summary);
1763    }
1764
1765    for candidate in &candidates {
1766        let ratio = candidate.usefulness_score / candidate.use_count.max(1) as f32;
1767        let reason = format!(
1768            "pruned_by_usefulness ratio={:+.2} use_count={}",
1769            ratio, candidate.use_count
1770        );
1771        match invalidate_memory(start, &candidate.memory_id, Some(&reason)) {
1772            Ok(()) => summary.invalidated += 1,
1773            Err(_) => summary.failed += 1,
1774        }
1775    }
1776    Ok(summary)
1777}
1778
1779pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
1780    let (_paths, _config, conn) = load_project(start)?;
1781    let mut sql = String::from(
1782        "
1783        SELECT proposal_id, run_id, scope, kind, text, rationale,
1784               proposed_confidence, status, decided_reason
1785        FROM memory_proposals
1786        ",
1787    );
1788    let mut clauses = Vec::<String>::new();
1789    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1790    if let Some(scope) = filter.scope.as_deref() {
1791        clauses.push("scope = ?".to_string());
1792        params.push(Box::new(scope.to_string()));
1793    }
1794    if let Some(kind) = filter.kind.as_deref() {
1795        clauses.push("kind = ?".to_string());
1796        params.push(Box::new(kind.to_string()));
1797    }
1798    if let Some(run_id) = filter.from_run.as_deref() {
1799        clauses.push("run_id = ?".to_string());
1800        params.push(Box::new(run_id.to_string()));
1801    }
1802    if let Some(min_conf) = filter.min_confidence {
1803        clauses.push("proposed_confidence >= ?".to_string());
1804        params.push(Box::new(min_conf as f64));
1805    }
1806    if let Some(status) = filter.status.as_deref()
1807        && !status.eq_ignore_ascii_case("any")
1808    {
1809        clauses.push("status = ?".to_string());
1810        params.push(Box::new(status.to_string()));
1811    }
1812    if !clauses.is_empty() {
1813        sql.push_str(" WHERE ");
1814        sql.push_str(&clauses.join(" AND "));
1815    }
1816    let limit = if filter.limit == 0 { 100 } else { filter.limit };
1817    sql.push_str(&format!(
1818        " ORDER BY rowid DESC LIMIT {limit} OFFSET {}",
1819        filter.offset
1820    ));
1821
1822    let mut stmt = conn.prepare(&sql)?;
1823    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1824    let rows = stmt.query_map(param_refs.as_slice(), |row| {
1825        Ok(ProposalRow {
1826            proposal_id: row.get(0)?,
1827            run_id: row.get(1)?,
1828            scope: row.get(2)?,
1829            kind: row.get(3)?,
1830            text: row.get(4)?,
1831            rationale: row.get(5)?,
1832            proposed_confidence: row.get(6)?,
1833            status: row.get(7)?,
1834            decided_reason: row.get(8)?,
1835        })
1836    })?;
1837
1838    let mut proposals = Vec::new();
1839    for row in rows {
1840        proposals.push(row?);
1841    }
1842    Ok(proposals)
1843}
1844
1845pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1846    let (paths, config, conn) = load_project(start)?;
1847    let run_id = RunId::new();
1848    let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1849
1850    let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1851
1852    let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1853
1854    let ingested = Event::new(
1855        run_id,
1856        "repo.ingested",
1857        serde_json::json!({
1858            "repo_root": summary.repo_root.to_string_lossy(),
1859            "indexed_files": summary.indexed_files,
1860            "skipped_files": summary.skipped_files,
1861            "manifests": summary.manifests,
1862        }),
1863    );
1864
1865    let finished = admin_finished_event(run_id);
1866    projector::apply_events(&conn, &[started, ingested, finished])?;
1867
1868    Ok(summary)
1869}
1870
1871/// Ingest files from `files_root` into the brain at `brain_root` (no git
1872/// discovery; the two roots differ on a server, where the brain lives under the
1873/// data dir and the files live in a managed checkout). Used by kimetsu-remote's
1874/// server-side ingest.
1875pub fn ingest_repo_at_root(
1876    brain_root: &Path,
1877    files_root: &Path,
1878) -> KimetsuResult<RepoIngestSummary> {
1879    let (mut paths, config, conn) = load_project_at_root(brain_root)?;
1880    // Walk the checkout, but keep the brain/lock under brain_root.
1881    paths.repo_root = files_root
1882        .canonicalize()
1883        .unwrap_or_else(|_| files_root.to_path_buf());
1884    let run_id = RunId::new();
1885    let _lock = ProjectLock::acquire(&paths, "brain ingest-repo (remote)", Some(run_id))?;
1886
1887    let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1888    let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1889    let ingested = Event::new(
1890        run_id,
1891        "repo.ingested",
1892        serde_json::json!({
1893            "repo_root": summary.repo_root.to_string_lossy(),
1894            "indexed_files": summary.indexed_files,
1895            "skipped_files": summary.skipped_files,
1896            "manifests": summary.manifests,
1897        }),
1898    );
1899    let finished = admin_finished_event(run_id);
1900    projector::apply_events(&conn, &[started, ingested, finished])?;
1901
1902    Ok(summary)
1903}
1904
1905pub fn search_files(
1906    start: &Path,
1907    query: &str,
1908    limit: u32,
1909) -> KimetsuResult<Vec<context::ContextCapsule>> {
1910    let (paths, _config, conn) = load_project(start)?;
1911    let repo_root = paths
1912        .repo_root
1913        .canonicalize()?
1914        .to_string_lossy()
1915        .to_string();
1916    context::search_repo_files(&conn, &repo_root, query, limit)
1917}
1918
1919pub fn retrieve_context(
1920    start: &Path,
1921    stage: &str,
1922    query: &str,
1923    budget_tokens: u32,
1924) -> KimetsuResult<ContextBundle> {
1925    BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1926}
1927
1928pub fn retrieve_context_readonly(
1929    start: &Path,
1930    stage: &str,
1931    query: &str,
1932    budget_tokens: u32,
1933) -> KimetsuResult<ContextBundle> {
1934    BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1935}
1936
1937/// v0.6: variant that accepts a full `ContextRequest` so callers can use
1938/// the new `tags`, `min_score`, `max_capsules`, and `prefer_roles` fields.
1939pub fn retrieve_context_readonly_with_request(
1940    start: &Path,
1941    request: ContextRequest,
1942) -> KimetsuResult<ContextBundle> {
1943    BrainSession::open_readonly(start)?.retrieve_context_with_request(request)
1944}
1945
1946/// v1.0.0: lexical (FTS-only) read-only retrieval. Used by the
1947/// `UserPromptSubmit` context-hook so its throwaway per-prompt process
1948/// never loads the semantic embedding model (a cold ONNX load there can
1949/// exceed the host's 30s hook timeout). See
1950/// [`BrainSession::retrieve_context_lexical`].
1951pub fn retrieve_context_lexical_readonly(
1952    start: &Path,
1953    request: ContextRequest,
1954) -> KimetsuResult<ContextBundle> {
1955    BrainSession::open_readonly(start)?.retrieve_context_lexical(request)
1956}
1957
1958/// v0.8: read-only proactive retrieval (lexical-FTS-only, no model
1959/// load). The caller builds a `ContextRequest` with `kinds` set to the
1960/// actionable set, a high `min_score`, and `max_capsules: 1`.
1961pub fn retrieve_proactive_readonly(
1962    start: &Path,
1963    request: ContextRequest,
1964) -> KimetsuResult<ContextBundle> {
1965    BrainSession::open_readonly(start)?.retrieve_proactive(request)
1966}
1967
1968/// v0.8: full-text search over memory text, for navigating the corpus
1969/// from the MCP surface. Project rows are paged by `limit`/`offset`;
1970/// user-brain rows are appended only on the first page so they appear
1971/// once. Returns empty when the query yields no FTS tokens.
1972pub fn search_memories(
1973    start: &Path,
1974    query: &str,
1975    limit: u32,
1976    offset: u32,
1977    kind: Option<&str>,
1978    scope: Option<&str>,
1979) -> KimetsuResult<Vec<MemorySearchHit>> {
1980    let Some(fts) = context::fts_query(query) else {
1981        return Ok(Vec::new());
1982    };
1983    let (_paths, config, conn) = load_project(start)?;
1984    let mut hits = search_memories_in_conn(&conn, &fts, limit, offset, kind, scope)?;
1985    // W3.3: honor config.kimetsu.use_user_brain with env override.
1986    if offset == 0
1987        && let Some(user_conn) =
1988            user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1989    {
1990        hits.extend(search_memories_in_conn(
1991            &user_conn, &fts, limit, 0, kind, scope,
1992        )?);
1993    }
1994    Ok(hits)
1995}
1996
1997fn search_memories_in_conn(
1998    conn: &Connection,
1999    fts_query: &str,
2000    limit: u32,
2001    offset: u32,
2002    kind: Option<&str>,
2003    scope: Option<&str>,
2004) -> KimetsuResult<Vec<MemorySearchHit>> {
2005    let limit = if limit == 0 { 20 } else { limit } as i64;
2006    let offset = offset as i64;
2007    let mut sql = String::from(
2008        "
2009        SELECT m.memory_id, m.scope, m.kind, m.text, bm25(memories_fts) AS rank
2010        FROM memories_fts
2011        JOIN memories m ON m.memory_id = memories_fts.memory_id
2012        WHERE m.invalidated_at IS NULL
2013          AND m.superseded_by IS NULL
2014          AND memories_fts MATCH ?
2015        ",
2016    );
2017    let mut bind: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(fts_query.to_string())];
2018    if let Some(k) = kind {
2019        sql.push_str(" AND m.kind = ?");
2020        bind.push(Box::new(k.to_string()));
2021    }
2022    if let Some(s) = scope {
2023        sql.push_str(" AND lower(m.scope) = lower(?)");
2024        bind.push(Box::new(s.to_string()));
2025    }
2026    // bm25() is more-negative = more-relevant, so ascending rank is best.
2027    sql.push_str(" ORDER BY rank LIMIT ? OFFSET ?");
2028    bind.push(Box::new(limit));
2029    bind.push(Box::new(offset));
2030
2031    let mut stmt = conn.prepare(&sql)?;
2032    let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
2033    let rows = stmt.query_map(refs.as_slice(), |row| {
2034        let raw_rank = row.get::<_, f64>(4)? as f32;
2035        Ok(MemorySearchHit {
2036            memory_id: row.get(0)?,
2037            scope: row.get(1)?,
2038            kind: row.get(2)?,
2039            text: row.get(3)?,
2040            // surface a positive relevance (higher = better) for callers.
2041            rank: (-raw_rank).max(0.0),
2042        })
2043    })?;
2044    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
2045}
2046
2047#[allow(clippy::too_many_arguments)]
2048pub fn retrieve_benchmark_context_readonly(
2049    start: &Path,
2050    task: &str,
2051    dataset: &str,
2052    task_slug: Option<&str>,
2053    warm_policy: benchmark::BenchmarkWarmPolicy,
2054    stage: &str,
2055    budget_tokens: u32,
2056    require_benchmark_memory: bool,
2057    max_capsules: usize,
2058) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
2059    retrieve_benchmark_context_readonly_with_ambient(
2060        start,
2061        task,
2062        dataset,
2063        task_slug,
2064        warm_policy,
2065        stage,
2066        budget_tokens,
2067        require_benchmark_memory,
2068        max_capsules,
2069        None,
2070    )
2071}
2072
2073/// v0.4.4: variant that appends an optional ambient-context suffix to
2074/// the canonical benchmark query AFTER slug detection. Used by the
2075/// MCP `kimetsu_benchmark_context` tool so the workspace fingerprint
2076/// (git branch, dirty files, recent edits) contributes to retrieval
2077/// without corrupting the slug parser.
2078#[allow(clippy::too_many_arguments)]
2079pub fn retrieve_benchmark_context_readonly_with_ambient(
2080    start: &Path,
2081    task: &str,
2082    dataset: &str,
2083    task_slug: Option<&str>,
2084    warm_policy: benchmark::BenchmarkWarmPolicy,
2085    stage: &str,
2086    budget_tokens: u32,
2087    require_benchmark_memory: bool,
2088    max_capsules: usize,
2089    ambient_suffix: Option<&str>,
2090) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
2091    let normalized_slug = task_slug
2092        .and_then(benchmark::normalize_task_slug)
2093        .or_else(|| benchmark::normalize_task_slug(task));
2094    let mut query =
2095        benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
2096    if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
2097        query.push_str(suffix);
2098    }
2099    let bundle =
2100        BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
2101    Ok(benchmark::build_benchmark_context(
2102        bundle,
2103        task,
2104        dataset,
2105        &query,
2106        normalized_slug,
2107        warm_policy,
2108        require_benchmark_memory,
2109        max_capsules,
2110    ))
2111}
2112
2113pub fn record_benchmark_outcome(
2114    start: &Path,
2115    outcome: benchmark::BenchmarkOutcome,
2116) -> KimetsuResult<RecordedBenchmarkOutcome> {
2117    let task_slug = outcome
2118        .task_slug
2119        .clone()
2120        .or_else(|| benchmark::normalize_task_slug(&outcome.task));
2121    let kind = benchmark::outcome_memory_kind(&outcome);
2122    let text = benchmark::outcome_memory_text(&outcome);
2123    let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
2124    let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
2125        Some(proposal) if proposal.role.is_generalizable() => {
2126            let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
2127            (Some(proposal_id), Some(proposal_text))
2128        }
2129        _ => (None, None),
2130    };
2131    Ok(RecordedBenchmarkOutcome {
2132        memory_id,
2133        task_slug,
2134        kind,
2135        text,
2136        proposal_id,
2137        proposal_text,
2138    })
2139}
2140
2141fn propose_benchmark_memory(
2142    start: &Path,
2143    outcome: &benchmark::BenchmarkOutcome,
2144    proposal: &benchmark::BenchmarkMemoryProposal,
2145) -> KimetsuResult<(String, String)> {
2146    let (paths, config, conn) = load_project(start)?;
2147    let run_id = RunId::new();
2148    let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
2149    let proposal_id = Ulid::new().to_string();
2150    // v0.4.5: redact secrets in the proposal text + rationale before
2151    // they hit the memory_proposals table. Benchmark outcomes pull from
2152    // tool output, which is exactly where a model-leaked token would surface.
2153    let raw_text = benchmark::proposal_memory_text(outcome, proposal);
2154    let text_redaction = redact::redact_secrets(&raw_text);
2155    if text_redaction.was_redacted() {
2156        eprintln!(
2157            "kimetsu-brain (benchmark proposal): {}",
2158            text_redaction.summary()
2159        );
2160    }
2161    let text = text_redaction.text;
2162    let kind = benchmark::proposal_memory_kind(proposal);
2163    let rationale_raw = if proposal.rationale.trim().is_empty() {
2164        "generalized from benchmark outcome".to_string()
2165    } else {
2166        proposal.rationale.trim().to_string()
2167    };
2168    let rationale = redact::redact_secrets(&rationale_raw).text;
2169
2170    let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
2171
2172    let proposed = Event::new(
2173        run_id,
2174        "memory.proposed",
2175        serde_json::json!({
2176            "proposal_id": proposal_id,
2177            "scope": "global_user",
2178            "kind": kind.to_string(),
2179            "text": text,
2180            "rationale": rationale,
2181            "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
2182            "source_event_ids": [],
2183        }),
2184    );
2185
2186    let finished = admin_finished_event(run_id);
2187
2188    projector::apply_events(&conn, &[started, proposed, finished])?;
2189    Ok((proposal_id, text))
2190}
2191
2192pub fn accept_proposal(
2193    start: &Path,
2194    proposal_id: &str,
2195    overrides: AcceptOverrides,
2196) -> KimetsuResult<String> {
2197    let (paths, config, conn) = load_project(start)?;
2198    let proposal = load_pending_proposal(&conn, proposal_id)?;
2199    let run_id = RunId::new();
2200    let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
2201    let memory_id = Ulid::new().to_string();
2202    let normalized = normalize_memory_text(&proposal.text);
2203
2204    let resolved_scope = match overrides.scope.as_deref() {
2205        Some(value) if !value.trim().is_empty() => value.trim().to_string(),
2206        _ => proposal.scope.clone(),
2207    };
2208    let resolved_confidence = overrides
2209        .confidence
2210        .map(|c| c.clamp(0.0, 1.0))
2211        .unwrap_or(proposal.proposed_confidence);
2212
2213    let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
2214
2215    let accepted = Event::new(
2216        run_id,
2217        "memory.accepted",
2218        serde_json::json!({
2219            "proposal_id": proposal.proposal_id,
2220            "memory_id": memory_id,
2221            "scope": resolved_scope,
2222            "kind": proposal.kind,
2223            "text": proposal.text,
2224            "normalized_text": normalized,
2225            "confidence": resolved_confidence,
2226            "provenance_snapshot": {
2227                "source": "memory_proposal",
2228                "proposal_id": proposal.proposal_id,
2229                "source_run_id": proposal.run_id,
2230                "scope_override": overrides.scope.clone(),
2231                "confidence_override": overrides.confidence,
2232            }
2233        }),
2234    );
2235
2236    let finished = admin_finished_event(run_id);
2237
2238    projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
2239    conn.execute(
2240        "
2241        UPDATE memory_proposals
2242        SET status = 'accepted',
2243            decided_at = ?2,
2244            decided_by = 'cli'
2245        WHERE proposal_id = ?1
2246        ",
2247        params![
2248            proposal_id,
2249            accepted
2250                .ts
2251                .format(&time::format_description::well_known::Rfc3339)?
2252        ],
2253    )?;
2254
2255    Ok(memory_id)
2256}
2257
2258/// MP-4d: human override that flags an accepted memory so the broker stops
2259/// surfacing it. Emits a `memory.invalidated` event and projects it. The
2260/// canonical trace keeps the original `memory.accepted`; invalidation is
2261/// purely additive metadata. Idempotent — re-invalidating a memory just
2262/// overwrites the timestamp/reason.
2263pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2264    let (paths, config, conn) = load_project(start)?;
2265    let exists: i64 = conn.query_row(
2266        "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
2267        params![memory_id],
2268        |row| row.get(0),
2269    )?;
2270    if exists == 0 {
2271        return Err(format!("memory not found: {memory_id}").into());
2272    }
2273
2274    let run_id = RunId::new();
2275    let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
2276
2277    let resolved_reason = reason
2278        .and_then(|s| {
2279            let trimmed = s.trim();
2280            if trimmed.is_empty() {
2281                None
2282            } else {
2283                Some(trimmed.to_string())
2284            }
2285        })
2286        .unwrap_or_else(|| "invalidated_by_cli".to_string());
2287
2288    let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
2289
2290    let invalidated = Event::new(
2291        run_id,
2292        "memory.invalidated",
2293        serde_json::json!({
2294            "memory_id": memory_id,
2295            "reason": resolved_reason,
2296        }),
2297    );
2298
2299    let finished = admin_finished_event(run_id);
2300
2301    projector::apply_events(&conn, &[started, invalidated, finished])?;
2302    Ok(())
2303}
2304
2305/// QoL: returned by [`undo_last_memory`] — the memory that was just invalidated.
2306#[derive(Debug, Clone)]
2307pub struct UndoneMemory {
2308    pub memory_id: String,
2309    pub text: String,
2310    pub scope: String,
2311    pub kind: String,
2312}
2313
2314/// QoL: edit an existing active memory in-place, preserving its usefulness history.
2315///
2316/// - `new_text`: if given, the text (and normalized_text) are updated, the FTS
2317///   index row is refreshed, and a new embedding is stored via the configured
2318///   embedder (no-op in lean builds). Secret-redaction is applied at the same
2319///   boundary as `add_memory`.
2320/// - `new_kind`: if given, the `kind` column is updated.
2321///
2322/// At least one of `new_text` / `new_kind` must be `Some`; otherwise an error
2323/// is returned. `use_count`, `usefulness_score`, `confidence`, and `created_at`
2324/// are intentionally left unchanged — the whole point of edit-in-place is to
2325/// preserve the memory's learned history.
2326///
2327/// Errors if the memory id is unknown or already invalidated.
2328pub fn edit_memory(
2329    start: &Path,
2330    memory_id: &str,
2331    new_text: Option<&str>,
2332    new_kind: Option<MemoryKind>,
2333) -> KimetsuResult<()> {
2334    if new_text.is_none() && new_kind.is_none() {
2335        return Err("edit_memory: at least one of --text or --kind must be provided".into());
2336    }
2337
2338    let (paths, config, conn) = load_project(start)?;
2339
2340    // Verify memory exists and is active (not invalidated).
2341    let row: Option<(String, String, String)> = conn
2342        .query_row(
2343            "SELECT scope, kind, invalidated_at FROM memories WHERE memory_id = ?1",
2344            params![memory_id],
2345            |row| {
2346                Ok((
2347                    row.get::<_, String>(0)?,
2348                    row.get::<_, String>(1)?,
2349                    row.get::<_, String>(2).unwrap_or_default(),
2350                ))
2351            },
2352        )
2353        .optional()?;
2354
2355    let (scope, current_kind, invalidated_at) = match row {
2356        None => return Err(format!("memory not found: {memory_id}").into()),
2357        Some(r) => r,
2358    };
2359    if !invalidated_at.is_empty() {
2360        return Err(format!("memory {memory_id} is already invalidated").into());
2361    }
2362
2363    let run_id = RunId::new();
2364    let _lock = ProjectLock::acquire(&paths, "brain memory edit", Some(run_id))?;
2365
2366    // Apply text update.
2367    if let Some(raw_text) = new_text {
2368        let redaction = redact::redact_secrets(raw_text);
2369        if redaction.was_redacted() {
2370            eprintln!("kimetsu-brain: {}", redaction.summary());
2371        }
2372        let text = &redaction.text;
2373        let normalized = normalize_memory_text(text);
2374
2375        conn.execute(
2376            "UPDATE memories SET text = ?1, normalized_text = ?2 WHERE memory_id = ?3",
2377            params![text, normalized, memory_id],
2378        )?;
2379
2380        // Refresh the FTS index row.
2381        conn.execute(
2382            "DELETE FROM memories_fts WHERE memory_id = ?1",
2383            params![memory_id],
2384        )?;
2385        let kind_for_fts = new_kind
2386            .as_ref()
2387            .map(|k| k.to_string())
2388            .unwrap_or(current_kind.clone());
2389        conn.execute(
2390            "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
2391            params![memory_id, text, kind_for_fts, scope],
2392        )?;
2393
2394        // Re-embed so semantic retrieval reflects the corrected text.
2395        let embedder = embeddings::open_embedder_for(config.embedder.enabled);
2396        embeddings::embed_and_persist(&conn, memory_id, text, embedder)?;
2397        // (return value not needed here — no conflict scan after an edit)
2398    }
2399
2400    // Apply kind update (FTS row may need refreshing if text wasn't also changed).
2401    if let Some(kind) = new_kind {
2402        conn.execute(
2403            "UPDATE memories SET kind = ?1 WHERE memory_id = ?2",
2404            params![kind.to_string(), memory_id],
2405        )?;
2406
2407        // Only refresh FTS kind column if we didn't already rebuild it above.
2408        if new_text.is_none() {
2409            // Re-read the current text from DB to rebuild the FTS row with
2410            // the new kind (text unchanged).
2411            let current_text: String = conn.query_row(
2412                "SELECT text FROM memories WHERE memory_id = ?1",
2413                params![memory_id],
2414                |row| row.get(0),
2415            )?;
2416            conn.execute(
2417                "DELETE FROM memories_fts WHERE memory_id = ?1",
2418                params![memory_id],
2419            )?;
2420            conn.execute(
2421                "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
2422                params![memory_id, current_text, kind.to_string(), scope],
2423            )?;
2424        }
2425    }
2426
2427    Ok(())
2428}
2429
2430/// QoL: return the most recently created active memory in the project brain
2431/// WITHOUT invalidating it — used by the CLI to show a preview before
2432/// asking for confirmation. Returns `Ok(None)` if there are no active memories.
2433pub fn peek_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2434    let (_paths, _config, conn) = load_project(start)?;
2435    // S4.4b: exclude superseded rows — a retired/merged memory is not a
2436    // sensible "last" memory to surface to the user.
2437    let row: Option<(String, String, String, String)> = conn
2438        .query_row(
2439            "SELECT memory_id, text, scope, kind FROM memories
2440             WHERE invalidated_at IS NULL
2441               AND superseded_by IS NULL
2442             ORDER BY created_at DESC, memory_id DESC
2443             LIMIT 1",
2444            [],
2445            |row| {
2446                Ok((
2447                    row.get::<_, String>(0)?,
2448                    row.get::<_, String>(1)?,
2449                    row.get::<_, String>(2)?,
2450                    row.get::<_, String>(3)?,
2451                ))
2452            },
2453        )
2454        .optional()?;
2455
2456    Ok(row.map(|(memory_id, text, scope, kind)| UndoneMemory {
2457        memory_id,
2458        text,
2459        scope,
2460        kind,
2461    }))
2462}
2463
2464/// QoL: invalidate the most recently created active memory in the project brain.
2465///
2466/// Finds the newest ACTIVE (non-invalidated) memory, invalidates it with the
2467/// reason `"undo: last recorded memory"`, and returns its details. Returns
2468/// `Ok(None)` when there are no active memories in the project brain.
2469///
2470/// Operates on the PROJECT brain only (the "agent just saved junk in this
2471/// project" case); the user brain is not touched.
2472pub fn undo_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2473    let (paths, _config, conn) = load_project(start)?;
2474
2475    // S4.4b: exclude superseded rows — undoing a retired/merged memory would
2476    // confuse the user; they should undo the survivor instead.
2477    let row: Option<(String, String, String, String)> = conn
2478        .query_row(
2479            "SELECT memory_id, text, scope, kind FROM memories
2480             WHERE invalidated_at IS NULL
2481               AND superseded_by IS NULL
2482             ORDER BY created_at DESC, memory_id DESC
2483             LIMIT 1",
2484            [],
2485            |row| {
2486                Ok((
2487                    row.get::<_, String>(0)?,
2488                    row.get::<_, String>(1)?,
2489                    row.get::<_, String>(2)?,
2490                    row.get::<_, String>(3)?,
2491                ))
2492            },
2493        )
2494        .optional()?;
2495
2496    let (memory_id, text, scope, kind) = match row {
2497        None => return Ok(None),
2498        Some(r) => r,
2499    };
2500
2501    // Release the read conn before calling invalidate_memory which opens its own.
2502    drop(conn);
2503    drop(paths);
2504
2505    invalidate_memory(start, &memory_id, Some("undo: last recorded memory"))?;
2506
2507    Ok(Some(UndoneMemory {
2508        memory_id,
2509        text,
2510        scope,
2511        kind,
2512    }))
2513}
2514
2515pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2516    let (paths, config, conn) = load_project(start)?;
2517    let _proposal = load_pending_proposal(&conn, proposal_id)?;
2518    let run_id = RunId::new();
2519    let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
2520
2521    let resolved_reason = reason
2522        .and_then(|s| {
2523            let trimmed = s.trim();
2524            if trimmed.is_empty() {
2525                None
2526            } else {
2527                Some(trimmed.to_string())
2528            }
2529        })
2530        .unwrap_or_else(|| "rejected_by_cli".to_string());
2531
2532    let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
2533
2534    let rejected = Event::new(
2535        run_id,
2536        "memory.rejected",
2537        serde_json::json!({
2538            "proposal_id": proposal_id,
2539            "reason": resolved_reason,
2540        }),
2541    );
2542
2543    let finished = admin_finished_event(run_id);
2544
2545    projector::apply_events(&conn, &[started, rejected, finished])?;
2546    Ok(())
2547}
2548
2549pub fn rebuild_projection(start: &Path, from_traces: bool) -> KimetsuResult<usize> {
2550    let (paths, _config, conn) = load_project(start)?;
2551    let _lock = ProjectLock::acquire(&paths, "brain rebuild", None)?;
2552
2553    // Explicit legacy import: rebuild from on-disk trace.jsonl files (inserts
2554    // any events missing from the table via OR IGNORE, then projects).
2555    if from_traces {
2556        let events = trace::read_all_traces(&paths)?;
2557        projector::rebuild(&conn, &events)?;
2558        return Ok(events.len());
2559    }
2560
2561    // Auto-fallback: a brain whose events table was wiped by a pre-W1.1 rebuild
2562    // still has its history only in trace.jsonl. If the table is empty but
2563    // traces exist, import them first, then proceed.
2564    let event_count: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))?;
2565    if event_count == 0 {
2566        let events = trace::read_all_traces(&paths)?;
2567        if !events.is_empty() {
2568            eprintln!(
2569                "[kimetsu] events table empty; importing {} event(s) from legacy traces",
2570                events.len()
2571            );
2572            projector::rebuild(&conn, &events)?;
2573            return Ok(events.len());
2574        }
2575    }
2576
2577    // Normal path: replay the durable events table in place.
2578    projector::rebuild_in_place(&conn)
2579}
2580
2581pub fn clear_lock(start: &Path) -> KimetsuResult<bool> {
2582    let paths = ProjectPaths::discover(start)?;
2583    crate::lock::clear_force(&paths)
2584}
2585
2586/// v0.5.2: list open conflict-detection hits across the project brain
2587/// and (when enabled) the user brain. Each `ConflictReport` carries a
2588/// `source` label so the CLI can render which brain originated it —
2589/// resolve takes a separate code path per brain since the row only
2590/// lives in one DB.
2591#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2592pub struct ScopedConflict {
2593    /// Either "project" or "user". Determines which DB `resolve_conflict`
2594    /// must target when the operator chooses to apply a resolution.
2595    pub source: String,
2596    #[serde(flatten)]
2597    pub report: conflict::ConflictReport,
2598}
2599
2600/// Merge open conflicts from project + user brains. `limit` is applied
2601/// per-brain, so the worst case is `limit * 2` rows returned — the CLI
2602/// can re-truncate on display if needed.
2603pub fn list_conflicts(start: &Path, limit: u32) -> KimetsuResult<Vec<ScopedConflict>> {
2604    let mut out = Vec::new();
2605    let (_paths, config, project_conn) = load_project_readonly(start)?;
2606    for report in conflict::list_unresolved_conflicts(&project_conn, limit)? {
2607        out.push(ScopedConflict {
2608            source: "project".to_string(),
2609            report,
2610        });
2611    }
2612    // W3.3: honor config.kimetsu.use_user_brain with env override.
2613    if let Some(user_conn) =
2614        user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
2615    {
2616        for report in conflict::list_unresolved_conflicts(&user_conn, limit)? {
2617            out.push(ScopedConflict {
2618                source: "user".to_string(),
2619                report,
2620            });
2621        }
2622    }
2623    out.sort_by(|a, b| b.report.detected_at.cmp(&a.report.detected_at));
2624    Ok(out)
2625}
2626
2627/// Resolve a single open conflict by id with one of `kept_new`,
2628/// `kept_existing`, or `kept_both`. The conflict can live in either
2629/// the project brain or the user brain — we try project first, and on
2630/// "not found" fall through to user. Returns Ok(true) if a row was
2631/// updated.
2632///
2633/// We deliberately don't emit a `memory.invalidated` trace event here
2634/// even though `kept_new` / `kept_existing` invalidates one side. The
2635/// `memory_conflicts` row IS the audit trail; double-recording would
2636/// duplicate state across two systems. Operators who want the trace-
2637/// event-style record can use `kimetsu brain memory invalidate` instead.
2638pub fn resolve_conflict(start: &Path, conflict_id: &str, resolution: &str) -> KimetsuResult<bool> {
2639    let (paths, config, project_conn) = load_project(start)?;
2640    let _lock = ProjectLock::acquire(&paths, "brain memory conflict resolve", None)?;
2641    if conflict::resolve_conflict(&project_conn, conflict_id, resolution)? {
2642        return Ok(true);
2643    }
2644    drop(project_conn); // release before opening user brain (avoid pseudo-conflict on flock semantics)
2645    // W3.3: honor config.kimetsu.use_user_brain with env override.
2646    if let Some(user_conn) = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?
2647    {
2648        return conflict::resolve_conflict(&user_conn, conflict_id, resolution);
2649    }
2650    Ok(false)
2651}
2652
2653fn load_pending_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<ProposalRow> {
2654    let mut stmt = conn.prepare(
2655        "
2656        SELECT proposal_id, run_id, scope, kind, text, rationale,
2657               proposed_confidence, status
2658        FROM memory_proposals
2659        WHERE proposal_id = ?1
2660        ",
2661    )?;
2662    let mut rows = stmt.query(params![proposal_id])?;
2663    let Some(row) = rows.next()? else {
2664        return Err(format!("memory proposal not found: {proposal_id}").into());
2665    };
2666
2667    let proposal = ProposalRow {
2668        proposal_id: row.get(0)?,
2669        run_id: row.get(1)?,
2670        scope: row.get(2)?,
2671        kind: row.get(3)?,
2672        text: row.get(4)?,
2673        rationale: row.get(5)?,
2674        proposed_confidence: row.get(6)?,
2675        status: row.get(7)?,
2676        decided_reason: None,
2677    };
2678
2679    if proposal.status != "pending" {
2680        return Err(format!(
2681            "memory proposal {proposal_id} is {}, not pending",
2682            proposal.status
2683        )
2684        .into());
2685    }
2686
2687    Ok(proposal)
2688}
2689
2690fn admin_started_event(
2691    paths: &ProjectPaths,
2692    config: &ProjectConfig,
2693    run_id: RunId,
2694    task: &str,
2695) -> KimetsuResult<Event> {
2696    Ok(Event::new(
2697        run_id,
2698        "run.started",
2699        serde_json::json!({
2700            "mode": "admin",
2701            "task": task,
2702            "project_id": config.kimetsu.project_id,
2703            "repo_root": paths.repo_root.to_string_lossy(),
2704            "model": null,
2705            "platform": std::env::consts::OS,
2706            "kimetsu_version": env!("CARGO_PKG_VERSION"),
2707            "config_hash": config_hash(&paths.project_toml)?,
2708        }),
2709    ))
2710}
2711
2712fn admin_finished_event(run_id: RunId) -> Event {
2713    Event::new(
2714        run_id,
2715        "run.finished",
2716        serde_json::json!({
2717            "status": "success",
2718            "final_report_path": null,
2719            "total_cost_usd": 0.0,
2720            "total_tool_calls": 0,
2721        }),
2722    )
2723}
2724
2725fn config_hash(path: &Path) -> KimetsuResult<String> {
2726    let bytes = fs::read(path)?;
2727    Ok(blake3::hash(&bytes).to_hex().to_string())
2728}
2729
2730/// D2: Abort a dangling run — cleanly finalize a run that has no terminal
2731/// event (e.g. the process was killed mid-way). Steps:
2732///
2733/// 1. Validate the run_id exists in `runs`.
2734/// 2. Error if the run already has a `terminal_kind` (already finished/failed/aborted).
2735/// 3. Append a `run.aborted` event to the run's trace.
2736/// 4. Project it (updates `runs.ended_at` + `terminal_kind`).
2737/// 5. Clear any stale writer lock so subsequent commands can proceed.
2738///
2739/// Returns the trace path on success. Errors if the run is unknown or already terminal.
2740pub fn abort_run(start: &Path, run_id_str: &str) -> KimetsuResult<()> {
2741    // 1. Validate the run_id exists + check terminal state (read-only query).
2742    {
2743        let (_paths, _config, ro_conn) = load_project_readonly(start)?;
2744        let row: Option<Option<String>> = ro_conn
2745            .query_row(
2746                "SELECT terminal_kind FROM runs WHERE run_id = ?1",
2747                rusqlite::params![run_id_str],
2748                |row| row.get::<_, Option<String>>(0),
2749            )
2750            .optional()?;
2751        match row {
2752            None => {
2753                return Err(format!("run abort: unknown run_id `{run_id_str}`").into());
2754            }
2755            Some(Some(terminal_kind)) => {
2756                return Err(format!(
2757                    "run abort: run `{run_id_str}` is already terminal ({})",
2758                    terminal_kind
2759                )
2760                .into());
2761            }
2762            Some(None) => {} // dangling — proceed
2763        }
2764    }
2765
2766    // 2. Parse the run_id as a RunId.
2767    let run_id: RunId = run_id_str
2768        .parse::<ulid::Ulid>()
2769        .map(RunId)
2770        .map_err(|_| format!("run abort: `{run_id_str}` is not a valid ULID run id"))?;
2771
2772    // 3. Open rw, append run.aborted, project it.
2773    let (paths, _config, conn) = load_project(start)?;
2774    let lock = ProjectLock::acquire(&paths, "run abort", Some(run_id))?;
2775
2776    // Open the trace in append mode (create_dirs is idempotent).
2777    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
2778
2779    let aborted_event = Event::new(
2780        run_id,
2781        "run.aborted",
2782        serde_json::json!({
2783            "reason": "manual_abort_via_cli",
2784        }),
2785    );
2786    writer.append(&aborted_event, true)?;
2787    projector::apply_events(&conn, &[aborted_event])?;
2788
2789    // 4. Release the write lock acquired above, then force-clear any
2790    //    additional stale lock file that may have been left by a
2791    //    previously killed process (clear_force is idempotent).
2792    lock.release()?;
2793    crate::lock::clear_force(&paths)?;
2794
2795    Ok(())
2796}
2797
2798/// C7: best-effort telemetry write from a hook context (no active run).
2799///
2800/// Appends a single event (e.g. `context.served`) directly to the project
2801/// brain's `events` table with a sentinel run_id (`"hook"` encoded as a
2802/// ULID-zero string). Swallows all errors — telemetry must never break
2803/// a hook. Opens the DB read-write so the hook can record misses without
2804/// holding a write lock (the DB is opened and closed immediately).
2805///
2806/// The sentinel run_id is a valid ULID-shaped string (`00000000000000000000000000`
2807/// padded to 26 chars). Crucially there is **no** corresponding row in the
2808/// `runs` table; analytics windows over `context.served` filter by `ts`, not
2809/// `run_id`, so this is correct.
2810pub fn log_telemetry_event(
2811    start: &Path,
2812    kind: &str,
2813    payload: serde_json::Value,
2814) -> KimetsuResult<()> {
2815    // We need a read-write connection to insert. Use a fresh Connection
2816    // (not load_project which also validates config) so a misconfigured
2817    // project.toml never prevents telemetry from writing.
2818    let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2819    let conn = Connection::open(&paths.brain_db)?;
2820    schema::initialize(&conn)?;
2821
2822    // Sentinel run_id: all-zero ULID (26 '0' chars), never in `runs`.
2823    let sentinel_run_id = RunId(ulid::Ulid::nil());
2824    let event = Event::new(sentinel_run_id, kind, payload);
2825    projector::insert_event(&conn, &event)?;
2826    Ok(())
2827}
2828
2829/// v1.5: scan `events` for `memory.cited` entries and, for each cited
2830/// memory id, check the dropped-capsule sidecar. When a cited memory
2831/// was in the recent-dropped window (it was excluded by the relevance
2832/// floor but the model cited it anyway), emit a `retrieval.regret`
2833/// telemetry event and remove the entry from the sidecar.
2834///
2835/// Purely best-effort: any sidecar or telemetry error is swallowed so
2836/// citation recording is never disrupted. Called from the pipeline
2837/// after `projector::apply_events` so citations are already in the DB
2838/// before we check for regrets.
2839///
2840/// Cross-process note: the sidecar is written by the `brain_context_hook`
2841/// (CLI process) and read here by the pipeline / MCP-server process.
2842/// Both derive the same cache dir from the repo root, so they
2843/// naturally share the file without coordination.
2844pub fn emit_regret_for_cited_memories(start: &Path, events: &[kimetsu_core::event::Event]) {
2845    use crate::dropped_capsule;
2846    use kimetsu_core::paths::{ProjectPaths, user_cache_dir_for};
2847
2848    // Derive the project cache dir; silently skip if the brain is not
2849    // initialised (e.g. during one-off tests that don't init a project).
2850    let cache_dir = match ProjectPaths::discover(start) {
2851        Ok(paths) => user_cache_dir_for(&paths.repo_root),
2852        Err(_) => return,
2853    };
2854
2855    let cited_at = dropped_capsule::now_secs();
2856
2857    for event in events {
2858        if event.kind != "memory.cited" {
2859            continue;
2860        }
2861        let Some(memory_id) = event.payload.get("memory_id").and_then(|v| v.as_str()) else {
2862            continue;
2863        };
2864        // Best-effort: swallow any sidecar error.
2865        let Some(dropped_entry) = dropped_capsule::take_if_dropped(&cache_dir, memory_id, cited_at)
2866        else {
2867            continue;
2868        };
2869        // Emit the regret event.
2870        let _ = log_telemetry_event(
2871            start,
2872            "retrieval.regret",
2873            serde_json::json!({
2874                "memory_id": memory_id,
2875                "dropped_at": dropped_entry.dropped_at,
2876                "cited_at": cited_at,
2877            }),
2878        );
2879    }
2880}
2881
2882/// v1.5: write a `memory.cited` event from the MCP `kimetsu_brain_cite` tool.
2883///
2884/// Uses the same sentinel run_id as [`log_telemetry_event`] (all-zero ULID)
2885/// so no corresponding `runs` row is required. The event is inserted then
2886/// projected (populating `memory_citations`) in one connection, and the
2887/// regret sidecar is checked best-effort.
2888pub fn record_mcp_citation(start: &Path, memory_id: &str, note: Option<&str>) -> KimetsuResult<()> {
2889    let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2890    let conn = Connection::open(&paths.brain_db)?;
2891    schema::initialize(&conn)?;
2892
2893    let sentinel_run_id = RunId(ulid::Ulid::nil());
2894    let mut payload = serde_json::json!({
2895        "memory_id": memory_id,
2896        "turn": 0,
2897    });
2898    if let Some(n) = note {
2899        payload["rationale"] = serde_json::json!(n);
2900    }
2901    let event = kimetsu_core::event::Event::new(sentinel_run_id, "memory.cited", payload);
2902    // apply_events calls insert_event + project_event in one transaction.
2903    projector::apply_events(&conn, std::slice::from_ref(&event))?;
2904
2905    // Best-effort regret check.
2906    emit_regret_for_cited_memories(start, std::slice::from_ref(&event));
2907
2908    Ok(())
2909}
2910
2911/// Inject a `retrieval.regret` telemetry event for a memory.
2912///
2913/// The auto-path ([`emit_regret_for_cited_memories`]) only fires when a memory
2914/// was dropped by a retrieval floor and then cited anyway. This is the explicit
2915/// path (the `kimetsu brain regret` CLI / benchmarks): it records the negative
2916/// signal directly so lifecycle review and calibration can be exercised without
2917/// reproducing the full drop-then-cite dance.
2918pub fn record_regret(start: &Path, memory_id: &str) -> KimetsuResult<()> {
2919    let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2920    let conn = Connection::open(&paths.brain_db)?;
2921    schema::initialize(&conn)?;
2922
2923    // Use the sentinel run id (no active run) and PROJECT the event so the
2924    // outcome handler (`apply_retrieval_regret`) runs live, not just on rebuild.
2925    let sentinel_run_id = RunId(ulid::Ulid::nil());
2926    let event = kimetsu_core::event::Event::new(
2927        sentinel_run_id,
2928        "retrieval.regret",
2929        serde_json::json!({ "memory_id": memory_id, "source": "manual" }),
2930    );
2931    projector::apply_events(&conn, std::slice::from_ref(&event))?;
2932    Ok(())
2933}
2934
2935/// Backdate a memory's `created_at` / `last_useful_at` by `days_ago` days via a
2936/// `memory.aged` event. A testing/benchmark affordance for exercising
2937/// age-sensitive policies (forgetting). The absolute target timestamp is stored
2938/// in the event payload, so replay on rebuild is deterministic.
2939pub fn record_set_age(start: &Path, memory_id: &str, days_ago: u32) -> KimetsuResult<()> {
2940    use time::format_description::well_known::Rfc3339;
2941
2942    let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2943    let conn = Connection::open(&paths.brain_db)?;
2944    schema::initialize(&conn)?;
2945
2946    let target = time::OffsetDateTime::now_utc() - time::Duration::days(days_ago as i64);
2947    let ts = target.format(&Rfc3339).unwrap_or_default();
2948
2949    let sentinel_run_id = RunId(ulid::Ulid::nil());
2950    let event = kimetsu_core::event::Event::new(
2951        sentinel_run_id,
2952        "memory.aged",
2953        serde_json::json!({ "memory_id": memory_id, "created_at": ts, "last_useful_at": ts }),
2954    );
2955    projector::apply_events(&conn, std::slice::from_ref(&event))?;
2956    Ok(())
2957}
2958
2959// ── #2 knowledge graph: build relation edges ─────────────────────────────────
2960
2961/// Summary of a `kimetsu brain graph build` run.
2962#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
2963pub struct GraphBuildSummary {
2964    /// Active (non-invalidated, non-superseded) memories scanned.
2965    pub active_memories: usize,
2966    /// Rule-derived `relates_to` edges proposed.
2967    pub rule_edges: usize,
2968    /// Enrichment (LLM typed) edges proposed by the caller.
2969    pub enrich_edges: usize,
2970    /// Edges actually written (0 when `dry_run`).
2971    pub written: usize,
2972    /// Proposed edge counts grouped by edge_type (rule + enrichment, pre-write).
2973    pub by_type: std::collections::BTreeMap<String, usize>,
2974    /// True when no edges were persisted (preview only).
2975    pub dry_run: bool,
2976}
2977
2978/// Read every active memory as `(id, text)` for graph enrichment. Read-only.
2979/// Exposed so the CLI (which owns the cheap-model provider) can compute typed
2980/// enrichment edges before calling [`build_graph`].
2981pub fn active_memory_texts(start: &Path) -> KimetsuResult<Vec<(String, String)>> {
2982    let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2983    let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2984    let mut stmt = conn.prepare(
2985        "SELECT memory_id, text
2986         FROM memories
2987         WHERE invalidated_at IS NULL AND superseded_by IS NULL
2988         ORDER BY memory_id",
2989    )?;
2990    let rows = stmt
2991        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
2992        .collect::<Result<Vec<_>, _>>()?;
2993    Ok(rows)
2994}
2995
2996/// #2: build the knowledge-graph edges for the workspace brain.
2997///
2998/// Combines the deterministic rule layer ([`crate::graph::build_relates_to_edges`])
2999/// with any caller-supplied `extra_edges` (LLM enrichment computed in the CLI),
3000/// de-duplicates, and — unless `dry_run` — persists them as rebuild-safe
3001/// `memory.edge` events via [`projector::add_memory_edges`]. Returns a summary.
3002///
3003/// `max_fan_out` caps rule edges per source memory (0 = the module default).
3004pub fn build_graph(
3005    start: &Path,
3006    extra_edges: &[(String, String, String)],
3007    max_fan_out: usize,
3008    dry_run: bool,
3009) -> KimetsuResult<GraphBuildSummary> {
3010    use std::collections::{BTreeMap, BTreeSet};
3011
3012    let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
3013    let conn = Connection::open(&paths.brain_db)?;
3014    schema::initialize(&conn)?;
3015
3016    let active_memories = conn.query_row(
3017        "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL AND superseded_by IS NULL",
3018        [],
3019        |r| r.get::<_, i64>(0),
3020    )? as usize;
3021
3022    let rule = crate::graph::build_relates_to_edges(&conn, max_fan_out)?;
3023    let rule_edges = rule.len();
3024    let enrich_edges = extra_edges.len();
3025
3026    // Merge rule + enrichment, de-duplicating on (src, dst, type). Self-loops are
3027    // dropped by add_memory_edges; we also drop them here for an accurate summary.
3028    let mut seen: BTreeSet<(String, String, String)> = BTreeSet::new();
3029    let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
3030    let mut merged: Vec<(String, String, String)> = Vec::new();
3031    let push = |src: String,
3032                dst: String,
3033                ty: String,
3034                seen: &mut BTreeSet<(String, String, String)>,
3035                by_type: &mut BTreeMap<String, usize>,
3036                merged: &mut Vec<(String, String, String)>| {
3037        if src == dst {
3038            return;
3039        }
3040        let key = (src.clone(), dst.clone(), ty.clone());
3041        if seen.insert(key) {
3042            *by_type.entry(ty.clone()).or_insert(0) += 1;
3043            merged.push((src, dst, ty));
3044        }
3045    };
3046    for e in &rule {
3047        push(
3048            e.src_id.clone(),
3049            e.dst_id.clone(),
3050            e.edge_type.clone(),
3051            &mut seen,
3052            &mut by_type,
3053            &mut merged,
3054        );
3055    }
3056    for (src, dst, ty) in extra_edges {
3057        push(
3058            src.clone(),
3059            dst.clone(),
3060            ty.clone(),
3061            &mut seen,
3062            &mut by_type,
3063            &mut merged,
3064        );
3065    }
3066
3067    let written = if dry_run {
3068        0
3069    } else {
3070        projector::add_memory_edges(&conn, &merged)?
3071    };
3072
3073    Ok(GraphBuildSummary {
3074        active_memories,
3075        rule_edges,
3076        enrich_edges,
3077        written,
3078        by_type,
3079        dry_run,
3080    })
3081}
3082
3083// ── Q5: portable memory export / import ──────────────────────────────────────
3084
3085/// A single memory in the portable JSON exchange format.
3086///
3087/// Carries only the fields needed to reconstruct the memory in another brain —
3088/// instance-specific data (`memory_id`, `usefulness_score`, `use_count`) is
3089/// intentionally excluded so importing always creates a fresh row with clean
3090/// stats.
3091#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3092pub struct MemoryExport {
3093    pub text: String,
3094    pub scope: String,
3095    pub kind: String,
3096    pub confidence: f32,
3097    pub created_at: Option<String>,
3098}
3099
3100/// v3.0 #4: a shareable brain PACK — a self-describing envelope (manifest +
3101/// memories) for distribution via the marketplace. Serialized to JSON then
3102/// gzip-compressed by the CLI. A bare `Vec<MemoryExport>` (the pre-pack export
3103/// format) also imports, for back-compat — see [`parse_pack_or_array`].
3104#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3105pub struct Pack {
3106    /// Pack format version (currently 1).
3107    pub kimetsu_pack: u32,
3108    #[serde(default, skip_serializing_if = "Option::is_none")]
3109    pub name: Option<String>,
3110    #[serde(default, skip_serializing_if = "Option::is_none")]
3111    pub version: Option<String>,
3112    #[serde(default, skip_serializing_if = "Option::is_none")]
3113    pub description: Option<String>,
3114    #[serde(default, skip_serializing_if = "Option::is_none")]
3115    pub exported_at: Option<String>,
3116    #[serde(default)]
3117    pub memory_count: usize,
3118    pub memories: Vec<MemoryExport>,
3119}
3120
3121/// Identity of an installed pack, stamped into each imported memory's provenance
3122/// so it can later be listed / updated / uninstalled.
3123#[derive(Debug, Clone, Default)]
3124pub struct PackRef {
3125    pub name: Option<String>,
3126    pub version: Option<String>,
3127}
3128
3129/// Parse a pack file body: a [`Pack`] envelope OR a bare `Vec<MemoryExport>`
3130/// (back-compat with pre-pack exports). Returns the manifest [`PackRef`] (empty
3131/// for a bare array) and the memory entries.
3132pub fn parse_pack_or_array(json: &str) -> KimetsuResult<(PackRef, Vec<MemoryExport>)> {
3133    // A Pack is a JSON object with `kimetsu_pack` + `memories`; a bare array is
3134    // a JSON array. Try the envelope first; fall back to the array.
3135    if let Ok(pack) = serde_json::from_str::<Pack>(json) {
3136        return Ok((
3137            PackRef {
3138                name: pack.name,
3139                version: pack.version,
3140            },
3141            pack.memories,
3142        ));
3143    }
3144    let entries: Vec<MemoryExport> = serde_json::from_str(json)
3145        .map_err(|e| format!("pack: not a Pack envelope or a memory array: {e}"))?;
3146    Ok((PackRef::default(), entries))
3147}
3148
3149/// Strip the trailing `(context: …)` segment from a memory text produced by
3150/// the distiller / `brain record` workflow, leaving only the lesson body.
3151///
3152/// Matches the literal pattern ` (context: <anything>)` at the very end of
3153/// the trimmed string. The match is case-sensitive to avoid false positives.
3154///
3155/// Returns the original `text` unchanged when:
3156///   - the pattern is absent, or
3157///   - stripping would leave an empty or whitespace-only string (safety
3158///     fallback: a blank lesson is worse than a slightly noisy one).
3159///
3160/// # Examples
3161/// ```
3162/// # use kimetsu_brain::project::redact_context_suffix;
3163/// assert_eq!(
3164///     redact_context_suffix("always use --locked (context: cargo build)"),
3165///     "always use --locked"
3166/// );
3167/// assert_eq!(
3168///     redact_context_suffix("bare lesson"),
3169///     "bare lesson"
3170/// );
3171/// ```
3172pub fn redact_context_suffix(text: &str) -> &str {
3173    let trimmed = text.trim_end();
3174    // Pattern: " (context: …)" where the parenthesised segment is at the end.
3175    // Walk backwards to find the matching open-paren for a ` (context: ` prefix.
3176    if let Some(pos) = find_trailing_context_paren(trimmed) {
3177        let candidate = trimmed[..pos].trim_end();
3178        if !candidate.is_empty() {
3179            return candidate;
3180        }
3181    }
3182    text
3183}
3184
3185/// Strip the leading `[tags: …]` prefix from a memory text, leaving only the
3186/// lesson body (and any trailing context segment unless that is separately
3187/// stripped by [`redact_context_suffix`]).
3188///
3189/// Matches `[tags: …] ` at the very start of the trimmed string.
3190/// Returns the original `text` when:
3191///   - the pattern is absent, or
3192///   - stripping would leave an empty or whitespace-only string.
3193///
3194/// # Examples
3195/// ```
3196/// # use kimetsu_brain::project::redact_tags_prefix;
3197/// assert_eq!(
3198///     redact_tags_prefix("[tags: rust, cargo] always use --locked"),
3199///     "always use --locked"
3200/// );
3201/// assert_eq!(
3202///     redact_tags_prefix("no tags here"),
3203///     "no tags here"
3204/// );
3205/// ```
3206pub fn redact_tags_prefix(text: &str) -> &str {
3207    let trimmed = text.trim_start();
3208    if let Some(rest) = trimmed.strip_prefix("[tags: ") {
3209        if let Some(close) = rest.find(']') {
3210            let after = rest[close + 1..].trim_start();
3211            if !after.is_empty() {
3212                return after;
3213            }
3214        }
3215    }
3216    text
3217}
3218
3219/// Apply export-time redaction to a single `MemoryExport`'s text field
3220/// according to the requested flags. Returns a new `MemoryExport` with the
3221/// text replaced (or the original when no patterns match and the safety
3222/// fallback applies).
3223///
3224/// The two-step order matters: strip tags first, then context, so that a
3225/// memory like `[tags: rust] lesson body (context: foo)` becomes
3226/// `lesson body` when both flags are active.
3227pub fn apply_export_redaction(
3228    entry: MemoryExport,
3229    redact: bool,
3230    redact_tags: bool,
3231) -> MemoryExport {
3232    if !redact && !redact_tags {
3233        return entry;
3234    }
3235    let mut text: &str = &entry.text;
3236    // Temporary storage so we can chain borrows without lifetime woes.
3237    let after_tags: String;
3238    let after_ctx: String;
3239    if redact_tags {
3240        let stripped = redact_tags_prefix(text);
3241        after_tags = stripped.to_string();
3242        text = &after_tags;
3243    }
3244    if redact {
3245        let stripped = redact_context_suffix(text);
3246        after_ctx = stripped.to_string();
3247        text = &after_ctx;
3248    }
3249    MemoryExport {
3250        text: text.to_string(),
3251        ..entry
3252    }
3253}
3254
3255// Helper: find the byte offset of the opening ` (context: ` run that closes
3256// at the very end of `s` (which must already be trimmed of trailing
3257// whitespace). Returns `None` when no such suffix is present.
3258fn find_trailing_context_paren(s: &str) -> Option<usize> {
3259    // We look for a closing `)` at the end, then walk left to find ` (context: `.
3260    if !s.ends_with(')') {
3261        return None;
3262    }
3263    // The minimum suffix is ` (context: x)` — 13 chars.
3264    let bytes = s.as_bytes();
3265    // Find the matching open paren by scanning backwards from the terminal `)`.
3266    let close = s.len() - 1;
3267    // We need at least " (context: " before the close paren, so start scanning
3268    // no further than close - len(" (context: ") = close - 11.
3269    // Use a simple prefix search scanning from the right.
3270    let prefix = b" (context: ";
3271    for start in (0..close).rev() {
3272        if start + prefix.len() > close {
3273            continue;
3274        }
3275        if &bytes[start..start + prefix.len()] == prefix {
3276            // Found the open sequence; the segment is s[start..=close].
3277            return Some(start);
3278        }
3279    }
3280    None
3281}
3282
3283/// Summary returned by [`import_memories`] / [`import_pack`].
3284#[derive(Debug, Clone, Default)]
3285pub struct ImportSummary {
3286    /// Memories that were actually written (new rows).
3287    pub imported: usize,
3288    /// Entries that were skipped because an identical memory already existed
3289    /// (detected by `add_memory`'s normalized-text dedup) or because the
3290    /// scope/kind was malformed.
3291    pub deduped: usize,
3292    /// v3.0 #4: memories superseded by a `replace`-mode pack install (existing
3293    /// active memories in the pack's scope(s), invalidated before the load).
3294    pub superseded: usize,
3295}
3296
3297thread_local! {
3298    /// v3.0 #4: provenance source stamped onto memories written during a pack
3299    /// install (e.g. `{source:"pack", pack_name, pack_version}`). When unset,
3300    /// `add_memory` uses its default `manual_cli` provenance. RAII-scoped by
3301    /// [`ImportProvenanceScope`] so it never leaks past the import.
3302    static IMPORT_PROVENANCE: std::cell::RefCell<Option<serde_json::Value>> =
3303        const { std::cell::RefCell::new(None) };
3304}
3305
3306struct ImportProvenanceScope;
3307impl ImportProvenanceScope {
3308    fn new(v: serde_json::Value) -> Self {
3309        IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = Some(v));
3310        ImportProvenanceScope
3311    }
3312}
3313impl Drop for ImportProvenanceScope {
3314    fn drop(&mut self) {
3315        IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = None);
3316    }
3317}
3318
3319/// Build a memory's `provenance_snapshot`. Uses the thread-local pack source
3320/// (set during a pack install) when present, else the default `manual_cli`.
3321fn build_provenance(run_id: RunId, text: &str) -> serde_json::Value {
3322    IMPORT_PROVENANCE.with(|c| {
3323        if let Some(src) = c.borrow().as_ref() {
3324            let mut v = src.clone();
3325            if let Some(obj) = v.as_object_mut() {
3326                obj.insert("run_id".into(), serde_json::json!(run_id.to_string()));
3327                obj.insert("text".into(), serde_json::json!(text));
3328            }
3329            v
3330        } else {
3331            serde_json::json!({
3332                "source": "manual_cli",
3333                "run_id": run_id.to_string(),
3334                "text": text,
3335            })
3336        }
3337    })
3338}
3339
3340/// Export active memories as a vec of portable records.
3341///
3342/// `scope` and `kind` are optional filters; `None` means "all".
3343/// `redact` strips the trailing `(context: …)` segment from each text.
3344/// `redact_tags` additionally strips the leading `[tags: …]` prefix.
3345/// Aggregate security-scrub findings across an export (no credentials / PII may
3346/// ship in a shareable pack). `kinds` maps each redaction kind to its count.
3347#[derive(Debug, Clone, Default, serde::Serialize)]
3348pub struct ScrubReport {
3349    pub total: usize,
3350    pub kinds: std::collections::BTreeMap<String, usize>,
3351}
3352
3353impl ScrubReport {
3354    pub fn is_clean(&self) -> bool {
3355        self.total == 0
3356    }
3357    /// One-liner like `"scrubbed 4: email×2, anthropic_oauth×1, ssn×1"`.
3358    pub fn summary(&self) -> String {
3359        if self.total == 0 {
3360            return "no credentials or PII found".to_string();
3361        }
3362        let parts: Vec<String> = self.kinds.iter().map(|(k, n)| format!("{k}×{n}")).collect();
3363        format!("scrubbed {}: {}", self.total, parts.join(", "))
3364    }
3365}
3366
3367pub fn export_memories(
3368    start: &Path,
3369    scope: Option<MemoryScope>,
3370    kind: Option<MemoryKind>,
3371    redact: bool,
3372    redact_tags: bool,
3373) -> KimetsuResult<(Vec<MemoryExport>, ScrubReport)> {
3374    // Build the SQL dynamically based on the optional filters, including
3375    // `created_at` so the JSON record carries the origin timestamp.
3376    let (sql, params_vec): (&str, Vec<String>) = match (scope.as_ref(), kind.as_ref()) {
3377        (Some(s), Some(k)) => (
3378            "SELECT scope, kind, text, confidence, created_at
3379             FROM memories
3380             WHERE invalidated_at IS NULL
3381               AND superseded_by IS NULL
3382               AND lower(scope) = lower(?1)
3383               AND lower(kind)  = lower(?2)
3384             ORDER BY created_at DESC",
3385            vec![s.to_string(), k.to_string()],
3386        ),
3387        (Some(s), None) => (
3388            "SELECT scope, kind, text, confidence, created_at
3389             FROM memories
3390             WHERE invalidated_at IS NULL
3391               AND superseded_by IS NULL
3392               AND lower(scope) = lower(?1)
3393             ORDER BY created_at DESC",
3394            vec![s.to_string()],
3395        ),
3396        (None, Some(k)) => (
3397            "SELECT scope, kind, text, confidence, created_at
3398             FROM memories
3399             WHERE invalidated_at IS NULL
3400               AND superseded_by IS NULL
3401               AND lower(kind) = lower(?1)
3402             ORDER BY created_at DESC",
3403            vec![k.to_string()],
3404        ),
3405        (None, None) => (
3406            "SELECT scope, kind, text, confidence, created_at
3407             FROM memories
3408             WHERE invalidated_at IS NULL
3409               AND superseded_by IS NULL
3410             ORDER BY created_at DESC",
3411            vec![],
3412        ),
3413    };
3414
3415    // Project-level memories only (user brain memories live in a separate DB;
3416    // callers wanting the user brain should call with scope=GlobalUser on the
3417    // user-brain path, or simply use list_memories which merges both).
3418    let (_paths, _config, conn) = load_project(start)?;
3419
3420    let mut stmt = conn.prepare(sql)?;
3421    let refs: Vec<&dyn rusqlite::ToSql> = params_vec
3422        .iter()
3423        .map(|s| s as &dyn rusqlite::ToSql)
3424        .collect();
3425    let rows = stmt.query_map(refs.as_slice(), |row| {
3426        Ok(MemoryExport {
3427            scope: row.get(0)?,
3428            kind: row.get(1)?,
3429            text: row.get(2)?,
3430            confidence: row.get::<_, f64>(3)? as f32,
3431            created_at: row.get(4)?,
3432        })
3433    })?;
3434
3435    // Security scrub (v3.0 #4): every exported memory passes through the
3436    // credential + PII scrubber so a shareable pack can never ship secrets or
3437    // personal data. The scrub is on the EXPORT COPY only — the source DB is
3438    // untouched. Findings are tallied for the caller to report (and --strict).
3439    let mut out = Vec::new();
3440    let mut report = ScrubReport::default();
3441    for row in rows {
3442        let mut entry = apply_export_redaction(row?, redact, redact_tags);
3443        let scrubbed = crate::redact::scrub_for_export(&entry.text);
3444        for m in &scrubbed.matches {
3445            *report.kinds.entry(m.kind.to_string()).or_insert(0) += 1;
3446            report.total += 1;
3447        }
3448        entry.text = scrubbed.text;
3449        out.push(entry);
3450    }
3451    Ok((out, report))
3452}
3453
3454/// Import a slice of [`MemoryExport`] records into the brain at `start`.
3455///
3456/// For each entry:
3457/// - Parse scope + kind from the string fields (with optional `scope_override`).
3458/// - Call `add_memory`, which dedups by normalized text. Dedup is detected by
3459///   comparing the set of active memory IDs in the project DB before vs after
3460///   each `add_memory` call — if the returned ID was already in the DB at
3461///   the start of this import batch, it counts as deduped.
3462/// - Malformed entries (bad scope/kind string) are skipped with a warning;
3463///   they do NOT abort the whole import.
3464///
3465/// Returns an [`ImportSummary`] with `imported` (new rows) and `deduped`
3466/// (entries that collapsed to an existing row or were skipped).
3467pub fn import_memories(
3468    start: &Path,
3469    entries: &[MemoryExport],
3470    scope_override: Option<MemoryScope>,
3471) -> KimetsuResult<ImportSummary> {
3472    let mut summary = ImportSummary::default();
3473
3474    // Snapshot all active memory IDs before we start importing.  Any ID
3475    // returned by add_memory that is already in this set is a dedup.
3476    let pre_existing_ids: std::collections::HashSet<String> = {
3477        // Open a read-only connection just for the snapshot; avoid holding it
3478        // across the write calls (each add_memory opens its own connection).
3479        match load_project_readonly(start) {
3480            Ok((_paths, _config, conn)) => {
3481                let mut stmt = conn
3482                    .prepare("SELECT memory_id FROM memories WHERE invalidated_at IS NULL")
3483                    .unwrap_or_else(|_| conn.prepare("SELECT memory_id FROM memories").unwrap());
3484                stmt.query_map([], |row| row.get::<_, String>(0))
3485                    .map(|rows| rows.filter_map(|r| r.ok()).collect())
3486                    .unwrap_or_default()
3487            }
3488            Err(_) => std::collections::HashSet::new(),
3489        }
3490    };
3491
3492    // Also track IDs minted during THIS batch so we can detect within-batch
3493    // duplicates (e.g. two identical entries in the import file).
3494    let mut this_batch_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
3495
3496    for entry in entries {
3497        // Resolve scope: prefer override, then parse from the entry.
3498        let scope = if let Some(ref ov) = scope_override {
3499            *ov
3500        } else {
3501            match entry.scope.parse::<MemoryScope>() {
3502                Ok(s) => s,
3503                Err(_) => {
3504                    eprintln!(
3505                        "kimetsu-brain import: skipping entry with unknown scope `{}`",
3506                        entry.scope
3507                    );
3508                    summary.deduped += 1;
3509                    continue;
3510                }
3511            }
3512        };
3513
3514        // Resolve kind.
3515        let kind = match entry.kind.parse::<MemoryKind>() {
3516            Ok(k) => k,
3517            Err(_) => {
3518                eprintln!(
3519                    "kimetsu-brain import: skipping entry with unknown kind `{}`",
3520                    entry.kind
3521                );
3522                summary.deduped += 1;
3523                continue;
3524            }
3525        };
3526
3527        match add_memory(start, scope, kind, &entry.text) {
3528            Ok(id) => {
3529                // Dedup if the ID was present before this import started OR
3530                // was already seen in this batch (within-batch duplicates).
3531                if pre_existing_ids.contains(&id) || !this_batch_ids.insert(id) {
3532                    summary.deduped += 1;
3533                } else {
3534                    summary.imported += 1;
3535                }
3536            }
3537            Err(e) => {
3538                eprintln!("kimetsu-brain import: failed to add memory: {e}");
3539                summary.deduped += 1;
3540            }
3541        }
3542    }
3543
3544    Ok(summary)
3545}
3546
3547/// v3.0 #4: install a pack's memories. `merge` adds additively (dedup against
3548/// existing). `replace` first invalidates active memories in the pack's scope(s)
3549/// — REVERSIBLE (events kept; rows marked invalidated) — then loads the pack.
3550/// Each installed memory is stamped with the `pack` provenance.
3551pub fn import_pack(
3552    start: &Path,
3553    entries: &[MemoryExport],
3554    scope_override: Option<MemoryScope>,
3555    replace: bool,
3556    pack: Option<&PackRef>,
3557) -> KimetsuResult<ImportSummary> {
3558    let mut superseded = 0usize;
3559    if replace {
3560        let scopes = pack_target_scopes(entries, scope_override);
3561        let reason = match pack {
3562            Some(p) => format!(
3563                "replaced_by_pack:{}@{}",
3564                p.name.as_deref().unwrap_or("unknown"),
3565                p.version.as_deref().unwrap_or("?")
3566            ),
3567            None => "replaced_by_import".to_string(),
3568        };
3569        for id in active_memory_ids_in_scopes(start, &scopes)? {
3570            invalidate_memory(start, &id, Some(&reason))?;
3571            superseded += 1;
3572        }
3573    }
3574
3575    // Defensive scrub: never INGEST a credential/PII from a pack, even if the
3576    // author bypassed export-time scrubbing. (Export already scrubs; this is
3577    // belt-and-suspenders on the receiving side.)
3578    let scrubbed: Vec<MemoryExport> = entries
3579        .iter()
3580        .map(|e| {
3581            let mut e = e.clone();
3582            e.text = crate::redact::scrub_for_export(&e.text).text;
3583            e
3584        })
3585        .collect();
3586
3587    // Stamp pack provenance on each installed memory for the duration of the load.
3588    let _prov = pack.map(|p| {
3589        ImportProvenanceScope::new(serde_json::json!({
3590            "source": "pack",
3591            "pack_name": p.name,
3592            "pack_version": p.version,
3593        }))
3594    });
3595    let mut summary = import_memories(start, &scrubbed, scope_override)?;
3596    summary.superseded = superseded;
3597    Ok(summary)
3598}
3599
3600/// Distinct scopes a pack will write to (override wins; else parsed per entry).
3601fn pack_target_scopes(
3602    entries: &[MemoryExport],
3603    scope_override: Option<MemoryScope>,
3604) -> Vec<MemoryScope> {
3605    if let Some(ov) = scope_override {
3606        return vec![ov];
3607    }
3608    let mut seen = std::collections::HashSet::new();
3609    let mut out = Vec::new();
3610    for e in entries {
3611        if let Ok(s) = e.scope.parse::<MemoryScope>() {
3612            if seen.insert(s.to_string()) {
3613                out.push(s);
3614            }
3615        }
3616    }
3617    out
3618}
3619
3620/// Active (non-invalidated, non-superseded) memory ids in the given scopes.
3621fn active_memory_ids_in_scopes(start: &Path, scopes: &[MemoryScope]) -> KimetsuResult<Vec<String>> {
3622    if scopes.is_empty() {
3623        return Ok(Vec::new());
3624    }
3625    let (_p, _c, conn) = load_project_readonly(start)?;
3626    let mut ids = Vec::new();
3627    for sc in scopes {
3628        let mut stmt = conn.prepare(
3629            "SELECT memory_id FROM memories
3630             WHERE scope = ?1 AND invalidated_at IS NULL AND superseded_by IS NULL",
3631        )?;
3632        let rows = stmt.query_map(params![sc.to_string()], |r| r.get::<_, String>(0))?;
3633        for r in rows {
3634            ids.push(r?);
3635        }
3636    }
3637    Ok(ids)
3638}
3639
3640// ── Q8: brain compact ────────────────────────────────────────────────────────
3641
3642/// Report returned by [`compact_brain`] describing what was freed.
3643#[derive(Debug, Clone, serde::Serialize)]
3644pub struct CompactReport {
3645    /// brain.db file size in bytes before compaction.
3646    pub bytes_before: u64,
3647    /// brain.db file size in bytes after compaction (WAL checkpointed first).
3648    pub bytes_after: u64,
3649    /// Number of events deleted by `--trim-events-older-than` (0 when not requested).
3650    pub events_trimmed: u64,
3651    /// Number of invalidated memory rows purged (0 when not requested).
3652    pub invalidated_memories_purged: u64,
3653}
3654
3655/// Reclaim dead space in brain.db.
3656///
3657/// 1. Acquires the project lock (same as `rebuild_projection`).
3658/// 2. Optionally purges invalidated memory rows (`purge_invalidated`).
3659/// 3. Optionally trims old events (`trim_events_older_than`).
3660/// 4. Runs `VACUUM` (outside any transaction) to rebuild the file in-place.
3661/// 5. Checkpoints the WAL before measuring `bytes_after` so the measurement
3662///    reflects the on-disk file, not the shadow WAL.
3663pub fn compact_brain(
3664    start: &Path,
3665    trim_events_older_than: Option<std::time::Duration>,
3666    purge_invalidated: bool,
3667) -> KimetsuResult<CompactReport> {
3668    let (paths, _config, conn) = load_project(start)?;
3669    let _lock = ProjectLock::acquire(&paths, "brain compact", None)?;
3670
3671    // Step 2: record bytes_before.
3672    let bytes_before = fs::metadata(&paths.brain_db).map(|m| m.len()).unwrap_or(0);
3673
3674    // Step 3: purge invalidated memories (optional, gated by caller).
3675    let invalidated_memories_purged = if purge_invalidated {
3676        let count: i64 = conn.query_row(
3677            "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NOT NULL",
3678            [],
3679            |r| r.get(0),
3680        )?;
3681        conn.execute_batch(
3682            "DELETE FROM memories_fts WHERE memory_id IN (
3683                 SELECT memory_id FROM memories WHERE invalidated_at IS NOT NULL
3684             );
3685             DELETE FROM memories WHERE invalidated_at IS NOT NULL;",
3686        )?;
3687        count as u64
3688    } else {
3689        0
3690    };
3691
3692    // Step 4: trim old events (optional, gated by caller).
3693    let events_trimmed = if let Some(dur) = trim_events_older_than {
3694        // Compute the cutoff as an RFC 3339 string (UTC) so it compares
3695        // correctly against the TEXT `ts` column.
3696        let cutoff_secs = dur.as_secs();
3697        let now_unix = std::time::SystemTime::now()
3698            .duration_since(std::time::UNIX_EPOCH)
3699            .map(|d| d.as_secs())
3700            .unwrap_or(0);
3701        let cutoff_unix = now_unix.saturating_sub(cutoff_secs);
3702        // Format as a naive UTC RFC 3339 string (matches the stored format).
3703        let cutoff_rfc3339 = {
3704            let secs = cutoff_unix as i64;
3705            // Use the `time` crate (already a dependency of projector.rs).
3706            use time::OffsetDateTime;
3707            use time::format_description::well_known::Rfc3339;
3708            OffsetDateTime::from_unix_timestamp(secs)
3709                .map_err(|e| format!("compact_brain: invalid cutoff timestamp: {e}"))?
3710                .format(&Rfc3339)
3711                .map_err(|e| format!("compact_brain: failed to format cutoff: {e}"))?
3712        };
3713        let count: i64 = conn.query_row(
3714            "SELECT COUNT(*) FROM events WHERE ts < ?1",
3715            rusqlite::params![cutoff_rfc3339],
3716            |r| r.get(0),
3717        )?;
3718        conn.execute(
3719            "DELETE FROM events WHERE ts < ?1",
3720            rusqlite::params![cutoff_rfc3339],
3721        )?;
3722        count as u64
3723    } else {
3724        0
3725    };
3726
3727    // Step 5: VACUUM — must run outside any active transaction.
3728    // `rusqlite::Connection` does not hold an implicit transaction here so
3729    // execute_batch is safe.
3730    conn.execute_batch("VACUUM;")?;
3731
3732    // Step 6: Checkpoint the WAL so bytes_after reflects the real file size
3733    // (on systems without WAL mode this is a no-op).
3734    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
3735
3736    let bytes_after = fs::metadata(&paths.brain_db).map(|m| m.len()).unwrap_or(0);
3737
3738    Ok(CompactReport {
3739        bytes_before,
3740        bytes_after,
3741        events_trimmed,
3742        invalidated_memories_purged,
3743    })
3744}
3745
3746#[cfg(test)]
3747mod tests {
3748    use std::fs;
3749
3750    use super::*;
3751    // v0.4.1: pre-v0.4 tests assume `MemoryScope::GlobalUser` writes
3752    // land in the project DB. With user-brain routing on by default
3753    // that's no longer true — wrap each affected test in
3754    // `with_user_brain_disabled` so it sees v0.3.5 routing. Tests
3755    // that specifically exercise the user-brain path live in
3756    // `user_brain::tests` and opt-in via `with_user_brain_at`.
3757    use crate::user_brain::with_user_brain_disabled;
3758
3759    /// v0.8: create an isolated temp project root. A minimal `git init`
3760    /// gives the dir its own git toplevel so `ProjectPaths::discover`
3761    /// resolves to THIS dir instead of climbing to an enclosing repo
3762    /// (e.g. a developer's `$HOME` git repo) — which would otherwise
3763    /// make parallel tests share one brain.db + project.lock. Without
3764    /// this, tests pass only when `TMP` points outside any git repo.
3765    fn test_root() -> std::path::PathBuf {
3766        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
3767        kimetsu_core::paths::git_init_boundary(&root);
3768        root
3769    }
3770
3771    #[test]
3772    fn w1_5_init_creates_kimetsu_dir_but_no_runs_dir() {
3773        with_user_brain_disabled(|| {
3774            let root = test_root();
3775            let summary = init_project(&root, false).expect("init");
3776            // The .kimetsu/ dir + brain.db + project.toml are created...
3777            assert!(summary.kimetsu_dir.exists(), ".kimetsu/ must exist");
3778            assert!(summary.brain_db.exists(), "brain.db must be created");
3779            assert!(
3780                summary.kimetsu_dir.join("project.toml").exists(),
3781                "project.toml must be written"
3782            );
3783            // ...but a fresh init does NOT eagerly create runs/ (it's created
3784            // lazily only when an agent run needs it).
3785            assert!(
3786                !summary.kimetsu_dir.join("runs").exists(),
3787                "fresh init must NOT create a runs/ dir"
3788            );
3789        });
3790    }
3791
3792    #[test]
3793    fn search_memories_paginates_and_filters_by_kind() {
3794        with_user_brain_disabled(|| {
3795            let root = test_root();
3796            init_project(&root, false).expect("init");
3797            add_memory(
3798                &root,
3799                MemoryScope::Project,
3800                MemoryKind::FailurePattern,
3801                "linker link.exe not found on windows",
3802            )
3803            .expect("add fp");
3804            add_memory(
3805                &root,
3806                MemoryScope::Project,
3807                MemoryKind::Command,
3808                "run cargo build with the link.exe linker on PATH",
3809            )
3810            .expect("add cmd");
3811            add_memory(
3812                &root,
3813                MemoryScope::Project,
3814                MemoryKind::Fact,
3815                "the office plant needs watering on tuesdays",
3816            )
3817            .expect("add fact");
3818
3819            // "linker" matches the two link.exe memories, not the plant fact.
3820            let hits = search_memories(&root, "linker", 10, 0, None, None).expect("search");
3821            assert!(hits.len() >= 2, "expected >=2 hits, got {}", hits.len());
3822            assert!(
3823                hits.iter()
3824                    .all(|h| h.text.to_ascii_lowercase().contains("link"))
3825            );
3826
3827            // Pagination: two single-row pages return distinct rows.
3828            let p1 = search_memories(&root, "linker", 1, 0, None, None).expect("p1");
3829            let p2 = search_memories(&root, "linker", 1, 1, None, None).expect("p2");
3830            assert_eq!(p1.len(), 1);
3831            assert_eq!(p2.len(), 1);
3832            assert_ne!(p1[0].memory_id, p2[0].memory_id, "offset must advance");
3833
3834            // Kind filter narrows to failure_pattern only.
3835            let fp =
3836                search_memories(&root, "linker", 10, 0, Some("failure_pattern"), None).expect("fp");
3837            assert!(!fp.is_empty());
3838            assert!(fp.iter().all(|h| h.kind == "failure_pattern"));
3839
3840            // A query with no FTS tokens returns empty, not an error.
3841            assert!(
3842                search_memories(&root, "   ", 10, 0, None, None)
3843                    .unwrap()
3844                    .is_empty()
3845            );
3846        });
3847    }
3848
3849    #[test]
3850    fn reindex_with_explicit_embedder_uses_that_model() {
3851        with_user_brain_disabled(|| {
3852            let root = test_root();
3853            init_project(&root, false).expect("init");
3854            add_memory(
3855                &root,
3856                MemoryScope::Project,
3857                MemoryKind::Fact,
3858                "alpha beta gamma",
3859            )
3860            .expect("add");
3861            // The explicit-embedder path (used by `model set`) must
3862            // re-embed with the GIVEN embedder, regardless of the
3863            // process default.
3864            use crate::embeddings::Embedder as _;
3865            let stub = crate::embeddings::StubEmbedder::new();
3866            let report = crate::reindex::reindex_all_with_embedder(
3867                &root,
3868                crate::reindex::ReindexOptions {
3869                    scope: crate::reindex::ReindexScope::Project,
3870                    dry_run: false,
3871                    force: false,
3872                    limit: None,
3873                },
3874                &stub,
3875            )
3876            .expect("reindex");
3877            assert_eq!(report.embedder_model_id, stub.model_id());
3878            assert!(
3879                report.project.updated >= 1,
3880                "the row should be re-embedded with the stub model"
3881            );
3882        });
3883    }
3884
3885    #[test]
3886    fn retrieve_proactive_returns_actionable_kind_and_excludes_others() {
3887        with_user_brain_disabled(|| {
3888            let root = test_root();
3889            init_project(&root, false).expect("init");
3890            add_memory(
3891                &root,
3892                MemoryScope::Project,
3893                MemoryKind::FailurePattern,
3894                "linker link.exe not found -> run from x64 Native Tools prompt",
3895            )
3896            .expect("add fp");
3897            // A high-overlap FACT that would outrank lexically but is NOT an
3898            // actionable kind — the kinds filter must drop it.
3899            add_memory(
3900                &root,
3901                MemoryScope::Project,
3902                MemoryKind::Fact,
3903                "linker link.exe trivia: link.exe ships with MSVC",
3904            )
3905            .expect("add fact");
3906
3907            let request = ContextRequest {
3908                stage: "localization".to_string(),
3909                query: "error: linker `link.exe` not found".to_string(),
3910                budget_tokens: 600,
3911                min_score: 0.2,
3912                max_capsules: 1,
3913                kinds: vec!["failure_pattern".to_string(), "command".to_string()],
3914                ..Default::default()
3915            };
3916            let bundle = retrieve_proactive_readonly(&root, request).expect("proactive");
3917            assert!(!bundle.skipped, "should surface the failure_pattern");
3918            assert_eq!(bundle.capsules.len(), 1);
3919            // The single capsule must be the failure_pattern, not the fact.
3920            assert!(
3921                bundle.capsules[0].summary.contains("failure_pattern"),
3922                "got summary: {}",
3923                bundle.capsules[0].summary
3924            );
3925            assert!(!bundle.capsules[0].summary.contains("trivia"));
3926        });
3927    }
3928
3929    #[test]
3930    fn memory_add_survives_projection_rebuild_from_trace() {
3931        with_user_brain_disabled(|| {
3932            let root = test_root();
3933            fs::create_dir_all(&root).expect("create temp project");
3934
3935            init_project(&root, false).expect("init project");
3936            let memory_id = add_memory(
3937                &root,
3938                MemoryScope::GlobalUser,
3939                MemoryKind::Preference,
3940                "User prefers Rust for core infrastructure.",
3941            )
3942            .expect("add memory");
3943
3944            let memories = list_memories(&root).expect("list memories");
3945            assert_eq!(memories.len(), 1);
3946            assert_eq!(memories[0].memory_id, memory_id);
3947
3948            let event_count = rebuild_projection(&root, false).expect("rebuild projection");
3949            assert_eq!(event_count, 3);
3950
3951            let memories = list_memories(&root).expect("list rebuilt memories");
3952            assert_eq!(memories.len(), 1);
3953            assert_eq!(memories[0].memory_id, memory_id);
3954            assert_eq!(
3955                memories[0].text,
3956                "User prefers Rust for core infrastructure."
3957            );
3958
3959            fs::remove_dir_all(root).expect("remove temp project");
3960        });
3961    }
3962
3963    /// v0.4.5 end-to-end: secrets in `add_memory` text never reach
3964    /// brain.db. The redacted row keeps the surrounding context so
3965    /// the memory is still useful — only the credential is scrubbed.
3966    #[test]
3967    fn add_memory_redacts_secrets_before_persist() {
3968        with_user_brain_disabled(|| {
3969            let root = test_root();
3970            fs::create_dir_all(&root).expect("create temp project");
3971            init_project(&root, false).expect("init project");
3972
3973            let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
3974            let memory_id =
3975                add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory");
3976
3977            let memories = list_memories(&root).expect("list");
3978            let stored = memories
3979                .iter()
3980                .find(|m| m.memory_id == memory_id)
3981                .expect("memory present");
3982            assert!(
3983                !stored.text.contains("sk-ant-api03"),
3984                "raw secret must NOT survive to brain.db: {}",
3985                stored.text
3986            );
3987            assert!(
3988                stored.text.contains("[REDACTED:anthropic_oauth]"),
3989                "placeholder must be present: {}",
3990                stored.text
3991            );
3992            assert!(
3993                stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"),
3994                "non-secret context must be preserved: {}",
3995                stored.text
3996            );
3997
3998            fs::remove_dir_all(root).expect("cleanup");
3999        });
4000    }
4001
4002    #[test]
4003    fn repo_ingest_indexes_searchable_files_and_context_capsules() {
4004        let root = test_root();
4005        fs::create_dir_all(root.join("src")).expect("create src");
4006        fs::create_dir_all(root.join("target")).expect("create target");
4007        fs::write(
4008            root.join("Cargo.toml"),
4009            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
4010        )
4011        .expect("write manifest");
4012        fs::write(
4013            root.join("src").join("lib.rs"),
4014            "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
4015        )
4016        .expect("write source");
4017        fs::write(
4018            root.join("target").join("generated.rs"),
4019            "projection rebuild",
4020        )
4021        .expect("write skipped");
4022        fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
4023        fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
4024
4025        init_project(&root, false).expect("init project");
4026        add_memory(
4027            &root,
4028            MemoryScope::GlobalUser,
4029            MemoryKind::Preference,
4030            "User prefers Rust for core infrastructure.",
4031        )
4032        .expect("add memory");
4033
4034        let summary = ingest_repo(&root).expect("ingest repo");
4035        assert_eq!(summary.indexed_files, 2);
4036        assert_eq!(summary.manifests, 1);
4037
4038        let matches = search_files(&root, "projection rebuild", 5).expect("search files");
4039        assert!(
4040            matches
4041                .iter()
4042                .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
4043            "expected src/lib.rs in search results: {matches:?}"
4044        );
4045        assert!(
4046            matches
4047                .iter()
4048                .all(|capsule| !capsule.expansion_handle.contains("target/")),
4049            "target files must not be indexed: {matches:?}"
4050        );
4051
4052        let context =
4053            retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
4054        assert!(
4055            context
4056                .capsules
4057                .iter()
4058                .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
4059            "expected memory capsule in context: {:?}",
4060            context.capsules
4061        );
4062
4063        rebuild_projection(&root, false).expect("rebuild projection");
4064        let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
4065        assert!(
4066            matches
4067                .iter()
4068                .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
4069            "repo index should survive event-only rebuild: {matches:?}"
4070        );
4071
4072        fs::remove_dir_all(root).expect("remove temp project");
4073    }
4074
4075    #[test]
4076    fn run_finished_increments_usefulness_for_injected_memories() {
4077        with_user_brain_disabled(|| {
4078            // MP-4a outcome attribution + v0.5.1 citation split:
4079            // a memory that is BOTH injected (in context.injected) AND
4080            // cited (via memory.cited from the cite_memory tool) earns
4081            // the strong +1.0 usefulness delta on run.finished.
4082            //
4083            // Per-run counting: the same memory injected into two
4084            // stages of one run still counts once.
4085            let root = test_root();
4086            fs::create_dir_all(&root).expect("create temp project");
4087            init_project(&root, false).expect("init project");
4088            let memory_id = add_memory(
4089                &root,
4090                MemoryScope::GlobalUser,
4091                MemoryKind::Preference,
4092                "Prefer ripgrep over grep.",
4093            )
4094            .expect("add memory");
4095
4096            {
4097                let (paths, _config, conn) = load_project(&root).expect("load");
4098                let run_id = RunId::new();
4099                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4100                let evs: Vec<Event> = vec![
4101                    Event::new(
4102                        run_id,
4103                        "run.started",
4104                        serde_json::json!({"project_id": "test", "task": "x"}),
4105                    ),
4106                    Event::new(
4107                        run_id,
4108                        "context.injected",
4109                        serde_json::json!({
4110                            "stage": "localization",
4111                            "capsule_handles": [format!("memory:{memory_id}")],
4112                            "memory_ids": [memory_id.clone()],
4113                            "prior_run_ids": [],
4114                            "file_paths": [],
4115                        }),
4116                    ),
4117                    Event::new(
4118                        run_id,
4119                        "context.injected",
4120                        serde_json::json!({
4121                            "stage": "patch_plan",
4122                            "capsule_handles": [format!("memory:{memory_id}")],
4123                            "memory_ids": [memory_id.clone()],
4124                            "prior_run_ids": [],
4125                            "file_paths": [],
4126                        }),
4127                    ),
4128                    // v0.5.1: model explicitly cited the memory in
4129                    // turn 3 — earns the strong +1.0 delta.
4130                    Event::new(
4131                        run_id,
4132                        "memory.cited",
4133                        serde_json::json!({
4134                            "memory_id": memory_id,
4135                            "turn": 3,
4136                            "rationale": "using rg from memory",
4137                        }),
4138                    ),
4139                    Event::new(
4140                        run_id,
4141                        "run.finished",
4142                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
4143                    ),
4144                ];
4145                for ev in &evs {
4146                    writer.append(ev, true).expect("append");
4147                }
4148                projector::apply_events(&conn, &evs).expect("project");
4149            }
4150
4151            let memories = list_memories(&root).expect("list memories");
4152            let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4153            assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
4154            // Flagship 2 / Story 2.1: memory starts with initial_kind_weight = 0.05
4155            // (Preference) and earns +1.0 strong delta on run.finished → 1.05.
4156            let expected = 1.0 + 0.05; // 1.0 strong delta + Preference kind weight
4157            assert!(
4158                (m.usefulness_score - expected).abs() < 1e-4,
4159                "expected strong-signal usefulness_score = {expected}, got {}",
4160                m.usefulness_score
4161            );
4162
4163            fs::remove_dir_all(root).expect("remove temp project");
4164        });
4165    }
4166
4167    /// v0.5.1: silent-passenger path. A memory that was retrieved
4168    /// (in context.injected) but the model never cited gets the
4169    /// weak +0.1 delta on run.finished, not the full +1.0.
4170    /// Encourages the model to actually call `cite_memory`.
4171    #[test]
4172    fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
4173        with_user_brain_disabled(|| {
4174            let root = test_root();
4175            fs::create_dir_all(&root).expect("create temp project");
4176            init_project(&root, false).expect("init project");
4177            let memory_id = add_memory(
4178                &root,
4179                MemoryScope::GlobalUser,
4180                MemoryKind::Preference,
4181                "Silent passenger memory.",
4182            )
4183            .expect("add memory");
4184
4185            {
4186                let (paths, _config, conn) = load_project(&root).expect("load");
4187                let run_id = RunId::new();
4188                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4189                let evs: Vec<Event> = vec![
4190                    Event::new(
4191                        run_id,
4192                        "run.started",
4193                        serde_json::json!({"project_id": "test", "task": "x"}),
4194                    ),
4195                    Event::new(
4196                        run_id,
4197                        "context.injected",
4198                        serde_json::json!({
4199                            "stage": "localization",
4200                            "memory_ids": [memory_id.clone()],
4201                            "prior_run_ids": [],
4202                            "file_paths": [],
4203                        }),
4204                    ),
4205                    // NO memory.cited event for this memory.
4206                    Event::new(
4207                        run_id,
4208                        "run.finished",
4209                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
4210                    ),
4211                ];
4212                for ev in &evs {
4213                    writer.append(ev, true).expect("append");
4214                }
4215                projector::apply_events(&conn, &evs).expect("project");
4216            }
4217
4218            let memories = list_memories(&root).expect("list memories");
4219            let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4220            assert_eq!(m.use_count, 1);
4221            // Flagship 2 / Story 2.1: memory starts with initial_kind_weight = 0.05
4222            // (Preference) and earns +0.1 weak delta on run.finished → 0.15.
4223            let expected = 0.1 + 0.05; // 0.1 weak delta + Preference kind weight
4224            assert!(
4225                (m.usefulness_score - expected).abs() < 1e-4,
4226                "silent passenger should get +0.1 on top of seed, got {}",
4227                m.usefulness_score
4228            );
4229        });
4230    }
4231
4232    /// v0.5.1 end-to-end: `blame_run` walks memory_citations +
4233    /// context.injected + terminal events and surfaces per-memory
4234    /// attribution. Cited memories appear under `cited`, retrieved-
4235    /// but-uncited under `silent_passengers`, and the outcome
4236    /// reflects the run's terminal event.
4237    #[test]
4238    fn blame_run_separates_cited_from_silent_passengers() {
4239        with_user_brain_disabled(|| {
4240            let root = test_root();
4241            fs::create_dir_all(&root).expect("create temp project");
4242            init_project(&root, false).expect("init project");
4243            let cited_id = add_memory(
4244                &root,
4245                MemoryScope::Repo,
4246                MemoryKind::Preference,
4247                "prefer ripgrep over grep",
4248            )
4249            .expect("add cited");
4250            let silent_id = add_memory(
4251                &root,
4252                MemoryScope::Repo,
4253                MemoryKind::Convention,
4254                "use cargo nextest for tests",
4255            )
4256            .expect("add silent");
4257
4258            let run_id = RunId::new();
4259            {
4260                let (paths, _config, conn) = load_project(&root).expect("load");
4261                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4262                let evs: Vec<Event> = vec![
4263                    Event::new(
4264                        run_id,
4265                        "run.started",
4266                        serde_json::json!({"project_id": "test", "task": "x"}),
4267                    ),
4268                    Event::new(
4269                        run_id,
4270                        "context.injected",
4271                        serde_json::json!({
4272                            "stage": "localization",
4273                            "memory_ids": [cited_id.clone(), silent_id.clone()],
4274                            "prior_run_ids": [],
4275                            "file_paths": [],
4276                        }),
4277                    ),
4278                    Event::new(
4279                        run_id,
4280                        "memory.cited",
4281                        serde_json::json!({
4282                            "memory_id": cited_id,
4283                            "turn": 4,
4284                            "rationale": "used the rg pattern",
4285                        }),
4286                    ),
4287                    Event::new(
4288                        run_id,
4289                        "run.finished",
4290                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
4291                    ),
4292                ];
4293                for ev in &evs {
4294                    writer.append(ev, true).expect("append");
4295                }
4296                projector::apply_events(&conn, &evs).expect("project");
4297            }
4298
4299            let report = blame_run(&root, &run_id.to_string()).expect("blame");
4300            assert_eq!(report.outcome, "success");
4301            assert!(report.failure_category.is_none());
4302            assert_eq!(report.cited.len(), 1, "exactly one cited memory");
4303            let cited = &report.cited[0];
4304            assert_eq!(cited.memory_id, cited_id);
4305            assert_eq!(cited.turn, 4);
4306            assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
4307            assert!(cited.text_preview.contains("ripgrep"));
4308
4309            assert_eq!(report.silent_passengers.len(), 1);
4310            let silent = &report.silent_passengers[0];
4311            assert_eq!(silent.memory_id, silent_id);
4312            assert!(silent.text_preview.contains("nextest"));
4313
4314            fs::remove_dir_all(root).expect("cleanup");
4315        });
4316    }
4317
4318    #[test]
4319    fn run_failed_decrements_usefulness_unless_gate() {
4320        // run.failed with category != "Gate" decrements; category == "Gate"
4321        // is a graceful early-exit (e.g. the plan-create existence guard)
4322        // and must not blame memories that happened to be in context.
4323        let root = test_root();
4324        fs::create_dir_all(&root).expect("create temp project");
4325        init_project(&root, false).expect("init project");
4326        let memory_id = add_memory(
4327            &root,
4328            MemoryScope::Repo,
4329            MemoryKind::Convention,
4330            "Use find_* for fallible lookups.",
4331        )
4332        .expect("add memory");
4333
4334        {
4335            let (paths, _config, conn) = load_project(&root).expect("load");
4336
4337            // First run: gate-failure -> no update at all.
4338            let gate_run = RunId::new();
4339            let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
4340            let gate_events: Vec<Event> = vec![
4341                Event::new(
4342                    gate_run,
4343                    "run.started",
4344                    serde_json::json!({"project_id": "test", "task": "g"}),
4345                ),
4346                Event::new(
4347                    gate_run,
4348                    "context.injected",
4349                    serde_json::json!({
4350                        "stage": "patch_plan",
4351                        "capsule_handles": [format!("memory:{memory_id}")],
4352                        "memory_ids": [memory_id.clone()],
4353                        "prior_run_ids": [],
4354                        "file_paths": [],
4355                    }),
4356                ),
4357                Event::new(
4358                    gate_run,
4359                    "run.failed",
4360                    serde_json::json!({
4361                        "category": "Gate",
4362                        "failed_stage": "patch_plan",
4363                        "message": "files_to_create_already_exist",
4364                    }),
4365                ),
4366            ];
4367            for ev in &gate_events {
4368                writer.append(ev, true).expect("append");
4369            }
4370            projector::apply_events(&conn, &gate_events).expect("project gate-fail");
4371
4372            // Second run: real implementation failure + the memory
4373            // was cited via memory.cited -> -1.0 strong signal.
4374            let impl_run = RunId::new();
4375            let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
4376            let impl_events: Vec<Event> = vec![
4377                Event::new(
4378                    impl_run,
4379                    "run.started",
4380                    serde_json::json!({"project_id": "test", "task": "i"}),
4381                ),
4382                Event::new(
4383                    impl_run,
4384                    "context.injected",
4385                    serde_json::json!({
4386                        "stage": "patch_plan",
4387                        "capsule_handles": [format!("memory:{memory_id}")],
4388                        "memory_ids": [memory_id.clone()],
4389                        "prior_run_ids": [],
4390                        "file_paths": [],
4391                    }),
4392                ),
4393                // v0.5.1: cite the memory so this run earns the
4394                // strong -1.0 penalty (the brain pushed wrong).
4395                Event::new(
4396                    impl_run,
4397                    "memory.cited",
4398                    serde_json::json!({
4399                        "memory_id": memory_id,
4400                        "turn": 2,
4401                        "rationale": "trusted the memory's pattern",
4402                    }),
4403                ),
4404                Event::new(
4405                    impl_run,
4406                    "run.failed",
4407                    serde_json::json!({
4408                        "category": "Implementation",
4409                        "failed_stage": "implementation",
4410                        "message": "test broke",
4411                    }),
4412                ),
4413            ];
4414            for ev in &impl_events {
4415                writer2.append(ev, true).expect("append");
4416            }
4417            projector::apply_events(&conn, &impl_events).expect("project impl-fail");
4418        }
4419
4420        let memories = list_memories(&root).expect("list memories");
4421        let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4422        assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
4423        // Flagship 2 / Story 2.1: memory starts with initial_kind_weight = 0.15
4424        // (Convention) and earns -1.0 strong delta on run.failed → -0.85.
4425        let expected = 0.15 - 1.0; // -1.0 strong delta + Convention kind weight
4426        assert!(
4427            (m.usefulness_score - expected).abs() < 1e-4,
4428            "expected usefulness_score = {expected}, got {}",
4429            m.usefulness_score
4430        );
4431
4432        fs::remove_dir_all(root).expect("remove temp project");
4433    }
4434
4435    #[test]
4436    fn run_aborted_does_not_update_usefulness() {
4437        let root = test_root();
4438        fs::create_dir_all(&root).expect("create temp project");
4439        init_project(&root, false).expect("init project");
4440        let memory_id = add_memory(
4441            &root,
4442            MemoryScope::Repo,
4443            MemoryKind::Convention,
4444            "Module re-exports live in lib.rs.",
4445        )
4446        .expect("add memory");
4447
4448        {
4449            let (paths, _config, conn) = load_project(&root).expect("load");
4450            let run_id = RunId::new();
4451            let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
4452            let evs: Vec<Event> = vec![
4453                Event::new(
4454                    run_id,
4455                    "run.started",
4456                    serde_json::json!({"project_id": "test", "task": "a"}),
4457                ),
4458                Event::new(
4459                    run_id,
4460                    "context.injected",
4461                    serde_json::json!({
4462                        "stage": "patch_plan",
4463                        "capsule_handles": [format!("memory:{memory_id}")],
4464                        "memory_ids": [memory_id.clone()],
4465                        "prior_run_ids": [],
4466                        "file_paths": [],
4467                    }),
4468                ),
4469                Event::new(
4470                    run_id,
4471                    "run.aborted",
4472                    serde_json::json!({"reason": "user_abort"}),
4473                ),
4474            ];
4475            for ev in &evs {
4476                writer.append(ev, true).expect("append");
4477            }
4478            projector::apply_events(&conn, &evs).expect("project");
4479        }
4480
4481        let memories = list_memories(&root).expect("list memories");
4482        let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4483        assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
4484        // Flagship 2 / Story 2.1: memory starts with initial_kind_weight = 0.15
4485        // (Convention). run.aborted must NOT change usefulness — only the seed remains.
4486        let expected_seed = 0.15_f32; // Convention kind weight
4487        assert!(
4488            (m.usefulness_score - expected_seed).abs() < 1e-4,
4489            "expected usefulness_score = {expected_seed} (initial seed only), got {}",
4490            m.usefulness_score
4491        );
4492
4493        fs::remove_dir_all(root).expect("remove temp project");
4494    }
4495
4496    #[test]
4497    fn list_proposals_filters_and_reject_records_reason() {
4498        let root = test_root();
4499        fs::create_dir_all(&root).expect("create temp project");
4500        init_project(&root, false).expect("init project");
4501
4502        // Inject three proposals straight via memory.proposed events.
4503        let proposals = [
4504            (
4505                "p1",
4506                "global_user",
4507                "preference",
4508                0.9_f32,
4509                "Prefer rg over grep",
4510            ),
4511            (
4512                "p2",
4513                "repo",
4514                "convention",
4515                0.8,
4516                "Use find_* for fallible lookups",
4517            ),
4518            (
4519                "p3",
4520                "repo",
4521                "convention",
4522                0.4,
4523                "Use let-else where possible",
4524            ),
4525        ];
4526        {
4527            let (paths, _config, conn) = load_project(&root).expect("load");
4528            let run_id = RunId::new();
4529            let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4530            for (proposal_id, scope, kind, conf, text) in &proposals {
4531                let event = Event::new(
4532                    run_id,
4533                    "memory.proposed",
4534                    serde_json::json!({
4535                        "proposal_id": proposal_id,
4536                        "scope": scope,
4537                        "kind": kind,
4538                        "text": text,
4539                        "rationale": "test rationale",
4540                        "proposed_confidence": conf,
4541                        "source_event_ids": [],
4542                    }),
4543                );
4544                writer.append(&event, true).expect("append proposal");
4545                projector::apply_events(&conn, &[event]).expect("project");
4546            }
4547        }
4548
4549        // Filter by scope.
4550        let global = list_proposals(
4551            &root,
4552            ProposalFilter {
4553                scope: Some("global_user".into()),
4554                status: Some("pending".into()),
4555                ..ProposalFilter::default()
4556            },
4557        )
4558        .expect("list proposals");
4559        assert_eq!(global.len(), 1);
4560        assert_eq!(global[0].proposal_id, "p1");
4561
4562        // Filter by min_confidence.
4563        let strong = list_proposals(
4564            &root,
4565            ProposalFilter {
4566                min_confidence: Some(0.7),
4567                status: Some("pending".into()),
4568                ..ProposalFilter::default()
4569            },
4570        )
4571        .expect("list strong");
4572        assert_eq!(strong.len(), 2);
4573        for row in &strong {
4574            assert!(row.proposed_confidence >= 0.7);
4575        }
4576
4577        // Reject one with a reason and confirm it persists on the projected row.
4578        reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
4579        let rejected = list_proposals(
4580            &root,
4581            ProposalFilter {
4582                status: Some("rejected".into()),
4583                ..ProposalFilter::default()
4584            },
4585        )
4586        .expect("list rejected");
4587        assert_eq!(rejected.len(), 1);
4588        assert_eq!(rejected[0].proposal_id, "p3");
4589        assert_eq!(
4590            rejected[0].decided_reason.as_deref(),
4591            Some("not specific to the user")
4592        );
4593
4594        // Accept with a confidence override and confirm the resulting memory
4595        // carries the overridden value.
4596        let memory_id = accept_proposal(
4597            &root,
4598            "p1",
4599            AcceptOverrides {
4600                scope: None,
4601                confidence: Some(0.55),
4602            },
4603        )
4604        .expect("accept");
4605        let memories = list_memories(&root).expect("list memories");
4606        let promoted = memories
4607            .into_iter()
4608            .find(|m| m.memory_id == memory_id)
4609            .expect("promoted memory present");
4610        assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
4611
4612        fs::remove_dir_all(root).expect("remove temp project");
4613    }
4614
4615    /// MP-4d: invalidate_memory emits a `memory.invalidated` event and
4616    /// projects it. The memory row keeps everything but gains
4617    /// `invalidated_at`/`invalidated_reason`, and the row survives a
4618    /// projection rebuild (event is canonical).
4619    #[test]
4620    fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
4621        let root = test_root();
4622        fs::create_dir_all(&root).expect("create temp project");
4623        init_project(&root, false).expect("init project");
4624
4625        let memory_id = add_memory(
4626            &root,
4627            MemoryScope::Repo,
4628            MemoryKind::Convention,
4629            "Use find_* for fallible lookups.",
4630        )
4631        .expect("add memory");
4632
4633        invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
4634            .expect("invalidate memory");
4635
4636        // Direct DB peek so we can read the new columns even before they are
4637        // surfaced via MemoryRow.
4638        {
4639            let (_paths, _config, conn) = load_project(&root).expect("load");
4640            let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
4641                .query_row(
4642                    "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
4643                    params![memory_id],
4644                    |row| Ok((row.get(0)?, row.get(1)?)),
4645                )
4646                .expect("query invalidated metadata");
4647            assert!(invalidated_at.is_some(), "invalidated_at must be set");
4648            assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
4649        }
4650
4651        // Rebuild from table and confirm invalidation survives.
4652        rebuild_projection(&root, false).expect("rebuild projection");
4653        {
4654            let (_paths, _config, conn) = load_project(&root).expect("load");
4655            let invalidated_at: Option<String> = conn
4656                .query_row(
4657                    "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
4658                    params![memory_id],
4659                    |row| row.get(0),
4660                )
4661                .expect("query after rebuild");
4662            assert!(
4663                invalidated_at.is_some(),
4664                "invalidated_at must survive event replay"
4665            );
4666        }
4667
4668        fs::remove_dir_all(root).expect("remove temp project");
4669    }
4670
4671    /// MP-4b broker integration: an invalidated memory must not appear in
4672    /// the retrieved context bundle, even though the row still exists in
4673    /// brain.db for replay/audit.
4674    #[test]
4675    fn invalidated_memory_is_excluded_from_broker_retrieval() {
4676        with_user_brain_disabled(|| {
4677            let root = test_root();
4678            fs::create_dir_all(&root).expect("create temp project");
4679            init_project(&root, false).expect("init project");
4680
4681            let memory_id = add_memory(
4682                &root,
4683                MemoryScope::GlobalUser,
4684                MemoryKind::Preference,
4685                "Prefer ripgrep over grep for repo search.",
4686            )
4687            .expect("add memory");
4688
4689            // Sanity: broker surfaces it pre-invalidation.
4690            let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
4691                .expect("pre context");
4692            assert!(
4693                pre.capsules
4694                    .iter()
4695                    .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4696                "memory must appear before invalidation: {:?}",
4697                pre.capsules
4698            );
4699
4700            invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
4701
4702            let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
4703                .expect("post context");
4704            assert!(
4705                post.capsules
4706                    .iter()
4707                    .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
4708                "invalidated memory must not be retrieved: {:?}",
4709                post.capsules
4710            );
4711
4712            // The row itself still exists in brain.db (S4.4: list_memories now
4713            // filters invalidated rows, matching user-brain behaviour, so we
4714            // verify persistence via a direct DB query instead).
4715            {
4716                let (_paths2, _config2, conn2) = load_project(&root).expect("load for check");
4717                let still_there: i64 = conn2
4718                    .query_row(
4719                        "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
4720                        rusqlite::params![&memory_id],
4721                        |row| row.get(0),
4722                    )
4723                    .expect("db query");
4724                assert_eq!(still_there, 1, "invalidated row must persist in brain.db");
4725            } // conn2 / _paths2 dropped here — Windows file lock released
4726            // But list_memories must NOT surface it (active-only since S4.4).
4727            let active = list_memories(&root).expect("list after invalidation");
4728            assert!(
4729                active.iter().all(|m| m.memory_id != memory_id),
4730                "invalidated memory must not appear in list_memories"
4731            );
4732
4733            fs::remove_dir_all(root).expect("remove temp project");
4734        });
4735    }
4736
4737    /// MP-6: `list_memories_top` returns invalidated_at IS NULL memories
4738    /// sorted by ratio descending, filtered by `min_uses`. Memories with
4739    /// use_count below the threshold are dropped entirely so the listing
4740    /// only shows entries the broker bias actually applies to.
4741    #[test]
4742    fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
4743        let root = test_root();
4744        fs::create_dir_all(&root).expect("create temp project");
4745        init_project(&root, false).expect("init project");
4746
4747        let m_great =
4748            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
4749        let m_meh =
4750            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
4751        let m_bad =
4752            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
4753        let _m_fresh =
4754            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
4755
4756        // Directly set usefulness data; the event-sourcing path is already
4757        // tested by `run_finished_increments_usefulness_for_injected_memories`.
4758        {
4759            let (_paths, _config, conn) = load_project(&root).expect("load");
4760            conn.execute(
4761                "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
4762                params![m_great],
4763            )
4764            .expect("set great");
4765            conn.execute(
4766                "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
4767                params![m_meh],
4768            )
4769            .expect("set meh");
4770            conn.execute(
4771                "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
4772                params![m_bad],
4773            )
4774            .expect("set bad");
4775            // m_fresh stays at use_count=0; should be excluded.
4776        }
4777
4778        let top = list_memories_top(
4779            &root,
4780            TopOptions {
4781                scope: None,
4782                min_uses: 3,
4783                limit: 10,
4784            },
4785        )
4786        .expect("top");
4787        assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
4788        assert_eq!(top[0].memory_id, m_great);
4789        assert_eq!(top[1].memory_id, m_meh);
4790        assert_eq!(top[2].memory_id, m_bad);
4791
4792        // Now invalidate the GREAT memory and confirm it disappears.
4793        invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
4794        let top_after = list_memories_top(
4795            &root,
4796            TopOptions {
4797                scope: None,
4798                min_uses: 3,
4799                limit: 10,
4800            },
4801        )
4802        .expect("top after");
4803        assert_eq!(top_after.len(), 2);
4804        assert!(top_after.iter().all(|m| m.memory_id != m_great));
4805
4806        fs::remove_dir_all(root).expect("remove temp project");
4807    }
4808
4809    /// MP-6: `prune_low_usefulness` lists candidates without writing when
4810    /// `apply = false`, and invalidates each match via the canonical
4811    /// `memory.invalidated` event path when `apply = true`. The prune
4812    /// reason includes the ratio + use_count so audit trail explains
4813    /// why the memory left.
4814    #[test]
4815    fn prune_low_usefulness_dry_run_then_apply() {
4816        with_user_brain_disabled(|| {
4817            prune_low_usefulness_dry_run_then_apply_body();
4818        });
4819    }
4820
4821    fn prune_low_usefulness_dry_run_then_apply_body() {
4822        let root = test_root();
4823        fs::create_dir_all(&root).expect("create temp project");
4824        init_project(&root, false).expect("init project");
4825
4826        let m_keep = add_memory(
4827            &root,
4828            MemoryScope::Repo,
4829            MemoryKind::Convention,
4830            "keep me, I help",
4831        )
4832        .expect("keep");
4833        let m_drop_1 = add_memory(
4834            &root,
4835            MemoryScope::Repo,
4836            MemoryKind::Convention,
4837            "drop me, I hurt",
4838        )
4839        .expect("drop1");
4840        let m_drop_2 = add_memory(
4841            &root,
4842            MemoryScope::Repo,
4843            MemoryKind::Convention,
4844            "drop me too",
4845        )
4846        .expect("drop2");
4847        let m_small_sample = add_memory(
4848            &root,
4849            MemoryScope::Repo,
4850            MemoryKind::Convention,
4851            "small sample shouldn't be pruned even if score is bad",
4852        )
4853        .expect("small");
4854
4855        {
4856            let (_paths, _config, conn) = load_project(&root).expect("load");
4857            // keep: ratio = +0.6 (above threshold)
4858            conn.execute(
4859                "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
4860                params![m_keep],
4861            )
4862            .expect("set keep");
4863            // drop_1: ratio = -0.6 (well below -0.2)
4864            conn.execute(
4865                "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
4866                params![m_drop_1],
4867            )
4868            .expect("set drop1");
4869            // drop_2: ratio = -0.4
4870            conn.execute(
4871                "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
4872                params![m_drop_2],
4873            )
4874            .expect("set drop2");
4875            // small_sample: ratio = -1.0 but only 2 uses, must NOT be pruned
4876            conn.execute(
4877                "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
4878                params![m_small_sample],
4879            )
4880            .expect("set small");
4881        }
4882
4883        // Dry-run: lists candidates but does not invalidate.
4884        let dry = prune_low_usefulness(
4885            &root,
4886            PruneOptions {
4887                scope: None,
4888                min_uses: 3,
4889                max_ratio: -0.2,
4890                apply: false,
4891            },
4892        )
4893        .expect("dry-run");
4894        assert_eq!(dry.candidates.len(), 2);
4895        assert_eq!(dry.invalidated, 0);
4896        let ids: Vec<&str> = dry
4897            .candidates
4898            .iter()
4899            .map(|c| c.memory_id.as_str())
4900            .collect();
4901        assert!(ids.contains(&m_drop_1.as_str()));
4902        assert!(ids.contains(&m_drop_2.as_str()));
4903        // Confirm small_sample stayed out of the candidate list.
4904        assert!(!ids.contains(&m_small_sample.as_str()));
4905
4906        // Pre-apply state: all four memories still active.
4907        let pre = list_memories(&root).expect("pre");
4908        assert_eq!(pre.len(), 4);
4909
4910        // Apply: both bad memories invalidated, keep + small_sample untouched.
4911        let applied = prune_low_usefulness(
4912            &root,
4913            PruneOptions {
4914                scope: None,
4915                min_uses: 3,
4916                max_ratio: -0.2,
4917                apply: true,
4918            },
4919        )
4920        .expect("apply");
4921        assert_eq!(applied.candidates.len(), 2);
4922        assert_eq!(applied.invalidated, 2);
4923        assert_eq!(applied.failed, 0);
4924
4925        // Post-apply: list_memories_top with min_uses=3 should now only
4926        // surface the keep memory (drops are invalidated_at IS NOT NULL,
4927        // small_sample is filtered by min_uses).
4928        let top = list_memories_top(
4929            &root,
4930            TopOptions {
4931                scope: None,
4932                min_uses: 3,
4933                limit: 10,
4934            },
4935        )
4936        .expect("top after prune");
4937        assert_eq!(top.len(), 1);
4938        assert_eq!(top[0].memory_id, m_keep);
4939
4940        // Confirm the canonical event trail: each pruned memory has a
4941        // non-null invalidated_at and the reason mentions "pruned_by_usefulness".
4942        // Scope the connection so it's dropped before fs::remove_dir_all
4943        // on Windows, where SQLite holds an exclusive lock on the journal.
4944        {
4945            let (_paths, _config, conn) = load_project(&root).expect("load");
4946            let reason: String = conn
4947                .query_row(
4948                    "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
4949                    params![m_drop_1],
4950                    |row| row.get(0),
4951                )
4952                .expect("invalidated reason");
4953            assert!(
4954                reason.starts_with("pruned_by_usefulness"),
4955                "unexpected reason: {reason}"
4956            );
4957        }
4958
4959        fs::remove_dir_all(root).expect("remove temp project");
4960    }
4961
4962    /// MP-5a: the brain primitives behind `kimetsu brain memory review`.
4963    /// Workflow: inject several proposals across two runs, filter by run +
4964    /// confidence to pick the keepers, batch-accept those, then
4965    /// batch-reject the remainder. The final state must show exactly the
4966    /// accepted proposals as memories and exactly the rejected proposals
4967    /// carrying a non-empty decided_reason.
4968    #[test]
4969    fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
4970        with_user_brain_disabled(|| {
4971            batch_review_accepts_filtered_subset_and_rejects_remainder_body();
4972        });
4973    }
4974
4975    fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
4976        let root = test_root();
4977        fs::create_dir_all(&root).expect("create temp project");
4978        init_project(&root, false).expect("init project");
4979
4980        let run_a = RunId::new();
4981        let run_b = RunId::new();
4982
4983        // Two proposals from run_a (one strong, one weak) plus two more
4984        // from run_b. The "review" flow will accept run_a's strong one,
4985        // reject everything else.
4986        let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
4987            (
4988                "p_a_strong",
4989                run_a,
4990                "global_user",
4991                "preference",
4992                0.92,
4993                "Prefer rg over grep",
4994            ),
4995            (
4996                "p_a_weak",
4997                run_a,
4998                "repo",
4999                "convention",
5000                0.55,
5001                "Always use let-else",
5002            ),
5003            (
5004                "p_b1",
5005                run_b,
5006                "repo",
5007                "convention",
5008                0.70,
5009                "Use Result not panic",
5010            ),
5011            (
5012                "p_b2",
5013                run_b,
5014                "global_user",
5015                "preference",
5016                0.88,
5017                "Open links in new tab",
5018            ),
5019        ];
5020
5021        {
5022            let (paths, _config, conn) = load_project(&root).expect("load");
5023            for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
5024                let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
5025                let event = Event::new(
5026                    *run_id,
5027                    "memory.proposed",
5028                    serde_json::json!({
5029                        "proposal_id": proposal_id,
5030                        "scope": scope,
5031                        "kind": kind,
5032                        "text": text,
5033                        "rationale": "fixture",
5034                        "proposed_confidence": conf,
5035                        "source_event_ids": [],
5036                    }),
5037                );
5038                writer.append(&event, true).expect("append");
5039                projector::apply_events(&conn, &[event]).expect("project");
5040            }
5041        }
5042
5043        // Step 1: --accept-all --from-run <run_a> --min-confidence 0.8
5044        // mirrors the CLI filter + accept loop.
5045        let to_accept = list_proposals(
5046            &root,
5047            ProposalFilter {
5048                from_run: Some(run_a.to_string()),
5049                min_confidence: Some(0.8),
5050                status: Some("pending".into()),
5051                limit: 100,
5052                ..ProposalFilter::default()
5053            },
5054        )
5055        .expect("list strong from run_a");
5056        assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
5057        assert_eq!(to_accept[0].proposal_id, "p_a_strong");
5058        let memory_id =
5059            accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
5060                .expect("accept p_a_strong");
5061
5062        // Step 2: --reject-all --reason "batch_reject" over the remaining
5063        // pending proposals.
5064        let to_reject = list_proposals(
5065            &root,
5066            ProposalFilter {
5067                status: Some("pending".into()),
5068                limit: 100,
5069                ..ProposalFilter::default()
5070            },
5071        )
5072        .expect("list remaining pending");
5073        assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
5074        for p in &to_reject {
5075            reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
5076        }
5077
5078        // Final state: exactly one memory; exactly three rejected proposals;
5079        // zero pending. Decision reason persisted on each rejected row.
5080        let memories = list_memories(&root).expect("list memories");
5081        assert_eq!(
5082            memories.len(),
5083            1,
5084            "only the accepted proposal becomes a memory"
5085        );
5086        assert_eq!(memories[0].memory_id, memory_id);
5087
5088        let pending = list_proposals(
5089            &root,
5090            ProposalFilter {
5091                status: Some("pending".into()),
5092                limit: 100,
5093                ..ProposalFilter::default()
5094            },
5095        )
5096        .expect("list pending");
5097        assert!(
5098            pending.is_empty(),
5099            "no proposals left pending after batch review"
5100        );
5101
5102        let rejected = list_proposals(
5103            &root,
5104            ProposalFilter {
5105                status: Some("rejected".into()),
5106                limit: 100,
5107                ..ProposalFilter::default()
5108            },
5109        )
5110        .expect("list rejected");
5111        assert_eq!(rejected.len(), 3);
5112        for row in &rejected {
5113            assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
5114        }
5115
5116        fs::remove_dir_all(root).expect("remove temp project");
5117    }
5118
5119    /// End-to-end regression for the add -> list_conflicts ->
5120    /// resolve_conflict plumbing. It must be AGNOSTIC to which
5121    /// embedder backs the build: `cargo test --workspace`
5122    /// feature-unifies `embeddings` into this crate (kimetsu-cli
5123    /// enables `kimetsu-brain/embeddings`), so
5124    /// `open_default_embedder()` returns the real fastembed model
5125    /// here, not the noop. The two memories below are therefore on
5126    /// unrelated topics: cosine stays well under the 0.82 conflict
5127    /// threshold for any real embedder, and the noop build trivially
5128    /// records zero -- so `list_conflicts` is deterministically empty
5129    /// either way.
5130    ///
5131    /// Real near-duplicate semantic detection is exercised
5132    /// exhaustively in `crate::conflict::tests` with a StubEmbedder;
5133    /// this test guards the project-level plumbing only.
5134    #[test]
5135    fn add_memory_distinct_texts_no_conflicts() {
5136        with_user_brain_disabled(|| {
5137            let root = test_root();
5138            fs::create_dir_all(&root).expect("create temp project");
5139            init_project(&root, false).expect("init project");
5140
5141            // Two memories on unrelated topics: neither the noop nor
5142            // a real embedder flags them as conflicting (cosine well
5143            // under the 0.82 threshold), and they don't collide via
5144            // the exact-text dedup gate, so both rows simply coexist.
5145            let _m1 = add_memory(
5146                &root,
5147                MemoryScope::GlobalUser,
5148                MemoryKind::Preference,
5149                "Prefer thiserror for library error types.",
5150            )
5151            .expect("add m1");
5152            let _m2 = add_memory(
5153                &root,
5154                MemoryScope::GlobalUser,
5155                MemoryKind::Preference,
5156                "Cache HTTP responses with a one-hour TTL.",
5157            )
5158            .expect("add m2");
5159
5160            let open = list_conflicts(&root, 50).expect("list_conflicts");
5161            assert!(
5162                open.is_empty(),
5163                "distinct-topic memories must not conflict; got {} rows",
5164                open.len()
5165            );
5166
5167            // Resolving a non-existent id should return false, not error.
5168            let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
5169                .expect("resolve_conflict on unknown id");
5170            assert!(!resolved, "unknown conflict id should resolve to false");
5171
5172            // Invalid resolution strings should be rejected up front.
5173            let err = resolve_conflict(&root, "does-not-exist", "garbage")
5174                .expect_err("invalid resolution should error");
5175            assert!(format!("{err}").contains("invalid conflict resolution"));
5176
5177            fs::remove_dir_all(root).expect("remove temp project");
5178        });
5179    }
5180
5181    /// A1: project.toml load gate is keyed to KIMETSU_CONFIG_VERSION, not
5182    /// KIMETSU_SCHEMA_VERSION. A config with schema_version =
5183    /// KIMETSU_CONFIG_VERSION + 1 must be REJECTED by load_project, proving
5184    /// the gate is active and uses the config constant (not the DB constant).
5185    /// When both constants are 1 this also demonstrates that the value of 1
5186    /// is the correct expected value.
5187    #[test]
5188    fn load_project_rejects_future_config_version() {
5189        with_user_brain_disabled(|| {
5190            use kimetsu_core::KIMETSU_CONFIG_VERSION;
5191            let root = test_root();
5192            fs::create_dir_all(&root).expect("create temp project");
5193            // Write a project.toml with an unsupported (future) config version.
5194            let paths = kimetsu_core::paths::ProjectPaths::discover(&root)
5195                .expect("discover paths after git_init_boundary");
5196            fs::create_dir_all(&paths.kimetsu_dir).expect("create .kimetsu dir");
5197            let bad_version = KIMETSU_CONFIG_VERSION + 1;
5198            let toml_str = format!(
5199                r#"
5200[kimetsu]
5201project_id = "test-config-gate"
5202schema_version = {bad_version}
5203
5204[model]
5205provider = "anthropic"
5206model = "claude-opus-4-7"
5207api_key_env = "ANTHROPIC_API_KEY"
5208max_output_tokens = 8192
5209temperature = 0.2
5210request_timeout_secs = 120
5211
5212[broker]
5213default_budget_tokens = 6000
5214
5215[broker.weights]
5216relevance = 0.5
5217confidence = 0.2
5218freshness = 0.2
5219scope = 0.1
5220
5221[shell]
5222default_timeout_secs = 60
5223max_timeout_secs = 600
5224env_allowlist_extra = []
5225redact_secrets = true
5226
5227[ingestion]
5228max_file_bytes = 524288
5229extra_skip_dirs = []
5230max_total_files = 50000
5231
5232[run]
5233max_total_tool_calls = 60
5234max_total_model_turns = 30
5235max_total_cost_usd = 250.0
5236"#
5237            );
5238            fs::write(&paths.project_toml, &toml_str).expect("write bad project.toml");
5239            let err = load_project(&root).expect_err("future config version must be rejected");
5240            let msg = format!("{err}");
5241            assert!(
5242                msg.contains(&bad_version.to_string()),
5243                "error message should mention the bad version; got: {msg}"
5244            );
5245            assert!(
5246                msg.contains(&KIMETSU_CONFIG_VERSION.to_string()),
5247                "error message should mention the expected version; got: {msg}"
5248            );
5249            fs::remove_dir_all(root).expect("remove temp project");
5250        });
5251    }
5252
5253    // ── D2: abort_run ──────────────────────────────────────────────────────────
5254
5255    /// Helper: create a dangling run (run.started only, no terminal event).
5256    fn make_dangling_run(root: &std::path::Path) -> RunId {
5257        let (paths, _config, conn) = load_project(root).expect("load project");
5258        let run_id = RunId::new();
5259        let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("create trace");
5260        let started = Event::new(
5261            run_id,
5262            "run.started",
5263            serde_json::json!({"project_id": "test", "task": "dangling task"}),
5264        );
5265        writer.append(&started, true).expect("append started");
5266        projector::apply_events(&conn, &[started]).expect("project started");
5267        run_id
5268    }
5269
5270    #[test]
5271    fn abort_run_stamps_aborted_and_frees_lock() {
5272        with_user_brain_disabled(|| {
5273            let root = test_root();
5274            fs::create_dir_all(&root).expect("mkdir");
5275            init_project(&root, false).expect("init");
5276
5277            let run_id = make_dangling_run(&root);
5278
5279            // Abort it.
5280            abort_run(&root, &run_id.to_string()).expect("abort_run");
5281
5282            // The run should now have terminal_kind = "run.aborted".
5283            let run = show_run(&root, &run_id.to_string())
5284                .expect("show_run")
5285                .expect("run exists");
5286            assert_eq!(
5287                run.terminal_kind.as_deref(),
5288                Some("run.aborted"),
5289                "terminal_kind should be run.aborted"
5290            );
5291
5292            // Lock should be absent (clear_force ran).
5293            let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
5294            assert!(
5295                !paths.lock_file.exists(),
5296                "lock file should not exist after abort"
5297            );
5298
5299            fs::remove_dir_all(root).expect("cleanup");
5300        });
5301    }
5302
5303    #[test]
5304    fn abort_run_already_finished_returns_err() {
5305        with_user_brain_disabled(|| {
5306            let root = test_root();
5307            fs::create_dir_all(&root).expect("mkdir");
5308            init_project(&root, false).expect("init");
5309
5310            // Use add_memory which creates a run.started + run.finished.
5311            add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "some fact")
5312                .expect("add memory");
5313
5314            let runs = list_runs(&root).expect("list runs");
5315            assert!(!runs.is_empty(), "should have at least one run");
5316            let finished_run = runs
5317                .iter()
5318                .find(|r| r.terminal_kind.is_some())
5319                .expect("should have a finished run");
5320
5321            let err = abort_run(&root, &finished_run.run_id)
5322                .expect_err("aborting a finished run should error");
5323            let msg = format!("{err}");
5324            assert!(
5325                msg.contains("already terminal"),
5326                "error should mention 'already terminal', got: {msg}"
5327            );
5328
5329            fs::remove_dir_all(root).expect("cleanup");
5330        });
5331    }
5332
5333    #[test]
5334    fn abort_run_unknown_id_returns_err() {
5335        with_user_brain_disabled(|| {
5336            let root = test_root();
5337            fs::create_dir_all(&root).expect("mkdir");
5338            init_project(&root, false).expect("init");
5339
5340            let fake_id = RunId::new().to_string();
5341            let err = abort_run(&root, &fake_id).expect_err("aborting an unknown run should error");
5342            let msg = format!("{err}");
5343            assert!(
5344                msg.contains("unknown run_id"),
5345                "error should mention 'unknown run_id', got: {msg}"
5346            );
5347
5348            fs::remove_dir_all(root).expect("cleanup");
5349        });
5350    }
5351
5352    // ── W1.3 tests ────────────────────────────────────────────────────────────
5353
5354    /// W1.3 normal path: add memories (events land in DB), wipe the derived
5355    /// tables, call rebuild_projection(false) — it replays the events table
5356    /// in-place and restores the memories without touching the events rows.
5357    #[test]
5358    fn rebuild_from_events_table_restores_memories() {
5359        with_user_brain_disabled(|| {
5360            let root = test_root();
5361            fs::create_dir_all(&root).expect("create temp project");
5362            init_project(&root, false).expect("init project");
5363
5364            let id1 = add_memory(
5365                &root,
5366                MemoryScope::Repo,
5367                MemoryKind::Convention,
5368                "W1.3: prefer explicit error types over anyhow in library crates",
5369            )
5370            .expect("add memory 1");
5371            let id2 = add_memory(
5372                &root,
5373                MemoryScope::Repo,
5374                MemoryKind::Command,
5375                "W1.3: run cargo fmt --all before committing",
5376            )
5377            .expect("add memory 2");
5378
5379            // Wipe the derived tables — events table stays intact.
5380            {
5381                let (_paths, _config, conn) = load_project(&root).expect("load");
5382                conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
5383                    .expect("wipe derived tables");
5384            }
5385
5386            // Sanity: memories are gone.
5387            let gone = list_memories(&root).expect("list after wipe");
5388            assert_eq!(gone.len(), 0, "derived tables should be empty after wipe");
5389
5390            // Rebuild from the events table (normal path, from_traces = false).
5391            let count = rebuild_projection(&root, false).expect("rebuild_projection");
5392            assert!(
5393                count > 0,
5394                "should have replayed at least one event; got {count}"
5395            );
5396
5397            // Both memories must be restored.
5398            let restored = list_memories(&root).expect("list after rebuild");
5399            assert_eq!(
5400                restored.len(),
5401                2,
5402                "both memories should be restored after rebuild; got {:?}",
5403                restored.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
5404            );
5405            let ids: Vec<_> = restored.iter().map(|m| m.memory_id.clone()).collect();
5406            assert!(ids.contains(&id1), "id1 must be restored");
5407            assert!(ids.contains(&id2), "id2 must be restored");
5408
5409            fs::remove_dir_all(root).expect("cleanup");
5410        });
5411    }
5412
5413    /// W1.3 --from-traces path: manually write a trace.jsonl on disk (simulating
5414    /// a legacy run that pre-dates W1.4, when memory ops did write trace files),
5415    /// wipe the events table and derived tables, then call rebuild_projection(true)
5416    /// — it must re-import from the on-disk trace file and restore the memory.
5417    ///
5418    /// W1.4 note: add_memory no longer writes trace files, so this test creates
5419    /// the trace file directly via TraceWriter (the same way agent runs still do).
5420    /// This keeps the --from-traces code-path exercised for genuine legacy traces.
5421    #[test]
5422    fn rebuild_from_traces_flag_reimports_on_disk_traces() {
5423        with_user_brain_disabled(|| {
5424            let root = test_root();
5425            fs::create_dir_all(&root).expect("create temp project");
5426            init_project(&root, false).expect("init project");
5427
5428            // Build a legacy trace.jsonl directly — simulates what add_memory
5429            // wrote before W1.4. This keeps --from-traces coverage alive for
5430            // genuine legacy brain directories that still have trace files.
5431            let memory_id = Ulid::new().to_string();
5432            let run_id = RunId::new();
5433            {
5434                let (paths, config, conn) = load_project(&root).expect("load");
5435                let (mut writer, _run_paths) =
5436                    TraceWriter::create(&paths, run_id).expect("trace writer");
5437                let text = "W1.3: from_traces re-imports events from trace.jsonl files";
5438                let normalized = kimetsu_core::memory::normalize_memory_text(text);
5439                let evs: Vec<Event> = vec![
5440                    admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
5441                    Event::new(
5442                        run_id,
5443                        "memory.accepted",
5444                        serde_json::json!({
5445                            "proposal_id": null,
5446                            "memory_id": memory_id,
5447                            "scope": "repo",
5448                            "kind": "fact",
5449                            "text": text,
5450                            "normalized_text": normalized,
5451                            "confidence": 1.0,
5452                            "provenance_snapshot": {
5453                                "source": "manual_cli",
5454                                "run_id": run_id.to_string(),
5455                                "text": text,
5456                            }
5457                        }),
5458                    ),
5459                    admin_finished_event(run_id),
5460                ];
5461                for ev in &evs {
5462                    writer.append(ev, true).expect("append");
5463                }
5464                // Also persist to events table so the memory shows up now.
5465                projector::apply_events(&conn, &evs).expect("apply");
5466            }
5467
5468            // Confirm memory is present.
5469            let initial = list_memories(&root).expect("list initial");
5470            assert_eq!(initial.len(), 1);
5471
5472            // Wipe both events table AND derived tables to simulate a fully
5473            // blank DB that still has trace.jsonl files on disk.
5474            {
5475                let (_paths, _config, conn) = load_project(&root).expect("load");
5476                conn.execute_batch(
5477                    "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
5478                )
5479                .expect("wipe events + derived tables");
5480            }
5481
5482            // rebuild_projection with from_traces = true must re-import.
5483            let count = rebuild_projection(&root, true).expect("rebuild_projection --from-traces");
5484            assert!(
5485                count > 0,
5486                "should have imported ≥1 event from on-disk traces; got {count}"
5487            );
5488
5489            let restored = list_memories(&root).expect("list after trace import");
5490            assert_eq!(
5491                restored.len(),
5492                1,
5493                "memory must be restored from on-disk traces"
5494            );
5495            assert_eq!(restored[0].memory_id, memory_id);
5496
5497            fs::remove_dir_all(root).expect("cleanup");
5498        });
5499    }
5500
5501    /// W1.3 auto-fallback: manually write a trace.jsonl (simulating a legacy run),
5502    /// wipe the events table and derived tables to simulate a pre-W1.1 state, then
5503    /// call rebuild_projection(false). The auto-fallback detects the empty events
5504    /// table, finds the on-disk traces, and imports them automatically.
5505    ///
5506    /// W1.4 note: add_memory no longer writes trace files, so the trace is created
5507    /// directly via TraceWriter — the same pattern a real legacy brain would have.
5508    #[test]
5509    fn rebuild_auto_fallback_imports_traces_when_events_table_empty() {
5510        with_user_brain_disabled(|| {
5511            let root = test_root();
5512            fs::create_dir_all(&root).expect("create temp project");
5513            init_project(&root, false).expect("init project");
5514
5515            // Write a legacy trace.jsonl directly to simulate a pre-W1.4 brain.
5516            let memory_id = Ulid::new().to_string();
5517            let run_id = RunId::new();
5518            {
5519                let (paths, config, conn) = load_project(&root).expect("load");
5520                let (mut writer, _run_paths) =
5521                    TraceWriter::create(&paths, run_id).expect("trace writer");
5522                let text = "W1.3: auto-fallback recovers from pre-W1.1 events wipe";
5523                let normalized = kimetsu_core::memory::normalize_memory_text(text);
5524                let evs: Vec<Event> = vec![
5525                    admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
5526                    Event::new(
5527                        run_id,
5528                        "memory.accepted",
5529                        serde_json::json!({
5530                            "proposal_id": null,
5531                            "memory_id": memory_id,
5532                            "scope": "repo",
5533                            "kind": "convention",
5534                            "text": text,
5535                            "normalized_text": normalized,
5536                            "confidence": 1.0,
5537                            "provenance_snapshot": {
5538                                "source": "manual_cli",
5539                                "run_id": run_id.to_string(),
5540                                "text": text,
5541                            }
5542                        }),
5543                    ),
5544                    admin_finished_event(run_id),
5545                ];
5546                for ev in &evs {
5547                    writer.append(ev, true).expect("append");
5548                }
5549                // Persist to events table (simulates a post-W1.1 add, pre-W1.4).
5550                projector::apply_events(&conn, &evs).expect("apply");
5551            }
5552
5553            // Simulate a pre-W1.1 rebuild that wiped the events table.
5554            // Leave the trace.jsonl files intact.
5555            {
5556                let (_paths, _config, conn) = load_project(&root).expect("load");
5557                conn.execute_batch(
5558                    "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
5559                )
5560                .expect("simulate pre-W1.1 wipe");
5561            }
5562
5563            // Call rebuild with from_traces = false; the auto-fallback should
5564            // detect the empty events table and import from traces.
5565            let count = rebuild_projection(&root, false).expect("rebuild_projection auto-fallback");
5566            assert!(
5567                count > 0,
5568                "auto-fallback should have imported ≥1 event from traces; got {count}"
5569            );
5570
5571            let restored = list_memories(&root).expect("list after auto-fallback");
5572            assert_eq!(
5573                restored.len(),
5574                1,
5575                "auto-fallback must restore memory from traces when events table was empty"
5576            );
5577            assert_eq!(restored[0].memory_id, memory_id);
5578
5579            fs::remove_dir_all(root).expect("cleanup");
5580        });
5581    }
5582
5583    // ── W1.4 tests ────────────────────────────────────────────────────────────
5584
5585    /// Helper: count subdirectories of `runs_dir` (each subdir is a run dir).
5586    fn run_subdir_count(runs_dir: &std::path::Path) -> usize {
5587        if !runs_dir.exists() {
5588            return 0;
5589        }
5590        fs::read_dir(runs_dir)
5591            .map(|rd| {
5592                rd.filter_map(|e| e.ok())
5593                    .filter(|e| e.path().is_dir())
5594                    .count()
5595            })
5596            .unwrap_or(0)
5597    }
5598
5599    /// W1.4: add_memory creates no on-disk run dir, but the memory is present
5600    /// and the runs TABLE row exists (so blame still works).
5601    #[test]
5602    fn w1_4_add_memory_creates_no_run_dir_but_memory_and_runs_row_exist() {
5603        with_user_brain_disabled(|| {
5604            let root = test_root();
5605            fs::create_dir_all(&root).expect("create temp project");
5606            init_project(&root, false).expect("init project");
5607
5608            // Derive runs_dir without holding a connection open across the test.
5609            let runs_dir = {
5610                let paths =
5611                    kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
5612                paths.runs_dir.clone()
5613            };
5614            let before = run_subdir_count(&runs_dir);
5615
5616            let memory_id = add_memory(
5617                &root,
5618                MemoryScope::Project,
5619                MemoryKind::Fact,
5620                "W1.4: no run dir should be created for memory writes",
5621            )
5622            .expect("add memory");
5623
5624            // (a) No new run subdir on disk.
5625            let after = run_subdir_count(&runs_dir);
5626            assert_eq!(
5627                after, before,
5628                "add_memory must not create a runs/<id>/ directory (before={before}, after={after})"
5629            );
5630
5631            // (b) Memory is listed.
5632            let memories = list_memories(&root).expect("list");
5633            assert!(
5634                memories.iter().any(|m| m.memory_id == memory_id),
5635                "memory must be present after add_memory"
5636            );
5637
5638            // (c) The runs TABLE row exists (projector created it from run.started).
5639            let runs_count: i64 = {
5640                let (_paths, _config, conn) = load_project(&root).expect("load for runs check");
5641                conn.query_row("SELECT COUNT(*) FROM runs", [], |r| r.get(0))
5642                    .expect("count runs")
5643            };
5644            assert!(
5645                runs_count >= 1,
5646                "projector must have inserted a runs row from the run.started event (got {runs_count})"
5647            );
5648
5649            fs::remove_dir_all(root).expect("cleanup");
5650        });
5651    }
5652
5653    /// W1.4: memory survives rebuild_projection(false) without any trace file
5654    /// — proving events landed in the durable table.
5655    #[test]
5656    fn w1_4_memory_survives_rebuild_from_events_table_no_trace() {
5657        with_user_brain_disabled(|| {
5658            let root = test_root();
5659            fs::create_dir_all(&root).expect("create temp project");
5660            init_project(&root, false).expect("init project");
5661
5662            let memory_id = add_memory(
5663                &root,
5664                MemoryScope::Repo,
5665                MemoryKind::Convention,
5666                "W1.4: events are durable without a trace file",
5667            )
5668            .expect("add memory");
5669
5670            // Wipe derived tables (leave events table).
5671            {
5672                let (_paths, _config, conn) = load_project(&root).expect("load");
5673                conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
5674                    .expect("wipe derived tables");
5675            }
5676
5677            // rebuild_projection(false) uses the events table — no trace files needed.
5678            let count = rebuild_projection(&root, false).expect("rebuild");
5679            assert!(count > 0, "should have replayed events; got {count}");
5680
5681            let restored = list_memories(&root).expect("list after rebuild");
5682            assert!(
5683                restored.iter().any(|m| m.memory_id == memory_id),
5684                "memory must survive rebuild from events table without a trace file"
5685            );
5686
5687            fs::remove_dir_all(root).expect("cleanup");
5688        });
5689    }
5690
5691    /// W1.4: propose_memory, ingest_repo, invalidate_memory, and reject_proposal
5692    /// likewise create no new on-disk run subdirectory.
5693    #[test]
5694    fn w1_4_memory_ops_create_no_run_dirs() {
5695        with_user_brain_disabled(|| {
5696            let root = test_root();
5697            // ingest_repo needs a real git repo with at least one file.
5698            fs::write(
5699                root.join("Cargo.toml"),
5700                "[package]\nname = \"w14-fixture\"\nversion = \"0.1.0\"\n",
5701            )
5702            .expect("write Cargo.toml");
5703            init_project(&root, false).expect("init project");
5704
5705            // Derive runs_dir without holding a connection open across the test.
5706            let runs_dir = {
5707                let paths =
5708                    kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
5709                paths.runs_dir.clone()
5710            };
5711
5712            // propose_memory — creates no dir.
5713            let before = run_subdir_count(&runs_dir);
5714            let proposal_id = propose_memory(
5715                &root,
5716                MemoryScope::Project,
5717                MemoryKind::Fact,
5718                "W1.4 propose: no run dir",
5719                0.5,
5720                "test rationale",
5721            )
5722            .expect("propose");
5723            assert_eq!(
5724                run_subdir_count(&runs_dir),
5725                before,
5726                "propose_memory must not create a run dir"
5727            );
5728
5729            // ingest_repo — creates no dir.
5730            let before = run_subdir_count(&runs_dir);
5731            ingest_repo(&root).expect("ingest");
5732            assert_eq!(
5733                run_subdir_count(&runs_dir),
5734                before,
5735                "ingest_repo must not create a run dir"
5736            );
5737
5738            // reject_proposal — creates no dir.
5739            let before = run_subdir_count(&runs_dir);
5740            reject_proposal(&root, &proposal_id, Some("W1.4 test")).expect("reject");
5741            assert_eq!(
5742                run_subdir_count(&runs_dir),
5743                before,
5744                "reject_proposal must not create a run dir"
5745            );
5746
5747            // invalidate_memory: add a real memory first, then invalidate it.
5748            let mem_id = add_memory(
5749                &root,
5750                MemoryScope::Project,
5751                MemoryKind::Command,
5752                "W1.4 invalidate: no run dir",
5753            )
5754            .expect("add");
5755            let before = run_subdir_count(&runs_dir);
5756            invalidate_memory(&root, &mem_id, Some("W1.4 test")).expect("invalidate");
5757            assert_eq!(
5758                run_subdir_count(&runs_dir),
5759                before,
5760                "invalidate_memory must not create a run dir"
5761            );
5762
5763            fs::remove_dir_all(root).expect("cleanup");
5764        });
5765    }
5766
5767    /// W1.4: dedup hit (second identical add_memory call) creates no orphan run dir.
5768    #[test]
5769    fn w1_4_dedup_hit_creates_no_orphan_run_dir() {
5770        with_user_brain_disabled(|| {
5771            let root = test_root();
5772            fs::create_dir_all(&root).expect("create temp project");
5773            init_project(&root, false).expect("init project");
5774
5775            // Derive runs_dir without holding a connection open across the test.
5776            let runs_dir = {
5777                let paths =
5778                    kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
5779                paths.runs_dir.clone()
5780            };
5781
5782            // First call: accepted.
5783            let id1 = add_memory(
5784                &root,
5785                MemoryScope::Project,
5786                MemoryKind::Fact,
5787                "W1.4 dedup: identical text",
5788            )
5789            .expect("first add");
5790
5791            // Both calls produce 0 run dirs total (first also creates none).
5792            let after_first = run_subdir_count(&runs_dir);
5793            assert_eq!(after_first, 0, "first add must create no run dir");
5794
5795            // Second call: dedup hit, returns the same id immediately.
5796            let id2 = add_memory(
5797                &root,
5798                MemoryScope::Project,
5799                MemoryKind::Fact,
5800                "W1.4 dedup: identical text",
5801            )
5802            .expect("second add");
5803
5804            assert_eq!(id1, id2, "dedup must return the same memory_id");
5805            let after_second = run_subdir_count(&runs_dir);
5806            assert_eq!(
5807                after_second, 0,
5808                "dedup hit must not create an orphan run dir"
5809            );
5810
5811            fs::remove_dir_all(root).expect("cleanup");
5812        });
5813    }
5814
5815    // ── W3.1: runtime wiring tests ─────────────────────────────────────────
5816
5817    /// W3.1: `open_embedder_for(false)` always returns a noop; `open_embedder_for(true)`
5818    /// matches `open_default_embedder().is_noop()`. Validates the resolver logic
5819    /// independently of disk I/O.
5820    #[test]
5821    fn w3_1_open_embedder_for_resolver() {
5822        use crate::embeddings;
5823        use crate::user_brain::test_env_lock;
5824
5825        let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
5826        let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
5827        // Ensure env is unset so config governs.
5828        unsafe {
5829            std::env::remove_var("KIMETSU_BRAIN_EMBEDDER");
5830        }
5831
5832        // config=false → always noop.
5833        assert!(
5834            embeddings::open_embedder_for(false).is_noop(),
5835            "open_embedder_for(false) must return a noop embedder"
5836        );
5837        // config=true → same as open_default_embedder (noop on lean, real on embeddings build).
5838        assert_eq!(
5839            embeddings::open_embedder_for(true).is_noop(),
5840            embeddings::open_default_embedder().is_noop(),
5841            "open_embedder_for(true) must match open_default_embedder().is_noop()"
5842        );
5843
5844        // Env disable overrides config=true.
5845        unsafe {
5846            std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
5847        }
5848        assert!(
5849            embeddings::open_embedder_for(true).is_noop(),
5850            "KIMETSU_BRAIN_EMBEDDER=noop must override config=true → noop"
5851        );
5852
5853        // Restore.
5854        unsafe {
5855            match prev {
5856                Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
5857                None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
5858            }
5859        }
5860    }
5861
5862    /// W3.1: `[embedder] enabled = false` in project.toml must result in
5863    /// NULL embedding column after `add_memory`.
5864    #[test]
5865    fn w3_1_config_disabled_writes_null_embedding() {
5866        with_user_brain_disabled(|| {
5867            let root = test_root();
5868            init_project(&root, false).expect("init");
5869
5870            // Flip embedder.enabled to false in project.toml.
5871            let (paths, mut config, _conn) = load_project(&root).expect("load");
5872            config.embedder.enabled = false;
5873            let toml = config.to_toml().expect("serialize");
5874            // Drop _conn before writing toml to release any WAL lock.
5875            drop(_conn);
5876            fs::write(&paths.project_toml, toml).expect("write project.toml");
5877
5878            let memory_id = add_memory(
5879                &root,
5880                MemoryScope::Project,
5881                MemoryKind::Fact,
5882                "w3.1 write-disabled: embedder disabled via config",
5883            )
5884            .expect("add memory");
5885
5886            // Assert the embedding column is NULL — no vector was written.
5887            let embedding: Option<Vec<u8>> = {
5888                let (_, _, conn) = load_project_readonly(&root).expect("reload");
5889                let val = conn
5890                    .query_row(
5891                        "SELECT embedding FROM memories WHERE memory_id = ?1",
5892                        rusqlite::params![memory_id],
5893                        |row| row.get(0),
5894                    )
5895                    .expect("query embedding");
5896                drop(conn);
5897                val
5898            };
5899            assert!(
5900                embedding.is_none(),
5901                "embedding must be NULL when [embedder] enabled = false"
5902            );
5903
5904            fs::remove_dir_all(root).ok(); // best-effort on Windows
5905        });
5906    }
5907
5908    /// W3.1: `[embedder] enabled = true` (default) does not regress —
5909    /// on the lean build (no `embeddings` feature) the column is still
5910    /// NULL (NoopEmbedder); on the embeddings build it would be non-NULL.
5911    /// This test stays build-agnostic: it just asserts `open_embedder_for(true)`
5912    /// matches the default embedder's noop status.
5913    #[test]
5914    fn w3_1_config_enabled_default_does_not_regress() {
5915        use crate::embeddings;
5916        // The lean build returns noop for both paths; embeddings build
5917        // returns a real embedder for both. Either way they must match.
5918        let e_default = embeddings::open_default_embedder();
5919        let e_config = embeddings::open_embedder_for(true);
5920        assert_eq!(
5921            e_default.is_noop(),
5922            e_config.is_noop(),
5923            "open_embedder_for(true) and open_default_embedder() must have identical noop status"
5924        );
5925    }
5926
5927    /// W3.1: retrieval with `[embedder] enabled = false` still returns
5928    /// FTS matches and does not panic (FTS-only path is taken).
5929    #[test]
5930    fn w3_1_retrieval_fts_only_when_embedder_disabled() {
5931        with_user_brain_disabled(|| {
5932            let root = test_root();
5933            init_project(&root, false).expect("init");
5934
5935            // Write a memory with default config (embedder enabled=true on this call).
5936            add_memory(
5937                &root,
5938                MemoryScope::Project,
5939                MemoryKind::Fact,
5940                "the quick brown fox jumps over the lazy dog",
5941            )
5942            .expect("add memory");
5943
5944            // Now disable the embedder in config.
5945            let (paths, mut config, _) = load_project(&root).expect("load");
5946            config.embedder.enabled = false;
5947            let toml = config.to_toml().expect("serialize");
5948            fs::write(&paths.project_toml, toml).expect("write project.toml");
5949
5950            // Retrieval must return something (FTS still works) and must not panic.
5951            // Wrap in a block so the session (and its Connection) drops before cleanup.
5952            {
5953                let session = BrainSession::open_readonly(&root).expect("open readonly");
5954                let bundle = session
5955                    .retrieve_context_with_request(crate::context::ContextRequest {
5956                        stage: "localization".to_string(),
5957                        query: "fox jumps".to_string(),
5958                        budget_tokens: 4096,
5959                        ..Default::default()
5960                    })
5961                    .expect("retrieve");
5962                // FTS should have returned the memory we added.
5963                // Even if the FTS index is empty (no tokens match), it must not error.
5964                // The memory text "fox jumps" overlaps with the query — FTS should hit it.
5965                let _ = bundle; // just assert no panic / error
5966            }
5967
5968            fs::remove_dir_all(root).ok(); // best-effort on Windows
5969        });
5970    }
5971
5972    /// v1.0.0: the `UserPromptSubmit` context-hook runs in a throwaway
5973    /// per-prompt process, so it must NOT load the semantic embedding
5974    /// model (cold ONNX load can blow the host's 30s hook timeout).
5975    /// `retrieve_context_lexical` pins the NoopEmbedder so the hook stays
5976    /// FTS-only and fast regardless of build flavor or `[embedder] enabled`.
5977    /// This test proves the lexical path still returns FTS matches.
5978    #[test]
5979    fn retrieve_context_lexical_returns_fts_hits_without_embedder() {
5980        with_user_brain_disabled(|| {
5981            let root = test_root();
5982            init_project(&root, false).expect("init");
5983
5984            let memory_id = add_memory(
5985                &root,
5986                MemoryScope::Project,
5987                MemoryKind::Convention,
5988                "Run zylophonecheck before finalizing the deployment pipeline.",
5989            )
5990            .expect("add memory");
5991
5992            {
5993                let session = BrainSession::open_readonly(&root).expect("open readonly");
5994                let bundle = session
5995                    .retrieve_context_lexical(crate::context::ContextRequest {
5996                        stage: "localization".to_string(),
5997                        query: "zylophonecheck deployment pipeline".to_string(),
5998                        budget_tokens: 4096,
5999                        ..Default::default()
6000                    })
6001                    .expect("retrieve lexical");
6002
6003                assert!(
6004                    bundle
6005                        .capsules
6006                        .iter()
6007                        .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
6008                    "FTS-only lexical retrieval must surface the seeded memory; \
6009                     got handles: {:?}",
6010                    bundle
6011                        .capsules
6012                        .iter()
6013                        .map(|c| &c.expansion_handle)
6014                        .collect::<Vec<_>>()
6015                );
6016            }
6017
6018            fs::remove_dir_all(root).ok(); // best-effort on Windows
6019        });
6020    }
6021
6022    /// v1.0.0: `retrieve_context_with_injected_embedder` must honour the
6023    /// caller-supplied embedder and still surface FTS matches (NoopEmbedder
6024    /// path). This is the API the warm embedder daemon will call so it can
6025    /// reuse a long-lived embedding model across requests.
6026    #[test]
6027    fn retrieve_with_injected_embedder_returns_fts_hits() {
6028        with_user_brain_disabled(|| {
6029            let root = test_root();
6030            init_project(&root, false).expect("init");
6031
6032            let memory_id = add_memory(
6033                &root,
6034                MemoryScope::Project,
6035                MemoryKind::Fact,
6036                "the distiller harvests lessons at session end",
6037            )
6038            .expect("add");
6039
6040            {
6041                let session = BrainSession::open_readonly(&root).expect("open ro");
6042                let bundle = session
6043                    .retrieve_context_with_injected_embedder(
6044                        crate::context::ContextRequest {
6045                            stage: "localization".to_string(),
6046                            query: "how does the distiller work".to_string(),
6047                            budget_tokens: 2000,
6048                            ..Default::default()
6049                        },
6050                        &crate::embeddings::NoopEmbedder,
6051                    )
6052                    .expect("retrieve");
6053                assert!(
6054                    bundle
6055                        .capsules
6056                        .iter()
6057                        .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
6058                    "FTS path via injected embedder must surface the memory; \
6059                     got handles: {:?}",
6060                    bundle
6061                        .capsules
6062                        .iter()
6063                        .map(|c| &c.expansion_handle)
6064                        .collect::<Vec<_>>()
6065                );
6066            }
6067
6068            fs::remove_dir_all(root).ok(); // best-effort on Windows
6069        });
6070    }
6071
6072    // ── P0 regression tests: GlobalUser add_memory must not require a project ─
6073
6074    /// Helper: run `f` with the user brain pointed at `dir`, under the
6075    /// process-wide env lock. Restores env when done and returns `f`'s value.
6076    fn with_user_brain_at_p0<R>(dir: &std::path::Path, f: impl FnOnce() -> R) -> R {
6077        use crate::user_brain::test_env_lock;
6078        let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
6079        let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
6080        let prev_en = std::env::var("KIMETSU_USER_BRAIN").ok();
6081        // SAFETY: scoped by the shared mutex.
6082        unsafe {
6083            std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
6084            std::env::remove_var("KIMETSU_USER_BRAIN");
6085        }
6086        let out = f();
6087        unsafe {
6088            match prev_dir {
6089                Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
6090                None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
6091            }
6092            match prev_en {
6093                Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
6094                None => std::env::remove_var("KIMETSU_USER_BRAIN"),
6095            }
6096        }
6097        out
6098    }
6099
6100    /// P0 regression: `add_memory` with `scope = GlobalUser` from a NON-project
6101    /// temp dir (no `.kimetsu/project.toml`) must succeed and land in the user
6102    /// brain. This is the exact scenario the global distiller hits.
6103    #[test]
6104    fn p0_global_user_add_memory_works_from_non_project_dir() {
6105        use crate::user_brain::{list_user_memories, open_user_brain_readonly};
6106
6107        let user_brain_dir =
6108            std::env::temp_dir().join(format!("kimetsu-p0-ubrain-{}", Ulid::new()));
6109        fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
6110
6111        // `start` is a plain temp dir — NOT a kimetsu project.
6112        let non_project_dir =
6113            std::env::temp_dir().join(format!("kimetsu-p0-nonproj-{}", Ulid::new()));
6114        fs::create_dir_all(&non_project_dir).expect("create non-project dir");
6115
6116        with_user_brain_at_p0(&user_brain_dir, || {
6117            add_memory(
6118                &non_project_dir,
6119                MemoryScope::GlobalUser,
6120                MemoryKind::Fact,
6121                "P0 regression: GlobalUser write from non-project dir",
6122            )
6123            .expect("P0: add_memory(GlobalUser) from a non-project dir must succeed");
6124
6125            // Verify the memory landed in the user brain.
6126            let conn = open_user_brain_readonly()
6127                .expect("open ok")
6128                .expect("user brain must exist after write");
6129            let mems = list_user_memories(&conn).expect("list");
6130            assert!(
6131                mems.iter().any(|m| m
6132                    .text
6133                    .contains("P0 regression: GlobalUser write from non-project dir")),
6134                "P0: the GlobalUser memory must land in the user brain"
6135            );
6136        });
6137
6138        fs::remove_dir_all(&non_project_dir).ok();
6139        fs::remove_dir_all(&user_brain_dir).ok();
6140    }
6141
6142    /// W3.3 toggle preserved: when `start` IS a project with
6143    /// `[kimetsu] use_user_brain = false`, a GlobalUser `add_memory`
6144    /// must NOT write to the user brain (falls through to project DB).
6145    #[test]
6146    fn p0_global_user_honors_use_user_brain_false_when_start_is_project() {
6147        use crate::user_brain::{list_user_memories, open_user_brain_readonly};
6148
6149        // User brain dir: a dedicated temp location so we can assert nothing was written.
6150        let user_brain_dir =
6151            std::env::temp_dir().join(format!("kimetsu-p0-w3-ubrain-{}", Ulid::new()));
6152        fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
6153
6154        // Create a real kimetsu project.
6155        let root = test_root();
6156        init_project(&root, false).expect("init project");
6157
6158        // Flip use_user_brain = false.
6159        {
6160            let (paths, mut config, _) = load_project(&root).expect("load project");
6161            config.kimetsu.use_user_brain = false;
6162            let toml = config.to_toml().expect("serialize");
6163            fs::write(&paths.project_toml, toml).expect("write project.toml");
6164        }
6165
6166        let mem_id = with_user_brain_at_p0(&user_brain_dir, || {
6167            // Write GlobalUser memory — user brain disabled by config → falls through
6168            // to project DB.
6169            let id = add_memory(
6170                &root,
6171                MemoryScope::GlobalUser,
6172                MemoryKind::Fact,
6173                "W3.3 toggle: this must stay in the project DB",
6174            )
6175            .expect("add_memory must succeed (falls through to project DB)");
6176
6177            // Assert user brain was NOT written to within the same env scope.
6178            let user_conn_opt = open_user_brain_readonly().expect("open ok");
6179            let user_mems_count = user_conn_opt
6180                .map(|c| list_user_memories(&c).unwrap_or_default().len())
6181                .unwrap_or(0);
6182            assert_eq!(
6183                user_mems_count, 0,
6184                "W3.3 toggle: user brain must be empty when use_user_brain=false"
6185            );
6186            id
6187        });
6188
6189        // Assert 1: memory is in the PROJECT db.
6190        let project_mems = list_memories(&root).expect("list project memories");
6191        assert!(
6192            project_mems.iter().any(|m| m.memory_id == mem_id),
6193            "W3.3 toggle: memory must be in the project DB when use_user_brain=false"
6194        );
6195
6196        fs::remove_dir_all(&root).ok();
6197        fs::remove_dir_all(&user_brain_dir).ok();
6198    }
6199
6200    // ── Q5: export / import tests ─────────────────────────────────────────────
6201
6202    /// Round-trip: add memories to project A, export, parse JSON, import into
6203    /// project B → `list_memories` on B contains all the texts.
6204    #[test]
6205    fn export_import_round_trip() {
6206        with_user_brain_disabled(|| {
6207            // --- project A: seed memories --------------------------------
6208            let root_a = test_root();
6209            init_project(&root_a, false).expect("init A");
6210            add_memory(
6211                &root_a,
6212                MemoryScope::Project,
6213                MemoryKind::Fact,
6214                "alpha fact",
6215            )
6216            .expect("add fact");
6217            add_memory(
6218                &root_a,
6219                MemoryScope::Project,
6220                MemoryKind::Convention,
6221                "beta convention",
6222            )
6223            .expect("add conv");
6224            add_memory(
6225                &root_a,
6226                MemoryScope::Project,
6227                MemoryKind::FailurePattern,
6228                "gamma failure",
6229            )
6230            .expect("add fp");
6231
6232            // Export
6233            let (exported, _scrub) =
6234                export_memories(&root_a, None, None, false, false).expect("export");
6235            assert_eq!(exported.len(), 3, "must export all 3 active memories");
6236
6237            // All fields present
6238            for e in &exported {
6239                assert!(!e.text.is_empty());
6240                assert!(!e.scope.is_empty());
6241                assert!(!e.kind.is_empty());
6242            }
6243
6244            // Serialize → parse (tests the JSON round-trip)
6245            let json = serde_json::to_string_pretty(&exported).expect("serialize");
6246            let parsed: Vec<MemoryExport> = serde_json::from_str(&json).expect("deserialize");
6247            assert_eq!(parsed.len(), 3);
6248
6249            // --- project B: import and verify ----------------------------
6250            let root_b = test_root();
6251            init_project(&root_b, false).expect("init B");
6252
6253            let summary = import_memories(&root_b, &parsed, None).expect("import");
6254            assert_eq!(
6255                summary.imported, 3,
6256                "all 3 must be imported into the empty project B"
6257            );
6258            assert_eq!(summary.deduped, 0, "no duplicates expected on first import");
6259
6260            let mems_b = list_memories(&root_b).expect("list B");
6261            let texts_b: Vec<&str> = mems_b.iter().map(|m| m.text.as_str()).collect();
6262            assert!(
6263                texts_b.contains(&"alpha fact"),
6264                "alpha fact missing from B: {texts_b:?}"
6265            );
6266            assert!(
6267                texts_b.contains(&"beta convention"),
6268                "beta convention missing from B: {texts_b:?}"
6269            );
6270            assert!(
6271                texts_b.contains(&"gamma failure"),
6272                "gamma failure missing from B: {texts_b:?}"
6273            );
6274
6275            fs::remove_dir_all(&root_a).ok();
6276            fs::remove_dir_all(&root_b).ok();
6277        });
6278    }
6279
6280    /// Filter: `export_memories(Some(Project), Some(FailurePattern))` returns
6281    /// only memories matching both the scope AND the kind filter.
6282    #[test]
6283    fn export_scope_kind_filter() {
6284        with_user_brain_disabled(|| {
6285            let root = test_root();
6286            init_project(&root, false).expect("init");
6287            add_memory(
6288                &root,
6289                MemoryScope::Project,
6290                MemoryKind::FailurePattern,
6291                "fp1",
6292            )
6293            .expect("add fp1");
6294            add_memory(
6295                &root,
6296                MemoryScope::Project,
6297                MemoryKind::FailurePattern,
6298                "fp2",
6299            )
6300            .expect("add fp2");
6301            add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact1").expect("add fact");
6302            add_memory(
6303                &root,
6304                MemoryScope::Repo,
6305                MemoryKind::FailurePattern,
6306                "repo-fp",
6307            )
6308            .expect("add repo-fp");
6309
6310            // Filter: project scope + failure_pattern kind
6311            let (filtered, _) = export_memories(
6312                &root,
6313                Some(MemoryScope::Project),
6314                Some(MemoryKind::FailurePattern),
6315                false,
6316                false,
6317            )
6318            .expect("export filtered");
6319            assert_eq!(
6320                filtered.len(),
6321                2,
6322                "must return only the 2 project-scope failure_patterns, got: {filtered:?}"
6323            );
6324            assert!(filtered.iter().all(|e| e.scope == "project"));
6325            assert!(filtered.iter().all(|e| e.kind == "failure_pattern"));
6326
6327            // Scope-only filter: all project memories
6328            let (scope_only, _) =
6329                export_memories(&root, Some(MemoryScope::Project), None, false, false)
6330                    .expect("scope filter");
6331            assert_eq!(scope_only.len(), 3, "3 project-scope memories total");
6332
6333            // Kind-only filter: all failure_patterns (project + repo)
6334            let (kind_only, _) =
6335                export_memories(&root, None, Some(MemoryKind::FailurePattern), false, false)
6336                    .expect("kind filter");
6337            assert_eq!(
6338                kind_only.len(),
6339                3,
6340                "3 failure_patterns total (2 project + 1 repo)"
6341            );
6342
6343            fs::remove_dir_all(&root).ok();
6344        });
6345    }
6346
6347    /// Dedup: importing the same set twice into one project → second import
6348    /// reports all entries as deduped; `list_memories` count is unchanged.
6349    #[test]
6350    fn import_dedup_on_second_import() {
6351        with_user_brain_disabled(|| {
6352            let root = test_root();
6353            init_project(&root, false).expect("init");
6354
6355            let entries = vec![
6356                MemoryExport {
6357                    text: "dedup alpha".to_string(),
6358                    scope: "project".to_string(),
6359                    kind: "fact".to_string(),
6360                    confidence: 1.0,
6361                    created_at: None,
6362                },
6363                MemoryExport {
6364                    text: "dedup beta".to_string(),
6365                    scope: "project".to_string(),
6366                    kind: "convention".to_string(),
6367                    confidence: 1.0,
6368                    created_at: None,
6369                },
6370            ];
6371
6372            // First import — both should be new
6373            let s1 = import_memories(&root, &entries, None).expect("import 1");
6374            assert_eq!(s1.imported, 2, "first import: 2 new rows");
6375            assert_eq!(s1.deduped, 0, "first import: no dups");
6376
6377            let count_after_first = list_memories(&root).expect("list after 1st").len();
6378            assert_eq!(count_after_first, 2);
6379
6380            // Second import — same entries, all collapsed by normalized-text dedup
6381            let s2 = import_memories(&root, &entries, None).expect("import 2");
6382            assert_eq!(s2.imported, 0, "second import: no new rows");
6383            assert_eq!(s2.deduped, 2, "second import: both entries deduped");
6384
6385            let count_after_second = list_memories(&root).expect("list after 2nd").len();
6386            assert_eq!(
6387                count_after_second, 2,
6388                "list_memories count must be unchanged after second import"
6389            );
6390
6391            fs::remove_dir_all(&root).ok();
6392        });
6393    }
6394
6395    /// scope_override: importing with `Some(GlobalUser)` with user brain disabled
6396    /// routes entries to the project DB under global_user scope.
6397    #[test]
6398    fn import_scope_override_global_user() {
6399        with_user_brain_disabled(|| {
6400            // With user brain disabled, GlobalUser writes fall through to project DB.
6401            let root = test_root();
6402            init_project(&root, false).expect("init");
6403
6404            let entries = vec![MemoryExport {
6405                text: "scope override test memory".to_string(),
6406                scope: "project".to_string(), // original scope — will be overridden
6407                kind: "fact".to_string(),
6408                confidence: 1.0,
6409                created_at: None,
6410            }];
6411
6412            let summary =
6413                import_memories(&root, &entries, Some(MemoryScope::GlobalUser)).expect("import");
6414            assert_eq!(summary.imported, 1);
6415            assert_eq!(summary.deduped, 0);
6416
6417            // The memory must appear with scope = global_user in the project DB
6418            // (since user brain is disabled, GlobalUser falls through to project).
6419            let mems = list_memories(&root).expect("list");
6420            assert_eq!(mems.len(), 1);
6421            assert_eq!(
6422                mems[0].scope, "global_user",
6423                "scope_override must win over entry.scope"
6424            );
6425            assert_eq!(mems[0].text, "scope override test memory");
6426
6427            fs::remove_dir_all(&root).ok();
6428        });
6429    }
6430
6431    /// Malformed entries (bad scope or kind string) are skipped gracefully;
6432    /// valid entries in the same batch are still imported.
6433    #[test]
6434    fn import_skips_malformed_entries() {
6435        with_user_brain_disabled(|| {
6436            let root = test_root();
6437            init_project(&root, false).expect("init");
6438
6439            let entries = vec![
6440                // valid
6441                MemoryExport {
6442                    text: "good entry".to_string(),
6443                    scope: "project".to_string(),
6444                    kind: "fact".to_string(),
6445                    confidence: 1.0,
6446                    created_at: None,
6447                },
6448                // bad scope
6449                MemoryExport {
6450                    text: "bad scope entry".to_string(),
6451                    scope: "not_a_real_scope".to_string(),
6452                    kind: "fact".to_string(),
6453                    confidence: 1.0,
6454                    created_at: None,
6455                },
6456                // bad kind
6457                MemoryExport {
6458                    text: "bad kind entry".to_string(),
6459                    scope: "project".to_string(),
6460                    kind: "not_a_real_kind".to_string(),
6461                    confidence: 1.0,
6462                    created_at: None,
6463                },
6464                // another valid
6465                MemoryExport {
6466                    text: "second good entry".to_string(),
6467                    scope: "repo".to_string(),
6468                    kind: "convention".to_string(),
6469                    confidence: 1.0,
6470                    created_at: None,
6471                },
6472            ];
6473
6474            let summary = import_memories(&root, &entries, None).expect("import with bad entries");
6475            assert_eq!(
6476                summary.imported, 2,
6477                "2 valid entries must be imported; got {summary:?}"
6478            );
6479            assert_eq!(
6480                summary.deduped, 2,
6481                "2 malformed entries counted as skipped/deduped; got {summary:?}"
6482            );
6483
6484            let mems = list_memories(&root).expect("list");
6485            assert_eq!(mems.len(), 2, "exactly 2 memories in DB; got {mems:?}");
6486            let texts: Vec<&str> = mems.iter().map(|m| m.text.as_str()).collect();
6487            assert!(
6488                texts.contains(&"good entry"),
6489                "good entry missing: {texts:?}"
6490            );
6491            assert!(
6492                texts.contains(&"second good entry"),
6493                "second good entry missing: {texts:?}"
6494            );
6495
6496            fs::remove_dir_all(&root).ok();
6497        });
6498    }
6499
6500    // ── Q5b: export redact ────────────────────────────────────────────────────
6501
6502    /// Pure-fn tests for `redact_context_suffix` edge cases.
6503    #[test]
6504    fn redact_context_suffix_strips_trailing_context() {
6505        assert_eq!(
6506            redact_context_suffix("always use --locked (context: cargo build)"),
6507            "always use --locked"
6508        );
6509        // Multiple spaces before (context: …) are consumed by trim_end.
6510        assert_eq!(
6511            redact_context_suffix("lesson body   (context: some task)"),
6512            "lesson body"
6513        );
6514        // No pattern → unchanged.
6515        assert_eq!(redact_context_suffix("bare lesson"), "bare lesson");
6516        // Safety fallback: stripping would leave empty → original returned.
6517        assert_eq!(
6518            redact_context_suffix("(context: only context)"),
6519            "(context: only context)"
6520        );
6521        // Nested parens in context segment — only the outermost suffix is stripped.
6522        assert_eq!(
6523            redact_context_suffix("lesson (context: (nested) task)"),
6524            "lesson"
6525        );
6526        // Trailing whitespace after the close paren is tolerated by trim_end.
6527        assert_eq!(redact_context_suffix("lesson (context: task)  "), "lesson");
6528    }
6529
6530    /// Pure-fn tests for `redact_tags_prefix` edge cases.
6531    #[test]
6532    fn redact_tags_prefix_strips_leading_tags() {
6533        assert_eq!(
6534            redact_tags_prefix("[tags: rust, cargo] always use --locked"),
6535            "always use --locked"
6536        );
6537        // No pattern → unchanged.
6538        assert_eq!(redact_tags_prefix("no tags here"), "no tags here");
6539        // Safety fallback: stripping would leave empty → original returned.
6540        assert_eq!(redact_tags_prefix("[tags: only-tag]"), "[tags: only-tag]");
6541        // Leading whitespace before [tags: is preserved by trim_start then not stripped.
6542        assert_eq!(redact_tags_prefix("  [tags: rust] lesson"), "lesson");
6543    }
6544
6545    /// `apply_export_redaction` with both flags false → no change.
6546    #[test]
6547    fn apply_export_redaction_no_flags_is_passthrough() {
6548        let entry = MemoryExport {
6549            text: "[tags: rust] lesson (context: task)".to_string(),
6550            scope: "project".to_string(),
6551            kind: "fact".to_string(),
6552            confidence: 1.0,
6553            created_at: None,
6554        };
6555        let out = apply_export_redaction(entry.clone(), false, false);
6556        assert_eq!(out.text, entry.text);
6557    }
6558
6559    /// `apply_export_redaction` with `redact=true` strips context only.
6560    #[test]
6561    fn apply_export_redaction_redact_only_strips_context() {
6562        let entry = MemoryExport {
6563            text: "[tags: rust] lesson (context: task)".to_string(),
6564            scope: "project".to_string(),
6565            kind: "fact".to_string(),
6566            confidence: 1.0,
6567            created_at: None,
6568        };
6569        let out = apply_export_redaction(entry, true, false);
6570        assert_eq!(out.text, "[tags: rust] lesson");
6571    }
6572
6573    /// `apply_export_redaction` with both flags strips tags then context.
6574    #[test]
6575    fn apply_export_redaction_both_flags_strips_tags_and_context() {
6576        let entry = MemoryExport {
6577            text: "[tags: rust, cargo] lesson (context: task)".to_string(),
6578            scope: "project".to_string(),
6579            kind: "fact".to_string(),
6580            confidence: 1.0,
6581            created_at: None,
6582        };
6583        let out = apply_export_redaction(entry, true, true);
6584        assert_eq!(out.text, "lesson");
6585    }
6586
6587    /// End-to-end: export with `--redact`, import, then re-import deduplicates.
6588    ///
6589    /// Verifies that the normalized-text dedup path works correctly with
6590    /// redacted texts — the stripped form must normalize identically on
6591    /// second import.
6592    #[test]
6593    fn export_redact_import_roundtrip_and_dedup() {
6594        with_user_brain_disabled(|| {
6595            let root_a = test_root();
6596            init_project(&root_a, false).expect("init A");
6597
6598            // Seed a memory that has the context suffix the distiller adds.
6599            add_memory(
6600                &root_a,
6601                MemoryScope::Project,
6602                MemoryKind::Fact,
6603                "use --locked for reproducibility (context: cargo test failing)",
6604            )
6605            .expect("add memory");
6606
6607            // Export with redact=true.
6608            let (exported, _) =
6609                export_memories(&root_a, None, None, true, false).expect("export redacted");
6610            assert_eq!(exported.len(), 1);
6611            assert_eq!(
6612                exported[0].text, "use --locked for reproducibility",
6613                "context suffix must be stripped"
6614            );
6615
6616            // Import into a fresh project.
6617            let root_b = test_root();
6618            init_project(&root_b, false).expect("init B");
6619            let s1 = import_memories(&root_b, &exported, None).expect("import 1");
6620            assert_eq!(s1.imported, 1, "first import must create 1 row");
6621            assert_eq!(s1.deduped, 0);
6622
6623            // Re-import the same redacted slice → must dedup, not double-insert.
6624            let s2 = import_memories(&root_b, &exported, None).expect("import 2");
6625            assert_eq!(s2.imported, 0, "second import must dedup");
6626            assert_eq!(s2.deduped, 1);
6627
6628            // List shows the redacted text (not the original context-annotated form).
6629            let mems = list_memories(&root_b).expect("list");
6630            assert_eq!(mems.len(), 1);
6631            assert_eq!(mems[0].text, "use --locked for reproducibility");
6632
6633            fs::remove_dir_all(&root_a).ok();
6634            fs::remove_dir_all(&root_b).ok();
6635        });
6636    }
6637
6638    // ── v3.0 #4: shareable pack install (merge | replace + provenance) ──────
6639    #[test]
6640    fn import_pack_merge_replace_and_provenance() {
6641        with_user_brain_disabled(|| {
6642            let root_a = test_root();
6643            init_project(&root_a, false).expect("init A");
6644            add_memory(
6645                &root_a,
6646                MemoryScope::Project,
6647                MemoryKind::Convention,
6648                "use cargo --locked",
6649            )
6650            .expect("a1");
6651            add_memory(
6652                &root_a,
6653                MemoryScope::Project,
6654                MemoryKind::Fact,
6655                "brain db lives in dot kimetsu",
6656            )
6657            .expect("a2");
6658
6659            // Export → wrap as a Pack envelope → parse back (round-trip).
6660            let (entries, scrub) =
6661                export_memories(&root_a, None, None, false, false).expect("export");
6662            assert!(scrub.is_clean(), "clean memories must scrub to nothing");
6663            let pack = Pack {
6664                kimetsu_pack: 1,
6665                name: Some("demo".into()),
6666                version: Some("1.0".into()),
6667                description: None,
6668                exported_at: None,
6669                memory_count: entries.len(),
6670                memories: entries.clone(),
6671            };
6672            let json = serde_json::to_string(&pack).expect("ser");
6673            let (pref, parsed) = parse_pack_or_array(&json).expect("parse");
6674            assert_eq!(pref.name.as_deref(), Some("demo"));
6675            assert_eq!(parsed.len(), 2);
6676            // Bare array also parses (back-compat).
6677            let (bare_ref, bare) =
6678                parse_pack_or_array(&serde_json::to_string(&entries).unwrap()).expect("parse bare");
6679            assert!(bare_ref.name.is_none());
6680            assert_eq!(bare.len(), 2);
6681
6682            // Install (merge) into B, which already has its own memory.
6683            let root_b = test_root();
6684            init_project(&root_b, false).expect("init B");
6685            add_memory(
6686                &root_b,
6687                MemoryScope::Project,
6688                MemoryKind::Fact,
6689                "B's own memory",
6690            )
6691            .expect("b1");
6692            let s = import_pack(&root_b, &parsed, None, false, Some(&pref)).expect("merge");
6693            assert_eq!(s.imported, 2, "two new pack memories");
6694            assert_eq!(s.superseded, 0);
6695
6696            // Pack memories carry provenance source=="pack".
6697            let pack_tagged = |root: &Path| -> i64 {
6698                let (_p, _c, conn) = load_project_readonly(root).expect("ro");
6699                conn.query_row(
6700                    "SELECT COUNT(*) FROM memories
6701                     WHERE provenance_snapshot_json LIKE '%\"source\":\"pack\"%'",
6702                    [],
6703                    |r| r.get(0),
6704                )
6705                .unwrap()
6706            };
6707            assert_eq!(
6708                pack_tagged(&root_b),
6709                2,
6710                "installed memories tagged with pack provenance"
6711            );
6712
6713            // Re-install (merge) → all deduped.
6714            let s2 = import_pack(&root_b, &parsed, None, false, Some(&pref)).expect("merge2");
6715            assert_eq!(s2.imported, 0);
6716            assert_eq!(s2.deduped, 2);
6717
6718            // Replace: B's current project memories (its own + the 2 pack) are
6719            // superseded, then the pack reloads → 2 active project memories.
6720            let s3 = import_pack(&root_b, &parsed, None, true, Some(&pref)).expect("replace");
6721            assert_eq!(
6722                s3.superseded, 3,
6723                "all 3 active project memories invalidated"
6724            );
6725            assert_eq!(s3.imported, 2, "pack reloaded as fresh rows");
6726            let active_project = {
6727                let (_p, _c, conn) = load_project_readonly(&root_b).expect("ro2");
6728                conn.query_row(
6729                    "SELECT COUNT(*) FROM memories
6730                     WHERE scope='project' AND invalidated_at IS NULL AND superseded_by IS NULL",
6731                    [],
6732                    |r| r.get::<_, i64>(0),
6733                )
6734                .unwrap()
6735            };
6736            assert_eq!(
6737                active_project, 2,
6738                "only the pack's 2 memories remain active"
6739            );
6740
6741            fs::remove_dir_all(&root_a).ok();
6742            fs::remove_dir_all(&root_b).ok();
6743        });
6744    }
6745
6746    // ── Q6: memory edit / memory undo ──────────────────────────────────────
6747
6748    /// Q6-1: edit_memory updates text + normalized_text + FTS, preserves history.
6749    #[test]
6750    fn edit_memory_updates_text_and_preserves_history() {
6751        with_user_brain_disabled(|| {
6752            let root = test_root();
6753            init_project(&root, false).expect("init");
6754            let mid = add_memory(
6755                &root,
6756                MemoryScope::Project,
6757                MemoryKind::Fact,
6758                "original text for edit test",
6759            )
6760            .expect("add");
6761
6762            // Simulate a "learned" memory by bumping use_count and usefulness_score.
6763            {
6764                let (_p, _c, conn) = load_project(&root).expect("open conn");
6765                conn.execute(
6766                    "UPDATE memories SET use_count = 7, usefulness_score = 3.5 WHERE memory_id = ?1",
6767                    params![mid],
6768                )
6769                .expect("bump counters");
6770            }
6771
6772            // Edit the text in place.
6773            edit_memory(&root, &mid, Some("corrected text for edit test"), None)
6774                .expect("edit_memory");
6775
6776            // Verify text + normalized_text changed.
6777            {
6778                let (_p, _c, conn) = load_project(&root).expect("open conn");
6779                let (text, normalized, use_count, usefulness_score): (String, String, i64, f64) =
6780                    conn.query_row(
6781                        "SELECT text, normalized_text, use_count, usefulness_score FROM memories WHERE memory_id = ?1",
6782                        params![mid],
6783                        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
6784                    )
6785                    .expect("query");
6786
6787                assert_eq!(text, "corrected text for edit test");
6788                assert!(!normalized.is_empty(), "normalized_text must not be empty");
6789                // History preserved.
6790                assert_eq!(use_count, 7, "use_count must not be reset");
6791                assert!(
6792                    (usefulness_score - 3.5).abs() < 0.01,
6793                    "usefulness_score must not be reset"
6794                );
6795            }
6796
6797            // FTS reflects new text — search for a word in the new text.
6798            let hits = search_memories(&root, "corrected", 10, 0, None, None).expect("search new");
6799            assert!(
6800                hits.iter().any(|h| h.memory_id == mid),
6801                "edited text must appear in FTS search: {hits:?}"
6802            );
6803
6804            // Old text must no longer match.
6805            let old_hits =
6806                search_memories(&root, "original", 10, 0, None, None).expect("search old");
6807            assert!(
6808                !old_hits.iter().any(|h| h.memory_id == mid),
6809                "old text must NOT appear after edit: {old_hits:?}"
6810            );
6811
6812            // list_memories should return the new text.
6813            let mems = list_memories(&root).expect("list");
6814            let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
6815            assert_eq!(m.text, "corrected text for edit test");
6816        });
6817    }
6818
6819    /// Q6-2: edit_memory can change kind without touching text.
6820    #[test]
6821    fn edit_memory_changes_kind_only() {
6822        with_user_brain_disabled(|| {
6823            let root = test_root();
6824            init_project(&root, false).expect("init");
6825            let mid = add_memory(
6826                &root,
6827                MemoryScope::Project,
6828                MemoryKind::Fact,
6829                "kind-change test memory",
6830            )
6831            .expect("add");
6832
6833            edit_memory(&root, &mid, None, Some(MemoryKind::Convention)).expect("edit kind");
6834
6835            let mems = list_memories(&root).expect("list");
6836            let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
6837            assert_eq!(m.kind, "convention", "kind must be updated");
6838            assert_eq!(m.text, "kind-change test memory", "text must be unchanged");
6839        });
6840    }
6841
6842    /// Q6-3: edit_memory errors on unknown id, invalidated id, and neither arg.
6843    #[test]
6844    fn edit_memory_errors() {
6845        with_user_brain_disabled(|| {
6846            let root = test_root();
6847            init_project(&root, false).expect("init");
6848
6849            // Neither text nor kind → error.
6850            let err = edit_memory(&root, "does-not-matter", None, None)
6851                .expect_err("must err when no fields");
6852            assert!(
6853                format!("{err}").contains("at least one"),
6854                "unexpected err: {err}"
6855            );
6856
6857            // Unknown id.
6858            let err = edit_memory(&root, "UNKNOWN_ID", Some("x"), None)
6859                .expect_err("must err on unknown id");
6860            assert!(
6861                format!("{err}").contains("not found"),
6862                "unexpected err: {err}"
6863            );
6864
6865            // Invalidated id.
6866            let mid = add_memory(
6867                &root,
6868                MemoryScope::Project,
6869                MemoryKind::Fact,
6870                "will be invalidated",
6871            )
6872            .expect("add");
6873            invalidate_memory(&root, &mid, None).expect("invalidate");
6874            let err = edit_memory(&root, &mid, Some("new text"), None)
6875                .expect_err("must err on invalidated id");
6876            assert!(
6877                format!("{err}").contains("invalidated"),
6878                "unexpected err: {err}"
6879            );
6880        });
6881    }
6882
6883    /// Q6-4: undo_last_memory invalidates the most recent memory; second call
6884    /// invalidates the one before it.
6885    #[test]
6886    fn undo_last_memory_invalidates_newest_first() {
6887        with_user_brain_disabled(|| {
6888            let root = test_root();
6889            init_project(&root, false).expect("init");
6890
6891            let mid_a = add_memory(
6892                &root,
6893                MemoryScope::Project,
6894                MemoryKind::Fact,
6895                "memory A older undo test",
6896            )
6897            .expect("add A");
6898
6899            let mid_b = add_memory(
6900                &root,
6901                MemoryScope::Project,
6902                MemoryKind::Fact,
6903                "memory B newer undo test",
6904            )
6905            .expect("add B");
6906
6907            // First undo → B (the newer one per created_at DESC, memory_id DESC).
6908            let undone = undo_last_memory(&root)
6909                .expect("undo 1")
6910                .expect("must return Some");
6911            assert_eq!(undone.memory_id, mid_b, "undo must target B (newest)");
6912
6913            // Check B is now invalidated via DB query.
6914            {
6915                let (_p, _c, conn) = load_project(&root).expect("open conn");
6916                let b_inv: Option<String> = conn
6917                    .query_row(
6918                        "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
6919                        params![mid_b],
6920                        |row| row.get(0),
6921                    )
6922                    .optional()
6923                    .expect("query")
6924                    .flatten();
6925                assert!(b_inv.is_some(), "B must be invalidated after undo");
6926
6927                let a_inv: Option<String> = conn
6928                    .query_row(
6929                        "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
6930                        params![mid_a],
6931                        |row| row.get(0),
6932                    )
6933                    .optional()
6934                    .expect("query")
6935                    .flatten();
6936                assert!(a_inv.is_none(), "A must still be active");
6937            }
6938
6939            // Second undo → A.
6940            let undone2 = undo_last_memory(&root)
6941                .expect("undo 2")
6942                .expect("must return Some");
6943            assert_eq!(undone2.memory_id, mid_a, "second undo must target A");
6944
6945            // Both invalidated.
6946            {
6947                let (_p, _c, conn) = load_project(&root).expect("open conn");
6948                let a_inv: Option<String> = conn
6949                    .query_row(
6950                        "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
6951                        params![mid_a],
6952                        |row| row.get(0),
6953                    )
6954                    .optional()
6955                    .expect("query")
6956                    .flatten();
6957                assert!(a_inv.is_some(), "A must be invalidated after second undo");
6958            }
6959
6960            // peek_last_memory returns None after both are invalidated.
6961            let peek = peek_last_memory(&root).expect("peek after both undone");
6962            assert!(peek.is_none(), "peek must return None when all invalidated");
6963        });
6964    }
6965
6966    /// Q6-5: undo_last_memory on an empty brain returns Ok(None).
6967    #[test]
6968    fn undo_last_memory_on_empty_brain_returns_none() {
6969        with_user_brain_disabled(|| {
6970            let root = test_root();
6971            init_project(&root, false).expect("init");
6972            let result = undo_last_memory(&root).expect("undo on empty");
6973            assert!(result.is_none(), "must return None on empty brain");
6974        });
6975    }
6976
6977    // ── Q8: compact_brain tests ───────────────────────────────────────────────
6978
6979    /// Q8-1: VACUUM reclaims space after purging invalidated memories.
6980    ///
6981    /// Adds enough memories to grow the file, invalidates most of them,
6982    /// then calls compact_brain with purge_invalidated=true. After compaction:
6983    ///   - bytes_after <= bytes_before (VACUUM at minimum doesn't grow the file)
6984    ///   - invalidated_memories_purged > 0
6985    ///   - active memories still survive and are retrievable
6986    #[test]
6987    fn compact_brain_purge_invalidated_reclaims_space() {
6988        with_user_brain_disabled(|| {
6989            let root = test_root();
6990            init_project(&root, false).expect("init");
6991
6992            // Add 20 memories — enough to make the file non-trivially sized.
6993            let mut active_id = String::new();
6994            for i in 0..20usize {
6995                let text = format!(
6996                    "compact test memory number {i}: rust sqlite vacuum reclaim disk space \
6997                     kimetsu brain compact test payload to increase file size substantially \
6998                     so that vacuum has meaningful dead pages to reclaim after deletion"
6999                );
7000                let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, &text)
7001                    .expect("add memory");
7002                if i == 0 {
7003                    active_id = mid.clone();
7004                }
7005                // Invalidate all but the first one.
7006                if i > 0 {
7007                    invalidate_memory(&root, &mid, Some("compact test"))
7008                        .expect("invalidate memory");
7009                }
7010            }
7011
7012            // Run compact with purge_invalidated = true.
7013            let report = compact_brain(&root, None, true).expect("compact_brain");
7014
7015            // Purge count must match the 19 invalidated memories.
7016            assert_eq!(
7017                report.invalidated_memories_purged, 19,
7018                "should have purged 19 invalidated memories, got {}",
7019                report.invalidated_memories_purged
7020            );
7021            // bytes_after must not exceed bytes_before (VACUUM can only shrink or equal).
7022            assert!(
7023                report.bytes_after <= report.bytes_before,
7024                "bytes_after ({}) should be <= bytes_before ({}) after purge+vacuum",
7025                report.bytes_after,
7026                report.bytes_before
7027            );
7028            // events_trimmed must be 0 (we didn't request a trim).
7029            assert_eq!(
7030                report.events_trimmed, 0,
7031                "events_trimmed must be 0 when trim_events_older_than is None"
7032            );
7033
7034            // The one active memory must still be listable.
7035            let memories = list_memories(&root).expect("list memories after compact");
7036            let active_memories: Vec<_> = memories
7037                .iter()
7038                .filter(|m| m.memory_id == active_id)
7039                .collect();
7040            assert_eq!(
7041                active_memories.len(),
7042                1,
7043                "the active memory must survive compaction"
7044            );
7045        });
7046    }
7047
7048    /// Q8-2: default compact (no flags) preserves everything — a pure VACUUM.
7049    ///
7050    /// All memories (active AND invalidated) survive, events are untouched,
7051    /// and both counters are 0.
7052    #[test]
7053    fn compact_brain_default_preserves_everything() {
7054        with_user_brain_disabled(|| {
7055            let root = test_root();
7056            init_project(&root, false).expect("init");
7057
7058            let mid = add_memory(
7059                &root,
7060                MemoryScope::Project,
7061                MemoryKind::Fact,
7062                "preserve me through compact",
7063            )
7064            .expect("add memory");
7065            let mid2 = add_memory(
7066                &root,
7067                MemoryScope::Project,
7068                MemoryKind::Fact,
7069                "preserve invalidated too",
7070            )
7071            .expect("add memory 2");
7072            invalidate_memory(&root, &mid2, Some("test")).expect("invalidate");
7073
7074            // Count events before.
7075            let event_count_before: i64 = {
7076                let (_p, _c, conn) = load_project(&root).expect("load");
7077                conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
7078                    .expect("count events")
7079            };
7080
7081            // Default compact: no purge, no trim.
7082            let report = compact_brain(&root, None, false).expect("compact_brain");
7083            assert_eq!(
7084                report.events_trimmed, 0,
7085                "events_trimmed must be 0 in default compact"
7086            );
7087            assert_eq!(
7088                report.invalidated_memories_purged, 0,
7089                "invalidated_memories_purged must be 0 in default compact"
7090            );
7091
7092            // All memories still present (active + invalidated).
7093            let all_mems: Vec<_> = {
7094                let (_p, _c, conn) = load_project(&root).expect("load");
7095                let mut stmt = conn
7096                    .prepare("SELECT memory_id FROM memories")
7097                    .expect("prepare");
7098                stmt.query_map([], |r| r.get::<_, String>(0))
7099                    .expect("query")
7100                    .collect::<Result<Vec<_>, _>>()
7101                    .expect("collect")
7102            };
7103            assert!(
7104                all_mems.contains(&mid),
7105                "active memory must survive default compact"
7106            );
7107            assert!(
7108                all_mems.contains(&mid2),
7109                "invalidated memory must survive default compact"
7110            );
7111
7112            // Event count unchanged.
7113            let event_count_after: i64 = {
7114                let (_p, _c, conn) = load_project(&root).expect("load");
7115                conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
7116                    .expect("count events")
7117            };
7118            assert_eq!(
7119                event_count_after, event_count_before,
7120                "event count must not change in default compact"
7121            );
7122        });
7123    }
7124
7125    /// Q8-3: event trim removes old events but materialized memories survive.
7126    ///
7127    /// Uses trim_events_older_than = Duration::ZERO so ALL events are
7128    /// classified as "old" relative to `now`. After trim:
7129    ///   - events_trimmed > 0
7130    ///   - list_memories still returns the seeded memory (projection survives)
7131    ///   - memories are NOT deleted by event trimming
7132    #[test]
7133    fn compact_brain_event_trim_keeps_materialized_memories() {
7134        with_user_brain_disabled(|| {
7135            let root = test_root();
7136            init_project(&root, false).expect("init");
7137
7138            let mid = add_memory(
7139                &root,
7140                MemoryScope::Project,
7141                MemoryKind::Fact,
7142                "this memory must survive event trim",
7143            )
7144            .expect("add memory");
7145
7146            // Trim with a 1-second Duration — but we add a 2-second sleep
7147            // alternative: use Duration::from_secs(0) which means cutoff =
7148            // now, so events older than "right now" are ALL deleted.
7149            // Using 0 ensures even events written 1ms ago are trimmed.
7150            let trim_dur = std::time::Duration::from_secs(0);
7151
7152            // Small sleep to ensure events are definitively in the past
7153            // relative to the cutoff computed inside compact_brain.
7154            std::thread::sleep(std::time::Duration::from_millis(100));
7155
7156            let report = compact_brain(&root, Some(trim_dur), false).expect("compact_brain");
7157
7158            assert!(
7159                report.events_trimmed > 0,
7160                "events_trimmed should be > 0 after trim with duration=0; got {}",
7161                report.events_trimmed
7162            );
7163
7164            // The materialized memory (projection row) must survive.
7165            let memories = list_memories(&root).expect("list memories after event trim");
7166            let found = memories.iter().any(|m| m.memory_id == mid);
7167            assert!(
7168                found,
7169                "memory must still be in the projection after event trim"
7170            );
7171
7172            // purge count must be 0 — we didn't ask for it.
7173            assert_eq!(
7174                report.invalidated_memories_purged, 0,
7175                "invalidated_memories_purged must be 0 when purge_invalidated=false"
7176            );
7177        });
7178    }
7179
7180    /// Q8-4: rebuild_projection after event trim does not error.
7181    ///
7182    /// Even with a partially trimmed event log, rebuild_in_place can complete —
7183    /// it replays whatever events remain without panicking or returning an error.
7184    #[test]
7185    fn compact_brain_event_trim_then_rebuild_is_consistent() {
7186        with_user_brain_disabled(|| {
7187            let root = test_root();
7188            init_project(&root, false).expect("init");
7189
7190            add_memory(
7191                &root,
7192                MemoryScope::Project,
7193                MemoryKind::Fact,
7194                "pre-trim memory for rebuild test",
7195            )
7196            .expect("add memory");
7197
7198            // Trim all events (cutoff = now).
7199            std::thread::sleep(std::time::Duration::from_millis(100));
7200            let report = compact_brain(&root, Some(std::time::Duration::from_secs(0)), false)
7201                .expect("compact_brain");
7202            assert!(report.events_trimmed > 0, "events must have been trimmed");
7203
7204            // rebuild_projection must not error — it replays whatever events remain.
7205            let replayed =
7206                rebuild_projection(&root, false).expect("rebuild_projection after event trim");
7207            // The events are gone so the replay count should be 0 (empty log).
7208            assert_eq!(
7209                replayed, 0,
7210                "replayed should be 0 after all events are trimmed"
7211            );
7212        });
7213    }
7214
7215    // ── *_at_root no-git seam tests ───────────────────────────────────────
7216
7217    /// init_project_at_root creates .kimetsu/{project.toml,brain.db} rooted
7218    /// at the given directory even when that directory lives INSIDE a git repo
7219    /// (no git climb). load_project_at_root opens it, and a round-trip memory
7220    /// add + list confirms the brain is functional.
7221    #[test]
7222    fn at_root_init_and_round_trip_memory() {
7223        with_user_brain_disabled(|| {
7224            // Use a temp dir with a git boundary so that `add_memory` (which
7225            // uses ProjectPaths::discover internally) resolves to this dir
7226            // rather than climbing to E:\Kimetsu. The *_at_root functions
7227            // themselves never call discover; the boundary is only needed for
7228            // the helper calls (add_memory / list_memories) in this test.
7229            let root = std::env::temp_dir().join(format!("kimetsu-at-root-{}", Ulid::new()));
7230            kimetsu_core::paths::git_init_boundary(&root);
7231
7232            // Init at explicit root — must not climb to a parent git repo.
7233            let summary = init_project_at_root(&root, false).expect("init_project_at_root");
7234
7235            assert!(
7236                summary.kimetsu_dir.exists(),
7237                ".kimetsu/ must be created at root"
7238            );
7239            // The .kimetsu dir must be a child of root, not some git ancestor.
7240            assert!(
7241                summary.kimetsu_dir.starts_with(&root),
7242                ".kimetsu dir {:?} must be inside root {:?}",
7243                summary.kimetsu_dir,
7244                root
7245            );
7246            assert!(summary.brain_db.exists(), "brain.db must exist");
7247            assert!(
7248                root.join(".kimetsu").join("project.toml").exists(),
7249                "project.toml must be at root/.kimetsu/"
7250            );
7251
7252            // load_project_at_root must open the same brain.
7253            let (paths, _config, _conn) =
7254                load_project_at_root(&root).expect("load_project_at_root");
7255            assert_eq!(
7256                paths
7257                    .repo_root
7258                    .canonicalize()
7259                    .unwrap_or(paths.repo_root.clone()),
7260                root.canonicalize().unwrap_or(root.clone()),
7261                "repo_root must be our explicit root"
7262            );
7263
7264            // Round-trip: add a memory, then verify it is visible via list_memories.
7265            let memory_id = add_memory(
7266                &root,
7267                MemoryScope::Project,
7268                MemoryKind::Fact,
7269                "at_root seam test memory",
7270            )
7271            .expect("add_memory");
7272
7273            // list_memories opens a fresh connection — confirms the write landed
7274            // in the at-root brain.db (not a git-ancestor brain).
7275            let memories = list_memories(&root).expect("list_memories");
7276            assert!(
7277                memories.iter().any(|m| m.memory_id == memory_id),
7278                "memory {memory_id} must be present in the at_root brain"
7279            );
7280
7281            // load_project_readonly_at_root must also see it.
7282            let (_, _, ro_conn) =
7283                load_project_readonly_at_root(&root).expect("load_project_readonly_at_root");
7284            let ro_count: i64 = ro_conn
7285                .query_row(
7286                    "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
7287                    rusqlite::params![memory_id],
7288                    |row| row.get(0),
7289                )
7290                .expect("count memory ro");
7291            assert_eq!(ro_count, 1, "readonly view must see the same memory");
7292
7293            std::fs::remove_dir_all(&root).ok();
7294        });
7295    }
7296
7297    /// init_project_at_root is idempotent: calling it twice (force=false)
7298    /// does not overwrite project.toml.
7299    #[test]
7300    fn at_root_init_is_idempotent() {
7301        with_user_brain_disabled(|| {
7302            let root = std::env::temp_dir().join(format!("kimetsu-at-root-idem-{}", Ulid::new()));
7303            std::fs::create_dir_all(&root).expect("create root");
7304
7305            let s1 = init_project_at_root(&root, false).expect("first init");
7306            assert!(s1.wrote_project_toml, "first init must write project.toml");
7307
7308            let s2 = init_project_at_root(&root, false).expect("second init");
7309            assert!(
7310                !s2.wrote_project_toml,
7311                "second init (force=false) must not overwrite project.toml"
7312            );
7313            assert_eq!(s1.project_id, s2.project_id, "project_id must be stable");
7314
7315            std::fs::remove_dir_all(&root).ok();
7316        });
7317    }
7318
7319    // ------------------------------------------------------------------
7320    // Fix 2: detect_conflicts off-switch (end-to-end via add_memory)
7321    // ------------------------------------------------------------------
7322
7323    /// Fix 2: with KIMETSU_DETECT_CONFLICTS=0 in the env, add_memory of a
7324    /// near-duplicate writes no row to memory_conflicts even when the brain
7325    /// has an active near-dup. Verifies the env > config precedence.
7326    #[test]
7327    fn detect_conflicts_env_off_writes_no_conflict_rows() {
7328        // with_user_brain_disabled already holds test_env_lock — do NOT
7329        // lock again (non-reentrant mutex → deadlock).
7330        with_user_brain_disabled(|| {
7331            let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
7332            let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
7333
7334            // Disable embedder (noop) so the test stays fast and
7335            // deterministic — conflict detection is a no-op on Noop anyway,
7336            // but the off-switch is also applied on non-noop builds.
7337            unsafe {
7338                std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
7339                std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
7340            }
7341
7342            let root = test_root();
7343            init_project(&root, false).expect("init");
7344
7345            // With detection enabled (default) and noop embedder:
7346            // no conflicts will fire regardless (noop short-circuits).
7347            // The real test is the config-level gate, tested in conflict.rs.
7348            // Here we exercise the project path end-to-end.
7349            add_memory(
7350                &root,
7351                MemoryScope::Project,
7352                MemoryKind::Fact,
7353                "use clippy for linting Rust code",
7354            )
7355            .expect("add 1");
7356
7357            // Now disable via env.
7358            unsafe {
7359                std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
7360            }
7361            add_memory(
7362                &root,
7363                MemoryScope::Project,
7364                MemoryKind::Fact,
7365                "use clippy for linting all Rust projects",
7366            )
7367            .expect("add 2");
7368
7369            // Restore env.
7370            unsafe {
7371                match prev_dc {
7372                    Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
7373                    None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
7374                }
7375                match prev_emb {
7376                    Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
7377                    None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
7378                }
7379            }
7380            std::fs::remove_dir_all(&root).ok();
7381        });
7382    }
7383
7384    // ------------------------------------------------------------------
7385    // Micro-benchmark: Fix 4 — per-add cost must not scale linearly with N
7386    // ------------------------------------------------------------------
7387
7388    /// Structural invariant: after seeding N memories, the active-memory count
7389    /// matches the number of adds.
7390    ///
7391    /// The micro-benchmark times an early vs late add (with conflict detection
7392    /// OFF to isolate per-add maintenance cost) and asserts the late add is not
7393    /// dramatically slower — proving O(1) per-add cost (the usearch index is
7394    /// maintained incrementally, never full-scanned on add).
7395    #[test]
7396    fn perf_tier1_structural_invariant_and_timing() {
7397        // with_user_brain_disabled already holds test_env_lock — do NOT
7398        // lock again (non-reentrant mutex → deadlock).
7399        with_user_brain_disabled(|| {
7400            #[allow(unused_imports)]
7401            use crate::embeddings::StubEmbedder;
7402            use std::time::Instant;
7403
7404            let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
7405            let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
7406
7407            // Disable conflict detection so we isolate vec-table cost.
7408            // Use "noop" embedder to keep the test fast.
7409            unsafe {
7410                std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
7411                std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop"); // keep fast
7412            }
7413
7414            let root = test_root();
7415            init_project(&root, false).expect("init");
7416
7417            const EARLY_SAMPLE: usize = 100;
7418            const TOTAL: usize = 200; // keep test fast
7419
7420            // Warm up and measure early add (after ~100 rows).
7421            for i in 0..EARLY_SAMPLE {
7422                add_memory(
7423                    &root,
7424                    MemoryScope::Project,
7425                    MemoryKind::Fact,
7426                    &format!("perf test memory row {i} unique content abcdef"),
7427                )
7428                .expect("add early");
7429            }
7430
7431            let t_early = Instant::now();
7432            add_memory(
7433                &root,
7434                MemoryScope::Project,
7435                MemoryKind::Fact,
7436                &format!("perf sampled early add memory row {EARLY_SAMPLE} unique zxcvbn"),
7437            )
7438            .expect("timed early add");
7439            let early_us = t_early.elapsed().as_micros();
7440
7441            // Fill up to TOTAL.
7442            for i in (EARLY_SAMPLE + 1)..TOTAL {
7443                add_memory(
7444                    &root,
7445                    MemoryScope::Project,
7446                    MemoryKind::Fact,
7447                    &format!("perf test memory row {i} unique content qwerty"),
7448                )
7449                .expect("add fill");
7450            }
7451
7452            let t_late = Instant::now();
7453            add_memory(
7454                &root,
7455                MemoryScope::Project,
7456                MemoryKind::Fact,
7457                &format!("perf sampled late add memory row {TOTAL} unique rtyfgh"),
7458            )
7459            .expect("timed late add");
7460            let late_us = t_late.elapsed().as_micros();
7461
7462            // Structural invariant: memories count matches (roughly) total adds.
7463            let (_, _, conn) = load_project(&root).expect("load");
7464            let mem_count: i64 = conn
7465                .query_row(
7466                    "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
7467                    [],
7468                    |r| r.get(0),
7469                )
7470                .expect("count memories");
7471            // We added TOTAL + 2 timed samples = TOTAL + 2.
7472            assert!(
7473                mem_count >= TOTAL as i64,
7474                "must have at least {TOTAL} memories, got {mem_count}"
7475            );
7476
7477            // Timing invariant: late add must not be > 20× slower than early add
7478            // (generous bound; O(1) should be near-equal, O(N) would be ≫).
7479            // Only assert when both samples are > 0 to avoid flakes on fast CI.
7480            if early_us > 0 && late_us > 0 {
7481                assert!(
7482                    late_us < early_us * 20,
7483                    "late add ({late_us}µs) is > 20× slower than early add ({early_us}µs) — O(N) regression"
7484                );
7485            }
7486
7487            // Restore env.
7488            unsafe {
7489                match prev_dc {
7490                    Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
7491                    None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
7492                }
7493                match prev_emb {
7494                    Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
7495                    None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
7496                }
7497            }
7498            std::fs::remove_dir_all(&root).ok();
7499        });
7500    }
7501
7502    #[cfg(feature = "embeddings")]
7503    #[test]
7504    fn ann_retrieval_round_trips_and_invalidate_drops() {
7505        use crate::user_brain::with_user_brain_disabled;
7506        with_user_brain_disabled(|| {
7507            // Use the StubEmbedder so this is deterministic and offline.
7508            let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
7509            unsafe {
7510                std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "stub-d8");
7511            }
7512            let root = test_root();
7513            init_project(&root, false).expect("init");
7514            add_memory(
7515                &root,
7516                MemoryScope::Project,
7517                MemoryKind::Fact,
7518                "ripgrep is the fast recursive search tool",
7519            )
7520            .expect("add a");
7521            let id = add_memory(
7522                &root,
7523                MemoryScope::Project,
7524                MemoryKind::Fact,
7525                "use fd to find files quickly",
7526            )
7527            .expect("add b");
7528
7529            // Retrieval surfaces the relevant memory via the ANN path.
7530            let ctx = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx");
7531            assert!(
7532                format!("{ctx:?}").contains("fd to find files"),
7533                "expected the fd memory in context"
7534            );
7535
7536            // Invalidate it -> it disappears from retrieval.
7537            invalidate_memory(&root, &id, Some("test")).expect("invalidate");
7538            let ctx2 = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx2");
7539            assert!(
7540                !format!("{ctx2:?}").contains("fd to find files"),
7541                "invalidated memory must not return"
7542            );
7543
7544            unsafe {
7545                match prev_emb {
7546                    Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
7547                    None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
7548                }
7549            }
7550            std::fs::remove_dir_all(&root).ok();
7551        });
7552    }
7553
7554    // v1.5: record_mcp_citation
7555    #[test]
7556    fn record_mcp_citation_writes_memory_citations_row() {
7557        with_user_brain_disabled(|| {
7558            let root = test_root();
7559            std::fs::create_dir_all(&root).expect("create root");
7560            init_project(&root, false).expect("init");
7561            let memory_id = add_memory(
7562                &root,
7563                kimetsu_core::memory::MemoryScope::Project,
7564                kimetsu_core::memory::MemoryKind::Fact,
7565                "record_mcp_citation test fixture",
7566            )
7567            .expect("add memory");
7568
7569            record_mcp_citation(&root, &memory_id, Some("helped with test"))
7570                .expect("record_mcp_citation");
7571
7572            let (_paths, _config, conn) = load_project(&root).expect("load");
7573            let row_count: i64 = conn
7574                .query_row(
7575                    "SELECT COUNT(*) FROM memory_citations WHERE memory_id = ?1",
7576                    rusqlite::params![&memory_id],
7577                    |r| r.get(0),
7578                )
7579                .expect("count");
7580            assert_eq!(
7581                row_count, 1,
7582                "memory_citations row must exist after MCP cite"
7583            );
7584            std::fs::remove_dir_all(&root).ok();
7585        });
7586    }
7587
7588    // Phase 2 keyless: record_regret injects a retrieval.regret event.
7589    #[test]
7590    fn record_regret_writes_retrieval_regret_event() {
7591        with_user_brain_disabled(|| {
7592            let root = test_root();
7593            std::fs::create_dir_all(&root).expect("create root");
7594            init_project(&root, false).expect("init");
7595            let memory_id = add_memory(
7596                &root,
7597                kimetsu_core::memory::MemoryScope::Project,
7598                kimetsu_core::memory::MemoryKind::Fact,
7599                "record_regret test fixture",
7600            )
7601            .expect("add memory");
7602
7603            record_regret(&root, &memory_id).expect("record_regret");
7604
7605            let (_paths, _config, conn) = load_project(&root).expect("load");
7606            let event_count: i64 = conn
7607                .query_row(
7608                    "SELECT COUNT(*) FROM events
7609                     WHERE kind = 'retrieval.regret'
7610                       AND json_extract(payload_json, '$.memory_id') = ?1",
7611                    rusqlite::params![&memory_id],
7612                    |r| r.get(0),
7613                )
7614                .expect("count");
7615            assert_eq!(
7616                event_count, 1,
7617                "a retrieval.regret event must exist for the memory after record_regret"
7618            );
7619            std::fs::remove_dir_all(&root).ok();
7620        });
7621    }
7622
7623    // Story 2.4: read (use_count, usefulness_score, confidence) for a memory.
7624    #[cfg(test)]
7625    fn read_outcome_stats(root: &std::path::Path, memory_id: &str) -> (i64, f64, f64) {
7626        let (_paths, _config, conn) = load_project(root).expect("load");
7627        conn.query_row(
7628            "SELECT use_count, usefulness_score, confidence FROM memories WHERE memory_id = ?1",
7629            rusqlite::params![memory_id],
7630            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
7631        )
7632        .expect("stats")
7633    }
7634
7635    // Story 2.4: a standalone citation raises use_count + usefulness (outcome
7636    // signal applied because the run_id is the sentinel).
7637    #[test]
7638    fn standalone_cite_raises_usefulness() {
7639        with_user_brain_disabled(|| {
7640            let root = test_root();
7641            std::fs::create_dir_all(&root).expect("create root");
7642            init_project(&root, false).expect("init");
7643            let memory_id = add_memory(
7644                &root,
7645                kimetsu_core::memory::MemoryScope::Project,
7646                kimetsu_core::memory::MemoryKind::Fact,
7647                "standalone cite outcome fixture",
7648            )
7649            .expect("add memory");
7650
7651            let (uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
7652            record_mcp_citation(&root, &memory_id, None).expect("cite");
7653            let (uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
7654
7655            assert_eq!(uc1, uc0 + 1, "use_count must increment on standalone cite");
7656            assert!(us1 > us0, "usefulness must rise: {us0} -> {us1}");
7657            // A fresh memory starts below the ceiling (DIRECT_ADD_CONFIDENCE), so a
7658            // positive outcome nudges confidence UP toward 1.0 — letting a proven
7659            // memory outrank a never-evaluated one.
7660            assert!(
7661                cf1 > cf0,
7662                "confidence must rise toward 1.0 on a positive outcome: {cf0} -> {cf1}"
7663            );
7664            std::fs::remove_dir_all(&root).ok();
7665        });
7666    }
7667
7668    // Story 2.4: a manual regret lowers usefulness AND confidence.
7669    #[test]
7670    fn manual_regret_lowers_usefulness_and_confidence() {
7671        with_user_brain_disabled(|| {
7672            let root = test_root();
7673            std::fs::create_dir_all(&root).expect("create root");
7674            init_project(&root, false).expect("init");
7675            let memory_id = add_memory(
7676                &root,
7677                kimetsu_core::memory::MemoryScope::Project,
7678                kimetsu_core::memory::MemoryKind::Fact,
7679                "manual regret outcome fixture",
7680            )
7681            .expect("add memory");
7682
7683            let (_uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
7684            record_regret(&root, &memory_id).expect("regret");
7685            let (_uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
7686
7687            assert!(us1 < us0, "usefulness must drop on regret: {us0} -> {us1}");
7688            assert!(cf1 < cf0, "confidence must drop on regret: {cf0} -> {cf1}");
7689            std::fs::remove_dir_all(&root).ok();
7690        });
7691    }
7692
7693    // Story 2.4 safety: a citation tied to a REAL run does NOT bump stats in
7694    // apply_memory_cited (the run-finalization path owns that) — no double-count.
7695    #[test]
7696    fn real_run_cite_does_not_bump_in_apply_memory_cited() {
7697        with_user_brain_disabled(|| {
7698            let root = test_root();
7699            std::fs::create_dir_all(&root).expect("create root");
7700            init_project(&root, false).expect("init");
7701            let memory_id = add_memory(
7702                &root,
7703                kimetsu_core::memory::MemoryScope::Project,
7704                kimetsu_core::memory::MemoryKind::Fact,
7705                "real run cite fixture",
7706            )
7707            .expect("add memory");
7708
7709            let (uc0, us0, _cf0) = read_outcome_stats(&root, &memory_id);
7710
7711            // A memory.cited event with a NON-nil (real) run_id.
7712            let real_run = kimetsu_core::ids::RunId::new();
7713            let event = kimetsu_core::event::Event::new(
7714                real_run,
7715                "memory.cited",
7716                serde_json::json!({ "memory_id": memory_id, "turn": 0 }),
7717            );
7718            {
7719                let (_paths, _config, conn) = load_project(&root).expect("load");
7720                crate::projector::apply_events(&conn, std::slice::from_ref(&event)).expect("apply");
7721            }
7722
7723            let (uc1, us1, _cf1) = read_outcome_stats(&root, &memory_id);
7724            assert_eq!(uc1, uc0, "real-run cite must NOT increment use_count here");
7725            assert!(
7726                (us1 - us0).abs() < 1e-9,
7727                "real-run cite must NOT change usefulness here"
7728            );
7729            std::fs::remove_dir_all(&root).ok();
7730        });
7731    }
7732
7733    // Story 2.4: outcome stats are event-sourced — a full rebuild replays the
7734    // cite/regret events and reproduces the same use_count/usefulness/confidence.
7735    #[test]
7736    fn cite_outcome_survives_rebuild() {
7737        with_user_brain_disabled(|| {
7738            let root = test_root();
7739            std::fs::create_dir_all(&root).expect("create root");
7740            init_project(&root, false).expect("init");
7741            let memory_id = add_memory(
7742                &root,
7743                kimetsu_core::memory::MemoryScope::Project,
7744                kimetsu_core::memory::MemoryKind::Fact,
7745                "rebuild outcome fixture",
7746            )
7747            .expect("add memory");
7748            record_mcp_citation(&root, &memory_id, None).expect("cite");
7749
7750            let before = read_outcome_stats(&root, &memory_id);
7751            {
7752                let (_paths, _config, conn) = load_project(&root).expect("load");
7753                crate::projector::rebuild_in_place(&conn).expect("rebuild");
7754            }
7755            let after = read_outcome_stats(&root, &memory_id);
7756            assert_eq!(before.0, after.0, "use_count must survive rebuild");
7757            assert!(
7758                (before.1 - after.1).abs() < 1e-9,
7759                "usefulness must survive rebuild"
7760            );
7761            assert!(
7762                (before.2 - after.2).abs() < 1e-9,
7763                "confidence must survive rebuild"
7764            );
7765            std::fs::remove_dir_all(&root).ok();
7766        });
7767    }
7768
7769    // Age injection: set-age backdates created_at (and survives rebuild).
7770    #[test]
7771    fn set_age_backdates_created_at_and_survives_rebuild() {
7772        with_user_brain_disabled(|| {
7773            let root = test_root();
7774            std::fs::create_dir_all(&root).expect("create root");
7775            init_project(&root, false).expect("init");
7776            let memory_id = add_memory(
7777                &root,
7778                kimetsu_core::memory::MemoryScope::Project,
7779                kimetsu_core::memory::MemoryKind::Fact,
7780                "age injection fixture",
7781            )
7782            .expect("add memory");
7783
7784            let read_created = |root: &std::path::Path| -> String {
7785                let (_paths, _config, conn) = load_project(root).expect("load");
7786                conn.query_row(
7787                    "SELECT created_at FROM memories WHERE memory_id = ?1",
7788                    rusqlite::params![&memory_id],
7789                    |r| r.get::<_, String>(0),
7790                )
7791                .expect("created_at")
7792            };
7793
7794            let created0 = read_created(&root);
7795            record_set_age(&root, &memory_id, 90).expect("set-age");
7796            let created1 = read_created(&root);
7797            // RFC3339 strings sort chronologically; 90 days ago < now.
7798            assert!(
7799                created1 < created0,
7800                "created_at must move into the past: {created0} -> {created1}"
7801            );
7802
7803            {
7804                let (_paths, _config, conn) = load_project(&root).expect("load");
7805                crate::projector::rebuild_in_place(&conn).expect("rebuild");
7806            }
7807            assert_eq!(
7808                read_created(&root),
7809                created1,
7810                "aged created_at survives rebuild"
7811            );
7812            std::fs::remove_dir_all(&root).ok();
7813        });
7814    }
7815
7816    // ------------------------------------------------------------------
7817    // Fix 2: search_memories must not return superseded rows
7818    // ------------------------------------------------------------------
7819    #[test]
7820    fn fix2_search_excludes_superseded_rows() {
7821        with_user_brain_disabled(|| {
7822            let root = test_root();
7823            init_project(&root, false).expect("init");
7824
7825            // Add a memory that will be superseded, and a live one.
7826            let superseded_id = add_memory(
7827                &root,
7828                MemoryScope::Project,
7829                MemoryKind::Fact,
7830                "unique superseded keyword alpha",
7831            )
7832            .expect("add superseded");
7833            add_memory(
7834                &root,
7835                MemoryScope::Project,
7836                MemoryKind::Fact,
7837                "live memory unrelated topic",
7838            )
7839            .expect("add live");
7840
7841            // Mark the first memory as superseded via direct SQL (simulating
7842            // a prior consolidation run).
7843            {
7844                let (_paths, _config, conn) = load_project(&root).expect("load for stamp");
7845                conn.execute(
7846                    "UPDATE memories SET superseded_by = 'fake-survivor' \
7847                     WHERE memory_id = ?1",
7848                    rusqlite::params![&superseded_id],
7849                )
7850                .expect("stamp superseded_by");
7851            }
7852
7853            // Search must not return the superseded row.
7854            let hits = search_memories(&root, "unique superseded keyword alpha", 20, 0, None, None)
7855                .expect("search");
7856            assert!(
7857                !hits.iter().any(|h| h.memory_id == superseded_id),
7858                "superseded row must not appear in search results"
7859            );
7860
7861            std::fs::remove_dir_all(&root).ok();
7862        });
7863    }
7864
7865    // ------------------------------------------------------------------
7866    // Fix 4: list_memories_top and prune_low_usefulness must not include
7867    // superseded rows
7868    // ------------------------------------------------------------------
7869    #[test]
7870    fn fix4_top_excludes_superseded_rows() {
7871        with_user_brain_disabled(|| {
7872            let root = test_root();
7873            init_project(&root, false).expect("init");
7874
7875            // Add a memory and give it a high score + use_count.
7876            let superseded_id = add_memory(
7877                &root,
7878                MemoryScope::Project,
7879                MemoryKind::Fact,
7880                "memory to be superseded with high usefulness",
7881            )
7882            .expect("add");
7883
7884            // Stamp it as superseded AND give it high stats.
7885            {
7886                let (_paths, _config, conn) = load_project(&root).expect("load");
7887                conn.execute(
7888                    "UPDATE memories \
7889                     SET superseded_by = 'fake-survivor', \
7890                         use_count = 10, usefulness_score = 50.0 \
7891                     WHERE memory_id = ?1",
7892                    rusqlite::params![&superseded_id],
7893                )
7894                .expect("stamp");
7895            }
7896
7897            // Also add a live memory with lower but real stats.
7898            let live_id = add_memory(
7899                &root,
7900                MemoryScope::Project,
7901                MemoryKind::Fact,
7902                "live memory with normal stats",
7903            )
7904            .expect("add live");
7905            {
7906                let (_paths, _config, conn) = load_project(&root).expect("load");
7907                conn.execute(
7908                    "UPDATE memories SET use_count = 5, usefulness_score = 5.0 \
7909                     WHERE memory_id = ?1",
7910                    rusqlite::params![&live_id],
7911                )
7912                .expect("seed stats");
7913            }
7914
7915            let opts = TopOptions {
7916                scope: None,
7917                min_uses: 1,
7918                limit: 20,
7919            };
7920            let top = list_memories_top(&root, opts).expect("list_memories_top");
7921
7922            assert!(
7923                !top.iter().any(|r| r.memory_id == superseded_id),
7924                "superseded row must not appear in top"
7925            );
7926            assert!(
7927                top.iter().any(|r| r.memory_id == live_id),
7928                "live row must appear in top"
7929            );
7930
7931            std::fs::remove_dir_all(&root).ok();
7932        });
7933    }
7934
7935    #[test]
7936    fn fix4_prune_excludes_superseded_rows() {
7937        with_user_brain_disabled(|| {
7938            let root = test_root();
7939            init_project(&root, false).expect("init");
7940
7941            let superseded_id = add_memory(
7942                &root,
7943                MemoryScope::Project,
7944                MemoryKind::Fact,
7945                "memory to be superseded with low usefulness",
7946            )
7947            .expect("add");
7948
7949            // Stamp it as superseded AND give it a very negative score.
7950            {
7951                let (_paths, _config, conn) = load_project(&root).expect("load");
7952                conn.execute(
7953                    "UPDATE memories \
7954                     SET superseded_by = 'fake-survivor', \
7955                         use_count = 10, usefulness_score = -99.0 \
7956                     WHERE memory_id = ?1",
7957                    rusqlite::params![&superseded_id],
7958                )
7959                .expect("stamp");
7960            }
7961
7962            // A live memory with a negative score (qualifies for prune).
7963            let live_id = add_memory(
7964                &root,
7965                MemoryScope::Project,
7966                MemoryKind::Fact,
7967                "live memory with negative usefulness for prune",
7968            )
7969            .expect("add live");
7970            {
7971                let (_paths, _config, conn) = load_project(&root).expect("load");
7972                conn.execute(
7973                    "UPDATE memories SET use_count = 5, usefulness_score = -5.0 \
7974                     WHERE memory_id = ?1",
7975                    rusqlite::params![&live_id],
7976                )
7977                .expect("seed stats");
7978            }
7979
7980            let opts = PruneOptions {
7981                scope: None,
7982                min_uses: 1,
7983                max_ratio: -0.1,
7984                apply: false,
7985            };
7986            let summary = prune_low_usefulness(&root, opts).expect("prune");
7987
7988            assert!(
7989                !summary
7990                    .candidates
7991                    .iter()
7992                    .any(|c| c.memory_id == superseded_id),
7993                "superseded row must not appear in prune candidates"
7994            );
7995            assert!(
7996                summary.candidates.iter().any(|c| c.memory_id == live_id),
7997                "live negative-score row must appear in prune candidates"
7998            );
7999
8000            std::fs::remove_dir_all(&root).ok();
8001        });
8002    }
8003
8004    // ── add_memories_batch ────────────────────────────────────────────────────
8005
8006    /// Core correctness: N memories added via add_memories_batch must be
8007    /// present, retrievable, and survive rebuild_in_place — byte-identical to
8008    /// memories written by individual add_memory calls.
8009    ///
8010    /// Embedding check: in the lean build the active embedder is NoopEmbedder
8011    /// (embedding IS NULL), exactly the same as for single-add. In the
8012    /// `--features embeddings` build a real model is loaded once and all
8013    /// entries get non-NULL embeddings. The test asserts consistency: every
8014    /// batch-added memory has the same embedding_model value as a single-added
8015    /// memory written in the same process.
8016    #[test]
8017    fn add_memories_batch_present_retrievable_rebuild_safe() {
8018        with_user_brain_disabled(|| {
8019            let root = test_root();
8020            fs::create_dir_all(&root).expect("create temp project");
8021            init_project(&root, false).expect("init project");
8022
8023            // --- Build 5 distinct batch entries ----------------------------
8024            let entries: Vec<BatchMemoryEntry> = (1..=5)
8025                .map(|i| BatchMemoryEntry {
8026                    text: format!(
8027                        "batch memory entry number {i} unique text for semantic distance"
8028                    ),
8029                    scope: kimetsu_core::memory::MemoryScope::Project,
8030                    kind: kimetsu_core::memory::MemoryKind::Fact,
8031                    valid_from: None,
8032                    valid_to: None,
8033                })
8034                .collect();
8035
8036            let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
8037
8038            // Correct count returned.
8039            assert_eq!(ids.len(), 5, "expected 5 ids back; got {:?}", ids);
8040            // All ids must be non-empty strings (valid ULIDs).
8041            for id in &ids {
8042                assert!(!id.is_empty(), "id must not be empty");
8043            }
8044
8045            // --- All memories visible in list --------------------------------
8046            let memories = list_memories(&root).expect("list_memories after batch");
8047            assert_eq!(
8048                memories.len(),
8049                5,
8050                "list_memories should return 5; got {:?}",
8051                memories.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
8052            );
8053            let stored_ids: Vec<_> = memories.iter().map(|m| m.memory_id.clone()).collect();
8054            for id in &ids {
8055                assert!(stored_ids.contains(id), "id {id} must be in list_memories");
8056            }
8057
8058            // --- Embedding consistency: batch == single-add for this build ---
8059            // Both paths call open_embedder_for once. In lean builds both
8060            // produce NULL (Noop). In the embeddings build both produce a real
8061            // model string. Confirm all batch rows share the same model as the
8062            // reference single-add row.
8063            let ref_id = add_memory(
8064                &root,
8065                kimetsu_core::memory::MemoryScope::Project,
8066                kimetsu_core::memory::MemoryKind::Fact,
8067                "single-add reference for embedding consistency check",
8068            )
8069            .expect("single add ref");
8070            {
8071                let (_paths, _config, conn) = load_project(&root).expect("load project");
8072                let ref_model: Option<String> = conn
8073                    .query_row(
8074                        "SELECT embedding_model FROM memories WHERE memory_id = ?1",
8075                        rusqlite::params![ref_id],
8076                        |r| r.get(0),
8077                    )
8078                    .expect("query ref embedding_model");
8079
8080                // All batch-added memories must have the same embedding_model.
8081                for id in &ids {
8082                    let bm: Option<String> = conn
8083                        .query_row(
8084                            "SELECT embedding_model FROM memories WHERE memory_id = ?1",
8085                            rusqlite::params![id],
8086                            |r| r.get(0),
8087                        )
8088                        .expect("query batch embedding_model");
8089                    assert_eq!(
8090                        bm, ref_model,
8091                        "batch memory {id} embedding_model ({bm:?}) must match single-add ref ({ref_model:?})"
8092                    );
8093                }
8094            }
8095
8096            // --- Survive rebuild_in_place ------------------------------------
8097            // After rebuild: 5 batch + 1 single-add = 6 active memories.
8098            {
8099                let (_paths, _config, conn) = load_project(&root).expect("load for rebuild");
8100                crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
8101                let after_count: i64 = conn
8102                    .query_row(
8103                        "SELECT COUNT(*) FROM memories \
8104                         WHERE invalidated_at IS NULL AND superseded_by IS NULL",
8105                        [],
8106                        |r| r.get(0),
8107                    )
8108                    .expect("count after rebuild");
8109                assert_eq!(
8110                    after_count, 6,
8111                    "all 6 memories (5 batch + 1 single) must survive rebuild_in_place; got {after_count}"
8112                );
8113                let rebuilt_ids: Vec<String> = {
8114                    let mut stmt = conn
8115                        .prepare(
8116                            "SELECT memory_id FROM memories \
8117                             WHERE invalidated_at IS NULL AND superseded_by IS NULL",
8118                        )
8119                        .expect("prepare");
8120                    stmt.query_map([], |r| r.get(0))
8121                        .expect("query")
8122                        .map(|r| r.expect("row"))
8123                        .collect()
8124                };
8125                for id in &ids {
8126                    assert!(
8127                        rebuilt_ids.contains(id),
8128                        "id {id} must survive rebuild_in_place"
8129                    );
8130                }
8131            }
8132
8133            // --- Temporal fields (valid_from / valid_to) survive rebuild -----
8134            let temporal_entries = vec![BatchMemoryEntry {
8135                text: "batch temporal test this fact expires soon".to_string(),
8136                scope: kimetsu_core::memory::MemoryScope::Project,
8137                kind: kimetsu_core::memory::MemoryKind::Fact,
8138                valid_from: Some("2025-01-01T00:00:00Z".to_string()),
8139                valid_to: Some("2099-12-31T00:00:00Z".to_string()),
8140            }];
8141            let temporal_ids =
8142                add_memories_batch(&root, temporal_entries).expect("add_memories_batch temporal");
8143            assert_eq!(temporal_ids.len(), 1);
8144            let temporal_id = &temporal_ids[0];
8145            {
8146                let (_paths, _config, conn) = load_project(&root).expect("load for temporal check");
8147                let (vf, vt): (Option<String>, Option<String>) = conn
8148                    .query_row(
8149                        "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
8150                        rusqlite::params![temporal_id],
8151                        |r| Ok((r.get(0)?, r.get(1)?)),
8152                    )
8153                    .expect("query valid_from/valid_to");
8154                assert!(
8155                    vf.is_some(),
8156                    "valid_from must be set for temporal batch entry"
8157                );
8158                assert!(
8159                    vt.is_some(),
8160                    "valid_to must be set for temporal batch entry"
8161                );
8162                // Survive rebuild.
8163                crate::projector::rebuild_in_place(&conn).expect("rebuild temporal");
8164                let (vf2, vt2): (Option<String>, Option<String>) = conn
8165                    .query_row(
8166                        "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
8167                        rusqlite::params![temporal_id],
8168                        |r| Ok((r.get(0)?, r.get(1)?)),
8169                    )
8170                    .expect("query after rebuild");
8171                assert_eq!(vf, vf2, "valid_from must survive rebuild");
8172                assert_eq!(vt, vt2, "valid_to must survive rebuild");
8173            }
8174
8175            fs::remove_dir_all(&root).ok();
8176        });
8177    }
8178
8179    /// Dedup: calling add_memories_batch with the same text twice must return
8180    /// the same memory_id both times without writing a duplicate row.
8181    #[test]
8182    fn add_memories_batch_deduplicates() {
8183        with_user_brain_disabled(|| {
8184            let root = test_root();
8185            fs::create_dir_all(&root).expect("create temp project");
8186            init_project(&root, false).expect("init project");
8187
8188            let text = "batch dedup test unique entry";
8189            let entries = vec![
8190                BatchMemoryEntry {
8191                    text: text.to_string(),
8192                    scope: kimetsu_core::memory::MemoryScope::Project,
8193                    kind: kimetsu_core::memory::MemoryKind::Fact,
8194                    valid_from: None,
8195                    valid_to: None,
8196                },
8197                BatchMemoryEntry {
8198                    text: text.to_string(),
8199                    scope: kimetsu_core::memory::MemoryScope::Project,
8200                    kind: kimetsu_core::memory::MemoryKind::Fact,
8201                    valid_from: None,
8202                    valid_to: None,
8203                },
8204            ];
8205
8206            let ids = add_memories_batch(&root, entries).expect("add_memories_batch dedup");
8207            assert_eq!(ids.len(), 2);
8208            assert_eq!(
8209                ids[0], ids[1],
8210                "duplicate text must return the same memory_id"
8211            );
8212
8213            // Only one row in the DB.
8214            let memories = list_memories(&root).expect("list");
8215            assert_eq!(
8216                memories.len(),
8217                1,
8218                "deduped batch must produce exactly 1 DB row; got {}",
8219                memories.len()
8220            );
8221
8222            fs::remove_dir_all(&root).ok();
8223        });
8224    }
8225
8226    /// Embedder-loaded-once structural check: add_memories_batch calls
8227    /// open_embedder_for exactly once before the loop. This test confirms that
8228    /// all batch-added memories have the same embedding_model value — a
8229    /// necessary condition for single-load: if the embedder were re-initialized
8230    /// per entry, different initializations could produce different model ids.
8231    ///
8232    /// In the lean build all entries have NULL embedding_model (Noop).
8233    /// In the embeddings build all entries share the same real model id.
8234    /// Either way: all N values are identical.
8235    #[test]
8236    fn add_memories_batch_all_entries_same_embedding_model() {
8237        with_user_brain_disabled(|| {
8238            let root = test_root();
8239            fs::create_dir_all(&root).expect("create temp project");
8240            init_project(&root, false).expect("init project");
8241
8242            let n = 8_usize;
8243            let entries: Vec<BatchMemoryEntry> = (0..n)
8244                .map(|i| BatchMemoryEntry {
8245                    text: format!(
8246                        "embedding model consistency test memory {i} distinct content here"
8247                    ),
8248                    scope: kimetsu_core::memory::MemoryScope::Project,
8249                    kind: kimetsu_core::memory::MemoryKind::Convention,
8250                    valid_from: None,
8251                    valid_to: None,
8252                })
8253                .collect();
8254
8255            let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
8256            assert_eq!(ids.len(), n);
8257
8258            let (_paths, _config, conn) = load_project(&root).expect("load project");
8259            let model_id_rows: Vec<Option<String>> = {
8260                let mut stmt = conn
8261                    .prepare("SELECT embedding_model FROM memories ORDER BY created_at")
8262                    .expect("prepare");
8263                stmt.query_map([], |r| r.get(0))
8264                    .expect("query")
8265                    .map(|r| r.expect("row"))
8266                    .collect()
8267            };
8268            assert_eq!(model_id_rows.len(), n, "expected {n} rows");
8269            // All entries must share the same embedding_model value (even if NULL).
8270            let first = &model_id_rows[0];
8271            for (i, model_id) in model_id_rows.iter().enumerate() {
8272                assert_eq!(
8273                    model_id, first,
8274                    "memory {i} embedding_model ({model_id:?}) must match first ({first:?})"
8275                );
8276            }
8277
8278            fs::remove_dir_all(&root).ok();
8279        });
8280    }
8281}