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 pub usefulness_score: f32,
59}
60
61#[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 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#[derive(Debug, Clone, serde::Serialize)]
120pub struct BlameReport {
121 pub run_id: String,
122 pub outcome: String,
126 pub failure_category: Option<String>,
129 pub cited: Vec<CitedMemory>,
132 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 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 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 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 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 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 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 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 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 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 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 let embedder = embeddings::open_default_embedder();
527 embeddings::embed_and_persist(&conn, &memory_id, text, embedder)?;
528
529 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
547pub 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#[derive(Debug)]
597pub enum ProposeResult {
598 Added(String), Proposed(String), Merged(String), Duplicate(String), }
603
604pub 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 {
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 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 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 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#[derive(Debug, Clone)]
685pub struct ListOptions {
686 pub limit: u32,
688 pub offset: u32,
690 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
708pub 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
723pub 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 let (outcome, failure_category) = run_outcome(&conn, run_id)?;
738
739 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 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 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
875fn 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#[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 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#[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 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
1305pub 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
1314pub fn retrieve_proactive_readonly(
1318 start: &Path,
1319 request: ContextRequest,
1320) -> KimetsuResult<ContextBundle> {
1321 BrainSession::open_readonly(start)?.retrieve_proactive(request)
1322}
1323
1324pub 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 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 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#[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 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
1620pub 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1728pub struct ScopedConflict {
1729 pub source: String,
1732 #[serde(flatten)]
1733 pub report: conflict::ConflictReport,
1734}
1735
1736pub 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
1760pub 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); 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 use crate::user_brain::with_user_brain_disabled;
1873
1874 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 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 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 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 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 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 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 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 #[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 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 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 #[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 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 #[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 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 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 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 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 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 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 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_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 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 #[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 {
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_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 #[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 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 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 #[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 {
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 }
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 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 #[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 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 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 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 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 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 assert!(!ids.contains(&m_small_sample.as_str()));
2970
2971 let pre = list_memories(&root).expect("pre");
2973 assert_eq!(pre.len(), 4);
2974
2975 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 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 {
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 #[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 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 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 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 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 #[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 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 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 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}