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