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_SCHEMA_VERSION, KimetsuResult};
11use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
12use ulid::Ulid;
13
14use crate::benchmark;
15use crate::conflict;
16use crate::context::{self, ContextBundle, ContextRequest};
17use crate::embeddings;
18use crate::ingest::{self, RepoIngestSummary};
19use crate::lock::ProjectLock;
20use crate::projector;
21use crate::redact;
22use crate::schema;
23use crate::trace::{self, TraceWriter};
24use crate::user_brain;
25
26#[derive(Debug, Clone)]
27pub struct InitSummary {
28    pub project_id: String,
29    pub repo_root: PathBuf,
30    pub kimetsu_dir: PathBuf,
31    pub brain_db: PathBuf,
32    pub model: String,
33    pub api_key_env: String,
34    pub api_key_present: bool,
35    pub wrote_project_toml: bool,
36}
37
38#[derive(Debug, Clone)]
39pub struct RunSummary {
40    pub run_id: String,
41    pub task: String,
42    pub started_at: String,
43    pub terminal_kind: Option<String>,
44}
45
46#[derive(Debug, Clone)]
47pub struct MemoryRow {
48    pub memory_id: String,
49    pub scope: String,
50    pub kind: String,
51    pub text: String,
52    pub confidence: f32,
53    pub use_count: u32,
54    /// MP-4a: running net outcome score. +1 for each run.finished that
55    /// surfaced this memory; -1 for each run.failed (excluding Gate
56    /// failures). Use_count tracks all updates, useful as a small-sample
57    /// guard before letting the score bias retrieval.
58    pub usefulness_score: f32,
59}
60
61/// v0.8: a full-text search hit over memory text, returned by
62/// [`search_memories`] and the `kimetsu_brain_memory_search` MCP tool.
63/// `rank` is the BM25-derived relevance (higher = more relevant).
64#[derive(Debug, Clone)]
65pub struct MemorySearchHit {
66    pub memory_id: String,
67    pub scope: String,
68    pub kind: String,
69    pub text: String,
70    pub rank: f32,
71}
72
73#[derive(Debug, Clone)]
74pub struct ProposalRow {
75    pub proposal_id: String,
76    pub run_id: String,
77    pub scope: String,
78    pub kind: String,
79    pub text: String,
80    pub rationale: String,
81    pub proposed_confidence: f32,
82    pub status: String,
83    pub decided_reason: Option<String>,
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct ProposalFilter {
88    pub scope: Option<String>,
89    pub kind: Option<String>,
90    pub from_run: Option<String>,
91    pub min_confidence: Option<f32>,
92    pub status: Option<String>,
93    pub limit: u32,
94    /// v0.8: row offset for paginated navigation from the MCP surface.
95    /// 0 = first page (prior behaviour).
96    pub offset: u32,
97}
98
99#[derive(Debug, Clone, Default)]
100pub struct AcceptOverrides {
101    pub scope: Option<String>,
102    pub confidence: Option<f32>,
103}
104
105#[derive(Debug, Clone)]
106pub struct RecordedBenchmarkOutcome {
107    pub memory_id: String,
108    pub task_slug: Option<String>,
109    pub kind: MemoryKind,
110    pub text: String,
111    pub proposal_id: Option<String>,
112    pub proposal_text: Option<String>,
113}
114
115// v0.5.1: blame surface — per-run memory attribution. Both the CLI
116// (`kimetsu brain memory blame <run-id>`) and the MCP tool
117// (`kimetsu_brain_memory_blame`) consume `BlameReport`.
118
119#[derive(Debug, Clone, serde::Serialize)]
120pub struct BlameReport {
121    pub run_id: String,
122    /// Terminal outcome of the run: "success" (run.finished),
123    /// "failed" (run.failed), "aborted" (run.aborted), or "unknown"
124    /// (no terminal event found yet).
125    pub outcome: String,
126    /// Failure category when outcome is "failed" (e.g. "Gate",
127    /// "Implementation"). None otherwise.
128    pub failure_category: Option<String>,
129    /// Memories the model explicitly cited via `cite_memory`,
130    /// ordered by turn.
131    pub cited: Vec<CitedMemory>,
132    /// Memories that were retrieved into the run's context but
133    /// never cited. They got the weak ±0.1 signal instead of ±1.0.
134    pub silent_passengers: Vec<SilentMemory>,
135}
136
137#[derive(Debug, Clone, serde::Serialize)]
138pub struct CitedMemory {
139    pub memory_id: String,
140    pub turn: i64,
141    pub rationale: Option<String>,
142    pub cited_at: String,
143    /// Truncated memory text for human-readable output.
144    pub text_preview: String,
145    pub scope: String,
146    pub kind: String,
147}
148
149#[derive(Debug, Clone, serde::Serialize)]
150pub struct SilentMemory {
151    pub memory_id: String,
152    pub text_preview: String,
153    pub scope: String,
154    pub kind: String,
155}
156
157pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
158    let paths = ProjectPaths::discover(start)?;
159    fs::create_dir_all(&paths.runs_dir)?;
160
161    let project_id = default_project_id(&paths.repo_root);
162    let config = ProjectConfig::default_for_project(project_id);
163    let wrote_project_toml = if force || !paths.project_toml.exists() {
164        fs::write(&paths.project_toml, config.to_toml()?)?;
165        true
166    } else {
167        false
168    };
169
170    let config = load_config(&paths)?;
171    let conn = Connection::open(&paths.brain_db)?;
172    schema::initialize(&conn)?;
173
174    let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
175
176    Ok(InitSummary {
177        project_id: config.kimetsu.project_id,
178        repo_root: paths.repo_root,
179        kimetsu_dir: paths.kimetsu_dir,
180        brain_db: paths.brain_db,
181        model: format!("{}/{}", config.model.provider, config.model.model),
182        api_key_env: config.model.api_key_env,
183        api_key_present,
184        wrote_project_toml,
185    })
186}
187
188pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
189    let paths = ProjectPaths::discover(start)?;
190    let config = load_config(&paths)?;
191    if config.kimetsu.schema_version != KIMETSU_SCHEMA_VERSION {
192        return Err(format!(
193            "project.toml schema version {} does not match expected {}",
194            config.kimetsu.schema_version, KIMETSU_SCHEMA_VERSION
195        )
196        .into());
197    }
198
199    let conn = Connection::open(&paths.brain_db)?;
200    schema::initialize(&conn)?;
201    Ok((paths, config, conn))
202}
203
204pub fn load_project_readonly(
205    start: &Path,
206) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
207    let paths = ProjectPaths::discover(start)?;
208    let config = load_config(&paths)?;
209    if config.kimetsu.schema_version != KIMETSU_SCHEMA_VERSION {
210        return Err(format!(
211            "project.toml schema version {} does not match expected {}",
212            config.kimetsu.schema_version, KIMETSU_SCHEMA_VERSION
213        )
214        .into());
215    }
216
217    let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
218    schema::validate(&conn)?;
219    Ok((paths, config, conn))
220}
221
222pub struct BrainSession {
223    paths: ProjectPaths,
224    config: ProjectConfig,
225    conn: Connection,
226    /// v0.4.1: user-scope brain at `~/.kimetsu/brain.db`. Opened
227    /// lazily during session construction; `None` when the user
228    /// brain is disabled (`KIMETSU_USER_BRAIN=0`), no home dir is
229    /// resolvable, or — for the read-only constructor — the file
230    /// hasn't been created yet. Retrieval merges memories from this
231    /// connection alongside the project DB; repo files and manifests
232    /// stay project-only.
233    user_conn: Option<Connection>,
234    repo_root: String,
235}
236
237impl BrainSession {
238    pub fn open(start: &Path) -> KimetsuResult<Self> {
239        let (paths, config, conn) = load_project(start)?;
240        // Read/write user brain — created on demand so a v0.4 binary
241        // running on a v0.3 home dir provisions the file the first
242        // time the user actually writes a GlobalUser memory.
243        let user_conn = user_brain::open_user_brain()?;
244        Self::from_parts(paths, config, conn, user_conn)
245    }
246
247    pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
248        let (paths, config, conn) = load_project_readonly(start)?;
249        // Read-only path skips file creation — if the user brain
250        // doesn't exist yet we just retrieve from the project DB
251        // alone, no surprise file under $HOME.
252        let user_conn = user_brain::open_user_brain_readonly()?;
253        Self::from_parts(paths, config, conn, user_conn)
254    }
255
256    fn from_parts(
257        paths: ProjectPaths,
258        config: ProjectConfig,
259        conn: Connection,
260        user_conn: Option<Connection>,
261    ) -> KimetsuResult<Self> {
262        let repo_root = paths
263            .repo_root
264            .canonicalize()?
265            .to_string_lossy()
266            .to_string();
267        Ok(Self {
268            paths,
269            config,
270            conn,
271            user_conn,
272            repo_root,
273        })
274    }
275
276    pub fn retrieve_context(
277        &self,
278        stage: &str,
279        query: &str,
280        budget_tokens: u32,
281    ) -> KimetsuResult<ContextBundle> {
282        self.retrieve_context_with_request(ContextRequest {
283            stage: stage.to_string(),
284            query: query.to_string(),
285            budget_tokens,
286            ..Default::default()
287        })
288    }
289
290    /// v0.6: full-request variant used by `kimetsu_brain_context` MCP tool
291    /// and `retrieve_context_readonly_with_request` to expose `tags`,
292    /// `min_score`, `max_capsules`, and `prefer_roles`.
293    pub fn retrieve_context_with_request(
294        &self,
295        request: ContextRequest,
296    ) -> KimetsuResult<ContextBundle> {
297        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
298        context::retrieve_context_multi(
299            &self.conn,
300            &self.repo_root,
301            &self.config.broker.weights,
302            request,
303            &extras,
304        )
305    }
306
307    /// v0.8: proactive (mid-work) retrieval. Pins [`NoopEmbedder`] so
308    /// it stays lexical-FTS-only — NO embedding model is loaded even in
309    /// `--features embeddings` builds, keeping the per-tool-call hook
310    /// cheap. `request.kinds` should restrict to actionable kinds; the
311    /// caller sets a high `min_score` and `max_capsules: 1` so recall is
312    /// rare and confident (the human-brain "it comes to you" model).
313    pub fn retrieve_proactive(&self, request: ContextRequest) -> KimetsuResult<ContextBundle> {
314        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
315        context::retrieve_context_with_embedder(
316            &self.conn,
317            &self.repo_root,
318            &self.config.broker.weights,
319            request,
320            &extras,
321            &embeddings::NoopEmbedder,
322        )
323    }
324
325    pub fn repo_root(&self) -> &Path {
326        &self.paths.repo_root
327    }
328
329    /// v0.4.1: expose the user-brain connection so callers (e.g.
330    /// `kimetsu brain status`) can report counts/paths without
331    /// re-opening the file. Returns None when the user brain is
332    /// disabled or unresolvable.
333    pub fn user_conn(&self) -> Option<&Connection> {
334        self.user_conn.as_ref()
335    }
336}
337
338pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
339    let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
340        format!(
341            "failed to read {}; run `kimetsu init` first: {err}",
342            paths.project_toml.display()
343        )
344    })?;
345    ProjectConfig::from_toml(&content)
346}
347
348pub fn config_text(start: &Path) -> KimetsuResult<String> {
349    let paths = ProjectPaths::discover(start)?;
350    Ok(fs::read_to_string(paths.project_toml)?)
351}
352
353pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
354    let (_paths, _config, conn) = load_project(start)?;
355    let mut stmt = conn.prepare(
356        "
357        SELECT run_id, task, started_at, terminal_kind
358        FROM runs
359        ORDER BY started_at DESC
360        LIMIT 100
361        ",
362    )?;
363
364    let rows = stmt.query_map([], |row| {
365        Ok(RunSummary {
366            run_id: row.get(0)?,
367            task: row.get(1)?,
368            started_at: row.get(2)?,
369            terminal_kind: row.get(3)?,
370        })
371    })?;
372
373    let mut runs = Vec::new();
374    for row in rows {
375        runs.push(row?);
376    }
377    Ok(runs)
378}
379
380pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
381    let (_paths, _config, conn) = load_project(start)?;
382    let mut stmt = conn.prepare(
383        "
384        SELECT run_id, task, started_at, terminal_kind
385        FROM runs
386        WHERE run_id = ?1
387        ",
388    )?;
389
390    let mut rows = stmt.query(params![run_id])?;
391    if let Some(row) = rows.next()? {
392        Ok(Some(RunSummary {
393            run_id: row.get(0)?,
394            task: row.get(1)?,
395            started_at: row.get(2)?,
396            terminal_kind: row.get(3)?,
397        }))
398    } else {
399        Ok(None)
400    }
401}
402
403pub fn add_memory(
404    start: &Path,
405    scope: MemoryScope,
406    kind: MemoryKind,
407    text: &str,
408) -> KimetsuResult<String> {
409    // v0.4.5: redact secrets at the ingest boundary. The redaction
410    // pipeline catches Anthropic/OpenAI/GitHub/AWS/Slack/Google
411    // credentials, JWTs, PEM blocks, and generic `api_key=...` /
412    // `bearer ...` / `token: ...` assignments. A leak that lands in
413    // brain.db is durable, replicated across user / project scopes,
414    // and shows up in every retrieval — better to false-positive on
415    // a config string than to leak a real key.
416    //
417    // On a hit we replace the bytes with `[REDACTED:<kind>]` and
418    // print a one-liner to stderr so the operator notices. We do
419    // NOT fail the write: keeping the user memorable (the rest of
420    // the text) is more useful than rejecting outright.
421    let redaction = redact::redact_secrets(text);
422    if redaction.was_redacted() {
423        eprintln!("kimetsu-brain: {}", redaction.summary());
424    }
425    let text = redaction.text.as_str();
426
427    // v0.4.1: GlobalUser memories route to `~/.kimetsu/brain.db` when
428    // the user brain is enabled. The user-brain write path is
429    // intentionally simpler (no run rows, no trace events, no project
430    // lock) because there's no project to attribute them to.
431    //
432    // If the user brain is disabled (KIMETSU_USER_BRAIN=0) OR
433    // unreachable (no $HOME), fall through to the project DB so
434    // backward compat is preserved — existing scripts that wrote
435    // GlobalUser memories into the project keep working.
436    if scope == MemoryScope::GlobalUser
437        && let Some(user_conn) = user_brain::open_user_brain()?
438    {
439        return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
440    }
441    let (paths, config, conn) = load_project(start)?;
442    let run_id = RunId::new();
443    let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
444    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
445    let memory_id = Ulid::new().to_string();
446    let normalized = normalize_memory_text(text);
447
448    // MP-17 #14: dedup. If an ACTIVE memory with the same scope + kind +
449    // normalized text already exists, return its ID without writing a
450    // duplicate. The scope/kind tuple keeps task-specific duplicates
451    // separate from global ones; the normalized form makes minor
452    // whitespace / punctuation differences collapse to the same row.
453    let existing: Option<String> = conn
454        .query_row(
455            "
456            SELECT memory_id FROM memories
457            WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
458              AND invalidated_at IS NULL
459            LIMIT 1
460            ",
461            rusqlite::params![scope.to_string(), kind.to_string(), normalized],
462            |row| row.get::<_, String>(0),
463        )
464        .optional()?;
465    if let Some(existing_id) = existing {
466        return Ok(existing_id);
467    }
468
469    let started = Event::new(
470        run_id,
471        "run.started",
472        serde_json::json!({
473            "mode": "admin",
474            "task": "memory add",
475            "project_id": config.kimetsu.project_id,
476            "repo_root": paths.repo_root.to_string_lossy(),
477            "model": null,
478            "platform": std::env::consts::OS,
479            "kimetsu_version": env!("CARGO_PKG_VERSION"),
480            "config_hash": config_hash(&paths.project_toml)?,
481        }),
482    );
483    writer.append(&started, true)?;
484
485    let accepted = Event::new(
486        run_id,
487        "memory.accepted",
488        serde_json::json!({
489            "proposal_id": null,
490            "memory_id": memory_id,
491            "scope": scope.to_string(),
492            "kind": kind.to_string(),
493            "text": text,
494            "normalized_text": normalized,
495            "confidence": 1.0,
496            "provenance_snapshot": {
497                "source": "manual_cli",
498                "run_id": run_id.to_string(),
499                "text": text,
500            }
501        }),
502    );
503    writer.append(&accepted, true)?;
504
505    let finished = Event::new(
506        run_id,
507        "run.finished",
508        serde_json::json!({
509            "status": "success",
510            "final_report_path": null,
511            "total_cost_usd": 0.0,
512            "total_tool_calls": 0,
513        }),
514    );
515    writer.append(&finished, true)?;
516
517    projector::apply_events(&conn, &[started, accepted, finished])?;
518
519    // v0.4.2: post-projection embedding write. v0.4.3 wired the
520    // default embedder behind a feature flag — see
521    // `embeddings::open_default_embedder`. Default build: NoopEmbedder
522    // (column stays NULL, FTS only). `--features embeddings` build:
523    // fastembed-rs BGE-small by default, configurable via
524    // KIMETSU_BRAIN_EMBEDDER. The embedder is cached in a
525    // process-static OnceLock so we only pay model-load cost once.
526    let embedder = embeddings::open_default_embedder();
527    embeddings::embed_and_persist(&conn, &memory_id, text, embedder)?;
528
529    // v0.5.2: conflict detection at ingest. Scans for high-cosine,
530    // different-text neighbors in the same scope and logs each pair
531    // to `memory_conflicts` for operator review via
532    // `kimetsu brain memory conflicts`. Best-effort: NoopEmbedder
533    // (lean build) returns 0 hits; embedder failures degrade to a
534    // stderr line, never to a failed insert.
535    let conflicts =
536        conflict::detect_and_record(&conn, &memory_id, &scope, &kind.to_string(), text, embedder);
537    if conflicts > 0 {
538        eprintln!(
539            "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
540            if conflicts == 1 { "y" } else { "ies" }
541        );
542    }
543
544    Ok(memory_id)
545}
546
547/// v0.6: write a `memory.proposed` event (pending proposal) without
548/// accepting it immediately. Used by `kimetsu_brain_record` when confidence
549/// is low and the lesson needs human review before entering the retrieval pool.
550/// Returns the `proposal_id`.
551pub fn propose_memory(
552    start: &Path,
553    scope: MemoryScope,
554    kind: MemoryKind,
555    text: &str,
556    confidence: f32,
557    rationale: &str,
558) -> KimetsuResult<String> {
559    let redaction = redact::redact_secrets(text);
560    if redaction.was_redacted() {
561        eprintln!("kimetsu-brain: {}", redaction.summary());
562    }
563    let text = redaction.text.as_str();
564    let (paths, config, conn) = load_project(start)?;
565    let run_id = RunId::new();
566    let _lock = ProjectLock::acquire(&paths, "memory propose", Some(run_id))?;
567    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
568    let proposal_id = Ulid::new().to_string();
569
570    let started = admin_started_event(&paths, &config, run_id, "memory propose")?;
571    writer.append(&started, true)?;
572
573    let proposed = Event::new(
574        run_id,
575        "memory.proposed",
576        serde_json::json!({
577            "proposal_id": proposal_id,
578            "scope": scope.to_string(),
579            "kind": kind.to_string(),
580            "text": text,
581            "rationale": rationale,
582            "proposed_confidence": confidence.clamp(0.0, 1.0),
583            "source_event_ids": [],
584        }),
585    );
586    writer.append(&proposed, true)?;
587
588    let finished = admin_finished_event(run_id);
589    writer.append(&finished, true)?;
590
591    projector::apply_events(&conn, &[started, proposed, finished])?;
592    Ok(proposal_id)
593}
594
595/// v0.7: outcome of a `propose_or_merge_memory` call.
596#[derive(Debug)]
597pub enum ProposeResult {
598    Added(String),     // memory_id — new memory, directly accepted
599    Proposed(String),  // proposal_id — pending for review (low confidence)
600    Merged(String),    // memory_id of the existing memory that was updated
601    Duplicate(String), // memory_id of the identical existing memory
602}
603
604/// v0.7: capture a lesson, automatically deduplicating against the existing brain.
605///
606/// Decision tree:
607/// 1. Exact normalized-text match → `Duplicate` (no write).
608/// 2. Cosine similarity ≥ 0.85 with an existing memory → `Merged` (append & re-embed).
609/// 3. confidence ≥ 0.7 and no close match → `Added` (direct acceptance).
610/// 4. confidence < 0.7 → `Proposed` (pending for human review).
611///
612/// Step 2 only fires when the embedder is active (bge-small or similar). In lean builds
613/// the cosine scan returns nothing and the function falls through to step 3/4.
614pub fn propose_or_merge_memory(
615    start: &Path,
616    scope: MemoryScope,
617    kind: MemoryKind,
618    text: &str,
619    confidence: f32,
620    rationale: &str,
621) -> KimetsuResult<ProposeResult> {
622    let redaction = redact::redact_secrets(text);
623    if redaction.was_redacted() {
624        eprintln!("kimetsu-brain: {}", redaction.summary());
625    }
626    let text = redaction.text.as_str();
627
628    // Step 1: exact normalized-text dedup (same as add_memory).
629    {
630        let (_, _, ro_conn) = load_project_readonly(start)?;
631        let normalized = normalize_memory_text(text);
632        let existing: Option<String> = ro_conn
633            .query_row(
634                "SELECT memory_id FROM memories
635                 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
636                   AND invalidated_at IS NULL
637                 LIMIT 1",
638                rusqlite::params![scope.to_string(), kind.to_string(), normalized],
639                |row| row.get::<_, String>(0),
640            )
641            .optional()?;
642        if let Some(id) = existing {
643            return Ok(ProposeResult::Duplicate(id));
644        }
645    }
646
647    // Step 2: semantic dedup — look for a high-cosine existing memory.
648    let embedder = embeddings::open_default_embedder();
649    {
650        let (_, _, ro_conn) = load_project_readonly(start)?;
651        let conflicts =
652            conflict::find_potential_conflicts(&ro_conn, &scope, text, embedder, 1, 0.85)?;
653        if let Some(hit) = conflicts.into_iter().next() {
654            // Append the new lesson to the existing memory and re-embed it.
655            let (paths, _config, conn) = load_project(start)?;
656            let run_id = RunId::new();
657            let _lock = ProjectLock::acquire(&paths, "memory merge", Some(run_id))?;
658            let merged_text = format!("{}\n\nAlso: {text}", hit.existing_text);
659            let new_normalized = normalize_memory_text(&merged_text);
660            conn.execute(
661                "UPDATE memories
662                 SET text = ?1, normalized_text = ?2, use_count = use_count + 1
663                 WHERE memory_id = ?3",
664                rusqlite::params![merged_text, new_normalized, hit.existing_memory_id],
665            )?;
666            embeddings::embed_and_persist(&conn, &hit.existing_memory_id, &merged_text, embedder)?;
667            return Ok(ProposeResult::Merged(hit.existing_memory_id));
668        }
669    }
670
671    // Step 3/4: no close match found — accept or propose based on confidence.
672    if confidence >= 0.7 {
673        let memory_id = add_memory(start, scope, kind, text)?;
674        Ok(ProposeResult::Added(memory_id))
675    } else {
676        let proposal_id = propose_memory(start, scope, kind, text, confidence, rationale)?;
677        Ok(ProposeResult::Proposed(proposal_id))
678    }
679}
680
681/// v0.8: pagination + scope filter for `list_memories_with`, surfaced
682/// by the `kimetsu_brain_memory_list` MCP tool so an agent can page
683/// through the corpus from inside Claude/Codex.
684#[derive(Debug, Clone)]
685pub struct ListOptions {
686    /// Max project rows to return. 0 → 100 (the prior default).
687    pub limit: u32,
688    /// Project-row offset (for paging). 0 → first page.
689    pub offset: u32,
690    /// Optional scope filter (global_user / project / repo / run).
691    pub scope: Option<String>,
692}
693
694impl Default for ListOptions {
695    fn default() -> Self {
696        Self {
697            limit: 100,
698            offset: 0,
699            scope: None,
700        }
701    }
702}
703
704pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
705    list_memories_with(start, ListOptions::default())
706}
707
708/// v0.8: paginated/scoped memory listing. The project page is bounded
709/// by `limit`/`offset`; the user brain's portable rows are appended
710/// only on the first page (`offset == 0`) so they appear exactly once
711/// during navigation rather than on every page.
712pub fn list_memories_with(start: &Path, opts: ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
713    let (_paths, _config, conn) = load_project(start)?;
714    let mut memories = list_memories_from_conn(&conn, &opts)?;
715    if opts.offset == 0
716        && let Some(user_conn) = user_brain::open_user_brain_readonly()?
717    {
718        memories.extend(user_brain::list_user_memories(&user_conn)?);
719    }
720    Ok(memories)
721}
722
723/// v0.5.1: per-run memory attribution. Walks `memory_citations`,
724/// the run's `context.injected` events, and (when present) the
725/// terminal run.finished/failed/aborted event to produce a
726/// `BlameReport` that surfaces which memories the model actually
727/// reasoned with vs which were silent passengers.
728///
729/// Lookups across user + project brains are merged so a cited
730/// user-scope memory shows its text even when the run lived in a
731/// project brain.
732pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
733    let (_paths, _config, conn) = load_project(start)?;
734    let user_conn = user_brain::open_user_brain_readonly()?;
735
736    // 1. Terminal outcome.
737    let (outcome, failure_category) = run_outcome(&conn, run_id)?;
738
739    // 2. Cited memories — ordered by turn.
740    let cited_rows: Vec<(String, i64, Option<String>, String)> = {
741        let mut stmt = conn.prepare(
742            "
743            SELECT memory_id, turn, rationale, cited_at
744            FROM memory_citations
745            WHERE run_id = ?1
746            ORDER BY turn ASC, cited_at ASC
747            ",
748        )?;
749        let rows = stmt.query_map(rusqlite::params![run_id], |row| {
750            Ok((
751                row.get::<_, String>(0)?,
752                row.get::<_, i64>(1)?,
753                row.get::<_, Option<String>>(2)?,
754                row.get::<_, String>(3)?,
755            ))
756        })?;
757        let mut out = Vec::new();
758        for row in rows {
759            out.push(row?);
760        }
761        out
762    };
763
764    let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
765    let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
766    for (memory_id, turn, rationale, cited_at) in cited_rows {
767        cited_set.insert(memory_id.clone());
768        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
769        cited.push(CitedMemory {
770            memory_id,
771            turn,
772            rationale,
773            cited_at,
774            text_preview: text_preview(&text, 120),
775            scope,
776            kind,
777        });
778    }
779
780    // 3. Silent passengers — retrieved but not cited.
781    let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
782    let mut silent: Vec<SilentMemory> = Vec::new();
783    for memory_id in retrieved_ids {
784        if cited_set.contains(&memory_id) {
785            continue;
786        }
787        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
788        silent.push(SilentMemory {
789            memory_id,
790            text_preview: text_preview(&text, 120),
791            scope,
792            kind,
793        });
794    }
795
796    Ok(BlameReport {
797        run_id: run_id.to_string(),
798        outcome,
799        failure_category,
800        cited,
801        silent_passengers: silent,
802    })
803}
804
805fn run_outcome(conn: &Connection, run_id: &str) -> KimetsuResult<(String, Option<String>)> {
806    // Pull the most recent terminal event for the run, if any.
807    let row: Option<(String, String)> = conn
808        .query_row(
809            "
810            SELECT kind, payload_json
811            FROM events
812            WHERE run_id = ?1
813              AND kind IN ('run.finished', 'run.failed', 'run.aborted')
814            ORDER BY ts DESC
815            LIMIT 1
816            ",
817            rusqlite::params![run_id],
818            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
819        )
820        .optional()?;
821    Ok(match row {
822        Some((kind, payload_json)) => {
823            let outcome = match kind.as_str() {
824                "run.finished" => "success".to_string(),
825                "run.failed" => "failed".to_string(),
826                "run.aborted" => "aborted".to_string(),
827                other => other.to_string(),
828            };
829            let category = if kind == "run.failed" {
830                serde_json::from_str::<serde_json::Value>(&payload_json)
831                    .ok()
832                    .and_then(|v| {
833                        v.get("category")
834                            .and_then(|c| c.as_str())
835                            .map(str::to_string)
836                    })
837            } else {
838                None
839            };
840            (outcome, category)
841        }
842        None => ("unknown".to_string(), None),
843    })
844}
845
846fn collect_injected_memory_ids_for_blame(
847    conn: &Connection,
848    run_id: &str,
849) -> KimetsuResult<Vec<String>> {
850    let mut stmt = conn.prepare(
851        "
852        SELECT payload_json
853        FROM events
854        WHERE run_id = ?1 AND kind = 'context.injected'
855        ",
856    )?;
857    let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
858    let mut seen = std::collections::BTreeSet::new();
859    for row in rows {
860        let payload_json = row?;
861        let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
862        if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
863            for id in ids {
864                if let Some(s) = id.as_str()
865                    && !s.is_empty()
866                {
867                    seen.insert(s.to_string());
868                }
869            }
870        }
871    }
872    Ok(seen.into_iter().collect())
873}
874
875/// Look up a memory's (text, scope, kind) across the project conn
876/// and the optional user-brain conn. Returns
877/// ("<unknown — deleted?>", "", "") when the row isn't found in
878/// either DB (e.g. invalidated + GC'd, or a typo'd memory_id in
879/// the citation).
880fn resolve_memory(
881    project_conn: &Connection,
882    user_conn: Option<&Connection>,
883    memory_id: &str,
884) -> (String, String, String) {
885    let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
886    let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
887        conn.query_row(q, rusqlite::params![memory_id], |row| {
888            Ok((
889                row.get::<_, String>(0)?,
890                row.get::<_, String>(1)?,
891                row.get::<_, String>(2)?,
892            ))
893        })
894        .optional()
895        .ok()
896        .flatten()
897    };
898    try_conn(project_conn)
899        .or_else(|| user_conn.and_then(try_conn))
900        .unwrap_or_else(|| {
901            (
902                "<unknown — deleted or invalid memory_id>".to_string(),
903                String::new(),
904                String::new(),
905            )
906        })
907}
908
909fn text_preview(text: &str, max_chars: usize) -> String {
910    let trimmed = text.trim();
911    if trimmed.chars().count() <= max_chars {
912        trimmed.to_string()
913    } else {
914        let head: String = trimmed.chars().take(max_chars).collect();
915        format!("{head}…")
916    }
917}
918
919fn list_memories_from_conn(conn: &Connection, opts: &ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
920    let limit = if opts.limit == 0 { 100 } else { opts.limit } as i64;
921    let offset = opts.offset as i64;
922
923    let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
924        (
925            "
926            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
927            FROM memories
928            WHERE lower(scope) = lower(?1)
929            ORDER BY created_at DESC
930            LIMIT ?2 OFFSET ?3
931            ",
932            Some(scope.to_string()),
933        )
934    } else {
935        (
936            "
937            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
938            FROM memories
939            ORDER BY created_at DESC
940            LIMIT ?1 OFFSET ?2
941            ",
942            None,
943        )
944    };
945
946    let mut stmt = conn.prepare(sql)?;
947    let rows = if let Some(scope) = scope_param {
948        stmt.query_map(params![scope, limit, offset], map_memory_row)?
949            .collect::<Result<Vec<_>, _>>()?
950    } else {
951        stmt.query_map(params![limit, offset], map_memory_row)?
952            .collect::<Result<Vec<_>, _>>()?
953    };
954    Ok(rows)
955}
956
957/// MP-6: ranked list of memories sorted by the same usefulness ratio the
958/// broker uses for retrieval scoring (`usefulness_score / use_count`).
959/// Filters out invalidated rows and any memory with `use_count < min_uses`
960/// (the small-sample guard; default 3 matches the broker's
961/// SMALL_SAMPLE_THRESHOLD). Optional scope filter narrows to a single
962/// memory class. Lets the user see which memories are actually doing
963/// work so they can prune the rest with `memory prune`.
964#[derive(Debug, Clone, Default)]
965pub struct TopOptions {
966    pub scope: Option<String>,
967    pub min_uses: u32,
968    pub limit: u32,
969}
970
971pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
972    let (_paths, _config, conn) = load_project(start)?;
973    let min_uses = opts.min_uses.max(1) as i64;
974    let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
975
976    let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
977        (
978            "
979            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
980            FROM memories
981            WHERE invalidated_at IS NULL
982              AND use_count >= ?1
983              AND lower(scope) = lower(?2)
984            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
985            LIMIT ?3
986            ",
987            Some(scope.to_string()),
988        )
989    } else {
990        (
991            "
992            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
993            FROM memories
994            WHERE invalidated_at IS NULL
995              AND use_count >= ?1
996            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
997            LIMIT ?2
998            ",
999            None,
1000        )
1001    };
1002
1003    let mut stmt = conn.prepare(sql)?;
1004    let mut rows = if let Some(scope) = scope_param {
1005        stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
1006            .collect::<Result<Vec<_>, _>>()?
1007    } else {
1008        stmt.query_map(params![min_uses, limit], map_memory_row)?
1009            .collect::<Result<Vec<_>, _>>()?
1010    };
1011
1012    // SQLite's NaN-from-zero protection: a freshly-created memory with
1013    // use_count=0 would division-zero, but the WHERE clause guards
1014    // min_uses >= 1, so we never see a NaN here. Sort is a defensive
1015    // tie-breaker only.
1016    rows.sort_by(|a, b| {
1017        let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
1018        let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
1019        rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
1020    });
1021    Ok(rows)
1022}
1023
1024fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
1025    Ok(MemoryRow {
1026        memory_id: row.get(0)?,
1027        scope: row.get(1)?,
1028        kind: row.get(2)?,
1029        text: row.get(3)?,
1030        confidence: row.get(4)?,
1031        use_count: row.get(5)?,
1032        usefulness_score: row.get::<_, f64>(6)? as f32,
1033    })
1034}
1035
1036/// MP-6: bulk prune of memories whose outcome-attribution data says they
1037/// are net-negative. Selection rules:
1038///   use_count >= min_uses
1039///   usefulness_score / use_count <= max_ratio
1040///   invalidated_at IS NULL
1041///   scope filter optional
1042///
1043/// `apply = false` is the default at the CLI layer so the user sees
1044/// what would be touched before any writes. `apply = true` invalidates
1045/// each match via the existing `invalidate_memory` path so every
1046/// removal still emits a canonical `memory.invalidated` event.
1047#[derive(Debug, Clone)]
1048pub struct PruneOptions {
1049    pub scope: Option<String>,
1050    pub min_uses: u32,
1051    pub max_ratio: f32,
1052    pub apply: bool,
1053}
1054
1055impl Default for PruneOptions {
1056    fn default() -> Self {
1057        Self {
1058            scope: None,
1059            min_uses: 3,
1060            max_ratio: -0.2,
1061            apply: false,
1062        }
1063    }
1064}
1065
1066#[derive(Debug, Clone)]
1067pub struct PruneCandidate {
1068    pub memory_id: String,
1069    pub scope: String,
1070    pub kind: String,
1071    pub use_count: u32,
1072    pub usefulness_score: f32,
1073    pub text: String,
1074}
1075
1076#[derive(Debug, Clone, Default)]
1077pub struct PruneSummary {
1078    pub candidates: Vec<PruneCandidate>,
1079    pub invalidated: u32,
1080    pub failed: u32,
1081}
1082
1083pub fn prune_low_usefulness(start: &Path, opts: PruneOptions) -> KimetsuResult<PruneSummary> {
1084    let min_uses = opts.min_uses.max(1) as i64;
1085
1086    let candidates = {
1087        let (_paths, _config, conn) = load_project(start)?;
1088        let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref()
1089        {
1090            (
1091                "
1092                SELECT memory_id, scope, kind, text, use_count, usefulness_score
1093                FROM memories
1094                WHERE invalidated_at IS NULL
1095                  AND use_count >= ?1
1096                  AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
1097                  AND lower(scope) = lower(?3)
1098                ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
1099                ",
1100                Some(scope.to_string()),
1101            )
1102        } else {
1103            (
1104                "
1105                SELECT memory_id, scope, kind, text, use_count, usefulness_score
1106                FROM memories
1107                WHERE invalidated_at IS NULL
1108                  AND use_count >= ?1
1109                  AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
1110                ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
1111                ",
1112                None,
1113            )
1114        };
1115        let mut stmt = conn.prepare(sql)?;
1116        let max_ratio = opts.max_ratio as f64;
1117        let mut found: Vec<PruneCandidate> = if let Some(scope) = scope_param {
1118            stmt.query_map(params![min_uses, max_ratio, scope], |row| {
1119                Ok(PruneCandidate {
1120                    memory_id: row.get(0)?,
1121                    scope: row.get(1)?,
1122                    kind: row.get(2)?,
1123                    text: row.get(3)?,
1124                    use_count: row.get(4)?,
1125                    usefulness_score: row.get::<_, f64>(5)? as f32,
1126                })
1127            })?
1128            .collect::<Result<Vec<_>, _>>()?
1129        } else {
1130            stmt.query_map(params![min_uses, max_ratio], |row| {
1131                Ok(PruneCandidate {
1132                    memory_id: row.get(0)?,
1133                    scope: row.get(1)?,
1134                    kind: row.get(2)?,
1135                    text: row.get(3)?,
1136                    use_count: row.get(4)?,
1137                    usefulness_score: row.get::<_, f64>(5)? as f32,
1138                })
1139            })?
1140            .collect::<Result<Vec<_>, _>>()?
1141        };
1142        // Stable tie-break: lowest ratio first, then highest use_count
1143        // first (penalize the long-running underperformers).
1144        found.sort_by(|a, b| {
1145            let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
1146            let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
1147            ra.partial_cmp(&rb)
1148                .unwrap_or(std::cmp::Ordering::Equal)
1149                .then_with(|| b.use_count.cmp(&a.use_count))
1150        });
1151        found
1152    };
1153
1154    let mut summary = PruneSummary {
1155        candidates: candidates.clone(),
1156        invalidated: 0,
1157        failed: 0,
1158    };
1159    if !opts.apply {
1160        return Ok(summary);
1161    }
1162
1163    for candidate in &candidates {
1164        let ratio = candidate.usefulness_score / candidate.use_count.max(1) as f32;
1165        let reason = format!(
1166            "pruned_by_usefulness ratio={:+.2} use_count={}",
1167            ratio, candidate.use_count
1168        );
1169        match invalidate_memory(start, &candidate.memory_id, Some(&reason)) {
1170            Ok(()) => summary.invalidated += 1,
1171            Err(_) => summary.failed += 1,
1172        }
1173    }
1174    Ok(summary)
1175}
1176
1177pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
1178    let (_paths, _config, conn) = load_project(start)?;
1179    let mut sql = String::from(
1180        "
1181        SELECT proposal_id, run_id, scope, kind, text, rationale,
1182               proposed_confidence, status, decided_reason
1183        FROM memory_proposals
1184        ",
1185    );
1186    let mut clauses = Vec::<String>::new();
1187    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1188    if let Some(scope) = filter.scope.as_deref() {
1189        clauses.push("scope = ?".to_string());
1190        params.push(Box::new(scope.to_string()));
1191    }
1192    if let Some(kind) = filter.kind.as_deref() {
1193        clauses.push("kind = ?".to_string());
1194        params.push(Box::new(kind.to_string()));
1195    }
1196    if let Some(run_id) = filter.from_run.as_deref() {
1197        clauses.push("run_id = ?".to_string());
1198        params.push(Box::new(run_id.to_string()));
1199    }
1200    if let Some(min_conf) = filter.min_confidence {
1201        clauses.push("proposed_confidence >= ?".to_string());
1202        params.push(Box::new(min_conf as f64));
1203    }
1204    if let Some(status) = filter.status.as_deref()
1205        && !status.eq_ignore_ascii_case("any")
1206    {
1207        clauses.push("status = ?".to_string());
1208        params.push(Box::new(status.to_string()));
1209    }
1210    if !clauses.is_empty() {
1211        sql.push_str(" WHERE ");
1212        sql.push_str(&clauses.join(" AND "));
1213    }
1214    let limit = if filter.limit == 0 { 100 } else { filter.limit };
1215    sql.push_str(&format!(
1216        " ORDER BY rowid DESC LIMIT {limit} OFFSET {}",
1217        filter.offset
1218    ));
1219
1220    let mut stmt = conn.prepare(&sql)?;
1221    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1222    let rows = stmt.query_map(param_refs.as_slice(), |row| {
1223        Ok(ProposalRow {
1224            proposal_id: row.get(0)?,
1225            run_id: row.get(1)?,
1226            scope: row.get(2)?,
1227            kind: row.get(3)?,
1228            text: row.get(4)?,
1229            rationale: row.get(5)?,
1230            proposed_confidence: row.get(6)?,
1231            status: row.get(7)?,
1232            decided_reason: row.get(8)?,
1233        })
1234    })?;
1235
1236    let mut proposals = Vec::new();
1237    for row in rows {
1238        proposals.push(row?);
1239    }
1240    Ok(proposals)
1241}
1242
1243pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1244    let (paths, config, conn) = load_project(start)?;
1245    let run_id = RunId::new();
1246    let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1247    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1248
1249    let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1250    writer.append(&started, true)?;
1251
1252    let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1253
1254    let ingested = Event::new(
1255        run_id,
1256        "repo.ingested",
1257        serde_json::json!({
1258            "repo_root": summary.repo_root.to_string_lossy(),
1259            "indexed_files": summary.indexed_files,
1260            "skipped_files": summary.skipped_files,
1261            "manifests": summary.manifests,
1262        }),
1263    );
1264    writer.append(&ingested, true)?;
1265
1266    let finished = admin_finished_event(run_id);
1267    writer.append(&finished, true)?;
1268    projector::apply_events(&conn, &[started, ingested, finished])?;
1269
1270    Ok(summary)
1271}
1272
1273pub fn search_files(
1274    start: &Path,
1275    query: &str,
1276    limit: u32,
1277) -> KimetsuResult<Vec<context::ContextCapsule>> {
1278    let (paths, _config, conn) = load_project(start)?;
1279    let repo_root = paths
1280        .repo_root
1281        .canonicalize()?
1282        .to_string_lossy()
1283        .to_string();
1284    context::search_repo_files(&conn, &repo_root, query, limit)
1285}
1286
1287pub fn retrieve_context(
1288    start: &Path,
1289    stage: &str,
1290    query: &str,
1291    budget_tokens: u32,
1292) -> KimetsuResult<ContextBundle> {
1293    BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1294}
1295
1296pub fn retrieve_context_readonly(
1297    start: &Path,
1298    stage: &str,
1299    query: &str,
1300    budget_tokens: u32,
1301) -> KimetsuResult<ContextBundle> {
1302    BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1303}
1304
1305/// v0.6: variant that accepts a full `ContextRequest` so callers can use
1306/// the new `tags`, `min_score`, `max_capsules`, and `prefer_roles` fields.
1307pub fn retrieve_context_readonly_with_request(
1308    start: &Path,
1309    request: ContextRequest,
1310) -> KimetsuResult<ContextBundle> {
1311    BrainSession::open_readonly(start)?.retrieve_context_with_request(request)
1312}
1313
1314/// v0.8: read-only proactive retrieval (lexical-FTS-only, no model
1315/// load). The caller builds a `ContextRequest` with `kinds` set to the
1316/// actionable set, a high `min_score`, and `max_capsules: 1`.
1317pub fn retrieve_proactive_readonly(
1318    start: &Path,
1319    request: ContextRequest,
1320) -> KimetsuResult<ContextBundle> {
1321    BrainSession::open_readonly(start)?.retrieve_proactive(request)
1322}
1323
1324/// v0.8: full-text search over memory text, for navigating the corpus
1325/// from the MCP surface. Project rows are paged by `limit`/`offset`;
1326/// user-brain rows are appended only on the first page so they appear
1327/// once. Returns empty when the query yields no FTS tokens.
1328pub fn search_memories(
1329    start: &Path,
1330    query: &str,
1331    limit: u32,
1332    offset: u32,
1333    kind: Option<&str>,
1334    scope: Option<&str>,
1335) -> KimetsuResult<Vec<MemorySearchHit>> {
1336    let Some(fts) = context::fts_query(query) else {
1337        return Ok(Vec::new());
1338    };
1339    let (_paths, _config, conn) = load_project(start)?;
1340    let mut hits = search_memories_in_conn(&conn, &fts, limit, offset, kind, scope)?;
1341    if offset == 0
1342        && let Some(user_conn) = user_brain::open_user_brain_readonly()?
1343    {
1344        hits.extend(search_memories_in_conn(
1345            &user_conn, &fts, limit, 0, kind, scope,
1346        )?);
1347    }
1348    Ok(hits)
1349}
1350
1351fn search_memories_in_conn(
1352    conn: &Connection,
1353    fts_query: &str,
1354    limit: u32,
1355    offset: u32,
1356    kind: Option<&str>,
1357    scope: Option<&str>,
1358) -> KimetsuResult<Vec<MemorySearchHit>> {
1359    let limit = if limit == 0 { 20 } else { limit } as i64;
1360    let offset = offset as i64;
1361    let mut sql = String::from(
1362        "
1363        SELECT m.memory_id, m.scope, m.kind, m.text, bm25(memories_fts) AS rank
1364        FROM memories_fts
1365        JOIN memories m ON m.memory_id = memories_fts.memory_id
1366        WHERE m.invalidated_at IS NULL
1367          AND memories_fts MATCH ?
1368        ",
1369    );
1370    let mut bind: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(fts_query.to_string())];
1371    if let Some(k) = kind {
1372        sql.push_str(" AND m.kind = ?");
1373        bind.push(Box::new(k.to_string()));
1374    }
1375    if let Some(s) = scope {
1376        sql.push_str(" AND lower(m.scope) = lower(?)");
1377        bind.push(Box::new(s.to_string()));
1378    }
1379    // bm25() is more-negative = more-relevant, so ascending rank is best.
1380    sql.push_str(" ORDER BY rank LIMIT ? OFFSET ?");
1381    bind.push(Box::new(limit));
1382    bind.push(Box::new(offset));
1383
1384    let mut stmt = conn.prepare(&sql)?;
1385    let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
1386    let rows = stmt.query_map(refs.as_slice(), |row| {
1387        let raw_rank = row.get::<_, f64>(4)? as f32;
1388        Ok(MemorySearchHit {
1389            memory_id: row.get(0)?,
1390            scope: row.get(1)?,
1391            kind: row.get(2)?,
1392            text: row.get(3)?,
1393            // surface a positive relevance (higher = better) for callers.
1394            rank: (-raw_rank).max(0.0),
1395        })
1396    })?;
1397    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1398}
1399
1400#[allow(clippy::too_many_arguments)]
1401pub fn retrieve_benchmark_context_readonly(
1402    start: &Path,
1403    task: &str,
1404    dataset: &str,
1405    task_slug: Option<&str>,
1406    warm_policy: benchmark::BenchmarkWarmPolicy,
1407    stage: &str,
1408    budget_tokens: u32,
1409    require_benchmark_memory: bool,
1410    max_capsules: usize,
1411) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1412    retrieve_benchmark_context_readonly_with_ambient(
1413        start,
1414        task,
1415        dataset,
1416        task_slug,
1417        warm_policy,
1418        stage,
1419        budget_tokens,
1420        require_benchmark_memory,
1421        max_capsules,
1422        None,
1423    )
1424}
1425
1426/// v0.4.4: variant that appends an optional ambient-context suffix to
1427/// the canonical benchmark query AFTER slug detection. Used by the
1428/// MCP `kimetsu_benchmark_context` tool so the workspace fingerprint
1429/// (git branch, dirty files, recent edits) contributes to retrieval
1430/// without corrupting the slug parser.
1431#[allow(clippy::too_many_arguments)]
1432pub fn retrieve_benchmark_context_readonly_with_ambient(
1433    start: &Path,
1434    task: &str,
1435    dataset: &str,
1436    task_slug: Option<&str>,
1437    warm_policy: benchmark::BenchmarkWarmPolicy,
1438    stage: &str,
1439    budget_tokens: u32,
1440    require_benchmark_memory: bool,
1441    max_capsules: usize,
1442    ambient_suffix: Option<&str>,
1443) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1444    let normalized_slug = task_slug
1445        .and_then(benchmark::normalize_task_slug)
1446        .or_else(|| benchmark::normalize_task_slug(task));
1447    let mut query =
1448        benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
1449    if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
1450        query.push_str(suffix);
1451    }
1452    let bundle =
1453        BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
1454    Ok(benchmark::build_benchmark_context(
1455        bundle,
1456        task,
1457        dataset,
1458        &query,
1459        normalized_slug,
1460        warm_policy,
1461        require_benchmark_memory,
1462        max_capsules,
1463    ))
1464}
1465
1466pub fn record_benchmark_outcome(
1467    start: &Path,
1468    outcome: benchmark::BenchmarkOutcome,
1469) -> KimetsuResult<RecordedBenchmarkOutcome> {
1470    let task_slug = outcome
1471        .task_slug
1472        .clone()
1473        .or_else(|| benchmark::normalize_task_slug(&outcome.task));
1474    let kind = benchmark::outcome_memory_kind(&outcome);
1475    let text = benchmark::outcome_memory_text(&outcome);
1476    let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
1477    let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
1478        Some(proposal) if proposal.role.is_generalizable() => {
1479            let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
1480            (Some(proposal_id), Some(proposal_text))
1481        }
1482        _ => (None, None),
1483    };
1484    Ok(RecordedBenchmarkOutcome {
1485        memory_id,
1486        task_slug,
1487        kind,
1488        text,
1489        proposal_id,
1490        proposal_text,
1491    })
1492}
1493
1494fn propose_benchmark_memory(
1495    start: &Path,
1496    outcome: &benchmark::BenchmarkOutcome,
1497    proposal: &benchmark::BenchmarkMemoryProposal,
1498) -> KimetsuResult<(String, String)> {
1499    let (paths, config, conn) = load_project(start)?;
1500    let run_id = RunId::new();
1501    let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
1502    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1503    let proposal_id = Ulid::new().to_string();
1504    // v0.4.5: redact secrets in the proposal text + rationale before
1505    // they hit the trace + memory_proposals table. Benchmark outcomes
1506    // pull from tool output, which is exactly where a model-leaked
1507    // token would surface.
1508    let raw_text = benchmark::proposal_memory_text(outcome, proposal);
1509    let text_redaction = redact::redact_secrets(&raw_text);
1510    if text_redaction.was_redacted() {
1511        eprintln!(
1512            "kimetsu-brain (benchmark proposal): {}",
1513            text_redaction.summary()
1514        );
1515    }
1516    let text = text_redaction.text;
1517    let kind = benchmark::proposal_memory_kind(proposal);
1518    let rationale_raw = if proposal.rationale.trim().is_empty() {
1519        "generalized from benchmark outcome".to_string()
1520    } else {
1521        proposal.rationale.trim().to_string()
1522    };
1523    let rationale = redact::redact_secrets(&rationale_raw).text;
1524
1525    let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
1526    writer.append(&started, true)?;
1527
1528    let proposed = Event::new(
1529        run_id,
1530        "memory.proposed",
1531        serde_json::json!({
1532            "proposal_id": proposal_id,
1533            "scope": "global_user",
1534            "kind": kind.to_string(),
1535            "text": text,
1536            "rationale": rationale,
1537            "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
1538            "source_event_ids": [],
1539        }),
1540    );
1541    writer.append(&proposed, true)?;
1542
1543    let finished = admin_finished_event(run_id);
1544    writer.append(&finished, true)?;
1545
1546    projector::apply_events(&conn, &[started, proposed, finished])?;
1547    Ok((proposal_id, text))
1548}
1549
1550pub fn accept_proposal(
1551    start: &Path,
1552    proposal_id: &str,
1553    overrides: AcceptOverrides,
1554) -> KimetsuResult<String> {
1555    let (paths, config, conn) = load_project(start)?;
1556    let proposal = load_pending_proposal(&conn, proposal_id)?;
1557    let run_id = RunId::new();
1558    let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
1559    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1560    let memory_id = Ulid::new().to_string();
1561    let normalized = normalize_memory_text(&proposal.text);
1562
1563    let resolved_scope = match overrides.scope.as_deref() {
1564        Some(value) if !value.trim().is_empty() => value.trim().to_string(),
1565        _ => proposal.scope.clone(),
1566    };
1567    let resolved_confidence = overrides
1568        .confidence
1569        .map(|c| c.clamp(0.0, 1.0))
1570        .unwrap_or(proposal.proposed_confidence);
1571
1572    let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
1573    writer.append(&started, true)?;
1574
1575    let accepted = Event::new(
1576        run_id,
1577        "memory.accepted",
1578        serde_json::json!({
1579            "proposal_id": proposal.proposal_id,
1580            "memory_id": memory_id,
1581            "scope": resolved_scope,
1582            "kind": proposal.kind,
1583            "text": proposal.text,
1584            "normalized_text": normalized,
1585            "confidence": resolved_confidence,
1586            "provenance_snapshot": {
1587                "source": "memory_proposal",
1588                "proposal_id": proposal.proposal_id,
1589                "source_run_id": proposal.run_id,
1590                "scope_override": overrides.scope.clone(),
1591                "confidence_override": overrides.confidence,
1592            }
1593        }),
1594    );
1595    writer.append(&accepted, true)?;
1596
1597    let finished = admin_finished_event(run_id);
1598    writer.append(&finished, true)?;
1599
1600    projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
1601    conn.execute(
1602        "
1603        UPDATE memory_proposals
1604        SET status = 'accepted',
1605            decided_at = ?2,
1606            decided_by = 'cli'
1607        WHERE proposal_id = ?1
1608        ",
1609        params![
1610            proposal_id,
1611            accepted
1612                .ts
1613                .format(&time::format_description::well_known::Rfc3339)?
1614        ],
1615    )?;
1616
1617    Ok(memory_id)
1618}
1619
1620/// MP-4d: human override that flags an accepted memory so the broker stops
1621/// surfacing it. Emits a `memory.invalidated` event and projects it. The
1622/// canonical trace keeps the original `memory.accepted`; invalidation is
1623/// purely additive metadata. Idempotent — re-invalidating a memory just
1624/// overwrites the timestamp/reason.
1625pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1626    let (paths, config, conn) = load_project(start)?;
1627    let exists: i64 = conn.query_row(
1628        "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1629        params![memory_id],
1630        |row| row.get(0),
1631    )?;
1632    if exists == 0 {
1633        return Err(format!("memory not found: {memory_id}").into());
1634    }
1635
1636    let run_id = RunId::new();
1637    let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
1638    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1639
1640    let resolved_reason = reason
1641        .and_then(|s| {
1642            let trimmed = s.trim();
1643            if trimmed.is_empty() {
1644                None
1645            } else {
1646                Some(trimmed.to_string())
1647            }
1648        })
1649        .unwrap_or_else(|| "invalidated_by_cli".to_string());
1650
1651    let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
1652    writer.append(&started, true)?;
1653
1654    let invalidated = Event::new(
1655        run_id,
1656        "memory.invalidated",
1657        serde_json::json!({
1658            "memory_id": memory_id,
1659            "reason": resolved_reason,
1660        }),
1661    );
1662    writer.append(&invalidated, true)?;
1663
1664    let finished = admin_finished_event(run_id);
1665    writer.append(&finished, true)?;
1666
1667    projector::apply_events(&conn, &[started, invalidated, finished])?;
1668    Ok(())
1669}
1670
1671pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1672    let (paths, config, conn) = load_project(start)?;
1673    let _proposal = load_pending_proposal(&conn, proposal_id)?;
1674    let run_id = RunId::new();
1675    let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
1676    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1677
1678    let resolved_reason = reason
1679        .and_then(|s| {
1680            let trimmed = s.trim();
1681            if trimmed.is_empty() {
1682                None
1683            } else {
1684                Some(trimmed.to_string())
1685            }
1686        })
1687        .unwrap_or_else(|| "rejected_by_cli".to_string());
1688
1689    let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
1690    writer.append(&started, true)?;
1691
1692    let rejected = Event::new(
1693        run_id,
1694        "memory.rejected",
1695        serde_json::json!({
1696            "proposal_id": proposal_id,
1697            "reason": resolved_reason,
1698        }),
1699    );
1700    writer.append(&rejected, true)?;
1701
1702    let finished = admin_finished_event(run_id);
1703    writer.append(&finished, true)?;
1704
1705    projector::apply_events(&conn, &[started, rejected, finished])?;
1706    Ok(())
1707}
1708
1709pub fn rebuild_projection(start: &Path) -> KimetsuResult<usize> {
1710    let (paths, _config, conn) = load_project(start)?;
1711    let _lock = ProjectLock::acquire(&paths, "brain rebuild", None)?;
1712    let events = trace::read_all_traces(&paths)?;
1713    projector::rebuild(&conn, &events)?;
1714    Ok(events.len())
1715}
1716
1717pub fn clear_lock(start: &Path) -> KimetsuResult<bool> {
1718    let paths = ProjectPaths::discover(start)?;
1719    crate::lock::clear_force(&paths)
1720}
1721
1722/// v0.5.2: list open conflict-detection hits across the project brain
1723/// and (when enabled) the user brain. Each `ConflictReport` carries a
1724/// `source` label so the CLI can render which brain originated it —
1725/// resolve takes a separate code path per brain since the row only
1726/// lives in one DB.
1727#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1728pub struct ScopedConflict {
1729    /// Either "project" or "user". Determines which DB `resolve_conflict`
1730    /// must target when the operator chooses to apply a resolution.
1731    pub source: String,
1732    #[serde(flatten)]
1733    pub report: conflict::ConflictReport,
1734}
1735
1736/// Merge open conflicts from project + user brains. `limit` is applied
1737/// per-brain, so the worst case is `limit * 2` rows returned — the CLI
1738/// can re-truncate on display if needed.
1739pub fn list_conflicts(start: &Path, limit: u32) -> KimetsuResult<Vec<ScopedConflict>> {
1740    let mut out = Vec::new();
1741    let (_paths, _config, project_conn) = load_project_readonly(start)?;
1742    for report in conflict::list_unresolved_conflicts(&project_conn, limit)? {
1743        out.push(ScopedConflict {
1744            source: "project".to_string(),
1745            report,
1746        });
1747    }
1748    if let Some(user_conn) = user_brain::open_user_brain_readonly()? {
1749        for report in conflict::list_unresolved_conflicts(&user_conn, limit)? {
1750            out.push(ScopedConflict {
1751                source: "user".to_string(),
1752                report,
1753            });
1754        }
1755    }
1756    out.sort_by(|a, b| b.report.detected_at.cmp(&a.report.detected_at));
1757    Ok(out)
1758}
1759
1760/// Resolve a single open conflict by id with one of `kept_new`,
1761/// `kept_existing`, or `kept_both`. The conflict can live in either
1762/// the project brain or the user brain — we try project first, and on
1763/// "not found" fall through to user. Returns Ok(true) if a row was
1764/// updated.
1765///
1766/// We deliberately don't emit a `memory.invalidated` trace event here
1767/// even though `kept_new` / `kept_existing` invalidates one side. The
1768/// `memory_conflicts` row IS the audit trail; double-recording would
1769/// duplicate state across two systems. Operators who want the trace-
1770/// event-style record can use `kimetsu brain memory invalidate` instead.
1771pub fn resolve_conflict(start: &Path, conflict_id: &str, resolution: &str) -> KimetsuResult<bool> {
1772    let (paths, _config, project_conn) = load_project(start)?;
1773    let _lock = ProjectLock::acquire(&paths, "brain memory conflict resolve", None)?;
1774    if conflict::resolve_conflict(&project_conn, conflict_id, resolution)? {
1775        return Ok(true);
1776    }
1777    drop(project_conn); // release before opening user brain (avoid pseudo-conflict on flock semantics)
1778    if let Some(user_conn) = user_brain::open_user_brain()? {
1779        return conflict::resolve_conflict(&user_conn, conflict_id, resolution);
1780    }
1781    Ok(false)
1782}
1783
1784fn load_pending_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<ProposalRow> {
1785    let mut stmt = conn.prepare(
1786        "
1787        SELECT proposal_id, run_id, scope, kind, text, rationale,
1788               proposed_confidence, status
1789        FROM memory_proposals
1790        WHERE proposal_id = ?1
1791        ",
1792    )?;
1793    let mut rows = stmt.query(params![proposal_id])?;
1794    let Some(row) = rows.next()? else {
1795        return Err(format!("memory proposal not found: {proposal_id}").into());
1796    };
1797
1798    let proposal = ProposalRow {
1799        proposal_id: row.get(0)?,
1800        run_id: row.get(1)?,
1801        scope: row.get(2)?,
1802        kind: row.get(3)?,
1803        text: row.get(4)?,
1804        rationale: row.get(5)?,
1805        proposed_confidence: row.get(6)?,
1806        status: row.get(7)?,
1807        decided_reason: None,
1808    };
1809
1810    if proposal.status != "pending" {
1811        return Err(format!(
1812            "memory proposal {proposal_id} is {}, not pending",
1813            proposal.status
1814        )
1815        .into());
1816    }
1817
1818    Ok(proposal)
1819}
1820
1821fn admin_started_event(
1822    paths: &ProjectPaths,
1823    config: &ProjectConfig,
1824    run_id: RunId,
1825    task: &str,
1826) -> KimetsuResult<Event> {
1827    Ok(Event::new(
1828        run_id,
1829        "run.started",
1830        serde_json::json!({
1831            "mode": "admin",
1832            "task": task,
1833            "project_id": config.kimetsu.project_id,
1834            "repo_root": paths.repo_root.to_string_lossy(),
1835            "model": null,
1836            "platform": std::env::consts::OS,
1837            "kimetsu_version": env!("CARGO_PKG_VERSION"),
1838            "config_hash": config_hash(&paths.project_toml)?,
1839        }),
1840    ))
1841}
1842
1843fn admin_finished_event(run_id: RunId) -> Event {
1844    Event::new(
1845        run_id,
1846        "run.finished",
1847        serde_json::json!({
1848            "status": "success",
1849            "final_report_path": null,
1850            "total_cost_usd": 0.0,
1851            "total_tool_calls": 0,
1852        }),
1853    )
1854}
1855
1856fn config_hash(path: &Path) -> KimetsuResult<String> {
1857    let bytes = fs::read(path)?;
1858    Ok(blake3::hash(&bytes).to_hex().to_string())
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use std::fs;
1864
1865    use super::*;
1866    // v0.4.1: pre-v0.4 tests assume `MemoryScope::GlobalUser` writes
1867    // land in the project DB. With user-brain routing on by default
1868    // that's no longer true — wrap each affected test in
1869    // `with_user_brain_disabled` so it sees v0.3.5 routing. Tests
1870    // that specifically exercise the user-brain path live in
1871    // `user_brain::tests` and opt-in via `with_user_brain_at`.
1872    use crate::user_brain::with_user_brain_disabled;
1873
1874    /// v0.8: create an isolated temp project root. A minimal `git init`
1875    /// gives the dir its own git toplevel so `ProjectPaths::discover`
1876    /// resolves to THIS dir instead of climbing to an enclosing repo
1877    /// (e.g. a developer's `$HOME` git repo) — which would otherwise
1878    /// make parallel tests share one brain.db + project.lock. Without
1879    /// this, tests pass only when `TMP` points outside any git repo.
1880    fn test_root() -> std::path::PathBuf {
1881        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1882        kimetsu_core::paths::git_init_boundary(&root);
1883        root
1884    }
1885
1886    #[test]
1887    fn search_memories_paginates_and_filters_by_kind() {
1888        with_user_brain_disabled(|| {
1889            let root = test_root();
1890            init_project(&root, false).expect("init");
1891            add_memory(
1892                &root,
1893                MemoryScope::Project,
1894                MemoryKind::FailurePattern,
1895                "linker link.exe not found on windows",
1896            )
1897            .expect("add fp");
1898            add_memory(
1899                &root,
1900                MemoryScope::Project,
1901                MemoryKind::Command,
1902                "run cargo build with the link.exe linker on PATH",
1903            )
1904            .expect("add cmd");
1905            add_memory(
1906                &root,
1907                MemoryScope::Project,
1908                MemoryKind::Fact,
1909                "the office plant needs watering on tuesdays",
1910            )
1911            .expect("add fact");
1912
1913            // "linker" matches the two link.exe memories, not the plant fact.
1914            let hits = search_memories(&root, "linker", 10, 0, None, None).expect("search");
1915            assert!(hits.len() >= 2, "expected >=2 hits, got {}", hits.len());
1916            assert!(
1917                hits.iter()
1918                    .all(|h| h.text.to_ascii_lowercase().contains("link"))
1919            );
1920
1921            // Pagination: two single-row pages return distinct rows.
1922            let p1 = search_memories(&root, "linker", 1, 0, None, None).expect("p1");
1923            let p2 = search_memories(&root, "linker", 1, 1, None, None).expect("p2");
1924            assert_eq!(p1.len(), 1);
1925            assert_eq!(p2.len(), 1);
1926            assert_ne!(p1[0].memory_id, p2[0].memory_id, "offset must advance");
1927
1928            // Kind filter narrows to failure_pattern only.
1929            let fp =
1930                search_memories(&root, "linker", 10, 0, Some("failure_pattern"), None).expect("fp");
1931            assert!(!fp.is_empty());
1932            assert!(fp.iter().all(|h| h.kind == "failure_pattern"));
1933
1934            // A query with no FTS tokens returns empty, not an error.
1935            assert!(
1936                search_memories(&root, "   ", 10, 0, None, None)
1937                    .unwrap()
1938                    .is_empty()
1939            );
1940        });
1941    }
1942
1943    #[test]
1944    fn reindex_with_explicit_embedder_uses_that_model() {
1945        with_user_brain_disabled(|| {
1946            let root = test_root();
1947            init_project(&root, false).expect("init");
1948            add_memory(
1949                &root,
1950                MemoryScope::Project,
1951                MemoryKind::Fact,
1952                "alpha beta gamma",
1953            )
1954            .expect("add");
1955            // The explicit-embedder path (used by `model set`) must
1956            // re-embed with the GIVEN embedder, regardless of the
1957            // process default.
1958            use crate::embeddings::Embedder as _;
1959            let stub = crate::embeddings::StubEmbedder::new();
1960            let report = crate::reindex::reindex_all_with_embedder(
1961                &root,
1962                crate::reindex::ReindexOptions {
1963                    scope: crate::reindex::ReindexScope::Project,
1964                    dry_run: false,
1965                    force: false,
1966                    limit: None,
1967                },
1968                &stub,
1969            )
1970            .expect("reindex");
1971            assert_eq!(report.embedder_model_id, stub.model_id());
1972            assert!(
1973                report.project.updated >= 1,
1974                "the row should be re-embedded with the stub model"
1975            );
1976        });
1977    }
1978
1979    #[test]
1980    fn retrieve_proactive_returns_actionable_kind_and_excludes_others() {
1981        with_user_brain_disabled(|| {
1982            let root = test_root();
1983            init_project(&root, false).expect("init");
1984            add_memory(
1985                &root,
1986                MemoryScope::Project,
1987                MemoryKind::FailurePattern,
1988                "linker link.exe not found -> run from x64 Native Tools prompt",
1989            )
1990            .expect("add fp");
1991            // A high-overlap FACT that would outrank lexically but is NOT an
1992            // actionable kind — the kinds filter must drop it.
1993            add_memory(
1994                &root,
1995                MemoryScope::Project,
1996                MemoryKind::Fact,
1997                "linker link.exe trivia: link.exe ships with MSVC",
1998            )
1999            .expect("add fact");
2000
2001            let request = ContextRequest {
2002                stage: "localization".to_string(),
2003                query: "error: linker `link.exe` not found".to_string(),
2004                budget_tokens: 600,
2005                min_score: 0.2,
2006                max_capsules: 1,
2007                kinds: vec!["failure_pattern".to_string(), "command".to_string()],
2008                ..Default::default()
2009            };
2010            let bundle = retrieve_proactive_readonly(&root, request).expect("proactive");
2011            assert!(!bundle.skipped, "should surface the failure_pattern");
2012            assert_eq!(bundle.capsules.len(), 1);
2013            // The single capsule must be the failure_pattern, not the fact.
2014            assert!(
2015                bundle.capsules[0].summary.contains("failure_pattern"),
2016                "got summary: {}",
2017                bundle.capsules[0].summary
2018            );
2019            assert!(!bundle.capsules[0].summary.contains("trivia"));
2020        });
2021    }
2022
2023    #[test]
2024    fn memory_add_survives_projection_rebuild_from_trace() {
2025        with_user_brain_disabled(|| {
2026            let root = test_root();
2027            fs::create_dir_all(&root).expect("create temp project");
2028
2029            init_project(&root, false).expect("init project");
2030            let memory_id = add_memory(
2031                &root,
2032                MemoryScope::GlobalUser,
2033                MemoryKind::Preference,
2034                "User prefers Rust for core infrastructure.",
2035            )
2036            .expect("add memory");
2037
2038            let memories = list_memories(&root).expect("list memories");
2039            assert_eq!(memories.len(), 1);
2040            assert_eq!(memories[0].memory_id, memory_id);
2041
2042            let event_count = rebuild_projection(&root).expect("rebuild projection");
2043            assert_eq!(event_count, 3);
2044
2045            let memories = list_memories(&root).expect("list rebuilt memories");
2046            assert_eq!(memories.len(), 1);
2047            assert_eq!(memories[0].memory_id, memory_id);
2048            assert_eq!(
2049                memories[0].text,
2050                "User prefers Rust for core infrastructure."
2051            );
2052
2053            fs::remove_dir_all(root).expect("remove temp project");
2054        });
2055    }
2056
2057    /// v0.4.5 end-to-end: secrets in `add_memory` text never reach
2058    /// brain.db. The redacted row keeps the surrounding context so
2059    /// the memory is still useful — only the credential is scrubbed.
2060    #[test]
2061    fn add_memory_redacts_secrets_before_persist() {
2062        with_user_brain_disabled(|| {
2063            let root = test_root();
2064            fs::create_dir_all(&root).expect("create temp project");
2065            init_project(&root, false).expect("init project");
2066
2067            let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
2068            let memory_id =
2069                add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory");
2070
2071            let memories = list_memories(&root).expect("list");
2072            let stored = memories
2073                .iter()
2074                .find(|m| m.memory_id == memory_id)
2075                .expect("memory present");
2076            assert!(
2077                !stored.text.contains("sk-ant-api03"),
2078                "raw secret must NOT survive to brain.db: {}",
2079                stored.text
2080            );
2081            assert!(
2082                stored.text.contains("[REDACTED:anthropic_oauth]"),
2083                "placeholder must be present: {}",
2084                stored.text
2085            );
2086            assert!(
2087                stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"),
2088                "non-secret context must be preserved: {}",
2089                stored.text
2090            );
2091
2092            fs::remove_dir_all(root).expect("cleanup");
2093        });
2094    }
2095
2096    #[test]
2097    fn repo_ingest_indexes_searchable_files_and_context_capsules() {
2098        let root = test_root();
2099        fs::create_dir_all(root.join("src")).expect("create src");
2100        fs::create_dir_all(root.join("target")).expect("create target");
2101        fs::write(
2102            root.join("Cargo.toml"),
2103            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
2104        )
2105        .expect("write manifest");
2106        fs::write(
2107            root.join("src").join("lib.rs"),
2108            "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
2109        )
2110        .expect("write source");
2111        fs::write(
2112            root.join("target").join("generated.rs"),
2113            "projection rebuild",
2114        )
2115        .expect("write skipped");
2116        fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
2117        fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
2118
2119        init_project(&root, false).expect("init project");
2120        add_memory(
2121            &root,
2122            MemoryScope::GlobalUser,
2123            MemoryKind::Preference,
2124            "User prefers Rust for core infrastructure.",
2125        )
2126        .expect("add memory");
2127
2128        let summary = ingest_repo(&root).expect("ingest repo");
2129        assert_eq!(summary.indexed_files, 2);
2130        assert_eq!(summary.manifests, 1);
2131
2132        let matches = search_files(&root, "projection rebuild", 5).expect("search files");
2133        assert!(
2134            matches
2135                .iter()
2136                .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2137            "expected src/lib.rs in search results: {matches:?}"
2138        );
2139        assert!(
2140            matches
2141                .iter()
2142                .all(|capsule| !capsule.expansion_handle.contains("target/")),
2143            "target files must not be indexed: {matches:?}"
2144        );
2145
2146        let context =
2147            retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
2148        assert!(
2149            context
2150                .capsules
2151                .iter()
2152                .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
2153            "expected memory capsule in context: {:?}",
2154            context.capsules
2155        );
2156
2157        rebuild_projection(&root).expect("rebuild projection");
2158        let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
2159        assert!(
2160            matches
2161                .iter()
2162                .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2163            "repo index should survive event-only rebuild: {matches:?}"
2164        );
2165
2166        fs::remove_dir_all(root).expect("remove temp project");
2167    }
2168
2169    #[test]
2170    fn run_finished_increments_usefulness_for_injected_memories() {
2171        with_user_brain_disabled(|| {
2172            // MP-4a outcome attribution + v0.5.1 citation split:
2173            // a memory that is BOTH injected (in context.injected) AND
2174            // cited (via memory.cited from the cite_memory tool) earns
2175            // the strong +1.0 usefulness delta on run.finished.
2176            //
2177            // Per-run counting: the same memory injected into two
2178            // stages of one run still counts once.
2179            let root = test_root();
2180            fs::create_dir_all(&root).expect("create temp project");
2181            init_project(&root, false).expect("init project");
2182            let memory_id = add_memory(
2183                &root,
2184                MemoryScope::GlobalUser,
2185                MemoryKind::Preference,
2186                "Prefer ripgrep over grep.",
2187            )
2188            .expect("add memory");
2189
2190            {
2191                let (paths, _config, conn) = load_project(&root).expect("load");
2192                let run_id = RunId::new();
2193                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2194                let evs: Vec<Event> = vec![
2195                    Event::new(
2196                        run_id,
2197                        "run.started",
2198                        serde_json::json!({"project_id": "test", "task": "x"}),
2199                    ),
2200                    Event::new(
2201                        run_id,
2202                        "context.injected",
2203                        serde_json::json!({
2204                            "stage": "localization",
2205                            "capsule_handles": [format!("memory:{memory_id}")],
2206                            "memory_ids": [memory_id.clone()],
2207                            "prior_run_ids": [],
2208                            "file_paths": [],
2209                        }),
2210                    ),
2211                    Event::new(
2212                        run_id,
2213                        "context.injected",
2214                        serde_json::json!({
2215                            "stage": "patch_plan",
2216                            "capsule_handles": [format!("memory:{memory_id}")],
2217                            "memory_ids": [memory_id.clone()],
2218                            "prior_run_ids": [],
2219                            "file_paths": [],
2220                        }),
2221                    ),
2222                    // v0.5.1: model explicitly cited the memory in
2223                    // turn 3 — earns the strong +1.0 delta.
2224                    Event::new(
2225                        run_id,
2226                        "memory.cited",
2227                        serde_json::json!({
2228                            "memory_id": memory_id,
2229                            "turn": 3,
2230                            "rationale": "using rg from memory",
2231                        }),
2232                    ),
2233                    Event::new(
2234                        run_id,
2235                        "run.finished",
2236                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2237                    ),
2238                ];
2239                for ev in &evs {
2240                    writer.append(ev, true).expect("append");
2241                }
2242                projector::apply_events(&conn, &evs).expect("project");
2243            }
2244
2245            let memories = list_memories(&root).expect("list memories");
2246            let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2247            assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
2248            assert!(
2249                (m.usefulness_score - 1.0).abs() < f32::EPSILON,
2250                "expected strong-signal usefulness_score = 1.0, got {}",
2251                m.usefulness_score
2252            );
2253
2254            fs::remove_dir_all(root).expect("remove temp project");
2255        });
2256    }
2257
2258    /// v0.5.1: silent-passenger path. A memory that was retrieved
2259    /// (in context.injected) but the model never cited gets the
2260    /// weak +0.1 delta on run.finished, not the full +1.0.
2261    /// Encourages the model to actually call `cite_memory`.
2262    #[test]
2263    fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
2264        with_user_brain_disabled(|| {
2265            let root = test_root();
2266            fs::create_dir_all(&root).expect("create temp project");
2267            init_project(&root, false).expect("init project");
2268            let memory_id = add_memory(
2269                &root,
2270                MemoryScope::GlobalUser,
2271                MemoryKind::Preference,
2272                "Silent passenger memory.",
2273            )
2274            .expect("add memory");
2275
2276            {
2277                let (paths, _config, conn) = load_project(&root).expect("load");
2278                let run_id = RunId::new();
2279                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2280                let evs: Vec<Event> = vec![
2281                    Event::new(
2282                        run_id,
2283                        "run.started",
2284                        serde_json::json!({"project_id": "test", "task": "x"}),
2285                    ),
2286                    Event::new(
2287                        run_id,
2288                        "context.injected",
2289                        serde_json::json!({
2290                            "stage": "localization",
2291                            "memory_ids": [memory_id.clone()],
2292                            "prior_run_ids": [],
2293                            "file_paths": [],
2294                        }),
2295                    ),
2296                    // NO memory.cited event for this memory.
2297                    Event::new(
2298                        run_id,
2299                        "run.finished",
2300                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2301                    ),
2302                ];
2303                for ev in &evs {
2304                    writer.append(ev, true).expect("append");
2305                }
2306                projector::apply_events(&conn, &evs).expect("project");
2307            }
2308
2309            let memories = list_memories(&root).expect("list memories");
2310            let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2311            assert_eq!(m.use_count, 1);
2312            assert!(
2313                (m.usefulness_score - 0.1).abs() < 1e-5,
2314                "silent passenger should get +0.1, got {}",
2315                m.usefulness_score
2316            );
2317        });
2318    }
2319
2320    /// v0.5.1 end-to-end: `blame_run` walks memory_citations +
2321    /// context.injected + terminal events and surfaces per-memory
2322    /// attribution. Cited memories appear under `cited`, retrieved-
2323    /// but-uncited under `silent_passengers`, and the outcome
2324    /// reflects the run's terminal event.
2325    #[test]
2326    fn blame_run_separates_cited_from_silent_passengers() {
2327        with_user_brain_disabled(|| {
2328            let root = test_root();
2329            fs::create_dir_all(&root).expect("create temp project");
2330            init_project(&root, false).expect("init project");
2331            let cited_id = add_memory(
2332                &root,
2333                MemoryScope::Repo,
2334                MemoryKind::Preference,
2335                "prefer ripgrep over grep",
2336            )
2337            .expect("add cited");
2338            let silent_id = add_memory(
2339                &root,
2340                MemoryScope::Repo,
2341                MemoryKind::Convention,
2342                "use cargo nextest for tests",
2343            )
2344            .expect("add silent");
2345
2346            let run_id = RunId::new();
2347            {
2348                let (paths, _config, conn) = load_project(&root).expect("load");
2349                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2350                let evs: Vec<Event> = vec![
2351                    Event::new(
2352                        run_id,
2353                        "run.started",
2354                        serde_json::json!({"project_id": "test", "task": "x"}),
2355                    ),
2356                    Event::new(
2357                        run_id,
2358                        "context.injected",
2359                        serde_json::json!({
2360                            "stage": "localization",
2361                            "memory_ids": [cited_id.clone(), silent_id.clone()],
2362                            "prior_run_ids": [],
2363                            "file_paths": [],
2364                        }),
2365                    ),
2366                    Event::new(
2367                        run_id,
2368                        "memory.cited",
2369                        serde_json::json!({
2370                            "memory_id": cited_id,
2371                            "turn": 4,
2372                            "rationale": "used the rg pattern",
2373                        }),
2374                    ),
2375                    Event::new(
2376                        run_id,
2377                        "run.finished",
2378                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2379                    ),
2380                ];
2381                for ev in &evs {
2382                    writer.append(ev, true).expect("append");
2383                }
2384                projector::apply_events(&conn, &evs).expect("project");
2385            }
2386
2387            let report = blame_run(&root, &run_id.to_string()).expect("blame");
2388            assert_eq!(report.outcome, "success");
2389            assert!(report.failure_category.is_none());
2390            assert_eq!(report.cited.len(), 1, "exactly one cited memory");
2391            let cited = &report.cited[0];
2392            assert_eq!(cited.memory_id, cited_id);
2393            assert_eq!(cited.turn, 4);
2394            assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
2395            assert!(cited.text_preview.contains("ripgrep"));
2396
2397            assert_eq!(report.silent_passengers.len(), 1);
2398            let silent = &report.silent_passengers[0];
2399            assert_eq!(silent.memory_id, silent_id);
2400            assert!(silent.text_preview.contains("nextest"));
2401
2402            fs::remove_dir_all(root).expect("cleanup");
2403        });
2404    }
2405
2406    #[test]
2407    fn run_failed_decrements_usefulness_unless_gate() {
2408        // run.failed with category != "Gate" decrements; category == "Gate"
2409        // is a graceful early-exit (e.g. the plan-create existence guard)
2410        // and must not blame memories that happened to be in context.
2411        let root = test_root();
2412        fs::create_dir_all(&root).expect("create temp project");
2413        init_project(&root, false).expect("init project");
2414        let memory_id = add_memory(
2415            &root,
2416            MemoryScope::Repo,
2417            MemoryKind::Convention,
2418            "Use find_* for fallible lookups.",
2419        )
2420        .expect("add memory");
2421
2422        {
2423            let (paths, _config, conn) = load_project(&root).expect("load");
2424
2425            // First run: gate-failure -> no update at all.
2426            let gate_run = RunId::new();
2427            let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
2428            let gate_events: Vec<Event> = vec![
2429                Event::new(
2430                    gate_run,
2431                    "run.started",
2432                    serde_json::json!({"project_id": "test", "task": "g"}),
2433                ),
2434                Event::new(
2435                    gate_run,
2436                    "context.injected",
2437                    serde_json::json!({
2438                        "stage": "patch_plan",
2439                        "capsule_handles": [format!("memory:{memory_id}")],
2440                        "memory_ids": [memory_id.clone()],
2441                        "prior_run_ids": [],
2442                        "file_paths": [],
2443                    }),
2444                ),
2445                Event::new(
2446                    gate_run,
2447                    "run.failed",
2448                    serde_json::json!({
2449                        "category": "Gate",
2450                        "failed_stage": "patch_plan",
2451                        "message": "files_to_create_already_exist",
2452                    }),
2453                ),
2454            ];
2455            for ev in &gate_events {
2456                writer.append(ev, true).expect("append");
2457            }
2458            projector::apply_events(&conn, &gate_events).expect("project gate-fail");
2459
2460            // Second run: real implementation failure + the memory
2461            // was cited via memory.cited -> -1.0 strong signal.
2462            let impl_run = RunId::new();
2463            let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
2464            let impl_events: Vec<Event> = vec![
2465                Event::new(
2466                    impl_run,
2467                    "run.started",
2468                    serde_json::json!({"project_id": "test", "task": "i"}),
2469                ),
2470                Event::new(
2471                    impl_run,
2472                    "context.injected",
2473                    serde_json::json!({
2474                        "stage": "patch_plan",
2475                        "capsule_handles": [format!("memory:{memory_id}")],
2476                        "memory_ids": [memory_id.clone()],
2477                        "prior_run_ids": [],
2478                        "file_paths": [],
2479                    }),
2480                ),
2481                // v0.5.1: cite the memory so this run earns the
2482                // strong -1.0 penalty (the brain pushed wrong).
2483                Event::new(
2484                    impl_run,
2485                    "memory.cited",
2486                    serde_json::json!({
2487                        "memory_id": memory_id,
2488                        "turn": 2,
2489                        "rationale": "trusted the memory's pattern",
2490                    }),
2491                ),
2492                Event::new(
2493                    impl_run,
2494                    "run.failed",
2495                    serde_json::json!({
2496                        "category": "Implementation",
2497                        "failed_stage": "implementation",
2498                        "message": "test broke",
2499                    }),
2500                ),
2501            ];
2502            for ev in &impl_events {
2503                writer2.append(ev, true).expect("append");
2504            }
2505            projector::apply_events(&conn, &impl_events).expect("project impl-fail");
2506        }
2507
2508        let memories = list_memories(&root).expect("list memories");
2509        let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2510        assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
2511        assert!(
2512            (m.usefulness_score - (-1.0)).abs() < f32::EPSILON,
2513            "expected usefulness_score = -1.0, got {}",
2514            m.usefulness_score
2515        );
2516
2517        fs::remove_dir_all(root).expect("remove temp project");
2518    }
2519
2520    #[test]
2521    fn run_aborted_does_not_update_usefulness() {
2522        let root = test_root();
2523        fs::create_dir_all(&root).expect("create temp project");
2524        init_project(&root, false).expect("init project");
2525        let memory_id = add_memory(
2526            &root,
2527            MemoryScope::Repo,
2528            MemoryKind::Convention,
2529            "Module re-exports live in lib.rs.",
2530        )
2531        .expect("add memory");
2532
2533        {
2534            let (paths, _config, conn) = load_project(&root).expect("load");
2535            let run_id = RunId::new();
2536            let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
2537            let evs: Vec<Event> = vec![
2538                Event::new(
2539                    run_id,
2540                    "run.started",
2541                    serde_json::json!({"project_id": "test", "task": "a"}),
2542                ),
2543                Event::new(
2544                    run_id,
2545                    "context.injected",
2546                    serde_json::json!({
2547                        "stage": "patch_plan",
2548                        "capsule_handles": [format!("memory:{memory_id}")],
2549                        "memory_ids": [memory_id.clone()],
2550                        "prior_run_ids": [],
2551                        "file_paths": [],
2552                    }),
2553                ),
2554                Event::new(
2555                    run_id,
2556                    "run.aborted",
2557                    serde_json::json!({"reason": "user_abort"}),
2558                ),
2559            ];
2560            for ev in &evs {
2561                writer.append(ev, true).expect("append");
2562            }
2563            projector::apply_events(&conn, &evs).expect("project");
2564        }
2565
2566        let memories = list_memories(&root).expect("list memories");
2567        let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2568        assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
2569        assert!(
2570            m.usefulness_score.abs() < f32::EPSILON,
2571            "expected usefulness_score = 0.0, got {}",
2572            m.usefulness_score
2573        );
2574
2575        fs::remove_dir_all(root).expect("remove temp project");
2576    }
2577
2578    #[test]
2579    fn list_proposals_filters_and_reject_records_reason() {
2580        let root = test_root();
2581        fs::create_dir_all(&root).expect("create temp project");
2582        init_project(&root, false).expect("init project");
2583
2584        // Inject three proposals straight via memory.proposed events.
2585        let proposals = [
2586            (
2587                "p1",
2588                "global_user",
2589                "preference",
2590                0.9_f32,
2591                "Prefer rg over grep",
2592            ),
2593            (
2594                "p2",
2595                "repo",
2596                "convention",
2597                0.8,
2598                "Use find_* for fallible lookups",
2599            ),
2600            (
2601                "p3",
2602                "repo",
2603                "convention",
2604                0.4,
2605                "Use let-else where possible",
2606            ),
2607        ];
2608        {
2609            let (paths, _config, conn) = load_project(&root).expect("load");
2610            let run_id = RunId::new();
2611            let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2612            for (proposal_id, scope, kind, conf, text) in &proposals {
2613                let event = Event::new(
2614                    run_id,
2615                    "memory.proposed",
2616                    serde_json::json!({
2617                        "proposal_id": proposal_id,
2618                        "scope": scope,
2619                        "kind": kind,
2620                        "text": text,
2621                        "rationale": "test rationale",
2622                        "proposed_confidence": conf,
2623                        "source_event_ids": [],
2624                    }),
2625                );
2626                writer.append(&event, true).expect("append proposal");
2627                projector::apply_events(&conn, &[event]).expect("project");
2628            }
2629        }
2630
2631        // Filter by scope.
2632        let global = list_proposals(
2633            &root,
2634            ProposalFilter {
2635                scope: Some("global_user".into()),
2636                status: Some("pending".into()),
2637                ..ProposalFilter::default()
2638            },
2639        )
2640        .expect("list proposals");
2641        assert_eq!(global.len(), 1);
2642        assert_eq!(global[0].proposal_id, "p1");
2643
2644        // Filter by min_confidence.
2645        let strong = list_proposals(
2646            &root,
2647            ProposalFilter {
2648                min_confidence: Some(0.7),
2649                status: Some("pending".into()),
2650                ..ProposalFilter::default()
2651            },
2652        )
2653        .expect("list strong");
2654        assert_eq!(strong.len(), 2);
2655        for row in &strong {
2656            assert!(row.proposed_confidence >= 0.7);
2657        }
2658
2659        // Reject one with a reason and confirm it persists on the projected row.
2660        reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
2661        let rejected = list_proposals(
2662            &root,
2663            ProposalFilter {
2664                status: Some("rejected".into()),
2665                ..ProposalFilter::default()
2666            },
2667        )
2668        .expect("list rejected");
2669        assert_eq!(rejected.len(), 1);
2670        assert_eq!(rejected[0].proposal_id, "p3");
2671        assert_eq!(
2672            rejected[0].decided_reason.as_deref(),
2673            Some("not specific to the user")
2674        );
2675
2676        // Accept with a confidence override and confirm the resulting memory
2677        // carries the overridden value.
2678        let memory_id = accept_proposal(
2679            &root,
2680            "p1",
2681            AcceptOverrides {
2682                scope: None,
2683                confidence: Some(0.55),
2684            },
2685        )
2686        .expect("accept");
2687        let memories = list_memories(&root).expect("list memories");
2688        let promoted = memories
2689            .into_iter()
2690            .find(|m| m.memory_id == memory_id)
2691            .expect("promoted memory present");
2692        assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
2693
2694        fs::remove_dir_all(root).expect("remove temp project");
2695    }
2696
2697    /// MP-4d: invalidate_memory emits a `memory.invalidated` event and
2698    /// projects it. The memory row keeps everything but gains
2699    /// `invalidated_at`/`invalidated_reason`, and the row survives a
2700    /// projection rebuild (event is canonical).
2701    #[test]
2702    fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
2703        let root = test_root();
2704        fs::create_dir_all(&root).expect("create temp project");
2705        init_project(&root, false).expect("init project");
2706
2707        let memory_id = add_memory(
2708            &root,
2709            MemoryScope::Repo,
2710            MemoryKind::Convention,
2711            "Use find_* for fallible lookups.",
2712        )
2713        .expect("add memory");
2714
2715        invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
2716            .expect("invalidate memory");
2717
2718        // Direct DB peek so we can read the new columns even before they are
2719        // surfaced via MemoryRow.
2720        {
2721            let (_paths, _config, conn) = load_project(&root).expect("load");
2722            let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
2723                .query_row(
2724                    "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
2725                    params![memory_id],
2726                    |row| Ok((row.get(0)?, row.get(1)?)),
2727                )
2728                .expect("query invalidated metadata");
2729            assert!(invalidated_at.is_some(), "invalidated_at must be set");
2730            assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
2731        }
2732
2733        // Rebuild from trace and confirm invalidation survives.
2734        rebuild_projection(&root).expect("rebuild projection");
2735        {
2736            let (_paths, _config, conn) = load_project(&root).expect("load");
2737            let invalidated_at: Option<String> = conn
2738                .query_row(
2739                    "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
2740                    params![memory_id],
2741                    |row| row.get(0),
2742                )
2743                .expect("query after rebuild");
2744            assert!(
2745                invalidated_at.is_some(),
2746                "invalidated_at must survive event replay"
2747            );
2748        }
2749
2750        fs::remove_dir_all(root).expect("remove temp project");
2751    }
2752
2753    /// MP-4b broker integration: an invalidated memory must not appear in
2754    /// the retrieved context bundle, even though the row still exists in
2755    /// brain.db for replay/audit.
2756    #[test]
2757    fn invalidated_memory_is_excluded_from_broker_retrieval() {
2758        with_user_brain_disabled(|| {
2759            let root = test_root();
2760            fs::create_dir_all(&root).expect("create temp project");
2761            init_project(&root, false).expect("init project");
2762
2763            let memory_id = add_memory(
2764                &root,
2765                MemoryScope::GlobalUser,
2766                MemoryKind::Preference,
2767                "Prefer ripgrep over grep for repo search.",
2768            )
2769            .expect("add memory");
2770
2771            // Sanity: broker surfaces it pre-invalidation.
2772            let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
2773                .expect("pre context");
2774            assert!(
2775                pre.capsules
2776                    .iter()
2777                    .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
2778                "memory must appear before invalidation: {:?}",
2779                pre.capsules
2780            );
2781
2782            invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
2783
2784            let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
2785                .expect("post context");
2786            assert!(
2787                post.capsules
2788                    .iter()
2789                    .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
2790                "invalidated memory must not be retrieved: {:?}",
2791                post.capsules
2792            );
2793
2794            // The row itself still exists in brain.db.
2795            let memories = list_memories(&root).expect("list");
2796            assert!(memories.iter().any(|m| m.memory_id == memory_id));
2797
2798            fs::remove_dir_all(root).expect("remove temp project");
2799        });
2800    }
2801
2802    /// MP-6: `list_memories_top` returns invalidated_at IS NULL memories
2803    /// sorted by ratio descending, filtered by `min_uses`. Memories with
2804    /// use_count below the threshold are dropped entirely so the listing
2805    /// only shows entries the broker bias actually applies to.
2806    #[test]
2807    fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
2808        let root = test_root();
2809        fs::create_dir_all(&root).expect("create temp project");
2810        init_project(&root, false).expect("init project");
2811
2812        let m_great =
2813            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
2814        let m_meh =
2815            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
2816        let m_bad =
2817            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
2818        let _m_fresh =
2819            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
2820
2821        // Directly set usefulness data; the event-sourcing path is already
2822        // tested by `run_finished_increments_usefulness_for_injected_memories`.
2823        {
2824            let (_paths, _config, conn) = load_project(&root).expect("load");
2825            conn.execute(
2826                "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
2827                params![m_great],
2828            )
2829            .expect("set great");
2830            conn.execute(
2831                "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
2832                params![m_meh],
2833            )
2834            .expect("set meh");
2835            conn.execute(
2836                "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
2837                params![m_bad],
2838            )
2839            .expect("set bad");
2840            // m_fresh stays at use_count=0; should be excluded.
2841        }
2842
2843        let top = list_memories_top(
2844            &root,
2845            TopOptions {
2846                scope: None,
2847                min_uses: 3,
2848                limit: 10,
2849            },
2850        )
2851        .expect("top");
2852        assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
2853        assert_eq!(top[0].memory_id, m_great);
2854        assert_eq!(top[1].memory_id, m_meh);
2855        assert_eq!(top[2].memory_id, m_bad);
2856
2857        // Now invalidate the GREAT memory and confirm it disappears.
2858        invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
2859        let top_after = list_memories_top(
2860            &root,
2861            TopOptions {
2862                scope: None,
2863                min_uses: 3,
2864                limit: 10,
2865            },
2866        )
2867        .expect("top after");
2868        assert_eq!(top_after.len(), 2);
2869        assert!(top_after.iter().all(|m| m.memory_id != m_great));
2870
2871        fs::remove_dir_all(root).expect("remove temp project");
2872    }
2873
2874    /// MP-6: `prune_low_usefulness` lists candidates without writing when
2875    /// `apply = false`, and invalidates each match via the canonical
2876    /// `memory.invalidated` event path when `apply = true`. The prune
2877    /// reason includes the ratio + use_count so audit trail explains
2878    /// why the memory left.
2879    #[test]
2880    fn prune_low_usefulness_dry_run_then_apply() {
2881        with_user_brain_disabled(|| {
2882            prune_low_usefulness_dry_run_then_apply_body();
2883        });
2884    }
2885
2886    fn prune_low_usefulness_dry_run_then_apply_body() {
2887        let root = test_root();
2888        fs::create_dir_all(&root).expect("create temp project");
2889        init_project(&root, false).expect("init project");
2890
2891        let m_keep = add_memory(
2892            &root,
2893            MemoryScope::Repo,
2894            MemoryKind::Convention,
2895            "keep me, I help",
2896        )
2897        .expect("keep");
2898        let m_drop_1 = add_memory(
2899            &root,
2900            MemoryScope::Repo,
2901            MemoryKind::Convention,
2902            "drop me, I hurt",
2903        )
2904        .expect("drop1");
2905        let m_drop_2 = add_memory(
2906            &root,
2907            MemoryScope::Repo,
2908            MemoryKind::Convention,
2909            "drop me too",
2910        )
2911        .expect("drop2");
2912        let m_small_sample = add_memory(
2913            &root,
2914            MemoryScope::Repo,
2915            MemoryKind::Convention,
2916            "small sample shouldn't be pruned even if score is bad",
2917        )
2918        .expect("small");
2919
2920        {
2921            let (_paths, _config, conn) = load_project(&root).expect("load");
2922            // keep: ratio = +0.6 (above threshold)
2923            conn.execute(
2924                "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
2925                params![m_keep],
2926            )
2927            .expect("set keep");
2928            // drop_1: ratio = -0.6 (well below -0.2)
2929            conn.execute(
2930                "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
2931                params![m_drop_1],
2932            )
2933            .expect("set drop1");
2934            // drop_2: ratio = -0.4
2935            conn.execute(
2936                "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
2937                params![m_drop_2],
2938            )
2939            .expect("set drop2");
2940            // small_sample: ratio = -1.0 but only 2 uses, must NOT be pruned
2941            conn.execute(
2942                "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
2943                params![m_small_sample],
2944            )
2945            .expect("set small");
2946        }
2947
2948        // Dry-run: lists candidates but does not invalidate.
2949        let dry = prune_low_usefulness(
2950            &root,
2951            PruneOptions {
2952                scope: None,
2953                min_uses: 3,
2954                max_ratio: -0.2,
2955                apply: false,
2956            },
2957        )
2958        .expect("dry-run");
2959        assert_eq!(dry.candidates.len(), 2);
2960        assert_eq!(dry.invalidated, 0);
2961        let ids: Vec<&str> = dry
2962            .candidates
2963            .iter()
2964            .map(|c| c.memory_id.as_str())
2965            .collect();
2966        assert!(ids.contains(&m_drop_1.as_str()));
2967        assert!(ids.contains(&m_drop_2.as_str()));
2968        // Confirm small_sample stayed out of the candidate list.
2969        assert!(!ids.contains(&m_small_sample.as_str()));
2970
2971        // Pre-apply state: all four memories still active.
2972        let pre = list_memories(&root).expect("pre");
2973        assert_eq!(pre.len(), 4);
2974
2975        // Apply: both bad memories invalidated, keep + small_sample untouched.
2976        let applied = prune_low_usefulness(
2977            &root,
2978            PruneOptions {
2979                scope: None,
2980                min_uses: 3,
2981                max_ratio: -0.2,
2982                apply: true,
2983            },
2984        )
2985        .expect("apply");
2986        assert_eq!(applied.candidates.len(), 2);
2987        assert_eq!(applied.invalidated, 2);
2988        assert_eq!(applied.failed, 0);
2989
2990        // Post-apply: list_memories_top with min_uses=3 should now only
2991        // surface the keep memory (drops are invalidated_at IS NOT NULL,
2992        // small_sample is filtered by min_uses).
2993        let top = list_memories_top(
2994            &root,
2995            TopOptions {
2996                scope: None,
2997                min_uses: 3,
2998                limit: 10,
2999            },
3000        )
3001        .expect("top after prune");
3002        assert_eq!(top.len(), 1);
3003        assert_eq!(top[0].memory_id, m_keep);
3004
3005        // Confirm the canonical event trail: each pruned memory has a
3006        // non-null invalidated_at and the reason mentions "pruned_by_usefulness".
3007        // Scope the connection so it's dropped before fs::remove_dir_all
3008        // on Windows, where SQLite holds an exclusive lock on the journal.
3009        {
3010            let (_paths, _config, conn) = load_project(&root).expect("load");
3011            let reason: String = conn
3012                .query_row(
3013                    "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
3014                    params![m_drop_1],
3015                    |row| row.get(0),
3016                )
3017                .expect("invalidated reason");
3018            assert!(
3019                reason.starts_with("pruned_by_usefulness"),
3020                "unexpected reason: {reason}"
3021            );
3022        }
3023
3024        fs::remove_dir_all(root).expect("remove temp project");
3025    }
3026
3027    /// MP-5a: the brain primitives behind `kimetsu brain memory review`.
3028    /// Workflow: inject several proposals across two runs, filter by run +
3029    /// confidence to pick the keepers, batch-accept those, then
3030    /// batch-reject the remainder. The final state must show exactly the
3031    /// accepted proposals as memories and exactly the rejected proposals
3032    /// carrying a non-empty decided_reason.
3033    #[test]
3034    fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
3035        with_user_brain_disabled(|| {
3036            batch_review_accepts_filtered_subset_and_rejects_remainder_body();
3037        });
3038    }
3039
3040    fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
3041        let root = test_root();
3042        fs::create_dir_all(&root).expect("create temp project");
3043        init_project(&root, false).expect("init project");
3044
3045        let run_a = RunId::new();
3046        let run_b = RunId::new();
3047
3048        // Two proposals from run_a (one strong, one weak) plus two more
3049        // from run_b. The "review" flow will accept run_a's strong one,
3050        // reject everything else.
3051        let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
3052            (
3053                "p_a_strong",
3054                run_a,
3055                "global_user",
3056                "preference",
3057                0.92,
3058                "Prefer rg over grep",
3059            ),
3060            (
3061                "p_a_weak",
3062                run_a,
3063                "repo",
3064                "convention",
3065                0.55,
3066                "Always use let-else",
3067            ),
3068            (
3069                "p_b1",
3070                run_b,
3071                "repo",
3072                "convention",
3073                0.70,
3074                "Use Result not panic",
3075            ),
3076            (
3077                "p_b2",
3078                run_b,
3079                "global_user",
3080                "preference",
3081                0.88,
3082                "Open links in new tab",
3083            ),
3084        ];
3085
3086        {
3087            let (paths, _config, conn) = load_project(&root).expect("load");
3088            for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
3089                let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
3090                let event = Event::new(
3091                    *run_id,
3092                    "memory.proposed",
3093                    serde_json::json!({
3094                        "proposal_id": proposal_id,
3095                        "scope": scope,
3096                        "kind": kind,
3097                        "text": text,
3098                        "rationale": "fixture",
3099                        "proposed_confidence": conf,
3100                        "source_event_ids": [],
3101                    }),
3102                );
3103                writer.append(&event, true).expect("append");
3104                projector::apply_events(&conn, &[event]).expect("project");
3105            }
3106        }
3107
3108        // Step 1: --accept-all --from-run <run_a> --min-confidence 0.8
3109        // mirrors the CLI filter + accept loop.
3110        let to_accept = list_proposals(
3111            &root,
3112            ProposalFilter {
3113                from_run: Some(run_a.to_string()),
3114                min_confidence: Some(0.8),
3115                status: Some("pending".into()),
3116                limit: 100,
3117                ..ProposalFilter::default()
3118            },
3119        )
3120        .expect("list strong from run_a");
3121        assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
3122        assert_eq!(to_accept[0].proposal_id, "p_a_strong");
3123        let memory_id =
3124            accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
3125                .expect("accept p_a_strong");
3126
3127        // Step 2: --reject-all --reason "batch_reject" over the remaining
3128        // pending proposals.
3129        let to_reject = list_proposals(
3130            &root,
3131            ProposalFilter {
3132                status: Some("pending".into()),
3133                limit: 100,
3134                ..ProposalFilter::default()
3135            },
3136        )
3137        .expect("list remaining pending");
3138        assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
3139        for p in &to_reject {
3140            reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
3141        }
3142
3143        // Final state: exactly one memory; exactly three rejected proposals;
3144        // zero pending. Decision reason persisted on each rejected row.
3145        let memories = list_memories(&root).expect("list memories");
3146        assert_eq!(
3147            memories.len(),
3148            1,
3149            "only the accepted proposal becomes a memory"
3150        );
3151        assert_eq!(memories[0].memory_id, memory_id);
3152
3153        let pending = list_proposals(
3154            &root,
3155            ProposalFilter {
3156                status: Some("pending".into()),
3157                limit: 100,
3158                ..ProposalFilter::default()
3159            },
3160        )
3161        .expect("list pending");
3162        assert!(
3163            pending.is_empty(),
3164            "no proposals left pending after batch review"
3165        );
3166
3167        let rejected = list_proposals(
3168            &root,
3169            ProposalFilter {
3170                status: Some("rejected".into()),
3171                limit: 100,
3172                ..ProposalFilter::default()
3173            },
3174        )
3175        .expect("list rejected");
3176        assert_eq!(rejected.len(), 3);
3177        for row in &rejected {
3178            assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
3179        }
3180
3181        fs::remove_dir_all(root).expect("remove temp project");
3182    }
3183
3184    /// End-to-end regression for the add -> list_conflicts ->
3185    /// resolve_conflict plumbing. It must be AGNOSTIC to which
3186    /// embedder backs the build: `cargo test --workspace`
3187    /// feature-unifies `embeddings` into this crate (kimetsu-cli
3188    /// enables `kimetsu-brain/embeddings`), so
3189    /// `open_default_embedder()` returns the real fastembed model
3190    /// here, not the noop. The two memories below are therefore on
3191    /// unrelated topics: cosine stays well under the 0.82 conflict
3192    /// threshold for any real embedder, and the noop build trivially
3193    /// records zero -- so `list_conflicts` is deterministically empty
3194    /// either way.
3195    ///
3196    /// Real near-duplicate semantic detection is exercised
3197    /// exhaustively in `crate::conflict::tests` with a StubEmbedder;
3198    /// this test guards the project-level plumbing only.
3199    #[test]
3200    fn add_memory_distinct_texts_no_conflicts() {
3201        with_user_brain_disabled(|| {
3202            let root = test_root();
3203            fs::create_dir_all(&root).expect("create temp project");
3204            init_project(&root, false).expect("init project");
3205
3206            // Two memories on unrelated topics: neither the noop nor
3207            // a real embedder flags them as conflicting (cosine well
3208            // under the 0.82 threshold), and they don't collide via
3209            // the exact-text dedup gate, so both rows simply coexist.
3210            let _m1 = add_memory(
3211                &root,
3212                MemoryScope::GlobalUser,
3213                MemoryKind::Preference,
3214                "Prefer thiserror for library error types.",
3215            )
3216            .expect("add m1");
3217            let _m2 = add_memory(
3218                &root,
3219                MemoryScope::GlobalUser,
3220                MemoryKind::Preference,
3221                "Cache HTTP responses with a one-hour TTL.",
3222            )
3223            .expect("add m2");
3224
3225            let open = list_conflicts(&root, 50).expect("list_conflicts");
3226            assert!(
3227                open.is_empty(),
3228                "distinct-topic memories must not conflict; got {} rows",
3229                open.len()
3230            );
3231
3232            // Resolving a non-existent id should return false, not error.
3233            let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
3234                .expect("resolve_conflict on unknown id");
3235            assert!(!resolved, "unknown conflict id should resolve to false");
3236
3237            // Invalid resolution strings should be rejected up front.
3238            let err = resolve_conflict(&root, "does-not-exist", "garbage")
3239                .expect_err("invalid resolution should error");
3240            assert!(format!("{err}").contains("invalid conflict resolution"));
3241
3242            fs::remove_dir_all(root).expect("remove temp project");
3243        });
3244    }
3245}