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