Skip to main content

kimetsu_brain/
project.rs

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