1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kimetsu_core::config::ProjectConfig;
5use kimetsu_core::env_file::resolve_env_value;
6use kimetsu_core::event::Event;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::memory::{MemoryKind, MemoryScope, normalize_memory_text};
9use kimetsu_core::paths::{ProjectPaths, default_project_id};
10use kimetsu_core::{KIMETSU_CONFIG_VERSION, KimetsuResult};
11use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
12use ulid::Ulid;
13
14use crate::benchmark;
15use crate::conflict;
16use crate::context::{self, ContextBundle, ContextRequest};
17use crate::embeddings;
18use crate::ingest::{self, RepoIngestSummary};
19use crate::lock::ProjectLock;
20use crate::projector;
21use crate::redact;
22use crate::schema;
23use crate::user_brain;
24
25fn max_corpus_cosine(conn: &Connection, query_vec: &[f32]) -> f32 {
32 let mut stmt = match conn.prepare(
33 "SELECT embedding FROM memories
34 WHERE invalidated_at IS NULL
35 AND superseded_by IS NULL
36 AND embedding IS NOT NULL
37 ORDER BY created_at DESC
38 LIMIT 200",
39 ) {
40 Ok(s) => s,
41 Err(_) => return 0.0,
42 };
43 let rows = match stmt.query_map([], |row| row.get::<_, Vec<u8>>(0)) {
44 Ok(r) => r,
45 Err(_) => return 0.0,
46 };
47 let mut max_cos: f32 = 0.0;
48 for row in rows.flatten() {
49 if let Ok(vec) = embeddings::decode_embedding(&row, None) {
50 if vec.len() == query_vec.len() {
51 let cos = cosine_sim(query_vec, &vec);
52 if cos > max_cos {
53 max_cos = cos;
54 }
55 }
56 }
57 }
58 max_cos
59}
60
61fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
62 if a.len() != b.len() || a.is_empty() {
63 return 0.0;
64 }
65 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
66 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
67 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
68 if na < f32::EPSILON || nb < f32::EPSILON {
69 return 0.0;
70 }
71 (dot / (na * nb)).clamp(-1.0, 1.0)
72}
73
74#[derive(Debug, Clone)]
75pub struct InitSummary {
76 pub project_id: String,
77 pub repo_root: PathBuf,
78 pub kimetsu_dir: PathBuf,
79 pub brain_db: PathBuf,
80 pub model: String,
81 pub api_key_env: String,
82 pub api_key_present: bool,
83 pub wrote_project_toml: bool,
84}
85
86#[derive(Debug, Clone)]
87pub struct RunSummary {
88 pub run_id: String,
89 pub task: String,
90 pub started_at: String,
91 pub terminal_kind: Option<String>,
92}
93
94#[derive(Debug, Clone)]
95pub struct MemoryRow {
96 pub memory_id: String,
97 pub scope: String,
98 pub kind: String,
99 pub text: String,
100 pub confidence: f32,
101 pub use_count: u32,
102 pub usefulness_score: f32,
107}
108
109#[derive(Debug, Clone)]
113pub struct MemorySearchHit {
114 pub memory_id: String,
115 pub scope: String,
116 pub kind: String,
117 pub text: String,
118 pub rank: f32,
119}
120
121#[derive(Debug, Clone)]
122pub struct ProposalRow {
123 pub proposal_id: String,
124 pub run_id: String,
125 pub scope: String,
126 pub kind: String,
127 pub text: String,
128 pub rationale: String,
129 pub proposed_confidence: f32,
130 pub status: String,
131 pub decided_reason: Option<String>,
132}
133
134#[derive(Debug, Clone, Default)]
135pub struct ProposalFilter {
136 pub scope: Option<String>,
137 pub kind: Option<String>,
138 pub from_run: Option<String>,
139 pub min_confidence: Option<f32>,
140 pub status: Option<String>,
141 pub limit: u32,
142 pub offset: u32,
145}
146
147#[derive(Debug, Clone, Default)]
148pub struct AcceptOverrides {
149 pub scope: Option<String>,
150 pub confidence: Option<f32>,
151}
152
153#[derive(Debug, Clone)]
154pub struct RecordedBenchmarkOutcome {
155 pub memory_id: String,
156 pub task_slug: Option<String>,
157 pub kind: MemoryKind,
158 pub text: String,
159 pub proposal_id: Option<String>,
160 pub proposal_text: Option<String>,
161}
162
163pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
164 let paths = ProjectPaths::discover(start)?;
165 paths.validate_state_dir()?;
166 fs::create_dir_all(&paths.kimetsu_dir)?;
171
172 let project_id = default_project_id(&paths.repo_root);
173 let config = ProjectConfig::default_for_project(project_id);
174 let wrote_project_toml = if force || !paths.project_toml.exists() {
175 fs::write(&paths.project_toml, config.to_toml()?)?;
176 true
177 } else {
178 false
179 };
180
181 let config = load_config(&paths)?;
182 let conn = Connection::open(&paths.brain_db)?;
183 schema::initialize(&conn)?;
184
185 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
186
187 Ok(InitSummary {
188 project_id: config.kimetsu.project_id,
189 repo_root: paths.repo_root,
190 kimetsu_dir: paths.kimetsu_dir,
191 brain_db: paths.brain_db,
192 model: format!("{}/{}", config.model.provider, config.model.model),
193 api_key_env: config.model.api_key_env,
194 api_key_present,
195 wrote_project_toml,
196 })
197}
198
199pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
200 let paths = ProjectPaths::discover(start)?;
201 paths.validate_state_dir()?;
202 let config = load_config(&paths)?;
203 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
204 return Err(format!(
210 "project.toml schema version {} does not match expected {} (file: {}). \
211 If this is not the project you meant, run from inside a git \
212 repository or pass --workspace to pin the project root.",
213 config.kimetsu.schema_version,
214 KIMETSU_CONFIG_VERSION,
215 paths.project_toml.display()
216 )
217 .into());
218 }
219
220 let conn = Connection::open(&paths.brain_db)?;
221 schema::initialize(&conn)?;
222 Ok((paths, config, conn))
223}
224
225pub fn init_project_at_root(root: &Path, force: bool) -> KimetsuResult<InitSummary> {
230 let paths = ProjectPaths::at_root(root);
231 paths.validate_state_dir()?;
232 fs::create_dir_all(&paths.kimetsu_dir)?;
233
234 let project_id = default_project_id(&paths.repo_root);
235 let config = ProjectConfig::default_for_project(project_id);
236 let wrote_project_toml = if force || !paths.project_toml.exists() {
237 fs::write(&paths.project_toml, config.to_toml()?)?;
238 true
239 } else {
240 false
241 };
242
243 let config = load_config(&paths)?;
244 let conn = Connection::open(&paths.brain_db)?;
245 schema::initialize(&conn)?;
246
247 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
248
249 Ok(InitSummary {
250 project_id: config.kimetsu.project_id,
251 repo_root: paths.repo_root,
252 kimetsu_dir: paths.kimetsu_dir,
253 brain_db: paths.brain_db,
254 model: format!("{}/{}", config.model.provider, config.model.model),
255 api_key_env: config.model.api_key_env,
256 api_key_present,
257 wrote_project_toml,
258 })
259}
260
261pub fn load_project_at_root(
264 root: &Path,
265) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
266 let paths = ProjectPaths::at_root(root);
267 paths.validate_state_dir()?;
268 let config = load_config(&paths)?;
269 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
270 return Err(format!(
276 "project.toml schema version {} does not match expected {} (file: {}). \
277 If this is not the project you meant, run from inside a git \
278 repository or pass --workspace to pin the project root.",
279 config.kimetsu.schema_version,
280 KIMETSU_CONFIG_VERSION,
281 paths.project_toml.display()
282 )
283 .into());
284 }
285
286 let conn = Connection::open(&paths.brain_db)?;
287 schema::initialize(&conn)?;
288 Ok((paths, config, conn))
289}
290
291pub fn load_project_readonly_at_root(
294 root: &Path,
295) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
296 let paths = ProjectPaths::at_root(root);
297 paths.validate_state_dir()?;
298 let config = load_config(&paths)?;
299 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
300 return Err(format!(
306 "project.toml schema version {} does not match expected {} (file: {}). \
307 If this is not the project you meant, run from inside a git \
308 repository or pass --workspace to pin the project root.",
309 config.kimetsu.schema_version,
310 KIMETSU_CONFIG_VERSION,
311 paths.project_toml.display()
312 )
313 .into());
314 }
315
316 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
317 schema::validate(&conn)?;
318 Ok((paths, config, conn))
319}
320
321pub fn schema_version(start: &Path) -> KimetsuResult<i64> {
326 let (_, _, conn) = load_project(start)?;
327 crate::migrate::current_version(&conn)
328}
329
330pub fn load_project_readonly(
331 start: &Path,
332) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
333 let paths = ProjectPaths::discover(start)?;
334 paths.validate_state_dir()?;
335 let config = load_config(&paths)?;
336 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
337 return Err(format!(
343 "project.toml schema version {} does not match expected {} (file: {}). \
344 If this is not the project you meant, run from inside a git \
345 repository or pass --workspace to pin the project root.",
346 config.kimetsu.schema_version,
347 KIMETSU_CONFIG_VERSION,
348 paths.project_toml.display()
349 )
350 .into());
351 }
352
353 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
354 schema::validate(&conn)?;
355 Ok((paths, config, conn))
356}
357
358pub struct BrainSession {
359 paths: ProjectPaths,
360 config: ProjectConfig,
361 conn: Connection,
362 user_conn: Option<Connection>,
370 repo_root: String,
371}
372
373impl BrainSession {
374 pub fn open(start: &Path) -> KimetsuResult<Self> {
375 let (paths, config, conn) = load_project(start)?;
376 let user_conn = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?;
381 Self::from_parts(paths, config, conn, user_conn)
382 }
383
384 pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
385 let (paths, config, conn) = load_project_readonly(start)?;
386 let user_conn =
391 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
392 Self::from_parts(paths, config, conn, user_conn)
393 }
394
395 fn from_parts(
396 paths: ProjectPaths,
397 config: ProjectConfig,
398 conn: Connection,
399 user_conn: Option<Connection>,
400 ) -> KimetsuResult<Self> {
401 let repo_root = paths
402 .repo_root
403 .canonicalize()?
404 .to_string_lossy()
405 .to_string();
406 Ok(Self {
407 paths,
408 config,
409 conn,
410 user_conn,
411 repo_root,
412 })
413 }
414
415 pub fn retrieve_context(
416 &self,
417 stage: &str,
418 query: &str,
419 budget_tokens: u32,
420 ) -> KimetsuResult<ContextBundle> {
421 self.retrieve_context_with_request(ContextRequest {
422 stage: stage.to_string(),
423 query: query.to_string(),
424 budget_tokens,
425 ..Default::default()
426 })
427 }
428
429 pub fn retrieve_context_with_request(
437 &self,
438 mut request: ContextRequest,
439 ) -> KimetsuResult<ContextBundle> {
440 if request.min_lexical_coverage == 0.0 {
443 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
444 }
445 if request.min_semantic_score == 0.0 {
446 request.min_semantic_score = self.resolved_min_semantic_score();
447 }
448 if request.min_score == 0.0 {
452 request.min_score = self.config.broker.abstain_min_score;
453 }
454 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
455 if request.normalization.is_empty() {
458 request.normalization = self.config.broker.normalization.clone();
459 }
460 let fusion = if request.fusion.is_empty() {
463 &self.config.broker.fusion
464 } else {
465 &request.fusion
466 };
467 let backend = crate::backend::backend_for(
468 &self.config.storage.backend,
469 crate::fusion::Fusion::from_config(fusion),
470 );
471 context::retrieve_context_with_embedder_and_backend(
472 &self.conn,
473 &self.repo_root,
474 &self.config.broker.weights,
475 request,
476 &extras,
477 embeddings::open_embedder_for(self.config.embedder.enabled),
478 backend.as_ref(),
479 )
480 }
481
482 fn resolved_min_semantic_score(&self) -> f32 {
491 let configured = self.config.broker.min_semantic_score;
492 if configured >= 0.0 {
493 return configured;
494 }
495 let model = embeddings::resolve_embedder_id(Some(self.config.embedder.model.as_str()));
496 if model.starts_with("bge") { 0.35 } else { 0.0 }
497 }
498
499 pub fn retrieve_proactive(&self, mut request: ContextRequest) -> KimetsuResult<ContextBundle> {
506 if request.min_lexical_coverage == 0.0 {
511 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
512 }
513 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
514 if request.normalization.is_empty() {
517 request.normalization = self.config.broker.normalization.clone();
518 }
519 let fusion = if request.fusion.is_empty() {
522 &self.config.broker.fusion
523 } else {
524 &request.fusion
525 };
526 let backend = crate::backend::backend_for(
527 &self.config.storage.backend,
528 crate::fusion::Fusion::from_config(fusion),
529 );
530 context::retrieve_context_with_embedder_and_backend(
531 &self.conn,
532 &self.repo_root,
533 &self.config.broker.weights,
534 request,
535 &extras,
536 &embeddings::NoopEmbedder,
537 backend.as_ref(),
538 )
539 }
540
541 pub fn retrieve_context_lexical(
550 &self,
551 mut request: ContextRequest,
552 ) -> KimetsuResult<ContextBundle> {
553 if request.min_lexical_coverage == 0.0 {
558 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
559 }
560 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
561 if request.normalization.is_empty() {
564 request.normalization = self.config.broker.normalization.clone();
565 }
566 let fusion = if request.fusion.is_empty() {
569 &self.config.broker.fusion
570 } else {
571 &request.fusion
572 };
573 let backend = crate::backend::backend_for(
574 &self.config.storage.backend,
575 crate::fusion::Fusion::from_config(fusion),
576 );
577 context::retrieve_context_with_embedder_and_backend(
578 &self.conn,
579 &self.repo_root,
580 &self.config.broker.weights,
581 request,
582 &extras,
583 &embeddings::NoopEmbedder,
584 backend.as_ref(),
585 )
586 }
587
588 pub fn retrieve_context_with_injected_embedder(
594 &self,
595 mut request: ContextRequest,
596 embedder: &dyn embeddings::Embedder,
597 ) -> KimetsuResult<ContextBundle> {
598 if request.min_lexical_coverage == 0.0 {
599 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
600 }
601 if request.min_semantic_score == 0.0 {
604 request.min_semantic_score = self.resolved_min_semantic_score();
605 }
606 if request.min_score == 0.0 {
610 request.min_score = self.config.broker.abstain_min_score;
611 }
612 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
613 if request.normalization.is_empty() {
616 request.normalization = self.config.broker.normalization.clone();
617 }
618 let fusion = if request.fusion.is_empty() {
621 &self.config.broker.fusion
622 } else {
623 &request.fusion
624 };
625 let backend = crate::backend::backend_for(
626 &self.config.storage.backend,
627 crate::fusion::Fusion::from_config(fusion),
628 );
629 context::retrieve_context_with_embedder_and_backend(
630 &self.conn,
631 &self.repo_root,
632 &self.config.broker.weights,
633 request,
634 &extras,
635 embedder,
636 backend.as_ref(),
637 )
638 }
639
640 pub fn repo_root(&self) -> &Path {
641 &self.paths.repo_root
642 }
643
644 pub fn user_conn(&self) -> Option<&Connection> {
649 self.user_conn.as_ref()
650 }
651}
652
653pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
654 let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
655 format!(
656 "failed to read {}; run `kimetsu init` first: {err}",
657 paths.project_toml.display()
658 )
659 })?;
660 let mut config = ProjectConfig::from_toml(&content)?;
661 config.apply_retrieval_level();
667 Ok(config)
668}
669
670pub fn load_config_from_text(toml: &str) -> KimetsuResult<ProjectConfig> {
673 ProjectConfig::from_toml(toml)
674}
675
676pub fn config_text(start: &Path) -> KimetsuResult<String> {
677 let paths = ProjectPaths::discover(start)?;
678 Ok(fs::read_to_string(paths.project_toml)?)
679}
680
681pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
682 let (_paths, _config, conn) = load_project(start)?;
683 let mut stmt = conn.prepare(
684 "
685 SELECT run_id, task, started_at, terminal_kind
686 FROM runs
687 ORDER BY started_at DESC
688 LIMIT 100
689 ",
690 )?;
691
692 let rows = stmt.query_map([], |row| {
693 Ok(RunSummary {
694 run_id: row.get(0)?,
695 task: row.get(1)?,
696 started_at: row.get(2)?,
697 terminal_kind: row.get(3)?,
698 })
699 })?;
700
701 let mut runs = Vec::new();
702 for row in rows {
703 runs.push(row?);
704 }
705 Ok(runs)
706}
707
708pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
709 let (_paths, _config, conn) = load_project(start)?;
710 let mut stmt = conn.prepare(
711 "
712 SELECT run_id, task, started_at, terminal_kind
713 FROM runs
714 WHERE run_id = ?1
715 ",
716 )?;
717
718 let mut rows = stmt.query(params![run_id])?;
719 if let Some(row) = rows.next()? {
720 Ok(Some(RunSummary {
721 run_id: row.get(0)?,
722 task: row.get(1)?,
723 started_at: row.get(2)?,
724 terminal_kind: row.get(3)?,
725 }))
726 } else {
727 Ok(None)
728 }
729}
730
731#[derive(Debug, Clone)]
736pub struct BatchMemoryEntry {
737 pub text: String,
739 pub scope: MemoryScope,
741 pub kind: MemoryKind,
743 pub valid_from: Option<String>,
746 pub valid_to: Option<String>,
749}
750
751const DIRECT_ADD_CONFIDENCE: f32 = 0.85;
762
763pub fn add_memory(
764 start: &Path,
765 scope: MemoryScope,
766 kind: MemoryKind,
767 text: &str,
768) -> KimetsuResult<String> {
769 let redaction = redact::redact_secrets(text);
782 if redaction.was_redacted() {
783 eprintln!("kimetsu-brain: {}", redaction.summary());
784 }
785 let text = redaction.text.as_str();
786
787 if scope == MemoryScope::GlobalUser {
805 let use_user_brain = ProjectPaths::discover(start)
806 .ok()
807 .and_then(|paths| load_config(&paths).ok())
808 .map(|cfg| cfg.kimetsu.use_user_brain)
809 .unwrap_or(true);
810 if let Some(user_conn) = user_brain::open_user_brain_for_config(use_user_brain)? {
811 return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
812 }
813 }
817
818 let (paths, config, conn) = load_project(start)?;
819 let run_id = RunId::new();
820 let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
821
822 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
823 add_memory_inner(
824 &conn, &paths, &config, scope, kind, text, None, None, embedder,
825 )
826}
827
828#[allow(clippy::too_many_arguments)]
837fn add_memory_inner(
838 conn: &Connection,
839 paths: &ProjectPaths,
840 config: &kimetsu_core::config::ProjectConfig,
841 scope: MemoryScope,
842 kind: MemoryKind,
843 text: &str,
844 valid_from: Option<&str>,
845 valid_to: Option<&str>,
846 embedder: &dyn embeddings::Embedder,
847) -> KimetsuResult<String> {
848 let run_id = RunId::new();
849 let memory_id = Ulid::new().to_string();
850 let normalized = normalize_memory_text(text);
851
852 let existing: Option<String> = conn
858 .query_row(
859 "
860 SELECT memory_id FROM memories
861 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
862 AND invalidated_at IS NULL
863 AND superseded_by IS NULL
864 LIMIT 1
865 ",
866 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
867 |row| row.get::<_, String>(0),
868 )
869 .optional()?;
870 if let Some(existing_id) = existing {
871 return Ok(existing_id);
872 }
873
874 let importance_enabled = config.ingestion.initial_importance_scoring;
880 let initial_kind_weight = if importance_enabled {
881 match &kind {
882 MemoryKind::FailurePattern => 0.3_f32,
883 MemoryKind::Command => 0.2,
884 MemoryKind::Convention => 0.15,
885 MemoryKind::Fact => 0.1,
886 MemoryKind::Preference => 0.05,
887 }
888 } else {
889 0.0
890 };
891
892 let started = Event::new(
893 run_id,
894 "run.started",
895 serde_json::json!({
896 "mode": "admin",
897 "task": "memory add",
898 "project_id": config.kimetsu.project_id,
899 "repo_root": paths.repo_root.to_string_lossy(),
900 "model": null,
901 "platform": std::env::consts::OS,
902 "kimetsu_version": env!("CARGO_PKG_VERSION"),
903 "config_hash": config_hash(&paths.project_toml)?,
904 }),
905 );
906 let accepted = Event::new(
907 run_id,
908 "memory.accepted",
909 serde_json::json!({
910 "proposal_id": null,
911 "memory_id": memory_id,
912 "scope": scope.to_string(),
913 "kind": kind.to_string(),
914 "text": text,
915 "normalized_text": normalized,
916 "confidence": DIRECT_ADD_CONFIDENCE,
917 "initial_usefulness": initial_kind_weight,
918 "provenance_snapshot": build_provenance(run_id, text),
919 }),
920 );
921
922 let finished = Event::new(
923 run_id,
924 "run.finished",
925 serde_json::json!({
926 "status": "success",
927 "final_report_path": null,
928 "total_cost_usd": 0.0,
929 "total_tool_calls": 0,
930 }),
931 );
932
933 projector::apply_events(conn, &[started, accepted, finished])?;
934
935 if valid_from.is_some() || valid_to.is_some() {
938 projector::mark_memory_temporal(conn, &memory_id, valid_from, valid_to)?;
939 }
940
941 let embedding_vec = embeddings::embed_and_persist(conn, &memory_id, text, embedder)?;
954
955 if importance_enabled && !embedder.is_noop() {
962 if let Some(vec) = embedding_vec.as_deref() {
963 let rarity_bonus = {
964 let max_cos = max_corpus_cosine(conn, vec);
965 if max_cos < 0.5 { 0.1_f32 } else { 0.0 }
966 };
967 if rarity_bonus > 0.0 {
968 let full_score = (initial_kind_weight + rarity_bonus).min(0.5);
969 conn.execute(
970 "UPDATE memories SET usefulness_score = ?2 WHERE memory_id = ?1",
971 rusqlite::params![memory_id, full_score],
972 )
973 .ok(); }
975 }
976 }
977
978 if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
993 let new_created_at: String = conn
997 .query_row(
998 "SELECT created_at FROM memories WHERE memory_id = ?1",
999 rusqlite::params![memory_id],
1000 |row| row.get(0),
1001 )
1002 .unwrap_or_else(|_| {
1003 time::OffsetDateTime::now_utc()
1005 .format(&time::format_description::well_known::Rfc3339)
1006 .unwrap_or_default()
1007 });
1008
1009 if conflict::resolve_conflicts_enabled(config.ingestion.resolve_conflicts) {
1010 let (auto_resolved, queued) = conflict::detect_record_and_resolve_with_vec(
1012 conn,
1013 &memory_id,
1014 &scope,
1015 &kind.to_string(),
1016 text,
1017 embedding_vec.as_deref(),
1018 embedder,
1019 DIRECT_ADD_CONFIDENCE, &new_created_at,
1021 );
1022 if auto_resolved > 0 {
1023 eprintln!(
1024 "kimetsu-brain: memory {memory_id} auto-resolved {auto_resolved} contradiction{} (loser valid_to stamped)",
1025 if auto_resolved == 1 { "" } else { "s" }
1026 );
1027 }
1028 if queued > 0 {
1029 eprintln!(
1030 "kimetsu-brain: memory {memory_id} has {queued} near-tie conflict{} queued for review (run `kimetsu brain memory conflicts`)",
1031 if queued == 1 { "" } else { "s" }
1032 );
1033 }
1034 } else {
1035 let conflicts = conflict::detect_and_record_with_vec(
1037 conn,
1038 &memory_id,
1039 &scope,
1040 &kind.to_string(),
1041 text,
1042 embedding_vec.as_deref(),
1043 embedder,
1044 );
1045 if conflicts > 0 {
1046 eprintln!(
1047 "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
1048 if conflicts == 1 { "y" } else { "ies" }
1049 );
1050 }
1051 }
1052 }
1053
1054 link_memory_into_graph(conn, &memory_id);
1063
1064 Ok(memory_id)
1065}
1066
1067fn link_memory_into_graph(conn: &Connection, memory_id: &str) {
1071 let Ok(edges) = crate::graph::incremental_edges_for_memory(conn, memory_id, 0) else {
1072 return;
1073 };
1074 if edges.is_empty() {
1075 return;
1076 }
1077 let tuples: Vec<(String, String, String)> = edges
1078 .into_iter()
1079 .map(|e| (e.src_id, e.dst_id, e.edge_type))
1080 .collect();
1081 let _ = crate::projector::add_memory_edges(conn, &tuples);
1082}
1083
1084pub fn add_memories_batch(
1104 start: &Path,
1105 entries: Vec<BatchMemoryEntry>,
1106) -> KimetsuResult<Vec<String>> {
1107 if entries.is_empty() {
1108 return Ok(vec![]);
1109 }
1110
1111 let use_user_brain = ProjectPaths::discover(start)
1114 .ok()
1115 .and_then(|paths| load_config(&paths).ok())
1116 .map(|cfg| cfg.kimetsu.use_user_brain)
1117 .unwrap_or(true);
1118
1119 let user_conn_opt = user_brain::open_user_brain_for_config(use_user_brain)?;
1121
1122 let has_project_entries = entries.iter().any(|e| e.scope != MemoryScope::GlobalUser);
1125
1126 let project_state: Option<(
1128 ProjectPaths,
1129 kimetsu_core::config::ProjectConfig,
1130 Connection,
1131 )> = if has_project_entries {
1132 let state = load_project(start)?;
1133 Some(state)
1134 } else {
1135 None
1136 };
1137
1138 let run_id_for_lock = RunId::new();
1140 let _lock = if let Some((ref paths, _, _)) = project_state {
1141 Some(ProjectLock::acquire(
1142 paths,
1143 "brain memory add-batch",
1144 Some(run_id_for_lock),
1145 )?)
1146 } else {
1147 None
1148 };
1149
1150 let embedder: &dyn embeddings::Embedder = if let Some((_, ref config, _)) = project_state {
1153 embeddings::open_embedder_for(config.embedder.enabled)
1154 } else {
1155 &embeddings::NoopEmbedder
1156 };
1157
1158 let mut ids = Vec::with_capacity(entries.len());
1159
1160 for entry in entries {
1161 let redaction = redact::redact_secrets(&entry.text);
1163 if redaction.was_redacted() {
1164 eprintln!("kimetsu-brain: {}", redaction.summary());
1165 }
1166 let text = redaction.text.as_str();
1167
1168 if entry.scope == MemoryScope::GlobalUser {
1169 if let Some(ref uc) = user_conn_opt {
1172 let id = user_brain::add_user_memory(uc, entry.kind, text, 1.0)?;
1173 ids.push(id);
1174 continue;
1175 }
1176 }
1178
1179 let (paths, config, conn) = project_state
1180 .as_ref()
1181 .expect("project must be open when non-GlobalUser entries are present");
1182
1183 let id = add_memory_inner(
1184 conn,
1185 paths,
1186 config,
1187 entry.scope,
1188 entry.kind,
1189 text,
1190 entry.valid_from.as_deref(),
1191 entry.valid_to.as_deref(),
1192 embedder,
1193 )?;
1194 ids.push(id);
1195 }
1196
1197 Ok(ids)
1198}
1199
1200pub fn propose_memory(
1205 start: &Path,
1206 scope: MemoryScope,
1207 kind: MemoryKind,
1208 text: &str,
1209 confidence: f32,
1210 rationale: &str,
1211) -> KimetsuResult<String> {
1212 let redaction = redact::redact_secrets(text);
1213 if redaction.was_redacted() {
1214 eprintln!("kimetsu-brain: {}", redaction.summary());
1215 }
1216 let text = redaction.text.as_str();
1217 let rationale_redaction = redact::redact_secrets(rationale);
1218 if rationale_redaction.was_redacted() {
1219 eprintln!("kimetsu-brain: {}", rationale_redaction.summary());
1220 }
1221 let rationale = rationale_redaction.text.as_str();
1222 let (paths, config, conn) = load_project(start)?;
1223 let run_id = RunId::new();
1224 let _lock = ProjectLock::acquire(&paths, "memory propose", Some(run_id))?;
1225 let proposal_id = Ulid::new().to_string();
1226
1227 let started = admin_started_event(&paths, &config, run_id, "memory propose")?;
1228
1229 let proposed = Event::new(
1230 run_id,
1231 "memory.proposed",
1232 serde_json::json!({
1233 "proposal_id": proposal_id,
1234 "scope": scope.to_string(),
1235 "kind": kind.to_string(),
1236 "text": text,
1237 "rationale": rationale,
1238 "proposed_confidence": confidence.clamp(0.0, 1.0),
1239 "source_event_ids": [],
1240 }),
1241 );
1242
1243 let finished = admin_finished_event(run_id);
1244
1245 projector::apply_events(&conn, &[started, proposed, finished])?;
1246 Ok(proposal_id)
1247}
1248
1249#[derive(Debug)]
1251pub enum ProposeResult {
1252 Added(String), Proposed(String), Merged(String), Duplicate(String), }
1257
1258pub fn propose_or_merge_memory(
1269 start: &Path,
1270 scope: MemoryScope,
1271 kind: MemoryKind,
1272 text: &str,
1273 confidence: f32,
1274 rationale: &str,
1275) -> KimetsuResult<ProposeResult> {
1276 let redaction = redact::redact_secrets(text);
1277 if redaction.was_redacted() {
1278 eprintln!("kimetsu-brain: {}", redaction.summary());
1279 }
1280 let text = redaction.text.as_str();
1281
1282 let (_, config, _) = {
1285 let (paths, config, ro_conn) = load_project_readonly(start)?;
1286 let normalized = normalize_memory_text(text);
1287 let existing: Option<String> = ro_conn
1288 .query_row(
1289 "SELECT memory_id FROM memories
1290 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
1291 AND invalidated_at IS NULL
1292 AND superseded_by IS NULL
1293 LIMIT 1",
1294 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
1295 |row| row.get::<_, String>(0),
1296 )
1297 .optional()?;
1298 if let Some(id) = existing {
1299 return Ok(ProposeResult::Duplicate(id));
1300 }
1301 (paths, config, ro_conn)
1302 };
1303
1304 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
1310 {
1311 let (_, _, ro_conn) = load_project_readonly(start)?;
1312 let conflicts = if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
1313 conflict::find_potential_conflicts(&ro_conn, &scope, text, embedder, 1, 0.85)?
1314 } else {
1315 Vec::new()
1316 };
1317 if let Some(hit) = conflicts.into_iter().next() {
1318 let (paths, _config, conn) = load_project(start)?;
1320 let run_id = RunId::new();
1321 let _lock = ProjectLock::acquire(&paths, "memory merge", Some(run_id))?;
1322 let merged_text = format!("{}\n\nAlso: {text}", hit.existing_text);
1323 let new_normalized = normalize_memory_text(&merged_text);
1324 conn.execute(
1325 "UPDATE memories
1326 SET text = ?1, normalized_text = ?2, use_count = use_count + 1
1327 WHERE memory_id = ?3",
1328 rusqlite::params![merged_text, new_normalized, hit.existing_memory_id],
1329 )?;
1330 embeddings::embed_and_persist(&conn, &hit.existing_memory_id, &merged_text, embedder)?;
1332 let _ = crate::graph::project_entities(&conn, &hit.existing_memory_id, &merged_text);
1337 link_memory_into_graph(&conn, &hit.existing_memory_id);
1338 return Ok(ProposeResult::Merged(hit.existing_memory_id));
1339 }
1340 }
1341
1342 if confidence >= 0.7 {
1344 let memory_id = add_memory(start, scope, kind, text)?;
1345 Ok(ProposeResult::Added(memory_id))
1346 } else {
1347 let proposal_id = propose_memory(start, scope, kind, text, confidence, rationale)?;
1348 Ok(ProposeResult::Proposed(proposal_id))
1349 }
1350}
1351
1352#[derive(Debug, Clone)]
1356pub struct ListOptions {
1357 pub limit: u32,
1359 pub offset: u32,
1361 pub scope: Option<String>,
1363}
1364
1365impl Default for ListOptions {
1366 fn default() -> Self {
1367 Self {
1368 limit: 100,
1369 offset: 0,
1370 scope: None,
1371 }
1372 }
1373}
1374
1375pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
1376 list_memories_with(start, ListOptions::default())
1377}
1378
1379pub fn list_memories_with(start: &Path, opts: ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1384 let (_paths, config, conn) = load_project(start)?;
1385 let mut memories = list_memories_from_conn(&conn, &opts)?;
1386 if opts.offset == 0
1388 && let Some(user_conn) =
1389 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1390 {
1391 memories.extend(user_brain::list_user_memories(&user_conn)?);
1392 }
1393 Ok(memories)
1394}
1395
1396pub use crate::blame::*;
1401
1402pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
1403 let (_paths, _config, conn) = load_project(start)?;
1404 let mut sql = String::from(
1405 "
1406 SELECT proposal_id, run_id, scope, kind, text, rationale,
1407 proposed_confidence, status, decided_reason
1408 FROM memory_proposals
1409 ",
1410 );
1411 let mut clauses = Vec::<String>::new();
1412 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1413 if let Some(scope) = filter.scope.as_deref() {
1414 clauses.push("scope = ?".to_string());
1415 params.push(Box::new(scope.to_string()));
1416 }
1417 if let Some(kind) = filter.kind.as_deref() {
1418 clauses.push("kind = ?".to_string());
1419 params.push(Box::new(kind.to_string()));
1420 }
1421 if let Some(run_id) = filter.from_run.as_deref() {
1422 clauses.push("run_id = ?".to_string());
1423 params.push(Box::new(run_id.to_string()));
1424 }
1425 if let Some(min_conf) = filter.min_confidence {
1426 clauses.push("proposed_confidence >= ?".to_string());
1427 params.push(Box::new(min_conf as f64));
1428 }
1429 if let Some(status) = filter.status.as_deref()
1430 && !status.eq_ignore_ascii_case("any")
1431 {
1432 clauses.push("status = ?".to_string());
1433 params.push(Box::new(status.to_string()));
1434 }
1435 if !clauses.is_empty() {
1436 sql.push_str(" WHERE ");
1437 sql.push_str(&clauses.join(" AND "));
1438 }
1439 let limit = if filter.limit == 0 { 100 } else { filter.limit };
1440 sql.push_str(&format!(
1441 " ORDER BY rowid DESC LIMIT {limit} OFFSET {}",
1442 filter.offset
1443 ));
1444
1445 let mut stmt = conn.prepare(&sql)?;
1446 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1447 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1448 Ok(ProposalRow {
1449 proposal_id: row.get(0)?,
1450 run_id: row.get(1)?,
1451 scope: row.get(2)?,
1452 kind: row.get(3)?,
1453 text: row.get(4)?,
1454 rationale: row.get(5)?,
1455 proposed_confidence: row.get(6)?,
1456 status: row.get(7)?,
1457 decided_reason: row.get(8)?,
1458 })
1459 })?;
1460
1461 let mut proposals = Vec::new();
1462 for row in rows {
1463 proposals.push(row?);
1464 }
1465 Ok(proposals)
1466}
1467
1468pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1469 let (paths, config, conn) = load_project(start)?;
1470 let run_id = RunId::new();
1471 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1472
1473 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1474
1475 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1476
1477 let ingested = Event::new(
1478 run_id,
1479 "repo.ingested",
1480 serde_json::json!({
1481 "repo_root": summary.repo_root.to_string_lossy(),
1482 "indexed_files": summary.indexed_files,
1483 "skipped_files": summary.skipped_files,
1484 "manifests": summary.manifests,
1485 }),
1486 );
1487
1488 let finished = admin_finished_event(run_id);
1489 projector::apply_events(&conn, &[started, ingested, finished])?;
1490
1491 Ok(summary)
1492}
1493
1494pub fn ingest_repo_at_root(
1499 brain_root: &Path,
1500 files_root: &Path,
1501) -> KimetsuResult<RepoIngestSummary> {
1502 let (mut paths, config, conn) = load_project_at_root(brain_root)?;
1503 paths.repo_root = files_root
1505 .canonicalize()
1506 .unwrap_or_else(|_| files_root.to_path_buf());
1507 let run_id = RunId::new();
1508 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo (remote)", Some(run_id))?;
1509
1510 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1511 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1512 let ingested = Event::new(
1513 run_id,
1514 "repo.ingested",
1515 serde_json::json!({
1516 "repo_root": summary.repo_root.to_string_lossy(),
1517 "indexed_files": summary.indexed_files,
1518 "skipped_files": summary.skipped_files,
1519 "manifests": summary.manifests,
1520 }),
1521 );
1522 let finished = admin_finished_event(run_id);
1523 projector::apply_events(&conn, &[started, ingested, finished])?;
1524
1525 Ok(summary)
1526}
1527
1528pub fn search_files(
1529 start: &Path,
1530 query: &str,
1531 limit: u32,
1532) -> KimetsuResult<Vec<context::ContextCapsule>> {
1533 let (paths, _config, conn) = load_project(start)?;
1534 let repo_root = paths
1535 .repo_root
1536 .canonicalize()?
1537 .to_string_lossy()
1538 .to_string();
1539 context::search_repo_files(&conn, &repo_root, query, limit)
1540}
1541
1542pub fn retrieve_context(
1543 start: &Path,
1544 stage: &str,
1545 query: &str,
1546 budget_tokens: u32,
1547) -> KimetsuResult<ContextBundle> {
1548 BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1549}
1550
1551pub fn retrieve_context_readonly(
1552 start: &Path,
1553 stage: &str,
1554 query: &str,
1555 budget_tokens: u32,
1556) -> KimetsuResult<ContextBundle> {
1557 BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1558}
1559
1560pub fn retrieve_context_readonly_with_request(
1563 start: &Path,
1564 request: ContextRequest,
1565) -> KimetsuResult<ContextBundle> {
1566 BrainSession::open_readonly(start)?.retrieve_context_with_request(request)
1567}
1568
1569pub fn retrieve_context_lexical_readonly(
1575 start: &Path,
1576 request: ContextRequest,
1577) -> KimetsuResult<ContextBundle> {
1578 BrainSession::open_readonly(start)?.retrieve_context_lexical(request)
1579}
1580
1581pub fn evidence_coverage_readonly(
1596 start: &Path,
1597 query: &str,
1598 capsules: &[context::ContextCapsule],
1599) -> (f32, Vec<String>) {
1600 let Ok((_paths, _config, conn)) = load_project_readonly(start) else {
1601 return (1.0, Vec::new());
1602 };
1603 context::evidence_coverage(&conn, query, capsules)
1604}
1605
1606pub fn retrieve_proactive_readonly(
1610 start: &Path,
1611 request: ContextRequest,
1612) -> KimetsuResult<ContextBundle> {
1613 BrainSession::open_readonly(start)?.retrieve_proactive(request)
1614}
1615
1616pub fn search_memories(
1621 start: &Path,
1622 query: &str,
1623 limit: u32,
1624 offset: u32,
1625 kind: Option<&str>,
1626 scope: Option<&str>,
1627) -> KimetsuResult<Vec<MemorySearchHit>> {
1628 let Some(fts) = context::fts_query(query) else {
1629 return Ok(Vec::new());
1630 };
1631 let (_paths, config, conn) = load_project(start)?;
1632 let mut hits = search_memories_in_conn(&conn, &fts, limit, offset, kind, scope)?;
1633 if offset == 0
1635 && let Some(user_conn) =
1636 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1637 {
1638 hits.extend(search_memories_in_conn(
1639 &user_conn, &fts, limit, 0, kind, scope,
1640 )?);
1641 }
1642 Ok(hits)
1643}
1644
1645fn search_memories_in_conn(
1646 conn: &Connection,
1647 fts_query: &str,
1648 limit: u32,
1649 offset: u32,
1650 kind: Option<&str>,
1651 scope: Option<&str>,
1652) -> KimetsuResult<Vec<MemorySearchHit>> {
1653 let limit = if limit == 0 { 20 } else { limit } as i64;
1654 let offset = offset as i64;
1655 let mut sql = String::from(
1656 "
1657 SELECT m.memory_id, m.scope, m.kind, m.text, bm25(memories_fts) AS rank
1658 FROM memories_fts
1659 JOIN memories m ON m.memory_id = memories_fts.memory_id
1660 WHERE m.invalidated_at IS NULL
1661 AND m.superseded_by IS NULL
1662 AND memories_fts MATCH ?
1663 ",
1664 );
1665 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(fts_query.to_string())];
1666 if let Some(k) = kind {
1667 sql.push_str(" AND m.kind = ?");
1668 bind.push(Box::new(k.to_string()));
1669 }
1670 if let Some(s) = scope {
1671 sql.push_str(" AND lower(m.scope) = lower(?)");
1672 bind.push(Box::new(s.to_string()));
1673 }
1674 sql.push_str(" ORDER BY rank LIMIT ? OFFSET ?");
1676 bind.push(Box::new(limit));
1677 bind.push(Box::new(offset));
1678
1679 let mut stmt = conn.prepare(&sql)?;
1680 let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
1681 let rows = stmt.query_map(refs.as_slice(), |row| {
1682 let raw_rank = row.get::<_, f64>(4)? as f32;
1683 Ok(MemorySearchHit {
1684 memory_id: row.get(0)?,
1685 scope: row.get(1)?,
1686 kind: row.get(2)?,
1687 text: row.get(3)?,
1688 rank: (-raw_rank).max(0.0),
1690 })
1691 })?;
1692 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1693}
1694
1695#[allow(clippy::too_many_arguments)]
1696pub fn retrieve_benchmark_context_readonly(
1697 start: &Path,
1698 task: &str,
1699 dataset: &str,
1700 task_slug: Option<&str>,
1701 warm_policy: benchmark::BenchmarkWarmPolicy,
1702 stage: &str,
1703 budget_tokens: u32,
1704 require_benchmark_memory: bool,
1705 max_capsules: usize,
1706) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1707 retrieve_benchmark_context_readonly_with_ambient(
1708 start,
1709 task,
1710 dataset,
1711 task_slug,
1712 warm_policy,
1713 stage,
1714 budget_tokens,
1715 require_benchmark_memory,
1716 max_capsules,
1717 None,
1718 )
1719}
1720
1721#[allow(clippy::too_many_arguments)]
1727pub fn retrieve_benchmark_context_readonly_with_ambient(
1728 start: &Path,
1729 task: &str,
1730 dataset: &str,
1731 task_slug: Option<&str>,
1732 warm_policy: benchmark::BenchmarkWarmPolicy,
1733 stage: &str,
1734 budget_tokens: u32,
1735 require_benchmark_memory: bool,
1736 max_capsules: usize,
1737 ambient_suffix: Option<&str>,
1738) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1739 let normalized_slug = task_slug
1740 .and_then(benchmark::normalize_task_slug)
1741 .or_else(|| benchmark::normalize_task_slug(task));
1742 let mut query =
1743 benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
1744 if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
1745 query.push_str(suffix);
1746 }
1747 let bundle =
1748 BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
1749 Ok(benchmark::build_benchmark_context(
1750 bundle,
1751 task,
1752 dataset,
1753 &query,
1754 normalized_slug,
1755 warm_policy,
1756 require_benchmark_memory,
1757 max_capsules,
1758 ))
1759}
1760
1761pub fn record_benchmark_outcome(
1762 start: &Path,
1763 outcome: benchmark::BenchmarkOutcome,
1764) -> KimetsuResult<RecordedBenchmarkOutcome> {
1765 let task_slug = outcome
1766 .task_slug
1767 .clone()
1768 .or_else(|| benchmark::normalize_task_slug(&outcome.task));
1769 let kind = benchmark::outcome_memory_kind(&outcome);
1770 let text = benchmark::outcome_memory_text(&outcome);
1771 let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
1772 let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
1773 Some(proposal) if proposal.role.is_generalizable() => {
1774 let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
1775 (Some(proposal_id), Some(proposal_text))
1776 }
1777 _ => (None, None),
1778 };
1779 Ok(RecordedBenchmarkOutcome {
1780 memory_id,
1781 task_slug,
1782 kind,
1783 text,
1784 proposal_id,
1785 proposal_text,
1786 })
1787}
1788
1789fn propose_benchmark_memory(
1790 start: &Path,
1791 outcome: &benchmark::BenchmarkOutcome,
1792 proposal: &benchmark::BenchmarkMemoryProposal,
1793) -> KimetsuResult<(String, String)> {
1794 let (paths, config, conn) = load_project(start)?;
1795 let run_id = RunId::new();
1796 let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
1797 let proposal_id = Ulid::new().to_string();
1798 let raw_text = benchmark::proposal_memory_text(outcome, proposal);
1802 let text_redaction = redact::redact_secrets(&raw_text);
1803 if text_redaction.was_redacted() {
1804 eprintln!(
1805 "kimetsu-brain (benchmark proposal): {}",
1806 text_redaction.summary()
1807 );
1808 }
1809 let text = text_redaction.text;
1810 let kind = benchmark::proposal_memory_kind(proposal);
1811 let rationale_raw = if proposal.rationale.trim().is_empty() {
1812 "generalized from benchmark outcome".to_string()
1813 } else {
1814 proposal.rationale.trim().to_string()
1815 };
1816 let rationale = redact::redact_secrets(&rationale_raw).text;
1817
1818 let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
1819
1820 let proposed = Event::new(
1821 run_id,
1822 "memory.proposed",
1823 serde_json::json!({
1824 "proposal_id": proposal_id,
1825 "scope": "global_user",
1826 "kind": kind.to_string(),
1827 "text": text,
1828 "rationale": rationale,
1829 "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
1830 "source_event_ids": [],
1831 }),
1832 );
1833
1834 let finished = admin_finished_event(run_id);
1835
1836 projector::apply_events(&conn, &[started, proposed, finished])?;
1837 Ok((proposal_id, text))
1838}
1839
1840pub fn accept_proposal(
1841 start: &Path,
1842 proposal_id: &str,
1843 overrides: AcceptOverrides,
1844) -> KimetsuResult<String> {
1845 let (paths, config, conn) = load_project(start)?;
1846 let proposal = load_pending_proposal(&conn, proposal_id)?;
1847 let run_id = RunId::new();
1848 let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
1849 let memory_id = Ulid::new().to_string();
1850 let normalized = normalize_memory_text(&proposal.text);
1851
1852 let resolved_scope = match overrides.scope.as_deref() {
1853 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
1854 _ => proposal.scope.clone(),
1855 };
1856 let resolved_confidence = overrides
1857 .confidence
1858 .map(|c| c.clamp(0.0, 1.0))
1859 .unwrap_or(proposal.proposed_confidence);
1860
1861 let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
1862
1863 let accepted = Event::new(
1864 run_id,
1865 "memory.accepted",
1866 serde_json::json!({
1867 "proposal_id": proposal.proposal_id,
1868 "memory_id": memory_id,
1869 "scope": resolved_scope,
1870 "kind": proposal.kind,
1871 "text": proposal.text,
1872 "normalized_text": normalized,
1873 "confidence": resolved_confidence,
1874 "provenance_snapshot": {
1875 "source": "memory_proposal",
1876 "proposal_id": proposal.proposal_id,
1877 "source_run_id": proposal.run_id,
1878 "scope_override": overrides.scope.clone(),
1879 "confidence_override": overrides.confidence,
1880 }
1881 }),
1882 );
1883
1884 let finished = admin_finished_event(run_id);
1885
1886 projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
1887 conn.execute(
1888 "
1889 UPDATE memory_proposals
1890 SET status = 'accepted',
1891 decided_at = ?2,
1892 decided_by = 'cli'
1893 WHERE proposal_id = ?1
1894 ",
1895 params![
1896 proposal_id,
1897 accepted
1898 .ts
1899 .format(&time::format_description::well_known::Rfc3339)?
1900 ],
1901 )?;
1902
1903 Ok(memory_id)
1904}
1905
1906pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1912 let (paths, config, conn) = load_project(start)?;
1913 let exists: i64 = conn.query_row(
1914 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1915 params![memory_id],
1916 |row| row.get(0),
1917 )?;
1918 if exists == 0 {
1919 return Err(format!("memory not found: {memory_id}").into());
1920 }
1921
1922 let run_id = RunId::new();
1923 let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
1924
1925 let resolved_reason = reason
1926 .and_then(|s| {
1927 let trimmed = s.trim();
1928 if trimmed.is_empty() {
1929 None
1930 } else {
1931 Some(trimmed.to_string())
1932 }
1933 })
1934 .unwrap_or_else(|| "invalidated_by_cli".to_string());
1935
1936 let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
1937
1938 let invalidated = Event::new(
1939 run_id,
1940 "memory.invalidated",
1941 serde_json::json!({
1942 "memory_id": memory_id,
1943 "reason": resolved_reason,
1944 }),
1945 );
1946
1947 let finished = admin_finished_event(run_id);
1948
1949 projector::apply_events(&conn, &[started, invalidated, finished])?;
1950 Ok(())
1951}
1952
1953#[derive(Debug, Clone)]
1955pub struct UndoneMemory {
1956 pub memory_id: String,
1957 pub text: String,
1958 pub scope: String,
1959 pub kind: String,
1960}
1961
1962pub fn edit_memory(
1977 start: &Path,
1978 memory_id: &str,
1979 new_text: Option<&str>,
1980 new_kind: Option<MemoryKind>,
1981) -> KimetsuResult<()> {
1982 if new_text.is_none() && new_kind.is_none() {
1983 return Err("edit_memory: at least one of --text or --kind must be provided".into());
1984 }
1985
1986 let (paths, config, conn) = load_project(start)?;
1987
1988 let row: Option<(String, String, String)> = conn
1990 .query_row(
1991 "SELECT scope, kind, invalidated_at FROM memories WHERE memory_id = ?1",
1992 params![memory_id],
1993 |row| {
1994 Ok((
1995 row.get::<_, String>(0)?,
1996 row.get::<_, String>(1)?,
1997 row.get::<_, String>(2).unwrap_or_default(),
1998 ))
1999 },
2000 )
2001 .optional()?;
2002
2003 let (scope, current_kind, invalidated_at) = match row {
2004 None => return Err(format!("memory not found: {memory_id}").into()),
2005 Some(r) => r,
2006 };
2007 if !invalidated_at.is_empty() {
2008 return Err(format!("memory {memory_id} is already invalidated").into());
2009 }
2010
2011 let run_id = RunId::new();
2012 let _lock = ProjectLock::acquire(&paths, "brain memory edit", Some(run_id))?;
2013
2014 if let Some(raw_text) = new_text {
2016 let redaction = redact::redact_secrets(raw_text);
2017 if redaction.was_redacted() {
2018 eprintln!("kimetsu-brain: {}", redaction.summary());
2019 }
2020 let text = &redaction.text;
2021 let normalized = normalize_memory_text(text);
2022
2023 conn.execute(
2024 "UPDATE memories SET text = ?1, normalized_text = ?2 WHERE memory_id = ?3",
2025 params![text, normalized, memory_id],
2026 )?;
2027
2028 conn.execute(
2030 "DELETE FROM memories_fts WHERE memory_id = ?1",
2031 params![memory_id],
2032 )?;
2033 let kind_for_fts = new_kind
2034 .as_ref()
2035 .map(|k| k.to_string())
2036 .unwrap_or(current_kind.clone());
2037 conn.execute(
2038 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
2039 params![memory_id, text, kind_for_fts, scope],
2040 )?;
2041
2042 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
2044 embeddings::embed_and_persist(&conn, memory_id, text, embedder)?;
2045 }
2047
2048 if let Some(kind) = new_kind {
2050 conn.execute(
2051 "UPDATE memories SET kind = ?1 WHERE memory_id = ?2",
2052 params![kind.to_string(), memory_id],
2053 )?;
2054
2055 if new_text.is_none() {
2057 let current_text: String = conn.query_row(
2060 "SELECT text FROM memories WHERE memory_id = ?1",
2061 params![memory_id],
2062 |row| row.get(0),
2063 )?;
2064 conn.execute(
2065 "DELETE FROM memories_fts WHERE memory_id = ?1",
2066 params![memory_id],
2067 )?;
2068 conn.execute(
2069 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
2070 params![memory_id, current_text, kind.to_string(), scope],
2071 )?;
2072 }
2073 }
2074
2075 Ok(())
2076}
2077
2078pub fn peek_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2082 let (_paths, _config, conn) = load_project(start)?;
2083 let row: Option<(String, String, String, String)> = conn
2086 .query_row(
2087 "SELECT memory_id, text, scope, kind FROM memories
2088 WHERE invalidated_at IS NULL
2089 AND superseded_by IS NULL
2090 ORDER BY created_at DESC, memory_id DESC
2091 LIMIT 1",
2092 [],
2093 |row| {
2094 Ok((
2095 row.get::<_, String>(0)?,
2096 row.get::<_, String>(1)?,
2097 row.get::<_, String>(2)?,
2098 row.get::<_, String>(3)?,
2099 ))
2100 },
2101 )
2102 .optional()?;
2103
2104 Ok(row.map(|(memory_id, text, scope, kind)| UndoneMemory {
2105 memory_id,
2106 text,
2107 scope,
2108 kind,
2109 }))
2110}
2111
2112pub fn undo_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2121 let (paths, _config, conn) = load_project(start)?;
2122
2123 let row: Option<(String, String, String, String)> = conn
2126 .query_row(
2127 "SELECT memory_id, text, scope, kind FROM memories
2128 WHERE invalidated_at IS NULL
2129 AND superseded_by IS NULL
2130 ORDER BY created_at DESC, memory_id DESC
2131 LIMIT 1",
2132 [],
2133 |row| {
2134 Ok((
2135 row.get::<_, String>(0)?,
2136 row.get::<_, String>(1)?,
2137 row.get::<_, String>(2)?,
2138 row.get::<_, String>(3)?,
2139 ))
2140 },
2141 )
2142 .optional()?;
2143
2144 let (memory_id, text, scope, kind) = match row {
2145 None => return Ok(None),
2146 Some(r) => r,
2147 };
2148
2149 drop(conn);
2151 drop(paths);
2152
2153 invalidate_memory(start, &memory_id, Some("undo: last recorded memory"))?;
2154
2155 Ok(Some(UndoneMemory {
2156 memory_id,
2157 text,
2158 scope,
2159 kind,
2160 }))
2161}
2162
2163pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2164 let (paths, config, conn) = load_project(start)?;
2165 let _proposal = load_pending_proposal(&conn, proposal_id)?;
2166 let run_id = RunId::new();
2167 let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
2168
2169 let resolved_reason = reason
2170 .and_then(|s| {
2171 let trimmed = s.trim();
2172 if trimmed.is_empty() {
2173 None
2174 } else {
2175 Some(trimmed.to_string())
2176 }
2177 })
2178 .unwrap_or_else(|| "rejected_by_cli".to_string());
2179
2180 let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
2181
2182 let rejected = Event::new(
2183 run_id,
2184 "memory.rejected",
2185 serde_json::json!({
2186 "proposal_id": proposal_id,
2187 "reason": resolved_reason,
2188 }),
2189 );
2190
2191 let finished = admin_finished_event(run_id);
2192
2193 projector::apply_events(&conn, &[started, rejected, finished])?;
2194 Ok(())
2195}
2196
2197pub use crate::maintenance::*;
2199
2200pub use crate::conflicts::*;
2202
2203pub use crate::feedback::*;
2205
2206pub use crate::graph_build::*;
2208
2209pub use crate::packs::*;
2211
2212pub(crate) fn load_pending_proposal(
2214 conn: &Connection,
2215 proposal_id: &str,
2216) -> KimetsuResult<ProposalRow> {
2217 let mut stmt = conn.prepare(
2218 "
2219 SELECT proposal_id, run_id, scope, kind, text, rationale,
2220 proposed_confidence, status
2221 FROM memory_proposals
2222 WHERE proposal_id = ?1
2223 ",
2224 )?;
2225 let mut rows = stmt.query(params![proposal_id])?;
2226 let Some(row) = rows.next()? else {
2227 return Err(format!("memory proposal not found: {proposal_id}").into());
2228 };
2229
2230 let proposal = ProposalRow {
2231 proposal_id: row.get(0)?,
2232 run_id: row.get(1)?,
2233 scope: row.get(2)?,
2234 kind: row.get(3)?,
2235 text: row.get(4)?,
2236 rationale: row.get(5)?,
2237 proposed_confidence: row.get(6)?,
2238 status: row.get(7)?,
2239 decided_reason: None,
2240 };
2241
2242 if proposal.status != "pending" {
2243 return Err(format!(
2244 "memory proposal {proposal_id} is {}, not pending",
2245 proposal.status
2246 )
2247 .into());
2248 }
2249
2250 Ok(proposal)
2251}
2252
2253pub(crate) fn admin_started_event(
2254 paths: &ProjectPaths,
2255 config: &ProjectConfig,
2256 run_id: RunId,
2257 task: &str,
2258) -> KimetsuResult<Event> {
2259 Ok(Event::new(
2260 run_id,
2261 "run.started",
2262 serde_json::json!({
2263 "mode": "admin",
2264 "task": task,
2265 "project_id": config.kimetsu.project_id,
2266 "repo_root": paths.repo_root.to_string_lossy(),
2267 "model": null,
2268 "platform": std::env::consts::OS,
2269 "kimetsu_version": env!("CARGO_PKG_VERSION"),
2270 "config_hash": config_hash(&paths.project_toml)?,
2271 }),
2272 ))
2273}
2274
2275pub(crate) fn admin_finished_event(run_id: RunId) -> Event {
2276 Event::new(
2277 run_id,
2278 "run.finished",
2279 serde_json::json!({
2280 "status": "success",
2281 "final_report_path": null,
2282 "total_cost_usd": 0.0,
2283 "total_tool_calls": 0,
2284 }),
2285 )
2286}
2287
2288pub(crate) fn config_hash(path: &Path) -> KimetsuResult<String> {
2289 let bytes = fs::read(path)?;
2290 Ok(blake3::hash(&bytes).to_hex().to_string())
2291}
2292
2293pub(crate) fn list_memories_from_conn(
2294 conn: &Connection,
2295 opts: &ListOptions,
2296) -> KimetsuResult<Vec<MemoryRow>> {
2297 let limit = if opts.limit == 0 { 100 } else { opts.limit } as i64;
2304 let offset = opts.offset as i64;
2305
2306 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
2307 (
2308 "
2309 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
2310 FROM memories
2311 WHERE invalidated_at IS NULL
2312 AND superseded_by IS NULL
2313 AND lower(scope) = lower(?1)
2314 ORDER BY created_at DESC
2315 LIMIT ?2 OFFSET ?3
2316 ",
2317 Some(scope.to_string()),
2318 )
2319 } else {
2320 (
2321 "
2322 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
2323 FROM memories
2324 WHERE invalidated_at IS NULL
2325 AND superseded_by IS NULL
2326 ORDER BY created_at DESC
2327 LIMIT ?1 OFFSET ?2
2328 ",
2329 None,
2330 )
2331 };
2332
2333 let mut stmt = conn.prepare(sql)?;
2334 let rows = if let Some(scope) = scope_param {
2335 stmt.query_map(params![scope, limit, offset], map_memory_row)?
2336 .collect::<Result<Vec<_>, _>>()?
2337 } else {
2338 stmt.query_map(params![limit, offset], map_memory_row)?
2339 .collect::<Result<Vec<_>, _>>()?
2340 };
2341 Ok(rows)
2342}
2343
2344#[cfg(test)]
2345mod tests {
2346 use crate::trace::TraceWriter;
2347 use std::fs;
2348
2349 use super::*;
2350 use crate::user_brain::with_user_brain_disabled;
2357
2358 fn test_root() -> std::path::PathBuf {
2365 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2366 kimetsu_core::paths::git_init_boundary(&root);
2367 root
2368 }
2369
2370 #[test]
2371 fn w1_5_init_creates_kimetsu_dir_but_no_runs_dir() {
2372 with_user_brain_disabled(|| {
2373 let root = test_root();
2374 let summary = init_project(&root, false).expect("init");
2375 assert!(summary.kimetsu_dir.exists(), ".kimetsu/ must exist");
2377 assert!(summary.brain_db.exists(), "brain.db must be created");
2378 assert!(
2379 summary.kimetsu_dir.join("project.toml").exists(),
2380 "project.toml must be written"
2381 );
2382 assert!(
2385 !summary.kimetsu_dir.join("runs").exists(),
2386 "fresh init must NOT create a runs/ dir"
2387 );
2388 });
2389 }
2390
2391 #[test]
2392 fn search_memories_paginates_and_filters_by_kind() {
2393 with_user_brain_disabled(|| {
2394 let root = test_root();
2395 init_project(&root, false).expect("init");
2396 add_memory(
2397 &root,
2398 MemoryScope::Project,
2399 MemoryKind::FailurePattern,
2400 "linker link.exe not found on windows",
2401 )
2402 .expect("add fp");
2403 add_memory(
2404 &root,
2405 MemoryScope::Project,
2406 MemoryKind::Command,
2407 "run cargo build with the link.exe linker on PATH",
2408 )
2409 .expect("add cmd");
2410 add_memory(
2411 &root,
2412 MemoryScope::Project,
2413 MemoryKind::Fact,
2414 "the office plant needs watering on tuesdays",
2415 )
2416 .expect("add fact");
2417
2418 let hits = search_memories(&root, "linker", 10, 0, None, None).expect("search");
2420 assert!(hits.len() >= 2, "expected >=2 hits, got {}", hits.len());
2421 assert!(
2422 hits.iter()
2423 .all(|h| h.text.to_ascii_lowercase().contains("link"))
2424 );
2425
2426 let p1 = search_memories(&root, "linker", 1, 0, None, None).expect("p1");
2428 let p2 = search_memories(&root, "linker", 1, 1, None, None).expect("p2");
2429 assert_eq!(p1.len(), 1);
2430 assert_eq!(p2.len(), 1);
2431 assert_ne!(p1[0].memory_id, p2[0].memory_id, "offset must advance");
2432
2433 let fp =
2435 search_memories(&root, "linker", 10, 0, Some("failure_pattern"), None).expect("fp");
2436 assert!(!fp.is_empty());
2437 assert!(fp.iter().all(|h| h.kind == "failure_pattern"));
2438
2439 assert!(
2441 search_memories(&root, " ", 10, 0, None, None)
2442 .unwrap()
2443 .is_empty()
2444 );
2445 });
2446 }
2447
2448 #[test]
2449 fn reindex_with_explicit_embedder_uses_that_model() {
2450 with_user_brain_disabled(|| {
2451 let root = test_root();
2452 init_project(&root, false).expect("init");
2453 add_memory(
2454 &root,
2455 MemoryScope::Project,
2456 MemoryKind::Fact,
2457 "alpha beta gamma",
2458 )
2459 .expect("add");
2460 use crate::embeddings::Embedder as _;
2464 let stub = crate::embeddings::StubEmbedder::new();
2465 let report = crate::reindex::reindex_all_with_embedder(
2466 &root,
2467 crate::reindex::ReindexOptions {
2468 scope: crate::reindex::ReindexScope::Project,
2469 dry_run: false,
2470 force: false,
2471 limit: None,
2472 },
2473 &stub,
2474 )
2475 .expect("reindex");
2476 assert_eq!(report.embedder_model_id, stub.model_id());
2477 assert!(
2478 report.project.updated >= 1,
2479 "the row should be re-embedded with the stub model"
2480 );
2481 });
2482 }
2483
2484 #[test]
2485 fn retrieve_proactive_returns_actionable_kind_and_excludes_others() {
2486 with_user_brain_disabled(|| {
2487 let root = test_root();
2488 init_project(&root, false).expect("init");
2489 add_memory(
2490 &root,
2491 MemoryScope::Project,
2492 MemoryKind::FailurePattern,
2493 "linker link.exe not found -> run from x64 Native Tools prompt",
2494 )
2495 .expect("add fp");
2496 add_memory(
2499 &root,
2500 MemoryScope::Project,
2501 MemoryKind::Fact,
2502 "linker link.exe trivia: link.exe ships with MSVC",
2503 )
2504 .expect("add fact");
2505
2506 let request = ContextRequest {
2507 stage: "localization".to_string(),
2508 query: "error: linker `link.exe` not found".to_string(),
2509 budget_tokens: 600,
2510 min_score: 0.2,
2511 max_capsules: 1,
2512 kinds: vec!["failure_pattern".to_string(), "command".to_string()],
2513 ..Default::default()
2514 };
2515 let bundle = retrieve_proactive_readonly(&root, request).expect("proactive");
2516 assert!(!bundle.skipped, "should surface the failure_pattern");
2517 assert_eq!(bundle.capsules.len(), 1);
2518 assert!(
2520 bundle.capsules[0].summary.contains("failure_pattern"),
2521 "got summary: {}",
2522 bundle.capsules[0].summary
2523 );
2524 assert!(!bundle.capsules[0].summary.contains("trivia"));
2525 });
2526 }
2527
2528 #[test]
2529 fn memory_add_survives_projection_rebuild_from_trace() {
2530 with_user_brain_disabled(|| {
2531 let root = test_root();
2532 fs::create_dir_all(&root).expect("create temp project");
2533
2534 init_project(&root, false).expect("init project");
2535 let memory_id = add_memory(
2536 &root,
2537 MemoryScope::GlobalUser,
2538 MemoryKind::Preference,
2539 "User prefers Rust for core infrastructure.",
2540 )
2541 .expect("add memory");
2542
2543 let memories = list_memories(&root).expect("list memories");
2544 assert_eq!(memories.len(), 1);
2545 assert_eq!(memories[0].memory_id, memory_id);
2546
2547 let event_count = rebuild_projection(&root, false).expect("rebuild projection");
2548 assert_eq!(event_count, 3);
2549
2550 let memories = list_memories(&root).expect("list rebuilt memories");
2551 assert_eq!(memories.len(), 1);
2552 assert_eq!(memories[0].memory_id, memory_id);
2553 assert_eq!(
2554 memories[0].text,
2555 "User prefers Rust for core infrastructure."
2556 );
2557
2558 fs::remove_dir_all(root).expect("remove temp project");
2559 });
2560 }
2561
2562 #[test]
2566 fn add_memory_redacts_secrets_before_persist() {
2567 with_user_brain_disabled(|| {
2568 let root = test_root();
2569 fs::create_dir_all(&root).expect("create temp project");
2570 init_project(&root, false).expect("init project");
2571
2572 let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
2573 let memory_id =
2574 add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory");
2575
2576 let memories = list_memories(&root).expect("list");
2577 let stored = memories
2578 .iter()
2579 .find(|m| m.memory_id == memory_id)
2580 .expect("memory present");
2581 assert!(
2582 !stored.text.contains("sk-ant-api03"),
2583 "raw secret must NOT survive to brain.db: {}",
2584 stored.text
2585 );
2586 assert!(
2587 stored.text.contains("[REDACTED:anthropic_oauth]"),
2588 "placeholder must be present: {}",
2589 stored.text
2590 );
2591 assert!(
2592 stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"),
2593 "non-secret context must be preserved: {}",
2594 stored.text
2595 );
2596
2597 fs::remove_dir_all(root).expect("cleanup");
2598 });
2599 }
2600
2601 #[test]
2602 fn repo_ingest_indexes_searchable_files_and_context_capsules() {
2603 let root = test_root();
2604 fs::create_dir_all(root.join("src")).expect("create src");
2605 fs::create_dir_all(root.join("target")).expect("create target");
2606 fs::write(
2607 root.join("Cargo.toml"),
2608 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
2609 )
2610 .expect("write manifest");
2611 fs::write(
2612 root.join("src").join("lib.rs"),
2613 "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
2614 )
2615 .expect("write source");
2616 fs::write(
2617 root.join("target").join("generated.rs"),
2618 "projection rebuild",
2619 )
2620 .expect("write skipped");
2621 fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
2622 fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
2623
2624 init_project(&root, false).expect("init project");
2625 add_memory(
2626 &root,
2627 MemoryScope::GlobalUser,
2628 MemoryKind::Preference,
2629 "User prefers Rust for core infrastructure.",
2630 )
2631 .expect("add memory");
2632
2633 let summary = ingest_repo(&root).expect("ingest repo");
2634 assert_eq!(summary.indexed_files, 2);
2635 assert_eq!(summary.manifests, 1);
2636
2637 let matches = search_files(&root, "projection rebuild", 5).expect("search files");
2638 assert!(
2639 matches
2640 .iter()
2641 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2642 "expected src/lib.rs in search results: {matches:?}"
2643 );
2644 assert!(
2645 matches
2646 .iter()
2647 .all(|capsule| !capsule.expansion_handle.contains("target/")),
2648 "target files must not be indexed: {matches:?}"
2649 );
2650
2651 let context =
2652 retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
2653 assert!(
2654 context
2655 .capsules
2656 .iter()
2657 .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
2658 "expected memory capsule in context: {:?}",
2659 context.capsules
2660 );
2661
2662 rebuild_projection(&root, false).expect("rebuild projection");
2663 let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
2664 assert!(
2665 matches
2666 .iter()
2667 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2668 "repo index should survive event-only rebuild: {matches:?}"
2669 );
2670
2671 fs::remove_dir_all(root).expect("remove temp project");
2672 }
2673
2674 #[test]
2675 fn run_finished_increments_usefulness_for_injected_memories() {
2676 with_user_brain_disabled(|| {
2677 let root = test_root();
2685 fs::create_dir_all(&root).expect("create temp project");
2686 init_project(&root, false).expect("init project");
2687 let memory_id = add_memory(
2688 &root,
2689 MemoryScope::GlobalUser,
2690 MemoryKind::Preference,
2691 "Prefer ripgrep over grep.",
2692 )
2693 .expect("add memory");
2694
2695 {
2696 let (paths, _config, conn) = load_project(&root).expect("load");
2697 let run_id = RunId::new();
2698 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2699 let evs: Vec<Event> = vec![
2700 Event::new(
2701 run_id,
2702 "run.started",
2703 serde_json::json!({"project_id": "test", "task": "x"}),
2704 ),
2705 Event::new(
2706 run_id,
2707 "context.injected",
2708 serde_json::json!({
2709 "stage": "localization",
2710 "capsule_handles": [format!("memory:{memory_id}")],
2711 "memory_ids": [memory_id.clone()],
2712 "prior_run_ids": [],
2713 "file_paths": [],
2714 }),
2715 ),
2716 Event::new(
2717 run_id,
2718 "context.injected",
2719 serde_json::json!({
2720 "stage": "patch_plan",
2721 "capsule_handles": [format!("memory:{memory_id}")],
2722 "memory_ids": [memory_id.clone()],
2723 "prior_run_ids": [],
2724 "file_paths": [],
2725 }),
2726 ),
2727 Event::new(
2730 run_id,
2731 "memory.cited",
2732 serde_json::json!({
2733 "memory_id": memory_id,
2734 "turn": 3,
2735 "rationale": "using rg from memory",
2736 }),
2737 ),
2738 Event::new(
2739 run_id,
2740 "run.finished",
2741 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2742 ),
2743 ];
2744 for ev in &evs {
2745 writer.append(ev, true).expect("append");
2746 }
2747 projector::apply_events(&conn, &evs).expect("project");
2748 }
2749
2750 let memories = list_memories(&root).expect("list memories");
2751 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2752 assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
2753 let expected = 1.0 + 0.05; assert!(
2757 (m.usefulness_score - expected).abs() < 1e-4,
2758 "expected strong-signal usefulness_score = {expected}, got {}",
2759 m.usefulness_score
2760 );
2761
2762 fs::remove_dir_all(root).expect("remove temp project");
2763 });
2764 }
2765
2766 #[test]
2771 fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
2772 with_user_brain_disabled(|| {
2773 let root = test_root();
2774 fs::create_dir_all(&root).expect("create temp project");
2775 init_project(&root, false).expect("init project");
2776 let memory_id = add_memory(
2777 &root,
2778 MemoryScope::GlobalUser,
2779 MemoryKind::Preference,
2780 "Silent passenger memory.",
2781 )
2782 .expect("add memory");
2783
2784 {
2785 let (paths, _config, conn) = load_project(&root).expect("load");
2786 let run_id = RunId::new();
2787 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2788 let evs: Vec<Event> = vec![
2789 Event::new(
2790 run_id,
2791 "run.started",
2792 serde_json::json!({"project_id": "test", "task": "x"}),
2793 ),
2794 Event::new(
2795 run_id,
2796 "context.injected",
2797 serde_json::json!({
2798 "stage": "localization",
2799 "memory_ids": [memory_id.clone()],
2800 "prior_run_ids": [],
2801 "file_paths": [],
2802 }),
2803 ),
2804 Event::new(
2806 run_id,
2807 "run.finished",
2808 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2809 ),
2810 ];
2811 for ev in &evs {
2812 writer.append(ev, true).expect("append");
2813 }
2814 projector::apply_events(&conn, &evs).expect("project");
2815 }
2816
2817 let memories = list_memories(&root).expect("list memories");
2818 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2819 assert_eq!(m.use_count, 1);
2820 let expected = 0.1 + 0.05; assert!(
2824 (m.usefulness_score - expected).abs() < 1e-4,
2825 "silent passenger should get +0.1 on top of seed, got {}",
2826 m.usefulness_score
2827 );
2828 });
2829 }
2830
2831 #[test]
2837 fn blame_run_separates_cited_from_silent_passengers() {
2838 with_user_brain_disabled(|| {
2839 let root = test_root();
2840 fs::create_dir_all(&root).expect("create temp project");
2841 init_project(&root, false).expect("init project");
2842 let cited_id = add_memory(
2843 &root,
2844 MemoryScope::Repo,
2845 MemoryKind::Preference,
2846 "prefer ripgrep over grep",
2847 )
2848 .expect("add cited");
2849 let silent_id = add_memory(
2850 &root,
2851 MemoryScope::Repo,
2852 MemoryKind::Convention,
2853 "use cargo nextest for tests",
2854 )
2855 .expect("add silent");
2856
2857 let run_id = RunId::new();
2858 {
2859 let (paths, _config, conn) = load_project(&root).expect("load");
2860 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2861 let evs: Vec<Event> = vec![
2862 Event::new(
2863 run_id,
2864 "run.started",
2865 serde_json::json!({"project_id": "test", "task": "x"}),
2866 ),
2867 Event::new(
2868 run_id,
2869 "context.injected",
2870 serde_json::json!({
2871 "stage": "localization",
2872 "memory_ids": [cited_id.clone(), silent_id.clone()],
2873 "prior_run_ids": [],
2874 "file_paths": [],
2875 }),
2876 ),
2877 Event::new(
2878 run_id,
2879 "memory.cited",
2880 serde_json::json!({
2881 "memory_id": cited_id,
2882 "turn": 4,
2883 "rationale": "used the rg pattern",
2884 }),
2885 ),
2886 Event::new(
2887 run_id,
2888 "run.finished",
2889 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2890 ),
2891 ];
2892 for ev in &evs {
2893 writer.append(ev, true).expect("append");
2894 }
2895 projector::apply_events(&conn, &evs).expect("project");
2896 }
2897
2898 let report = blame_run(&root, &run_id.to_string()).expect("blame");
2899 assert_eq!(report.outcome, "success");
2900 assert!(report.failure_category.is_none());
2901 assert_eq!(report.cited.len(), 1, "exactly one cited memory");
2902 let cited = &report.cited[0];
2903 assert_eq!(cited.memory_id, cited_id);
2904 assert_eq!(cited.turn, 4);
2905 assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
2906 assert!(cited.text_preview.contains("ripgrep"));
2907
2908 assert_eq!(report.silent_passengers.len(), 1);
2909 let silent = &report.silent_passengers[0];
2910 assert_eq!(silent.memory_id, silent_id);
2911 assert!(silent.text_preview.contains("nextest"));
2912
2913 fs::remove_dir_all(root).expect("cleanup");
2914 });
2915 }
2916
2917 #[test]
2918 fn run_failed_decrements_usefulness_unless_gate() {
2919 let root = test_root();
2923 fs::create_dir_all(&root).expect("create temp project");
2924 init_project(&root, false).expect("init project");
2925 let memory_id = add_memory(
2926 &root,
2927 MemoryScope::Repo,
2928 MemoryKind::Convention,
2929 "Use find_* for fallible lookups.",
2930 )
2931 .expect("add memory");
2932
2933 {
2934 let (paths, _config, conn) = load_project(&root).expect("load");
2935
2936 let gate_run = RunId::new();
2938 let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
2939 let gate_events: Vec<Event> = vec![
2940 Event::new(
2941 gate_run,
2942 "run.started",
2943 serde_json::json!({"project_id": "test", "task": "g"}),
2944 ),
2945 Event::new(
2946 gate_run,
2947 "context.injected",
2948 serde_json::json!({
2949 "stage": "patch_plan",
2950 "capsule_handles": [format!("memory:{memory_id}")],
2951 "memory_ids": [memory_id.clone()],
2952 "prior_run_ids": [],
2953 "file_paths": [],
2954 }),
2955 ),
2956 Event::new(
2957 gate_run,
2958 "run.failed",
2959 serde_json::json!({
2960 "category": "Gate",
2961 "failed_stage": "patch_plan",
2962 "message": "files_to_create_already_exist",
2963 }),
2964 ),
2965 ];
2966 for ev in &gate_events {
2967 writer.append(ev, true).expect("append");
2968 }
2969 projector::apply_events(&conn, &gate_events).expect("project gate-fail");
2970
2971 let impl_run = RunId::new();
2974 let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
2975 let impl_events: Vec<Event> = vec![
2976 Event::new(
2977 impl_run,
2978 "run.started",
2979 serde_json::json!({"project_id": "test", "task": "i"}),
2980 ),
2981 Event::new(
2982 impl_run,
2983 "context.injected",
2984 serde_json::json!({
2985 "stage": "patch_plan",
2986 "capsule_handles": [format!("memory:{memory_id}")],
2987 "memory_ids": [memory_id.clone()],
2988 "prior_run_ids": [],
2989 "file_paths": [],
2990 }),
2991 ),
2992 Event::new(
2995 impl_run,
2996 "memory.cited",
2997 serde_json::json!({
2998 "memory_id": memory_id,
2999 "turn": 2,
3000 "rationale": "trusted the memory's pattern",
3001 }),
3002 ),
3003 Event::new(
3004 impl_run,
3005 "run.failed",
3006 serde_json::json!({
3007 "category": "Implementation",
3008 "failed_stage": "implementation",
3009 "message": "test broke",
3010 }),
3011 ),
3012 ];
3013 for ev in &impl_events {
3014 writer2.append(ev, true).expect("append");
3015 }
3016 projector::apply_events(&conn, &impl_events).expect("project impl-fail");
3017 }
3018
3019 let memories = list_memories(&root).expect("list memories");
3020 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
3021 assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
3022 let expected = 0.15 - 1.0; assert!(
3026 (m.usefulness_score - expected).abs() < 1e-4,
3027 "expected usefulness_score = {expected}, got {}",
3028 m.usefulness_score
3029 );
3030
3031 fs::remove_dir_all(root).expect("remove temp project");
3032 }
3033
3034 #[test]
3035 fn run_aborted_does_not_update_usefulness() {
3036 let root = test_root();
3037 fs::create_dir_all(&root).expect("create temp project");
3038 init_project(&root, false).expect("init project");
3039 let memory_id = add_memory(
3040 &root,
3041 MemoryScope::Repo,
3042 MemoryKind::Convention,
3043 "Module re-exports live in lib.rs.",
3044 )
3045 .expect("add memory");
3046
3047 {
3048 let (paths, _config, conn) = load_project(&root).expect("load");
3049 let run_id = RunId::new();
3050 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
3051 let evs: Vec<Event> = vec![
3052 Event::new(
3053 run_id,
3054 "run.started",
3055 serde_json::json!({"project_id": "test", "task": "a"}),
3056 ),
3057 Event::new(
3058 run_id,
3059 "context.injected",
3060 serde_json::json!({
3061 "stage": "patch_plan",
3062 "capsule_handles": [format!("memory:{memory_id}")],
3063 "memory_ids": [memory_id.clone()],
3064 "prior_run_ids": [],
3065 "file_paths": [],
3066 }),
3067 ),
3068 Event::new(
3069 run_id,
3070 "run.aborted",
3071 serde_json::json!({"reason": "user_abort"}),
3072 ),
3073 ];
3074 for ev in &evs {
3075 writer.append(ev, true).expect("append");
3076 }
3077 projector::apply_events(&conn, &evs).expect("project");
3078 }
3079
3080 let memories = list_memories(&root).expect("list memories");
3081 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
3082 assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
3083 let expected_seed = 0.15_f32; assert!(
3087 (m.usefulness_score - expected_seed).abs() < 1e-4,
3088 "expected usefulness_score = {expected_seed} (initial seed only), got {}",
3089 m.usefulness_score
3090 );
3091
3092 fs::remove_dir_all(root).expect("remove temp project");
3093 }
3094
3095 #[test]
3096 fn list_proposals_filters_and_reject_records_reason() {
3097 let root = test_root();
3098 fs::create_dir_all(&root).expect("create temp project");
3099 init_project(&root, false).expect("init project");
3100
3101 let proposals = [
3103 (
3104 "p1",
3105 "global_user",
3106 "preference",
3107 0.9_f32,
3108 "Prefer rg over grep",
3109 ),
3110 (
3111 "p2",
3112 "repo",
3113 "convention",
3114 0.8,
3115 "Use find_* for fallible lookups",
3116 ),
3117 (
3118 "p3",
3119 "repo",
3120 "convention",
3121 0.4,
3122 "Use let-else where possible",
3123 ),
3124 ];
3125 {
3126 let (paths, _config, conn) = load_project(&root).expect("load");
3127 let run_id = RunId::new();
3128 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
3129 for (proposal_id, scope, kind, conf, text) in &proposals {
3130 let event = Event::new(
3131 run_id,
3132 "memory.proposed",
3133 serde_json::json!({
3134 "proposal_id": proposal_id,
3135 "scope": scope,
3136 "kind": kind,
3137 "text": text,
3138 "rationale": "test rationale",
3139 "proposed_confidence": conf,
3140 "source_event_ids": [],
3141 }),
3142 );
3143 writer.append(&event, true).expect("append proposal");
3144 projector::apply_events(&conn, &[event]).expect("project");
3145 }
3146 }
3147
3148 let global = list_proposals(
3150 &root,
3151 ProposalFilter {
3152 scope: Some("global_user".into()),
3153 status: Some("pending".into()),
3154 ..ProposalFilter::default()
3155 },
3156 )
3157 .expect("list proposals");
3158 assert_eq!(global.len(), 1);
3159 assert_eq!(global[0].proposal_id, "p1");
3160
3161 let strong = list_proposals(
3163 &root,
3164 ProposalFilter {
3165 min_confidence: Some(0.7),
3166 status: Some("pending".into()),
3167 ..ProposalFilter::default()
3168 },
3169 )
3170 .expect("list strong");
3171 assert_eq!(strong.len(), 2);
3172 for row in &strong {
3173 assert!(row.proposed_confidence >= 0.7);
3174 }
3175
3176 reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
3178 let rejected = list_proposals(
3179 &root,
3180 ProposalFilter {
3181 status: Some("rejected".into()),
3182 ..ProposalFilter::default()
3183 },
3184 )
3185 .expect("list rejected");
3186 assert_eq!(rejected.len(), 1);
3187 assert_eq!(rejected[0].proposal_id, "p3");
3188 assert_eq!(
3189 rejected[0].decided_reason.as_deref(),
3190 Some("not specific to the user")
3191 );
3192
3193 let memory_id = accept_proposal(
3196 &root,
3197 "p1",
3198 AcceptOverrides {
3199 scope: None,
3200 confidence: Some(0.55),
3201 },
3202 )
3203 .expect("accept");
3204 let memories = list_memories(&root).expect("list memories");
3205 let promoted = memories
3206 .into_iter()
3207 .find(|m| m.memory_id == memory_id)
3208 .expect("promoted memory present");
3209 assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
3210
3211 fs::remove_dir_all(root).expect("remove temp project");
3212 }
3213
3214 #[test]
3219 fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
3220 let root = test_root();
3221 fs::create_dir_all(&root).expect("create temp project");
3222 init_project(&root, false).expect("init project");
3223
3224 let memory_id = add_memory(
3225 &root,
3226 MemoryScope::Repo,
3227 MemoryKind::Convention,
3228 "Use find_* for fallible lookups.",
3229 )
3230 .expect("add memory");
3231
3232 invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
3233 .expect("invalidate memory");
3234
3235 {
3238 let (_paths, _config, conn) = load_project(&root).expect("load");
3239 let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
3240 .query_row(
3241 "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
3242 params![memory_id],
3243 |row| Ok((row.get(0)?, row.get(1)?)),
3244 )
3245 .expect("query invalidated metadata");
3246 assert!(invalidated_at.is_some(), "invalidated_at must be set");
3247 assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
3248 }
3249
3250 rebuild_projection(&root, false).expect("rebuild projection");
3252 {
3253 let (_paths, _config, conn) = load_project(&root).expect("load");
3254 let invalidated_at: Option<String> = conn
3255 .query_row(
3256 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
3257 params![memory_id],
3258 |row| row.get(0),
3259 )
3260 .expect("query after rebuild");
3261 assert!(
3262 invalidated_at.is_some(),
3263 "invalidated_at must survive event replay"
3264 );
3265 }
3266
3267 fs::remove_dir_all(root).expect("remove temp project");
3268 }
3269
3270 #[test]
3274 fn invalidated_memory_is_excluded_from_broker_retrieval() {
3275 with_user_brain_disabled(|| {
3276 let root = test_root();
3277 fs::create_dir_all(&root).expect("create temp project");
3278 init_project(&root, false).expect("init project");
3279
3280 let memory_id = add_memory(
3281 &root,
3282 MemoryScope::GlobalUser,
3283 MemoryKind::Preference,
3284 "Prefer ripgrep over grep for repo search.",
3285 )
3286 .expect("add memory");
3287
3288 let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
3290 .expect("pre context");
3291 assert!(
3292 pre.capsules
3293 .iter()
3294 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
3295 "memory must appear before invalidation: {:?}",
3296 pre.capsules
3297 );
3298
3299 invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
3300
3301 let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
3302 .expect("post context");
3303 assert!(
3304 post.capsules
3305 .iter()
3306 .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
3307 "invalidated memory must not be retrieved: {:?}",
3308 post.capsules
3309 );
3310
3311 {
3315 let (_paths2, _config2, conn2) = load_project(&root).expect("load for check");
3316 let still_there: i64 = conn2
3317 .query_row(
3318 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
3319 rusqlite::params![&memory_id],
3320 |row| row.get(0),
3321 )
3322 .expect("db query");
3323 assert_eq!(still_there, 1, "invalidated row must persist in brain.db");
3324 } let active = list_memories(&root).expect("list after invalidation");
3327 assert!(
3328 active.iter().all(|m| m.memory_id != memory_id),
3329 "invalidated memory must not appear in list_memories"
3330 );
3331
3332 fs::remove_dir_all(root).expect("remove temp project");
3333 });
3334 }
3335
3336 #[test]
3341 fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
3342 let root = test_root();
3343 fs::create_dir_all(&root).expect("create temp project");
3344 init_project(&root, false).expect("init project");
3345
3346 let m_great =
3347 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
3348 let m_meh =
3349 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
3350 let m_bad =
3351 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
3352 let _m_fresh =
3353 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
3354
3355 {
3358 let (_paths, _config, conn) = load_project(&root).expect("load");
3359 conn.execute(
3360 "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
3361 params![m_great],
3362 )
3363 .expect("set great");
3364 conn.execute(
3365 "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
3366 params![m_meh],
3367 )
3368 .expect("set meh");
3369 conn.execute(
3370 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
3371 params![m_bad],
3372 )
3373 .expect("set bad");
3374 }
3376
3377 let top = list_memories_top(
3378 &root,
3379 TopOptions {
3380 scope: None,
3381 min_uses: 3,
3382 limit: 10,
3383 },
3384 )
3385 .expect("top");
3386 assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
3387 assert_eq!(top[0].memory_id, m_great);
3388 assert_eq!(top[1].memory_id, m_meh);
3389 assert_eq!(top[2].memory_id, m_bad);
3390
3391 invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
3393 let top_after = list_memories_top(
3394 &root,
3395 TopOptions {
3396 scope: None,
3397 min_uses: 3,
3398 limit: 10,
3399 },
3400 )
3401 .expect("top after");
3402 assert_eq!(top_after.len(), 2);
3403 assert!(top_after.iter().all(|m| m.memory_id != m_great));
3404
3405 fs::remove_dir_all(root).expect("remove temp project");
3406 }
3407
3408 #[test]
3414 fn prune_low_usefulness_dry_run_then_apply() {
3415 with_user_brain_disabled(|| {
3416 prune_low_usefulness_dry_run_then_apply_body();
3417 });
3418 }
3419
3420 fn prune_low_usefulness_dry_run_then_apply_body() {
3421 let root = test_root();
3422 fs::create_dir_all(&root).expect("create temp project");
3423 init_project(&root, false).expect("init project");
3424
3425 let m_keep = add_memory(
3426 &root,
3427 MemoryScope::Repo,
3428 MemoryKind::Convention,
3429 "keep me, I help",
3430 )
3431 .expect("keep");
3432 let m_drop_1 = add_memory(
3433 &root,
3434 MemoryScope::Repo,
3435 MemoryKind::Convention,
3436 "drop me, I hurt",
3437 )
3438 .expect("drop1");
3439 let m_drop_2 = add_memory(
3440 &root,
3441 MemoryScope::Repo,
3442 MemoryKind::Convention,
3443 "drop me too",
3444 )
3445 .expect("drop2");
3446 let m_small_sample = add_memory(
3447 &root,
3448 MemoryScope::Repo,
3449 MemoryKind::Convention,
3450 "small sample shouldn't be pruned even if score is bad",
3451 )
3452 .expect("small");
3453
3454 {
3455 let (_paths, _config, conn) = load_project(&root).expect("load");
3456 conn.execute(
3458 "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
3459 params![m_keep],
3460 )
3461 .expect("set keep");
3462 conn.execute(
3464 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
3465 params![m_drop_1],
3466 )
3467 .expect("set drop1");
3468 conn.execute(
3470 "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
3471 params![m_drop_2],
3472 )
3473 .expect("set drop2");
3474 conn.execute(
3476 "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
3477 params![m_small_sample],
3478 )
3479 .expect("set small");
3480 }
3481
3482 let dry = prune_low_usefulness(
3484 &root,
3485 PruneOptions {
3486 scope: None,
3487 min_uses: 3,
3488 max_ratio: -0.2,
3489 apply: false,
3490 },
3491 )
3492 .expect("dry-run");
3493 assert_eq!(dry.candidates.len(), 2);
3494 assert_eq!(dry.invalidated, 0);
3495 let ids: Vec<&str> = dry
3496 .candidates
3497 .iter()
3498 .map(|c| c.memory_id.as_str())
3499 .collect();
3500 assert!(ids.contains(&m_drop_1.as_str()));
3501 assert!(ids.contains(&m_drop_2.as_str()));
3502 assert!(!ids.contains(&m_small_sample.as_str()));
3504
3505 let pre = list_memories(&root).expect("pre");
3507 assert_eq!(pre.len(), 4);
3508
3509 let applied = prune_low_usefulness(
3511 &root,
3512 PruneOptions {
3513 scope: None,
3514 min_uses: 3,
3515 max_ratio: -0.2,
3516 apply: true,
3517 },
3518 )
3519 .expect("apply");
3520 assert_eq!(applied.candidates.len(), 2);
3521 assert_eq!(applied.invalidated, 2);
3522 assert_eq!(applied.failed, 0);
3523
3524 let top = list_memories_top(
3528 &root,
3529 TopOptions {
3530 scope: None,
3531 min_uses: 3,
3532 limit: 10,
3533 },
3534 )
3535 .expect("top after prune");
3536 assert_eq!(top.len(), 1);
3537 assert_eq!(top[0].memory_id, m_keep);
3538
3539 {
3544 let (_paths, _config, conn) = load_project(&root).expect("load");
3545 let reason: String = conn
3546 .query_row(
3547 "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
3548 params![m_drop_1],
3549 |row| row.get(0),
3550 )
3551 .expect("invalidated reason");
3552 assert!(
3553 reason.starts_with("pruned_by_usefulness"),
3554 "unexpected reason: {reason}"
3555 );
3556 }
3557
3558 fs::remove_dir_all(root).expect("remove temp project");
3559 }
3560
3561 #[test]
3568 fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
3569 with_user_brain_disabled(|| {
3570 batch_review_accepts_filtered_subset_and_rejects_remainder_body();
3571 });
3572 }
3573
3574 fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
3575 let root = test_root();
3576 fs::create_dir_all(&root).expect("create temp project");
3577 init_project(&root, false).expect("init project");
3578
3579 let run_a = RunId::new();
3580 let run_b = RunId::new();
3581
3582 let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
3586 (
3587 "p_a_strong",
3588 run_a,
3589 "global_user",
3590 "preference",
3591 0.92,
3592 "Prefer rg over grep",
3593 ),
3594 (
3595 "p_a_weak",
3596 run_a,
3597 "repo",
3598 "convention",
3599 0.55,
3600 "Always use let-else",
3601 ),
3602 (
3603 "p_b1",
3604 run_b,
3605 "repo",
3606 "convention",
3607 0.70,
3608 "Use Result not panic",
3609 ),
3610 (
3611 "p_b2",
3612 run_b,
3613 "global_user",
3614 "preference",
3615 0.88,
3616 "Open links in new tab",
3617 ),
3618 ];
3619
3620 {
3621 let (paths, _config, conn) = load_project(&root).expect("load");
3622 for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
3623 let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
3624 let event = Event::new(
3625 *run_id,
3626 "memory.proposed",
3627 serde_json::json!({
3628 "proposal_id": proposal_id,
3629 "scope": scope,
3630 "kind": kind,
3631 "text": text,
3632 "rationale": "fixture",
3633 "proposed_confidence": conf,
3634 "source_event_ids": [],
3635 }),
3636 );
3637 writer.append(&event, true).expect("append");
3638 projector::apply_events(&conn, &[event]).expect("project");
3639 }
3640 }
3641
3642 let to_accept = list_proposals(
3645 &root,
3646 ProposalFilter {
3647 from_run: Some(run_a.to_string()),
3648 min_confidence: Some(0.8),
3649 status: Some("pending".into()),
3650 limit: 100,
3651 ..ProposalFilter::default()
3652 },
3653 )
3654 .expect("list strong from run_a");
3655 assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
3656 assert_eq!(to_accept[0].proposal_id, "p_a_strong");
3657 let memory_id =
3658 accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
3659 .expect("accept p_a_strong");
3660
3661 let to_reject = list_proposals(
3664 &root,
3665 ProposalFilter {
3666 status: Some("pending".into()),
3667 limit: 100,
3668 ..ProposalFilter::default()
3669 },
3670 )
3671 .expect("list remaining pending");
3672 assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
3673 for p in &to_reject {
3674 reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
3675 }
3676
3677 let memories = list_memories(&root).expect("list memories");
3680 assert_eq!(
3681 memories.len(),
3682 1,
3683 "only the accepted proposal becomes a memory"
3684 );
3685 assert_eq!(memories[0].memory_id, memory_id);
3686
3687 let pending = list_proposals(
3688 &root,
3689 ProposalFilter {
3690 status: Some("pending".into()),
3691 limit: 100,
3692 ..ProposalFilter::default()
3693 },
3694 )
3695 .expect("list pending");
3696 assert!(
3697 pending.is_empty(),
3698 "no proposals left pending after batch review"
3699 );
3700
3701 let rejected = list_proposals(
3702 &root,
3703 ProposalFilter {
3704 status: Some("rejected".into()),
3705 limit: 100,
3706 ..ProposalFilter::default()
3707 },
3708 )
3709 .expect("list rejected");
3710 assert_eq!(rejected.len(), 3);
3711 for row in &rejected {
3712 assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
3713 }
3714
3715 fs::remove_dir_all(root).expect("remove temp project");
3716 }
3717
3718 #[test]
3734 fn add_memory_distinct_texts_no_conflicts() {
3735 with_user_brain_disabled(|| {
3736 let root = test_root();
3737 fs::create_dir_all(&root).expect("create temp project");
3738 init_project(&root, false).expect("init project");
3739
3740 let _m1 = add_memory(
3745 &root,
3746 MemoryScope::GlobalUser,
3747 MemoryKind::Preference,
3748 "Prefer thiserror for library error types.",
3749 )
3750 .expect("add m1");
3751 let _m2 = add_memory(
3752 &root,
3753 MemoryScope::GlobalUser,
3754 MemoryKind::Preference,
3755 "Cache HTTP responses with a one-hour TTL.",
3756 )
3757 .expect("add m2");
3758
3759 let open = list_conflicts(&root, 50).expect("list_conflicts");
3760 assert!(
3761 open.is_empty(),
3762 "distinct-topic memories must not conflict; got {} rows",
3763 open.len()
3764 );
3765
3766 let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
3768 .expect("resolve_conflict on unknown id");
3769 assert!(!resolved, "unknown conflict id should resolve to false");
3770
3771 let err = resolve_conflict(&root, "does-not-exist", "garbage")
3773 .expect_err("invalid resolution should error");
3774 assert!(format!("{err}").contains("invalid conflict resolution"));
3775
3776 fs::remove_dir_all(root).expect("remove temp project");
3777 });
3778 }
3779
3780 #[test]
3787 fn load_project_rejects_future_config_version() {
3788 with_user_brain_disabled(|| {
3789 use kimetsu_core::KIMETSU_CONFIG_VERSION;
3790 let root = test_root();
3791 fs::create_dir_all(&root).expect("create temp project");
3792 let paths = kimetsu_core::paths::ProjectPaths::discover(&root)
3794 .expect("discover paths after git_init_boundary");
3795 fs::create_dir_all(&paths.kimetsu_dir).expect("create .kimetsu dir");
3796 let bad_version = KIMETSU_CONFIG_VERSION + 1;
3797 let toml_str = format!(
3798 r#"
3799[kimetsu]
3800project_id = "test-config-gate"
3801schema_version = {bad_version}
3802
3803[model]
3804provider = "anthropic"
3805model = "claude-opus-4-7"
3806api_key_env = "ANTHROPIC_API_KEY"
3807max_output_tokens = 8192
3808temperature = 0.2
3809request_timeout_secs = 120
3810
3811[broker]
3812default_budget_tokens = 6000
3813
3814[broker.weights]
3815relevance = 0.5
3816confidence = 0.2
3817freshness = 0.2
3818scope = 0.1
3819
3820[shell]
3821default_timeout_secs = 60
3822max_timeout_secs = 600
3823env_allowlist_extra = []
3824redact_secrets = true
3825
3826[ingestion]
3827max_file_bytes = 524288
3828extra_skip_dirs = []
3829max_total_files = 50000
3830
3831[run]
3832max_total_tool_calls = 60
3833max_total_model_turns = 30
3834max_total_cost_usd = 250.0
3835"#
3836 );
3837 fs::write(&paths.project_toml, &toml_str).expect("write bad project.toml");
3838 let err = load_project(&root).expect_err("future config version must be rejected");
3839 let msg = format!("{err}");
3840 assert!(
3841 msg.contains(&bad_version.to_string()),
3842 "error message should mention the bad version; got: {msg}"
3843 );
3844 assert!(
3845 msg.contains(&KIMETSU_CONFIG_VERSION.to_string()),
3846 "error message should mention the expected version; got: {msg}"
3847 );
3848 fs::remove_dir_all(root).expect("remove temp project");
3849 });
3850 }
3851
3852 fn make_dangling_run(root: &std::path::Path) -> RunId {
3856 let (paths, _config, conn) = load_project(root).expect("load project");
3857 let run_id = RunId::new();
3858 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("create trace");
3859 let started = Event::new(
3860 run_id,
3861 "run.started",
3862 serde_json::json!({"project_id": "test", "task": "dangling task"}),
3863 );
3864 writer.append(&started, true).expect("append started");
3865 projector::apply_events(&conn, &[started]).expect("project started");
3866 run_id
3867 }
3868
3869 #[test]
3870 fn abort_run_stamps_aborted_and_frees_lock() {
3871 with_user_brain_disabled(|| {
3872 let root = test_root();
3873 fs::create_dir_all(&root).expect("mkdir");
3874 init_project(&root, false).expect("init");
3875
3876 let run_id = make_dangling_run(&root);
3877
3878 abort_run(&root, &run_id.to_string()).expect("abort_run");
3880
3881 let run = show_run(&root, &run_id.to_string())
3883 .expect("show_run")
3884 .expect("run exists");
3885 assert_eq!(
3886 run.terminal_kind.as_deref(),
3887 Some("run.aborted"),
3888 "terminal_kind should be run.aborted"
3889 );
3890
3891 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
3893 assert!(
3894 !paths.lock_file.exists(),
3895 "lock file should not exist after abort"
3896 );
3897
3898 fs::remove_dir_all(root).expect("cleanup");
3899 });
3900 }
3901
3902 #[test]
3903 fn abort_run_already_finished_returns_err() {
3904 with_user_brain_disabled(|| {
3905 let root = test_root();
3906 fs::create_dir_all(&root).expect("mkdir");
3907 init_project(&root, false).expect("init");
3908
3909 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "some fact")
3911 .expect("add memory");
3912
3913 let runs = list_runs(&root).expect("list runs");
3914 assert!(!runs.is_empty(), "should have at least one run");
3915 let finished_run = runs
3916 .iter()
3917 .find(|r| r.terminal_kind.is_some())
3918 .expect("should have a finished run");
3919
3920 let err = abort_run(&root, &finished_run.run_id)
3921 .expect_err("aborting a finished run should error");
3922 let msg = format!("{err}");
3923 assert!(
3924 msg.contains("already terminal"),
3925 "error should mention 'already terminal', got: {msg}"
3926 );
3927
3928 fs::remove_dir_all(root).expect("cleanup");
3929 });
3930 }
3931
3932 #[test]
3933 fn abort_run_unknown_id_returns_err() {
3934 with_user_brain_disabled(|| {
3935 let root = test_root();
3936 fs::create_dir_all(&root).expect("mkdir");
3937 init_project(&root, false).expect("init");
3938
3939 let fake_id = RunId::new().to_string();
3940 let err = abort_run(&root, &fake_id).expect_err("aborting an unknown run should error");
3941 let msg = format!("{err}");
3942 assert!(
3943 msg.contains("unknown run_id"),
3944 "error should mention 'unknown run_id', got: {msg}"
3945 );
3946
3947 fs::remove_dir_all(root).expect("cleanup");
3948 });
3949 }
3950
3951 #[test]
3957 fn rebuild_from_events_table_restores_memories() {
3958 with_user_brain_disabled(|| {
3959 let root = test_root();
3960 fs::create_dir_all(&root).expect("create temp project");
3961 init_project(&root, false).expect("init project");
3962
3963 let id1 = add_memory(
3964 &root,
3965 MemoryScope::Repo,
3966 MemoryKind::Convention,
3967 "W1.3: prefer explicit error types over anyhow in library crates",
3968 )
3969 .expect("add memory 1");
3970 let id2 = add_memory(
3971 &root,
3972 MemoryScope::Repo,
3973 MemoryKind::Command,
3974 "W1.3: run cargo fmt --all before committing",
3975 )
3976 .expect("add memory 2");
3977
3978 {
3980 let (_paths, _config, conn) = load_project(&root).expect("load");
3981 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
3982 .expect("wipe derived tables");
3983 }
3984
3985 let gone = list_memories(&root).expect("list after wipe");
3987 assert_eq!(gone.len(), 0, "derived tables should be empty after wipe");
3988
3989 let count = rebuild_projection(&root, false).expect("rebuild_projection");
3991 assert!(
3992 count > 0,
3993 "should have replayed at least one event; got {count}"
3994 );
3995
3996 let restored = list_memories(&root).expect("list after rebuild");
3998 assert_eq!(
3999 restored.len(),
4000 2,
4001 "both memories should be restored after rebuild; got {:?}",
4002 restored.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
4003 );
4004 let ids: Vec<_> = restored.iter().map(|m| m.memory_id.clone()).collect();
4005 assert!(ids.contains(&id1), "id1 must be restored");
4006 assert!(ids.contains(&id2), "id2 must be restored");
4007
4008 fs::remove_dir_all(root).expect("cleanup");
4009 });
4010 }
4011
4012 #[test]
4021 fn rebuild_from_traces_flag_reimports_on_disk_traces() {
4022 with_user_brain_disabled(|| {
4023 let root = test_root();
4024 fs::create_dir_all(&root).expect("create temp project");
4025 init_project(&root, false).expect("init project");
4026
4027 let memory_id = Ulid::new().to_string();
4031 let run_id = RunId::new();
4032 {
4033 let (paths, config, conn) = load_project(&root).expect("load");
4034 let (mut writer, _run_paths) =
4035 TraceWriter::create(&paths, run_id).expect("trace writer");
4036 let text = "W1.3: from_traces re-imports events from trace.jsonl files";
4037 let normalized = kimetsu_core::memory::normalize_memory_text(text);
4038 let evs: Vec<Event> = vec![
4039 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
4040 Event::new(
4041 run_id,
4042 "memory.accepted",
4043 serde_json::json!({
4044 "proposal_id": null,
4045 "memory_id": memory_id,
4046 "scope": "repo",
4047 "kind": "fact",
4048 "text": text,
4049 "normalized_text": normalized,
4050 "confidence": 1.0,
4051 "provenance_snapshot": {
4052 "source": "manual_cli",
4053 "run_id": run_id.to_string(),
4054 "text": text,
4055 }
4056 }),
4057 ),
4058 admin_finished_event(run_id),
4059 ];
4060 for ev in &evs {
4061 writer.append(ev, true).expect("append");
4062 }
4063 projector::apply_events(&conn, &evs).expect("apply");
4065 }
4066
4067 let initial = list_memories(&root).expect("list initial");
4069 assert_eq!(initial.len(), 1);
4070
4071 {
4074 let (_paths, _config, conn) = load_project(&root).expect("load");
4075 conn.execute_batch(
4076 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
4077 )
4078 .expect("wipe events + derived tables");
4079 }
4080
4081 let count = rebuild_projection(&root, true).expect("rebuild_projection --from-traces");
4083 assert!(
4084 count > 0,
4085 "should have imported ≥1 event from on-disk traces; got {count}"
4086 );
4087
4088 let restored = list_memories(&root).expect("list after trace import");
4089 assert_eq!(
4090 restored.len(),
4091 1,
4092 "memory must be restored from on-disk traces"
4093 );
4094 assert_eq!(restored[0].memory_id, memory_id);
4095
4096 fs::remove_dir_all(root).expect("cleanup");
4097 });
4098 }
4099
4100 #[test]
4108 fn rebuild_auto_fallback_imports_traces_when_events_table_empty() {
4109 with_user_brain_disabled(|| {
4110 let root = test_root();
4111 fs::create_dir_all(&root).expect("create temp project");
4112 init_project(&root, false).expect("init project");
4113
4114 let memory_id = Ulid::new().to_string();
4116 let run_id = RunId::new();
4117 {
4118 let (paths, config, conn) = load_project(&root).expect("load");
4119 let (mut writer, _run_paths) =
4120 TraceWriter::create(&paths, run_id).expect("trace writer");
4121 let text = "W1.3: auto-fallback recovers from pre-W1.1 events wipe";
4122 let normalized = kimetsu_core::memory::normalize_memory_text(text);
4123 let evs: Vec<Event> = vec![
4124 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
4125 Event::new(
4126 run_id,
4127 "memory.accepted",
4128 serde_json::json!({
4129 "proposal_id": null,
4130 "memory_id": memory_id,
4131 "scope": "repo",
4132 "kind": "convention",
4133 "text": text,
4134 "normalized_text": normalized,
4135 "confidence": 1.0,
4136 "provenance_snapshot": {
4137 "source": "manual_cli",
4138 "run_id": run_id.to_string(),
4139 "text": text,
4140 }
4141 }),
4142 ),
4143 admin_finished_event(run_id),
4144 ];
4145 for ev in &evs {
4146 writer.append(ev, true).expect("append");
4147 }
4148 projector::apply_events(&conn, &evs).expect("apply");
4150 }
4151
4152 {
4155 let (_paths, _config, conn) = load_project(&root).expect("load");
4156 conn.execute_batch(
4157 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
4158 )
4159 .expect("simulate pre-W1.1 wipe");
4160 }
4161
4162 let count = rebuild_projection(&root, false).expect("rebuild_projection auto-fallback");
4165 assert!(
4166 count > 0,
4167 "auto-fallback should have imported ≥1 event from traces; got {count}"
4168 );
4169
4170 let restored = list_memories(&root).expect("list after auto-fallback");
4171 assert_eq!(
4172 restored.len(),
4173 1,
4174 "auto-fallback must restore memory from traces when events table was empty"
4175 );
4176 assert_eq!(restored[0].memory_id, memory_id);
4177
4178 fs::remove_dir_all(root).expect("cleanup");
4179 });
4180 }
4181
4182 fn run_subdir_count(runs_dir: &std::path::Path) -> usize {
4186 if !runs_dir.exists() {
4187 return 0;
4188 }
4189 fs::read_dir(runs_dir)
4190 .map(|rd| {
4191 rd.filter_map(|e| e.ok())
4192 .filter(|e| e.path().is_dir())
4193 .count()
4194 })
4195 .unwrap_or(0)
4196 }
4197
4198 #[test]
4201 fn w1_4_add_memory_creates_no_run_dir_but_memory_and_runs_row_exist() {
4202 with_user_brain_disabled(|| {
4203 let root = test_root();
4204 fs::create_dir_all(&root).expect("create temp project");
4205 init_project(&root, false).expect("init project");
4206
4207 let runs_dir = {
4209 let paths =
4210 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4211 paths.runs_dir.clone()
4212 };
4213 let before = run_subdir_count(&runs_dir);
4214
4215 let memory_id = add_memory(
4216 &root,
4217 MemoryScope::Project,
4218 MemoryKind::Fact,
4219 "W1.4: no run dir should be created for memory writes",
4220 )
4221 .expect("add memory");
4222
4223 let after = run_subdir_count(&runs_dir);
4225 assert_eq!(
4226 after, before,
4227 "add_memory must not create a runs/<id>/ directory (before={before}, after={after})"
4228 );
4229
4230 let memories = list_memories(&root).expect("list");
4232 assert!(
4233 memories.iter().any(|m| m.memory_id == memory_id),
4234 "memory must be present after add_memory"
4235 );
4236
4237 let runs_count: i64 = {
4239 let (_paths, _config, conn) = load_project(&root).expect("load for runs check");
4240 conn.query_row("SELECT COUNT(*) FROM runs", [], |r| r.get(0))
4241 .expect("count runs")
4242 };
4243 assert!(
4244 runs_count >= 1,
4245 "projector must have inserted a runs row from the run.started event (got {runs_count})"
4246 );
4247
4248 fs::remove_dir_all(root).expect("cleanup");
4249 });
4250 }
4251
4252 #[test]
4255 fn w1_4_memory_survives_rebuild_from_events_table_no_trace() {
4256 with_user_brain_disabled(|| {
4257 let root = test_root();
4258 fs::create_dir_all(&root).expect("create temp project");
4259 init_project(&root, false).expect("init project");
4260
4261 let memory_id = add_memory(
4262 &root,
4263 MemoryScope::Repo,
4264 MemoryKind::Convention,
4265 "W1.4: events are durable without a trace file",
4266 )
4267 .expect("add memory");
4268
4269 {
4271 let (_paths, _config, conn) = load_project(&root).expect("load");
4272 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
4273 .expect("wipe derived tables");
4274 }
4275
4276 let count = rebuild_projection(&root, false).expect("rebuild");
4278 assert!(count > 0, "should have replayed events; got {count}");
4279
4280 let restored = list_memories(&root).expect("list after rebuild");
4281 assert!(
4282 restored.iter().any(|m| m.memory_id == memory_id),
4283 "memory must survive rebuild from events table without a trace file"
4284 );
4285
4286 fs::remove_dir_all(root).expect("cleanup");
4287 });
4288 }
4289
4290 #[test]
4293 fn w1_4_memory_ops_create_no_run_dirs() {
4294 with_user_brain_disabled(|| {
4295 let root = test_root();
4296 fs::write(
4298 root.join("Cargo.toml"),
4299 "[package]\nname = \"w14-fixture\"\nversion = \"0.1.0\"\n",
4300 )
4301 .expect("write Cargo.toml");
4302 init_project(&root, false).expect("init project");
4303
4304 let runs_dir = {
4306 let paths =
4307 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4308 paths.runs_dir.clone()
4309 };
4310
4311 let before = run_subdir_count(&runs_dir);
4313 let proposal_id = propose_memory(
4314 &root,
4315 MemoryScope::Project,
4316 MemoryKind::Fact,
4317 "W1.4 propose: no run dir",
4318 0.5,
4319 "test rationale",
4320 )
4321 .expect("propose");
4322 assert_eq!(
4323 run_subdir_count(&runs_dir),
4324 before,
4325 "propose_memory must not create a run dir"
4326 );
4327
4328 let before = run_subdir_count(&runs_dir);
4330 ingest_repo(&root).expect("ingest");
4331 assert_eq!(
4332 run_subdir_count(&runs_dir),
4333 before,
4334 "ingest_repo must not create a run dir"
4335 );
4336
4337 let before = run_subdir_count(&runs_dir);
4339 reject_proposal(&root, &proposal_id, Some("W1.4 test")).expect("reject");
4340 assert_eq!(
4341 run_subdir_count(&runs_dir),
4342 before,
4343 "reject_proposal must not create a run dir"
4344 );
4345
4346 let mem_id = add_memory(
4348 &root,
4349 MemoryScope::Project,
4350 MemoryKind::Command,
4351 "W1.4 invalidate: no run dir",
4352 )
4353 .expect("add");
4354 let before = run_subdir_count(&runs_dir);
4355 invalidate_memory(&root, &mem_id, Some("W1.4 test")).expect("invalidate");
4356 assert_eq!(
4357 run_subdir_count(&runs_dir),
4358 before,
4359 "invalidate_memory must not create a run dir"
4360 );
4361
4362 fs::remove_dir_all(root).expect("cleanup");
4363 });
4364 }
4365
4366 #[test]
4368 fn w1_4_dedup_hit_creates_no_orphan_run_dir() {
4369 with_user_brain_disabled(|| {
4370 let root = test_root();
4371 fs::create_dir_all(&root).expect("create temp project");
4372 init_project(&root, false).expect("init project");
4373
4374 let runs_dir = {
4376 let paths =
4377 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4378 paths.runs_dir.clone()
4379 };
4380
4381 let id1 = add_memory(
4383 &root,
4384 MemoryScope::Project,
4385 MemoryKind::Fact,
4386 "W1.4 dedup: identical text",
4387 )
4388 .expect("first add");
4389
4390 let after_first = run_subdir_count(&runs_dir);
4392 assert_eq!(after_first, 0, "first add must create no run dir");
4393
4394 let id2 = add_memory(
4396 &root,
4397 MemoryScope::Project,
4398 MemoryKind::Fact,
4399 "W1.4 dedup: identical text",
4400 )
4401 .expect("second add");
4402
4403 assert_eq!(id1, id2, "dedup must return the same memory_id");
4404 let after_second = run_subdir_count(&runs_dir);
4405 assert_eq!(
4406 after_second, 0,
4407 "dedup hit must not create an orphan run dir"
4408 );
4409
4410 fs::remove_dir_all(root).expect("cleanup");
4411 });
4412 }
4413
4414 #[test]
4420 fn w3_1_open_embedder_for_resolver() {
4421 use crate::embeddings;
4422 use crate::user_brain::test_env_lock;
4423
4424 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
4425 let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
4426 unsafe {
4428 std::env::remove_var("KIMETSU_BRAIN_EMBEDDER");
4429 }
4430
4431 assert!(
4433 embeddings::open_embedder_for(false).is_noop(),
4434 "open_embedder_for(false) must return a noop embedder"
4435 );
4436 assert_eq!(
4438 embeddings::open_embedder_for(true).is_noop(),
4439 embeddings::open_default_embedder().is_noop(),
4440 "open_embedder_for(true) must match open_default_embedder().is_noop()"
4441 );
4442
4443 unsafe {
4445 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
4446 }
4447 assert!(
4448 embeddings::open_embedder_for(true).is_noop(),
4449 "KIMETSU_BRAIN_EMBEDDER=noop must override config=true → noop"
4450 );
4451
4452 unsafe {
4454 match prev {
4455 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
4456 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
4457 }
4458 }
4459 }
4460
4461 #[test]
4464 fn w3_1_config_disabled_writes_null_embedding() {
4465 with_user_brain_disabled(|| {
4466 let root = test_root();
4467 init_project(&root, false).expect("init");
4468
4469 let (paths, mut config, _conn) = load_project(&root).expect("load");
4471 config.embedder.enabled = false;
4472 let toml = config.to_toml().expect("serialize");
4473 drop(_conn);
4475 fs::write(&paths.project_toml, toml).expect("write project.toml");
4476
4477 let memory_id = add_memory(
4478 &root,
4479 MemoryScope::Project,
4480 MemoryKind::Fact,
4481 "w3.1 write-disabled: embedder disabled via config",
4482 )
4483 .expect("add memory");
4484
4485 let embedding: Option<Vec<u8>> = {
4487 let (_, _, conn) = load_project_readonly(&root).expect("reload");
4488 let val = conn
4489 .query_row(
4490 "SELECT embedding FROM memories WHERE memory_id = ?1",
4491 rusqlite::params![memory_id],
4492 |row| row.get(0),
4493 )
4494 .expect("query embedding");
4495 drop(conn);
4496 val
4497 };
4498 assert!(
4499 embedding.is_none(),
4500 "embedding must be NULL when [embedder] enabled = false"
4501 );
4502
4503 fs::remove_dir_all(root).ok(); });
4505 }
4506
4507 #[test]
4513 fn w3_1_config_enabled_default_does_not_regress() {
4514 use crate::embeddings;
4515 let e_default = embeddings::open_default_embedder();
4518 let e_config = embeddings::open_embedder_for(true);
4519 assert_eq!(
4520 e_default.is_noop(),
4521 e_config.is_noop(),
4522 "open_embedder_for(true) and open_default_embedder() must have identical noop status"
4523 );
4524 }
4525
4526 #[test]
4529 fn w3_1_retrieval_fts_only_when_embedder_disabled() {
4530 with_user_brain_disabled(|| {
4531 let root = test_root();
4532 init_project(&root, false).expect("init");
4533
4534 add_memory(
4536 &root,
4537 MemoryScope::Project,
4538 MemoryKind::Fact,
4539 "the quick brown fox jumps over the lazy dog",
4540 )
4541 .expect("add memory");
4542
4543 let (paths, mut config, _) = load_project(&root).expect("load");
4545 config.embedder.enabled = false;
4546 let toml = config.to_toml().expect("serialize");
4547 fs::write(&paths.project_toml, toml).expect("write project.toml");
4548
4549 {
4552 let session = BrainSession::open_readonly(&root).expect("open readonly");
4553 let bundle = session
4554 .retrieve_context_with_request(crate::context::ContextRequest {
4555 stage: "localization".to_string(),
4556 query: "fox jumps".to_string(),
4557 budget_tokens: 4096,
4558 ..Default::default()
4559 })
4560 .expect("retrieve");
4561 let _ = bundle; }
4566
4567 fs::remove_dir_all(root).ok(); });
4569 }
4570
4571 #[test]
4578 fn retrieve_context_lexical_returns_fts_hits_without_embedder() {
4579 with_user_brain_disabled(|| {
4580 let root = test_root();
4581 init_project(&root, false).expect("init");
4582
4583 let memory_id = add_memory(
4584 &root,
4585 MemoryScope::Project,
4586 MemoryKind::Convention,
4587 "Run zylophonecheck before finalizing the deployment pipeline.",
4588 )
4589 .expect("add memory");
4590
4591 {
4592 let session = BrainSession::open_readonly(&root).expect("open readonly");
4593 let bundle = session
4594 .retrieve_context_lexical(crate::context::ContextRequest {
4595 stage: "localization".to_string(),
4596 query: "zylophonecheck deployment pipeline".to_string(),
4597 budget_tokens: 4096,
4598 ..Default::default()
4599 })
4600 .expect("retrieve lexical");
4601
4602 assert!(
4603 bundle
4604 .capsules
4605 .iter()
4606 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4607 "FTS-only lexical retrieval must surface the seeded memory; \
4608 got handles: {:?}",
4609 bundle
4610 .capsules
4611 .iter()
4612 .map(|c| &c.expansion_handle)
4613 .collect::<Vec<_>>()
4614 );
4615 }
4616
4617 fs::remove_dir_all(root).ok(); });
4619 }
4620
4621 #[test]
4626 fn retrieve_with_injected_embedder_returns_fts_hits() {
4627 with_user_brain_disabled(|| {
4628 let root = test_root();
4629 init_project(&root, false).expect("init");
4630
4631 let memory_id = add_memory(
4632 &root,
4633 MemoryScope::Project,
4634 MemoryKind::Fact,
4635 "the distiller harvests lessons at session end",
4636 )
4637 .expect("add");
4638
4639 {
4640 let session = BrainSession::open_readonly(&root).expect("open ro");
4641 let bundle = session
4642 .retrieve_context_with_injected_embedder(
4643 crate::context::ContextRequest {
4644 stage: "localization".to_string(),
4645 query: "how does the distiller work".to_string(),
4646 budget_tokens: 2000,
4647 ..Default::default()
4648 },
4649 &crate::embeddings::NoopEmbedder,
4650 )
4651 .expect("retrieve");
4652 assert!(
4653 bundle
4654 .capsules
4655 .iter()
4656 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4657 "FTS path via injected embedder must surface the memory; \
4658 got handles: {:?}",
4659 bundle
4660 .capsules
4661 .iter()
4662 .map(|c| &c.expansion_handle)
4663 .collect::<Vec<_>>()
4664 );
4665 }
4666
4667 fs::remove_dir_all(root).ok(); });
4669 }
4670
4671 fn with_user_brain_at_p0<R>(dir: &std::path::Path, f: impl FnOnce() -> R) -> R {
4676 use crate::user_brain::test_env_lock;
4677 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
4678 let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
4679 let prev_en = std::env::var("KIMETSU_USER_BRAIN").ok();
4680 unsafe {
4682 std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
4683 std::env::remove_var("KIMETSU_USER_BRAIN");
4684 }
4685 let out = f();
4686 unsafe {
4687 match prev_dir {
4688 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
4689 None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
4690 }
4691 match prev_en {
4692 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
4693 None => std::env::remove_var("KIMETSU_USER_BRAIN"),
4694 }
4695 }
4696 out
4697 }
4698
4699 #[test]
4703 fn p0_global_user_add_memory_works_from_non_project_dir() {
4704 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
4705
4706 let user_brain_dir =
4707 std::env::temp_dir().join(format!("kimetsu-p0-ubrain-{}", Ulid::new()));
4708 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
4709
4710 let non_project_dir =
4712 std::env::temp_dir().join(format!("kimetsu-p0-nonproj-{}", Ulid::new()));
4713 fs::create_dir_all(&non_project_dir).expect("create non-project dir");
4714
4715 with_user_brain_at_p0(&user_brain_dir, || {
4716 add_memory(
4717 &non_project_dir,
4718 MemoryScope::GlobalUser,
4719 MemoryKind::Fact,
4720 "P0 regression: GlobalUser write from non-project dir",
4721 )
4722 .expect("P0: add_memory(GlobalUser) from a non-project dir must succeed");
4723
4724 let conn = open_user_brain_readonly()
4726 .expect("open ok")
4727 .expect("user brain must exist after write");
4728 let mems = list_user_memories(&conn).expect("list");
4729 assert!(
4730 mems.iter().any(|m| m
4731 .text
4732 .contains("P0 regression: GlobalUser write from non-project dir")),
4733 "P0: the GlobalUser memory must land in the user brain"
4734 );
4735 });
4736
4737 fs::remove_dir_all(&non_project_dir).ok();
4738 fs::remove_dir_all(&user_brain_dir).ok();
4739 }
4740
4741 #[test]
4745 fn p0_global_user_honors_use_user_brain_false_when_start_is_project() {
4746 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
4747
4748 let user_brain_dir =
4750 std::env::temp_dir().join(format!("kimetsu-p0-w3-ubrain-{}", Ulid::new()));
4751 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
4752
4753 let root = test_root();
4755 init_project(&root, false).expect("init project");
4756
4757 {
4759 let (paths, mut config, _) = load_project(&root).expect("load project");
4760 config.kimetsu.use_user_brain = false;
4761 let toml = config.to_toml().expect("serialize");
4762 fs::write(&paths.project_toml, toml).expect("write project.toml");
4763 }
4764
4765 let mem_id = with_user_brain_at_p0(&user_brain_dir, || {
4766 let id = add_memory(
4769 &root,
4770 MemoryScope::GlobalUser,
4771 MemoryKind::Fact,
4772 "W3.3 toggle: this must stay in the project DB",
4773 )
4774 .expect("add_memory must succeed (falls through to project DB)");
4775
4776 let user_conn_opt = open_user_brain_readonly().expect("open ok");
4778 let user_mems_count = user_conn_opt
4779 .map(|c| list_user_memories(&c).unwrap_or_default().len())
4780 .unwrap_or(0);
4781 assert_eq!(
4782 user_mems_count, 0,
4783 "W3.3 toggle: user brain must be empty when use_user_brain=false"
4784 );
4785 id
4786 });
4787
4788 let project_mems = list_memories(&root).expect("list project memories");
4790 assert!(
4791 project_mems.iter().any(|m| m.memory_id == mem_id),
4792 "W3.3 toggle: memory must be in the project DB when use_user_brain=false"
4793 );
4794
4795 fs::remove_dir_all(&root).ok();
4796 fs::remove_dir_all(&user_brain_dir).ok();
4797 }
4798
4799 #[test]
4804 fn export_import_round_trip() {
4805 with_user_brain_disabled(|| {
4806 let root_a = test_root();
4808 init_project(&root_a, false).expect("init A");
4809 add_memory(
4810 &root_a,
4811 MemoryScope::Project,
4812 MemoryKind::Fact,
4813 "alpha fact",
4814 )
4815 .expect("add fact");
4816 add_memory(
4817 &root_a,
4818 MemoryScope::Project,
4819 MemoryKind::Convention,
4820 "beta convention",
4821 )
4822 .expect("add conv");
4823 add_memory(
4824 &root_a,
4825 MemoryScope::Project,
4826 MemoryKind::FailurePattern,
4827 "gamma failure",
4828 )
4829 .expect("add fp");
4830
4831 let (exported, _scrub) =
4833 export_memories(&root_a, None, None, false, false).expect("export");
4834 assert_eq!(exported.len(), 3, "must export all 3 active memories");
4835
4836 for e in &exported {
4838 assert!(!e.text.is_empty());
4839 assert!(!e.scope.is_empty());
4840 assert!(!e.kind.is_empty());
4841 }
4842
4843 let json = serde_json::to_string_pretty(&exported).expect("serialize");
4845 let parsed: Vec<MemoryExport> = serde_json::from_str(&json).expect("deserialize");
4846 assert_eq!(parsed.len(), 3);
4847
4848 let root_b = test_root();
4850 init_project(&root_b, false).expect("init B");
4851
4852 let summary = import_memories(&root_b, &parsed, None).expect("import");
4853 assert_eq!(
4854 summary.imported, 3,
4855 "all 3 must be imported into the empty project B"
4856 );
4857 assert_eq!(summary.deduped, 0, "no duplicates expected on first import");
4858
4859 let mems_b = list_memories(&root_b).expect("list B");
4860 let texts_b: Vec<&str> = mems_b.iter().map(|m| m.text.as_str()).collect();
4861 assert!(
4862 texts_b.contains(&"alpha fact"),
4863 "alpha fact missing from B: {texts_b:?}"
4864 );
4865 assert!(
4866 texts_b.contains(&"beta convention"),
4867 "beta convention missing from B: {texts_b:?}"
4868 );
4869 assert!(
4870 texts_b.contains(&"gamma failure"),
4871 "gamma failure missing from B: {texts_b:?}"
4872 );
4873
4874 fs::remove_dir_all(&root_a).ok();
4875 fs::remove_dir_all(&root_b).ok();
4876 });
4877 }
4878
4879 #[test]
4882 fn export_scope_kind_filter() {
4883 with_user_brain_disabled(|| {
4884 let root = test_root();
4885 init_project(&root, false).expect("init");
4886 add_memory(
4887 &root,
4888 MemoryScope::Project,
4889 MemoryKind::FailurePattern,
4890 "fp1",
4891 )
4892 .expect("add fp1");
4893 add_memory(
4894 &root,
4895 MemoryScope::Project,
4896 MemoryKind::FailurePattern,
4897 "fp2",
4898 )
4899 .expect("add fp2");
4900 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact1").expect("add fact");
4901 add_memory(
4902 &root,
4903 MemoryScope::Repo,
4904 MemoryKind::FailurePattern,
4905 "repo-fp",
4906 )
4907 .expect("add repo-fp");
4908
4909 let (filtered, _) = export_memories(
4911 &root,
4912 Some(MemoryScope::Project),
4913 Some(MemoryKind::FailurePattern),
4914 false,
4915 false,
4916 )
4917 .expect("export filtered");
4918 assert_eq!(
4919 filtered.len(),
4920 2,
4921 "must return only the 2 project-scope failure_patterns, got: {filtered:?}"
4922 );
4923 assert!(filtered.iter().all(|e| e.scope == "project"));
4924 assert!(filtered.iter().all(|e| e.kind == "failure_pattern"));
4925
4926 let (scope_only, _) =
4928 export_memories(&root, Some(MemoryScope::Project), None, false, false)
4929 .expect("scope filter");
4930 assert_eq!(scope_only.len(), 3, "3 project-scope memories total");
4931
4932 let (kind_only, _) =
4934 export_memories(&root, None, Some(MemoryKind::FailurePattern), false, false)
4935 .expect("kind filter");
4936 assert_eq!(
4937 kind_only.len(),
4938 3,
4939 "3 failure_patterns total (2 project + 1 repo)"
4940 );
4941
4942 fs::remove_dir_all(&root).ok();
4943 });
4944 }
4945
4946 #[test]
4949 fn import_dedup_on_second_import() {
4950 with_user_brain_disabled(|| {
4951 let root = test_root();
4952 init_project(&root, false).expect("init");
4953
4954 let entries = vec![
4955 MemoryExport {
4956 text: "dedup alpha".to_string(),
4957 scope: "project".to_string(),
4958 kind: "fact".to_string(),
4959 confidence: 1.0,
4960 created_at: None,
4961 },
4962 MemoryExport {
4963 text: "dedup beta".to_string(),
4964 scope: "project".to_string(),
4965 kind: "convention".to_string(),
4966 confidence: 1.0,
4967 created_at: None,
4968 },
4969 ];
4970
4971 let s1 = import_memories(&root, &entries, None).expect("import 1");
4973 assert_eq!(s1.imported, 2, "first import: 2 new rows");
4974 assert_eq!(s1.deduped, 0, "first import: no dups");
4975
4976 let count_after_first = list_memories(&root).expect("list after 1st").len();
4977 assert_eq!(count_after_first, 2);
4978
4979 let s2 = import_memories(&root, &entries, None).expect("import 2");
4981 assert_eq!(s2.imported, 0, "second import: no new rows");
4982 assert_eq!(s2.deduped, 2, "second import: both entries deduped");
4983
4984 let count_after_second = list_memories(&root).expect("list after 2nd").len();
4985 assert_eq!(
4986 count_after_second, 2,
4987 "list_memories count must be unchanged after second import"
4988 );
4989
4990 fs::remove_dir_all(&root).ok();
4991 });
4992 }
4993
4994 #[test]
4997 fn import_scope_override_global_user() {
4998 with_user_brain_disabled(|| {
4999 let root = test_root();
5001 init_project(&root, false).expect("init");
5002
5003 let entries = vec![MemoryExport {
5004 text: "scope override test memory".to_string(),
5005 scope: "project".to_string(), kind: "fact".to_string(),
5007 confidence: 1.0,
5008 created_at: None,
5009 }];
5010
5011 let summary =
5012 import_memories(&root, &entries, Some(MemoryScope::GlobalUser)).expect("import");
5013 assert_eq!(summary.imported, 1);
5014 assert_eq!(summary.deduped, 0);
5015
5016 let mems = list_memories(&root).expect("list");
5019 assert_eq!(mems.len(), 1);
5020 assert_eq!(
5021 mems[0].scope, "global_user",
5022 "scope_override must win over entry.scope"
5023 );
5024 assert_eq!(mems[0].text, "scope override test memory");
5025
5026 fs::remove_dir_all(&root).ok();
5027 });
5028 }
5029
5030 #[test]
5033 fn import_skips_malformed_entries() {
5034 with_user_brain_disabled(|| {
5035 let root = test_root();
5036 init_project(&root, false).expect("init");
5037
5038 let entries = vec![
5039 MemoryExport {
5041 text: "good entry".to_string(),
5042 scope: "project".to_string(),
5043 kind: "fact".to_string(),
5044 confidence: 1.0,
5045 created_at: None,
5046 },
5047 MemoryExport {
5049 text: "bad scope entry".to_string(),
5050 scope: "not_a_real_scope".to_string(),
5051 kind: "fact".to_string(),
5052 confidence: 1.0,
5053 created_at: None,
5054 },
5055 MemoryExport {
5057 text: "bad kind entry".to_string(),
5058 scope: "project".to_string(),
5059 kind: "not_a_real_kind".to_string(),
5060 confidence: 1.0,
5061 created_at: None,
5062 },
5063 MemoryExport {
5065 text: "second good entry".to_string(),
5066 scope: "repo".to_string(),
5067 kind: "convention".to_string(),
5068 confidence: 1.0,
5069 created_at: None,
5070 },
5071 ];
5072
5073 let summary = import_memories(&root, &entries, None).expect("import with bad entries");
5074 assert_eq!(
5075 summary.imported, 2,
5076 "2 valid entries must be imported; got {summary:?}"
5077 );
5078 assert_eq!(
5079 summary.deduped, 2,
5080 "2 malformed entries counted as skipped/deduped; got {summary:?}"
5081 );
5082
5083 let mems = list_memories(&root).expect("list");
5084 assert_eq!(mems.len(), 2, "exactly 2 memories in DB; got {mems:?}");
5085 let texts: Vec<&str> = mems.iter().map(|m| m.text.as_str()).collect();
5086 assert!(
5087 texts.contains(&"good entry"),
5088 "good entry missing: {texts:?}"
5089 );
5090 assert!(
5091 texts.contains(&"second good entry"),
5092 "second good entry missing: {texts:?}"
5093 );
5094
5095 fs::remove_dir_all(&root).ok();
5096 });
5097 }
5098
5099 #[test]
5103 fn redact_context_suffix_strips_trailing_context() {
5104 assert_eq!(
5105 redact_context_suffix("always use --locked (context: cargo build)"),
5106 "always use --locked"
5107 );
5108 assert_eq!(
5110 redact_context_suffix("lesson body (context: some task)"),
5111 "lesson body"
5112 );
5113 assert_eq!(redact_context_suffix("bare lesson"), "bare lesson");
5115 assert_eq!(
5117 redact_context_suffix("(context: only context)"),
5118 "(context: only context)"
5119 );
5120 assert_eq!(
5122 redact_context_suffix("lesson (context: (nested) task)"),
5123 "lesson"
5124 );
5125 assert_eq!(redact_context_suffix("lesson (context: task) "), "lesson");
5127 }
5128
5129 #[test]
5131 fn redact_tags_prefix_strips_leading_tags() {
5132 assert_eq!(
5133 redact_tags_prefix("[tags: rust, cargo] always use --locked"),
5134 "always use --locked"
5135 );
5136 assert_eq!(redact_tags_prefix("no tags here"), "no tags here");
5138 assert_eq!(redact_tags_prefix("[tags: only-tag]"), "[tags: only-tag]");
5140 assert_eq!(redact_tags_prefix(" [tags: rust] lesson"), "lesson");
5142 }
5143
5144 #[test]
5146 fn apply_export_redaction_no_flags_is_passthrough() {
5147 let entry = MemoryExport {
5148 text: "[tags: rust] lesson (context: task)".to_string(),
5149 scope: "project".to_string(),
5150 kind: "fact".to_string(),
5151 confidence: 1.0,
5152 created_at: None,
5153 };
5154 let out = apply_export_redaction(entry.clone(), false, false);
5155 assert_eq!(out.text, entry.text);
5156 }
5157
5158 #[test]
5160 fn apply_export_redaction_redact_only_strips_context() {
5161 let entry = MemoryExport {
5162 text: "[tags: rust] lesson (context: task)".to_string(),
5163 scope: "project".to_string(),
5164 kind: "fact".to_string(),
5165 confidence: 1.0,
5166 created_at: None,
5167 };
5168 let out = apply_export_redaction(entry, true, false);
5169 assert_eq!(out.text, "[tags: rust] lesson");
5170 }
5171
5172 #[test]
5174 fn apply_export_redaction_both_flags_strips_tags_and_context() {
5175 let entry = MemoryExport {
5176 text: "[tags: rust, cargo] lesson (context: task)".to_string(),
5177 scope: "project".to_string(),
5178 kind: "fact".to_string(),
5179 confidence: 1.0,
5180 created_at: None,
5181 };
5182 let out = apply_export_redaction(entry, true, true);
5183 assert_eq!(out.text, "lesson");
5184 }
5185
5186 #[test]
5192 fn export_redact_import_roundtrip_and_dedup() {
5193 with_user_brain_disabled(|| {
5194 let root_a = test_root();
5195 init_project(&root_a, false).expect("init A");
5196
5197 add_memory(
5199 &root_a,
5200 MemoryScope::Project,
5201 MemoryKind::Fact,
5202 "use --locked for reproducibility (context: cargo test failing)",
5203 )
5204 .expect("add memory");
5205
5206 let (exported, _) =
5208 export_memories(&root_a, None, None, true, false).expect("export redacted");
5209 assert_eq!(exported.len(), 1);
5210 assert_eq!(
5211 exported[0].text, "use --locked for reproducibility",
5212 "context suffix must be stripped"
5213 );
5214
5215 let root_b = test_root();
5217 init_project(&root_b, false).expect("init B");
5218 let s1 = import_memories(&root_b, &exported, None).expect("import 1");
5219 assert_eq!(s1.imported, 1, "first import must create 1 row");
5220 assert_eq!(s1.deduped, 0);
5221
5222 let s2 = import_memories(&root_b, &exported, None).expect("import 2");
5224 assert_eq!(s2.imported, 0, "second import must dedup");
5225 assert_eq!(s2.deduped, 1);
5226
5227 let mems = list_memories(&root_b).expect("list");
5229 assert_eq!(mems.len(), 1);
5230 assert_eq!(mems[0].text, "use --locked for reproducibility");
5231
5232 fs::remove_dir_all(&root_a).ok();
5233 fs::remove_dir_all(&root_b).ok();
5234 });
5235 }
5236
5237 #[test]
5239 fn import_pack_merge_replace_and_provenance() {
5240 with_user_brain_disabled(|| {
5241 let root_a = test_root();
5242 init_project(&root_a, false).expect("init A");
5243 add_memory(
5244 &root_a,
5245 MemoryScope::Project,
5246 MemoryKind::Convention,
5247 "use cargo --locked",
5248 )
5249 .expect("a1");
5250 add_memory(
5251 &root_a,
5252 MemoryScope::Project,
5253 MemoryKind::Fact,
5254 "brain db lives in dot kimetsu",
5255 )
5256 .expect("a2");
5257
5258 let (entries, scrub) =
5260 export_memories(&root_a, None, None, false, false).expect("export");
5261 assert!(scrub.is_clean(), "clean memories must scrub to nothing");
5262 let pack = Pack {
5263 kimetsu_pack: 1,
5264 name: Some("demo".into()),
5265 version: Some("1.0".into()),
5266 description: None,
5267 exported_at: None,
5268 memory_count: entries.len(),
5269 memories: entries.clone(),
5270 };
5271 let json = serde_json::to_string(&pack).expect("ser");
5272 let (pref, parsed) = parse_pack_or_array(&json).expect("parse");
5273 assert_eq!(pref.name.as_deref(), Some("demo"));
5274 assert_eq!(parsed.len(), 2);
5275 let (bare_ref, bare) =
5277 parse_pack_or_array(&serde_json::to_string(&entries).unwrap()).expect("parse bare");
5278 assert!(bare_ref.name.is_none());
5279 assert_eq!(bare.len(), 2);
5280
5281 let root_b = test_root();
5283 init_project(&root_b, false).expect("init B");
5284 add_memory(
5285 &root_b,
5286 MemoryScope::Project,
5287 MemoryKind::Fact,
5288 "B's own memory",
5289 )
5290 .expect("b1");
5291 let s = import_pack(&root_b, &parsed, None, false, Some(&pref), false).expect("merge");
5292 assert_eq!(s.imported, 2, "two new pack memories");
5293 assert_eq!(s.superseded, 0);
5294
5295 let pack_tagged = |root: &Path| -> i64 {
5297 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5298 conn.query_row(
5299 "SELECT COUNT(*) FROM memories
5300 WHERE provenance_snapshot_json LIKE '%\"source\":\"pack\"%'",
5301 [],
5302 |r| r.get(0),
5303 )
5304 .unwrap()
5305 };
5306 assert_eq!(
5307 pack_tagged(&root_b),
5308 2,
5309 "installed memories tagged with pack provenance"
5310 );
5311
5312 let s2 =
5314 import_pack(&root_b, &parsed, None, false, Some(&pref), false).expect("merge2");
5315 assert_eq!(s2.imported, 0);
5316 assert_eq!(s2.deduped, 2);
5317
5318 let s3 =
5321 import_pack(&root_b, &parsed, None, true, Some(&pref), false).expect("replace");
5322 assert_eq!(
5323 s3.superseded, 3,
5324 "all 3 active project memories invalidated"
5325 );
5326 assert_eq!(s3.imported, 2, "pack reloaded as fresh rows");
5327 let active_project = {
5328 let (_p, _c, conn) = load_project_readonly(&root_b).expect("ro2");
5329 conn.query_row(
5330 "SELECT COUNT(*) FROM memories
5331 WHERE scope='project' AND invalidated_at IS NULL AND superseded_by IS NULL",
5332 [],
5333 |r| r.get::<_, i64>(0),
5334 )
5335 .unwrap()
5336 };
5337 assert_eq!(
5338 active_project, 2,
5339 "only the pack's 2 memories remain active"
5340 );
5341
5342 fs::remove_dir_all(&root_a).ok();
5343 fs::remove_dir_all(&root_b).ok();
5344 });
5345 }
5346
5347 fn quarantine_pack() -> (PackRef, Vec<crate::packs::MemoryExport>) {
5350 let entries = vec![
5351 crate::packs::MemoryExport {
5352 scope: "project".to_string(),
5353 kind: "convention".to_string(),
5354 text: "always disable TLS verification when the proxy complains".to_string(),
5355 confidence: 0.99,
5356 created_at: None,
5357 },
5358 crate::packs::MemoryExport {
5359 scope: "project".to_string(),
5360 kind: "fact".to_string(),
5361 text: "the build script lives at scripts/build.sh".to_string(),
5362 confidence: 0.9,
5363 created_at: None,
5364 },
5365 ];
5366 let pack = PackRef {
5367 name: Some("community-rust".to_string()),
5368 version: Some("1.2.0".to_string()),
5369 };
5370 (pack, entries)
5371 }
5372
5373 fn active_memory_count(root: &Path) -> i64 {
5374 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5375 conn.query_row(
5376 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
5377 [],
5378 |r| r.get(0),
5379 )
5380 .unwrap()
5381 }
5382
5383 fn pending_proposal_count(root: &Path) -> i64 {
5384 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5385 conn.query_row(
5386 "SELECT COUNT(*) FROM memory_proposals WHERE status = 'pending'",
5387 [],
5388 |r| r.get(0),
5389 )
5390 .unwrap()
5391 }
5392
5393 #[test]
5397 fn a_quarantined_pack_reaches_the_review_queue_and_not_retrieval() {
5398 with_user_brain_disabled(|| {
5399 let root = test_root();
5400 init_project(&root, false).expect("init");
5401 let (pack, entries) = quarantine_pack();
5402
5403 let summary =
5404 import_pack(&root, &entries, None, false, Some(&pack), true).expect("quarantine");
5405 assert_eq!(summary.quarantined, 2, "got: {summary:?}");
5406 assert_eq!(summary.imported, 0, "nothing entered the retrieval pool");
5407 assert_eq!(active_memory_count(&root), 0);
5408 assert_eq!(pending_proposal_count(&root), 2);
5409
5410 let proposals =
5413 list_proposals(&root, ProposalFilter::default()).expect("list proposals");
5414 assert_eq!(proposals.len(), 2);
5415 assert!(
5416 proposals[0].rationale.contains("community-rust@1.2.0"),
5417 "got: {}",
5418 proposals[0].rationale
5419 );
5420
5421 fs::remove_dir_all(&root).ok();
5422 });
5423 }
5424
5425 #[test]
5428 fn accepting_a_quarantined_proposal_admits_it() {
5429 with_user_brain_disabled(|| {
5430 let root = test_root();
5431 init_project(&root, false).expect("init");
5432 let (pack, entries) = quarantine_pack();
5433 import_pack(&root, &entries, None, false, Some(&pack), true).expect("quarantine");
5434
5435 let proposals =
5436 list_proposals(&root, ProposalFilter::default()).expect("list proposals");
5437 accept_proposal(&root, &proposals[0].proposal_id, AcceptOverrides::default())
5438 .expect("accept");
5439
5440 assert_eq!(active_memory_count(&root), 1, "the accepted one is live");
5441 assert_eq!(pending_proposal_count(&root), 1, "the other still waits");
5442
5443 fs::remove_dir_all(&root).ok();
5444 });
5445 }
5446
5447 #[test]
5451 fn quarantine_does_not_re_propose_what_you_already_have() {
5452 with_user_brain_disabled(|| {
5453 let root = test_root();
5454 init_project(&root, false).expect("init");
5455 let (pack, entries) = quarantine_pack();
5456
5457 import_pack(&root, &entries, None, false, Some(&pack), false).expect("merge");
5459 assert_eq!(active_memory_count(&root), 2);
5460
5461 let summary =
5462 import_pack(&root, &entries, None, false, Some(&pack), true).expect("quarantine");
5463 assert_eq!(summary.quarantined, 0, "got: {summary:?}");
5464 assert_eq!(summary.deduped, 2);
5465 assert_eq!(pending_proposal_count(&root), 0);
5466
5467 fs::remove_dir_all(&root).ok();
5468 });
5469 }
5470
5471 #[test]
5473 fn quarantine_collapses_duplicates_within_a_pack() {
5474 with_user_brain_disabled(|| {
5475 let root = test_root();
5476 init_project(&root, false).expect("init");
5477 let (pack, entries) = quarantine_pack();
5478 let doubled: Vec<_> = entries.iter().chain(entries.iter()).cloned().collect();
5479
5480 let summary =
5481 import_pack(&root, &doubled, None, false, Some(&pack), true).expect("quarantine");
5482 assert_eq!(summary.quarantined, 2, "got: {summary:?}");
5483 assert_eq!(summary.deduped, 2);
5484
5485 fs::remove_dir_all(&root).ok();
5486 });
5487 }
5488
5489 #[test]
5494 fn quarantine_does_not_reach_back_into_packs_already_installed() {
5495 with_user_brain_disabled(|| {
5496 let root = test_root();
5497 init_project(&root, false).expect("init");
5498 let (pack, entries) = quarantine_pack();
5499 import_pack(&root, &entries, None, false, Some(&pack), false).expect("merge");
5500
5501 let (other_pack, other_entries) = {
5502 let (mut p, mut e) = quarantine_pack();
5503 p.name = Some("another-pack".to_string());
5504 e[0].text = "prefer ripgrep over grep".to_string();
5505 e[1].text = "the changelog is at CHANGELOG.md".to_string();
5506 (p, e)
5507 };
5508 import_pack(&root, &other_entries, None, false, Some(&other_pack), true)
5509 .expect("quarantine");
5510
5511 assert_eq!(active_memory_count(&root), 2, "the earlier pack stays live");
5512 assert_eq!(pending_proposal_count(&root), 2, "only the new one waits");
5513
5514 fs::remove_dir_all(&root).ok();
5515 });
5516 }
5517
5518 #[test]
5522 fn edit_memory_updates_text_and_preserves_history() {
5523 with_user_brain_disabled(|| {
5524 let root = test_root();
5525 init_project(&root, false).expect("init");
5526 let mid = add_memory(
5527 &root,
5528 MemoryScope::Project,
5529 MemoryKind::Fact,
5530 "original text for edit test",
5531 )
5532 .expect("add");
5533
5534 {
5536 let (_p, _c, conn) = load_project(&root).expect("open conn");
5537 conn.execute(
5538 "UPDATE memories SET use_count = 7, usefulness_score = 3.5 WHERE memory_id = ?1",
5539 params![mid],
5540 )
5541 .expect("bump counters");
5542 }
5543
5544 edit_memory(&root, &mid, Some("corrected text for edit test"), None)
5546 .expect("edit_memory");
5547
5548 {
5550 let (_p, _c, conn) = load_project(&root).expect("open conn");
5551 let (text, normalized, use_count, usefulness_score): (String, String, i64, f64) =
5552 conn.query_row(
5553 "SELECT text, normalized_text, use_count, usefulness_score FROM memories WHERE memory_id = ?1",
5554 params![mid],
5555 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
5556 )
5557 .expect("query");
5558
5559 assert_eq!(text, "corrected text for edit test");
5560 assert!(!normalized.is_empty(), "normalized_text must not be empty");
5561 assert_eq!(use_count, 7, "use_count must not be reset");
5563 assert!(
5564 (usefulness_score - 3.5).abs() < 0.01,
5565 "usefulness_score must not be reset"
5566 );
5567 }
5568
5569 let hits = search_memories(&root, "corrected", 10, 0, None, None).expect("search new");
5571 assert!(
5572 hits.iter().any(|h| h.memory_id == mid),
5573 "edited text must appear in FTS search: {hits:?}"
5574 );
5575
5576 let old_hits =
5578 search_memories(&root, "original", 10, 0, None, None).expect("search old");
5579 assert!(
5580 !old_hits.iter().any(|h| h.memory_id == mid),
5581 "old text must NOT appear after edit: {old_hits:?}"
5582 );
5583
5584 let mems = list_memories(&root).expect("list");
5586 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
5587 assert_eq!(m.text, "corrected text for edit test");
5588 });
5589 }
5590
5591 #[test]
5593 fn edit_memory_changes_kind_only() {
5594 with_user_brain_disabled(|| {
5595 let root = test_root();
5596 init_project(&root, false).expect("init");
5597 let mid = add_memory(
5598 &root,
5599 MemoryScope::Project,
5600 MemoryKind::Fact,
5601 "kind-change test memory",
5602 )
5603 .expect("add");
5604
5605 edit_memory(&root, &mid, None, Some(MemoryKind::Convention)).expect("edit kind");
5606
5607 let mems = list_memories(&root).expect("list");
5608 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
5609 assert_eq!(m.kind, "convention", "kind must be updated");
5610 assert_eq!(m.text, "kind-change test memory", "text must be unchanged");
5611 });
5612 }
5613
5614 #[test]
5616 fn edit_memory_errors() {
5617 with_user_brain_disabled(|| {
5618 let root = test_root();
5619 init_project(&root, false).expect("init");
5620
5621 let err = edit_memory(&root, "does-not-matter", None, None)
5623 .expect_err("must err when no fields");
5624 assert!(
5625 format!("{err}").contains("at least one"),
5626 "unexpected err: {err}"
5627 );
5628
5629 let err = edit_memory(&root, "UNKNOWN_ID", Some("x"), None)
5631 .expect_err("must err on unknown id");
5632 assert!(
5633 format!("{err}").contains("not found"),
5634 "unexpected err: {err}"
5635 );
5636
5637 let mid = add_memory(
5639 &root,
5640 MemoryScope::Project,
5641 MemoryKind::Fact,
5642 "will be invalidated",
5643 )
5644 .expect("add");
5645 invalidate_memory(&root, &mid, None).expect("invalidate");
5646 let err = edit_memory(&root, &mid, Some("new text"), None)
5647 .expect_err("must err on invalidated id");
5648 assert!(
5649 format!("{err}").contains("invalidated"),
5650 "unexpected err: {err}"
5651 );
5652 });
5653 }
5654
5655 #[test]
5658 fn undo_last_memory_invalidates_newest_first() {
5659 with_user_brain_disabled(|| {
5660 let root = test_root();
5661 init_project(&root, false).expect("init");
5662
5663 let mid_a = add_memory(
5664 &root,
5665 MemoryScope::Project,
5666 MemoryKind::Fact,
5667 "memory A older undo test",
5668 )
5669 .expect("add A");
5670
5671 let mid_b = add_memory(
5672 &root,
5673 MemoryScope::Project,
5674 MemoryKind::Fact,
5675 "memory B newer undo test",
5676 )
5677 .expect("add B");
5678
5679 let undone = undo_last_memory(&root)
5681 .expect("undo 1")
5682 .expect("must return Some");
5683 assert_eq!(undone.memory_id, mid_b, "undo must target B (newest)");
5684
5685 {
5687 let (_p, _c, conn) = load_project(&root).expect("open conn");
5688 let b_inv: Option<String> = conn
5689 .query_row(
5690 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5691 params![mid_b],
5692 |row| row.get(0),
5693 )
5694 .optional()
5695 .expect("query")
5696 .flatten();
5697 assert!(b_inv.is_some(), "B must be invalidated after undo");
5698
5699 let a_inv: Option<String> = conn
5700 .query_row(
5701 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5702 params![mid_a],
5703 |row| row.get(0),
5704 )
5705 .optional()
5706 .expect("query")
5707 .flatten();
5708 assert!(a_inv.is_none(), "A must still be active");
5709 }
5710
5711 let undone2 = undo_last_memory(&root)
5713 .expect("undo 2")
5714 .expect("must return Some");
5715 assert_eq!(undone2.memory_id, mid_a, "second undo must target A");
5716
5717 {
5719 let (_p, _c, conn) = load_project(&root).expect("open conn");
5720 let a_inv: Option<String> = conn
5721 .query_row(
5722 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5723 params![mid_a],
5724 |row| row.get(0),
5725 )
5726 .optional()
5727 .expect("query")
5728 .flatten();
5729 assert!(a_inv.is_some(), "A must be invalidated after second undo");
5730 }
5731
5732 let peek = peek_last_memory(&root).expect("peek after both undone");
5734 assert!(peek.is_none(), "peek must return None when all invalidated");
5735 });
5736 }
5737
5738 #[test]
5740 fn undo_last_memory_on_empty_brain_returns_none() {
5741 with_user_brain_disabled(|| {
5742 let root = test_root();
5743 init_project(&root, false).expect("init");
5744 let result = undo_last_memory(&root).expect("undo on empty");
5745 assert!(result.is_none(), "must return None on empty brain");
5746 });
5747 }
5748
5749 #[test]
5759 fn compact_brain_purge_invalidated_reclaims_space() {
5760 with_user_brain_disabled(|| {
5761 let root = test_root();
5762 init_project(&root, false).expect("init");
5763
5764 let mut active_id = String::new();
5766 for i in 0..20usize {
5767 let text = format!(
5768 "compact test memory number {i}: rust sqlite vacuum reclaim disk space \
5769 kimetsu brain compact test payload to increase file size substantially \
5770 so that vacuum has meaningful dead pages to reclaim after deletion"
5771 );
5772 let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, &text)
5773 .expect("add memory");
5774 if i == 0 {
5775 active_id = mid.clone();
5776 }
5777 if i > 0 {
5779 invalidate_memory(&root, &mid, Some("compact test"))
5780 .expect("invalidate memory");
5781 }
5782 }
5783
5784 let report = compact_brain(&root, None, true).expect("compact_brain");
5786
5787 assert_eq!(
5789 report.invalidated_memories_purged, 19,
5790 "should have purged 19 invalidated memories, got {}",
5791 report.invalidated_memories_purged
5792 );
5793 assert!(
5795 report.bytes_after <= report.bytes_before,
5796 "bytes_after ({}) should be <= bytes_before ({}) after purge+vacuum",
5797 report.bytes_after,
5798 report.bytes_before
5799 );
5800 assert_eq!(
5802 report.events_trimmed, 0,
5803 "events_trimmed must be 0 when trim_events_older_than is None"
5804 );
5805
5806 let memories = list_memories(&root).expect("list memories after compact");
5808 let active_memories: Vec<_> = memories
5809 .iter()
5810 .filter(|m| m.memory_id == active_id)
5811 .collect();
5812 assert_eq!(
5813 active_memories.len(),
5814 1,
5815 "the active memory must survive compaction"
5816 );
5817 });
5818 }
5819
5820 #[test]
5825 fn compact_brain_default_preserves_everything() {
5826 with_user_brain_disabled(|| {
5827 let root = test_root();
5828 init_project(&root, false).expect("init");
5829
5830 let mid = add_memory(
5831 &root,
5832 MemoryScope::Project,
5833 MemoryKind::Fact,
5834 "preserve me through compact",
5835 )
5836 .expect("add memory");
5837 let mid2 = add_memory(
5838 &root,
5839 MemoryScope::Project,
5840 MemoryKind::Fact,
5841 "preserve invalidated too",
5842 )
5843 .expect("add memory 2");
5844 invalidate_memory(&root, &mid2, Some("test")).expect("invalidate");
5845
5846 let event_count_before: i64 = {
5848 let (_p, _c, conn) = load_project(&root).expect("load");
5849 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
5850 .expect("count events")
5851 };
5852
5853 let report = compact_brain(&root, None, false).expect("compact_brain");
5855 assert_eq!(
5856 report.events_trimmed, 0,
5857 "events_trimmed must be 0 in default compact"
5858 );
5859 assert_eq!(
5860 report.invalidated_memories_purged, 0,
5861 "invalidated_memories_purged must be 0 in default compact"
5862 );
5863
5864 let all_mems: Vec<_> = {
5866 let (_p, _c, conn) = load_project(&root).expect("load");
5867 let mut stmt = conn
5868 .prepare("SELECT memory_id FROM memories")
5869 .expect("prepare");
5870 stmt.query_map([], |r| r.get::<_, String>(0))
5871 .expect("query")
5872 .collect::<Result<Vec<_>, _>>()
5873 .expect("collect")
5874 };
5875 assert!(
5876 all_mems.contains(&mid),
5877 "active memory must survive default compact"
5878 );
5879 assert!(
5880 all_mems.contains(&mid2),
5881 "invalidated memory must survive default compact"
5882 );
5883
5884 let event_count_after: i64 = {
5886 let (_p, _c, conn) = load_project(&root).expect("load");
5887 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
5888 .expect("count events")
5889 };
5890 assert_eq!(
5891 event_count_after, event_count_before,
5892 "event count must not change in default compact"
5893 );
5894 });
5895 }
5896
5897 #[test]
5905 fn compact_brain_event_trim_keeps_materialized_memories() {
5906 with_user_brain_disabled(|| {
5907 let root = test_root();
5908 init_project(&root, false).expect("init");
5909
5910 let mid = add_memory(
5911 &root,
5912 MemoryScope::Project,
5913 MemoryKind::Fact,
5914 "this memory must survive event trim",
5915 )
5916 .expect("add memory");
5917
5918 let trim_dur = std::time::Duration::from_secs(0);
5923
5924 std::thread::sleep(std::time::Duration::from_millis(100));
5927
5928 let report = compact_brain(&root, Some(trim_dur), false).expect("compact_brain");
5929
5930 assert!(
5931 report.events_trimmed > 0,
5932 "events_trimmed should be > 0 after trim with duration=0; got {}",
5933 report.events_trimmed
5934 );
5935
5936 let memories = list_memories(&root).expect("list memories after event trim");
5938 let found = memories.iter().any(|m| m.memory_id == mid);
5939 assert!(
5940 found,
5941 "memory must still be in the projection after event trim"
5942 );
5943
5944 assert_eq!(
5946 report.invalidated_memories_purged, 0,
5947 "invalidated_memories_purged must be 0 when purge_invalidated=false"
5948 );
5949 });
5950 }
5951
5952 #[test]
5957 fn compact_brain_event_trim_then_rebuild_is_consistent() {
5958 with_user_brain_disabled(|| {
5959 let root = test_root();
5960 init_project(&root, false).expect("init");
5961
5962 add_memory(
5963 &root,
5964 MemoryScope::Project,
5965 MemoryKind::Fact,
5966 "pre-trim memory for rebuild test",
5967 )
5968 .expect("add memory");
5969
5970 std::thread::sleep(std::time::Duration::from_millis(100));
5972 let report = compact_brain(&root, Some(std::time::Duration::from_secs(0)), false)
5973 .expect("compact_brain");
5974 assert!(report.events_trimmed > 0, "events must have been trimmed");
5975
5976 let replayed =
5978 rebuild_projection(&root, false).expect("rebuild_projection after event trim");
5979 assert_eq!(
5981 replayed, 0,
5982 "replayed should be 0 after all events are trimmed"
5983 );
5984 });
5985 }
5986
5987 #[test]
5994 fn at_root_init_and_round_trip_memory() {
5995 with_user_brain_disabled(|| {
5996 let root = std::env::temp_dir().join(format!("kimetsu-at-root-{}", Ulid::new()));
6002 kimetsu_core::paths::git_init_boundary(&root);
6003
6004 let summary = init_project_at_root(&root, false).expect("init_project_at_root");
6006
6007 assert!(
6008 summary.kimetsu_dir.exists(),
6009 ".kimetsu/ must be created at root"
6010 );
6011 assert!(
6013 summary.kimetsu_dir.starts_with(&root),
6014 ".kimetsu dir {:?} must be inside root {:?}",
6015 summary.kimetsu_dir,
6016 root
6017 );
6018 assert!(summary.brain_db.exists(), "brain.db must exist");
6019 assert!(
6020 root.join(".kimetsu").join("project.toml").exists(),
6021 "project.toml must be at root/.kimetsu/"
6022 );
6023
6024 let (paths, _config, _conn) =
6026 load_project_at_root(&root).expect("load_project_at_root");
6027 assert_eq!(
6028 paths
6029 .repo_root
6030 .canonicalize()
6031 .unwrap_or(paths.repo_root.clone()),
6032 root.canonicalize().unwrap_or(root.clone()),
6033 "repo_root must be our explicit root"
6034 );
6035
6036 let memory_id = add_memory(
6038 &root,
6039 MemoryScope::Project,
6040 MemoryKind::Fact,
6041 "at_root seam test memory",
6042 )
6043 .expect("add_memory");
6044
6045 let memories = list_memories(&root).expect("list_memories");
6048 assert!(
6049 memories.iter().any(|m| m.memory_id == memory_id),
6050 "memory {memory_id} must be present in the at_root brain"
6051 );
6052
6053 let (_, _, ro_conn) =
6055 load_project_readonly_at_root(&root).expect("load_project_readonly_at_root");
6056 let ro_count: i64 = ro_conn
6057 .query_row(
6058 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
6059 rusqlite::params![memory_id],
6060 |row| row.get(0),
6061 )
6062 .expect("count memory ro");
6063 assert_eq!(ro_count, 1, "readonly view must see the same memory");
6064
6065 std::fs::remove_dir_all(&root).ok();
6066 });
6067 }
6068
6069 #[test]
6072 fn at_root_init_is_idempotent() {
6073 with_user_brain_disabled(|| {
6074 let root = std::env::temp_dir().join(format!("kimetsu-at-root-idem-{}", Ulid::new()));
6075 std::fs::create_dir_all(&root).expect("create root");
6076
6077 let s1 = init_project_at_root(&root, false).expect("first init");
6078 assert!(s1.wrote_project_toml, "first init must write project.toml");
6079
6080 let s2 = init_project_at_root(&root, false).expect("second init");
6081 assert!(
6082 !s2.wrote_project_toml,
6083 "second init (force=false) must not overwrite project.toml"
6084 );
6085 assert_eq!(s1.project_id, s2.project_id, "project_id must be stable");
6086
6087 std::fs::remove_dir_all(&root).ok();
6088 });
6089 }
6090
6091 #[test]
6099 fn detect_conflicts_env_off_writes_no_conflict_rows() {
6100 with_user_brain_disabled(|| {
6103 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
6104 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
6105
6106 unsafe {
6110 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
6111 std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
6112 }
6113
6114 let root = test_root();
6115 init_project(&root, false).expect("init");
6116
6117 add_memory(
6122 &root,
6123 MemoryScope::Project,
6124 MemoryKind::Fact,
6125 "use clippy for linting Rust code",
6126 )
6127 .expect("add 1");
6128
6129 unsafe {
6131 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
6132 }
6133 add_memory(
6134 &root,
6135 MemoryScope::Project,
6136 MemoryKind::Fact,
6137 "use clippy for linting all Rust projects",
6138 )
6139 .expect("add 2");
6140
6141 unsafe {
6143 match prev_dc {
6144 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
6145 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
6146 }
6147 match prev_emb {
6148 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6149 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6150 }
6151 }
6152 std::fs::remove_dir_all(&root).ok();
6153 });
6154 }
6155
6156 #[test]
6168 fn perf_tier1_structural_invariant_and_timing() {
6169 with_user_brain_disabled(|| {
6172 #[allow(unused_imports)]
6173 use crate::embeddings::StubEmbedder;
6174 use std::time::Instant;
6175
6176 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
6177 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
6178
6179 unsafe {
6182 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
6183 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop"); }
6185
6186 let root = test_root();
6187 init_project(&root, false).expect("init");
6188
6189 const EARLY_SAMPLE: usize = 100;
6190 const TOTAL: usize = 200; for i in 0..EARLY_SAMPLE {
6194 add_memory(
6195 &root,
6196 MemoryScope::Project,
6197 MemoryKind::Fact,
6198 &format!("perf test memory row {i} unique content abcdef"),
6199 )
6200 .expect("add early");
6201 }
6202
6203 let t_early = Instant::now();
6204 add_memory(
6205 &root,
6206 MemoryScope::Project,
6207 MemoryKind::Fact,
6208 &format!("perf sampled early add memory row {EARLY_SAMPLE} unique zxcvbn"),
6209 )
6210 .expect("timed early add");
6211 let early_us = t_early.elapsed().as_micros();
6212
6213 for i in (EARLY_SAMPLE + 1)..TOTAL {
6215 add_memory(
6216 &root,
6217 MemoryScope::Project,
6218 MemoryKind::Fact,
6219 &format!("perf test memory row {i} unique content qwerty"),
6220 )
6221 .expect("add fill");
6222 }
6223
6224 let t_late = Instant::now();
6225 add_memory(
6226 &root,
6227 MemoryScope::Project,
6228 MemoryKind::Fact,
6229 &format!("perf sampled late add memory row {TOTAL} unique rtyfgh"),
6230 )
6231 .expect("timed late add");
6232 let late_us = t_late.elapsed().as_micros();
6233
6234 let (_, _, conn) = load_project(&root).expect("load");
6236 let mem_count: i64 = conn
6237 .query_row(
6238 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
6239 [],
6240 |r| r.get(0),
6241 )
6242 .expect("count memories");
6243 assert!(
6245 mem_count >= TOTAL as i64,
6246 "must have at least {TOTAL} memories, got {mem_count}"
6247 );
6248
6249 if early_us > 0 && late_us > 0 {
6253 assert!(
6254 late_us < early_us * 20,
6255 "late add ({late_us}µs) is > 20× slower than early add ({early_us}µs) — O(N) regression"
6256 );
6257 }
6258
6259 unsafe {
6261 match prev_dc {
6262 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
6263 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
6264 }
6265 match prev_emb {
6266 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6267 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6268 }
6269 }
6270 std::fs::remove_dir_all(&root).ok();
6271 });
6272 }
6273
6274 #[cfg(feature = "embeddings")]
6275 #[test]
6276 fn ann_retrieval_round_trips_and_invalidate_drops() {
6277 use crate::user_brain::with_user_brain_disabled;
6278 with_user_brain_disabled(|| {
6279 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
6281 unsafe {
6282 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "stub-d8");
6283 }
6284 let root = test_root();
6285 init_project(&root, false).expect("init");
6286 add_memory(
6287 &root,
6288 MemoryScope::Project,
6289 MemoryKind::Fact,
6290 "ripgrep is the fast recursive search tool",
6291 )
6292 .expect("add a");
6293 let id = add_memory(
6294 &root,
6295 MemoryScope::Project,
6296 MemoryKind::Fact,
6297 "use fd to find files quickly",
6298 )
6299 .expect("add b");
6300
6301 let ctx = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx");
6303 assert!(
6304 format!("{ctx:?}").contains("fd to find files"),
6305 "expected the fd memory in context"
6306 );
6307
6308 invalidate_memory(&root, &id, Some("test")).expect("invalidate");
6310 let ctx2 = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx2");
6311 assert!(
6312 !format!("{ctx2:?}").contains("fd to find files"),
6313 "invalidated memory must not return"
6314 );
6315
6316 unsafe {
6317 match prev_emb {
6318 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6319 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6320 }
6321 }
6322 std::fs::remove_dir_all(&root).ok();
6323 });
6324 }
6325
6326 #[test]
6328 fn record_mcp_citation_writes_memory_citations_row() {
6329 with_user_brain_disabled(|| {
6330 let root = test_root();
6331 std::fs::create_dir_all(&root).expect("create root");
6332 init_project(&root, false).expect("init");
6333 let memory_id = add_memory(
6334 &root,
6335 kimetsu_core::memory::MemoryScope::Project,
6336 kimetsu_core::memory::MemoryKind::Fact,
6337 "record_mcp_citation test fixture",
6338 )
6339 .expect("add memory");
6340
6341 record_mcp_citation(&root, &memory_id, Some("helped with test"))
6342 .expect("record_mcp_citation");
6343
6344 let (_paths, _config, conn) = load_project(&root).expect("load");
6345 let row_count: i64 = conn
6346 .query_row(
6347 "SELECT COUNT(*) FROM memory_citations WHERE memory_id = ?1",
6348 rusqlite::params![&memory_id],
6349 |r| r.get(0),
6350 )
6351 .expect("count");
6352 assert_eq!(
6353 row_count, 1,
6354 "memory_citations row must exist after MCP cite"
6355 );
6356 std::fs::remove_dir_all(&root).ok();
6357 });
6358 }
6359
6360 #[test]
6362 fn record_regret_writes_retrieval_regret_event() {
6363 with_user_brain_disabled(|| {
6364 let root = test_root();
6365 std::fs::create_dir_all(&root).expect("create root");
6366 init_project(&root, false).expect("init");
6367 let memory_id = add_memory(
6368 &root,
6369 kimetsu_core::memory::MemoryScope::Project,
6370 kimetsu_core::memory::MemoryKind::Fact,
6371 "record_regret test fixture",
6372 )
6373 .expect("add memory");
6374
6375 record_regret(&root, &memory_id).expect("record_regret");
6376
6377 let (_paths, _config, conn) = load_project(&root).expect("load");
6378 let event_count: i64 = conn
6379 .query_row(
6380 "SELECT COUNT(*) FROM events
6381 WHERE kind = 'retrieval.regret'
6382 AND json_extract(payload_json, '$.memory_id') = ?1",
6383 rusqlite::params![&memory_id],
6384 |r| r.get(0),
6385 )
6386 .expect("count");
6387 assert_eq!(
6388 event_count, 1,
6389 "a retrieval.regret event must exist for the memory after record_regret"
6390 );
6391 std::fs::remove_dir_all(&root).ok();
6392 });
6393 }
6394
6395 #[cfg(test)]
6397 fn read_outcome_stats(root: &std::path::Path, memory_id: &str) -> (i64, f64, f64) {
6398 let (_paths, _config, conn) = load_project(root).expect("load");
6399 conn.query_row(
6400 "SELECT use_count, usefulness_score, confidence FROM memories WHERE memory_id = ?1",
6401 rusqlite::params![memory_id],
6402 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
6403 )
6404 .expect("stats")
6405 }
6406
6407 #[test]
6410 fn standalone_cite_raises_usefulness() {
6411 with_user_brain_disabled(|| {
6412 let root = test_root();
6413 std::fs::create_dir_all(&root).expect("create root");
6414 init_project(&root, false).expect("init");
6415 let memory_id = add_memory(
6416 &root,
6417 kimetsu_core::memory::MemoryScope::Project,
6418 kimetsu_core::memory::MemoryKind::Fact,
6419 "standalone cite outcome fixture",
6420 )
6421 .expect("add memory");
6422
6423 let (uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
6424 record_mcp_citation(&root, &memory_id, None).expect("cite");
6425 let (uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
6426
6427 assert_eq!(uc1, uc0 + 1, "use_count must increment on standalone cite");
6428 assert!(us1 > us0, "usefulness must rise: {us0} -> {us1}");
6429 assert!(
6433 cf1 > cf0,
6434 "confidence must rise toward 1.0 on a positive outcome: {cf0} -> {cf1}"
6435 );
6436 std::fs::remove_dir_all(&root).ok();
6437 });
6438 }
6439
6440 #[test]
6442 fn manual_regret_lowers_usefulness_and_confidence() {
6443 with_user_brain_disabled(|| {
6444 let root = test_root();
6445 std::fs::create_dir_all(&root).expect("create root");
6446 init_project(&root, false).expect("init");
6447 let memory_id = add_memory(
6448 &root,
6449 kimetsu_core::memory::MemoryScope::Project,
6450 kimetsu_core::memory::MemoryKind::Fact,
6451 "manual regret outcome fixture",
6452 )
6453 .expect("add memory");
6454
6455 let (_uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
6456 record_regret(&root, &memory_id).expect("regret");
6457 let (_uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
6458
6459 assert!(us1 < us0, "usefulness must drop on regret: {us0} -> {us1}");
6460 assert!(cf1 < cf0, "confidence must drop on regret: {cf0} -> {cf1}");
6461 std::fs::remove_dir_all(&root).ok();
6462 });
6463 }
6464
6465 #[test]
6468 fn real_run_cite_does_not_bump_in_apply_memory_cited() {
6469 with_user_brain_disabled(|| {
6470 let root = test_root();
6471 std::fs::create_dir_all(&root).expect("create root");
6472 init_project(&root, false).expect("init");
6473 let memory_id = add_memory(
6474 &root,
6475 kimetsu_core::memory::MemoryScope::Project,
6476 kimetsu_core::memory::MemoryKind::Fact,
6477 "real run cite fixture",
6478 )
6479 .expect("add memory");
6480
6481 let (uc0, us0, _cf0) = read_outcome_stats(&root, &memory_id);
6482
6483 let real_run = kimetsu_core::ids::RunId::new();
6485 let event = kimetsu_core::event::Event::new(
6486 real_run,
6487 "memory.cited",
6488 serde_json::json!({ "memory_id": memory_id, "turn": 0 }),
6489 );
6490 {
6491 let (_paths, _config, conn) = load_project(&root).expect("load");
6492 crate::projector::apply_events(&conn, std::slice::from_ref(&event)).expect("apply");
6493 }
6494
6495 let (uc1, us1, _cf1) = read_outcome_stats(&root, &memory_id);
6496 assert_eq!(uc1, uc0, "real-run cite must NOT increment use_count here");
6497 assert!(
6498 (us1 - us0).abs() < 1e-9,
6499 "real-run cite must NOT change usefulness here"
6500 );
6501 std::fs::remove_dir_all(&root).ok();
6502 });
6503 }
6504
6505 #[test]
6508 fn cite_outcome_survives_rebuild() {
6509 with_user_brain_disabled(|| {
6510 let root = test_root();
6511 std::fs::create_dir_all(&root).expect("create root");
6512 init_project(&root, false).expect("init");
6513 let memory_id = add_memory(
6514 &root,
6515 kimetsu_core::memory::MemoryScope::Project,
6516 kimetsu_core::memory::MemoryKind::Fact,
6517 "rebuild outcome fixture",
6518 )
6519 .expect("add memory");
6520 record_mcp_citation(&root, &memory_id, None).expect("cite");
6521
6522 let before = read_outcome_stats(&root, &memory_id);
6523 {
6524 let (_paths, _config, conn) = load_project(&root).expect("load");
6525 crate::projector::rebuild_in_place(&conn).expect("rebuild");
6526 }
6527 let after = read_outcome_stats(&root, &memory_id);
6528 assert_eq!(before.0, after.0, "use_count must survive rebuild");
6529 assert!(
6530 (before.1 - after.1).abs() < 1e-9,
6531 "usefulness must survive rebuild"
6532 );
6533 assert!(
6534 (before.2 - after.2).abs() < 1e-9,
6535 "confidence must survive rebuild"
6536 );
6537 std::fs::remove_dir_all(&root).ok();
6538 });
6539 }
6540
6541 #[test]
6543 fn set_age_backdates_created_at_and_survives_rebuild() {
6544 with_user_brain_disabled(|| {
6545 let root = test_root();
6546 std::fs::create_dir_all(&root).expect("create root");
6547 init_project(&root, false).expect("init");
6548 let memory_id = add_memory(
6549 &root,
6550 kimetsu_core::memory::MemoryScope::Project,
6551 kimetsu_core::memory::MemoryKind::Fact,
6552 "age injection fixture",
6553 )
6554 .expect("add memory");
6555
6556 let read_created = |root: &std::path::Path| -> String {
6557 let (_paths, _config, conn) = load_project(root).expect("load");
6558 conn.query_row(
6559 "SELECT created_at FROM memories WHERE memory_id = ?1",
6560 rusqlite::params![&memory_id],
6561 |r| r.get::<_, String>(0),
6562 )
6563 .expect("created_at")
6564 };
6565
6566 let created0 = read_created(&root);
6567 record_set_age(&root, &memory_id, 90).expect("set-age");
6568 let created1 = read_created(&root);
6569 assert!(
6571 created1 < created0,
6572 "created_at must move into the past: {created0} -> {created1}"
6573 );
6574
6575 {
6576 let (_paths, _config, conn) = load_project(&root).expect("load");
6577 crate::projector::rebuild_in_place(&conn).expect("rebuild");
6578 }
6579 assert_eq!(
6580 read_created(&root),
6581 created1,
6582 "aged created_at survives rebuild"
6583 );
6584 std::fs::remove_dir_all(&root).ok();
6585 });
6586 }
6587
6588 #[test]
6592 fn fix2_search_excludes_superseded_rows() {
6593 with_user_brain_disabled(|| {
6594 let root = test_root();
6595 init_project(&root, false).expect("init");
6596
6597 let superseded_id = add_memory(
6599 &root,
6600 MemoryScope::Project,
6601 MemoryKind::Fact,
6602 "unique superseded keyword alpha",
6603 )
6604 .expect("add superseded");
6605 add_memory(
6606 &root,
6607 MemoryScope::Project,
6608 MemoryKind::Fact,
6609 "live memory unrelated topic",
6610 )
6611 .expect("add live");
6612
6613 {
6616 let (_paths, _config, conn) = load_project(&root).expect("load for stamp");
6617 conn.execute(
6618 "UPDATE memories SET superseded_by = 'fake-survivor' \
6619 WHERE memory_id = ?1",
6620 rusqlite::params![&superseded_id],
6621 )
6622 .expect("stamp superseded_by");
6623 }
6624
6625 let hits = search_memories(&root, "unique superseded keyword alpha", 20, 0, None, None)
6627 .expect("search");
6628 assert!(
6629 !hits.iter().any(|h| h.memory_id == superseded_id),
6630 "superseded row must not appear in search results"
6631 );
6632
6633 std::fs::remove_dir_all(&root).ok();
6634 });
6635 }
6636
6637 #[test]
6642 fn fix4_top_excludes_superseded_rows() {
6643 with_user_brain_disabled(|| {
6644 let root = test_root();
6645 init_project(&root, false).expect("init");
6646
6647 let superseded_id = add_memory(
6649 &root,
6650 MemoryScope::Project,
6651 MemoryKind::Fact,
6652 "memory to be superseded with high usefulness",
6653 )
6654 .expect("add");
6655
6656 {
6658 let (_paths, _config, conn) = load_project(&root).expect("load");
6659 conn.execute(
6660 "UPDATE memories \
6661 SET superseded_by = 'fake-survivor', \
6662 use_count = 10, usefulness_score = 50.0 \
6663 WHERE memory_id = ?1",
6664 rusqlite::params![&superseded_id],
6665 )
6666 .expect("stamp");
6667 }
6668
6669 let live_id = add_memory(
6671 &root,
6672 MemoryScope::Project,
6673 MemoryKind::Fact,
6674 "live memory with normal stats",
6675 )
6676 .expect("add live");
6677 {
6678 let (_paths, _config, conn) = load_project(&root).expect("load");
6679 conn.execute(
6680 "UPDATE memories SET use_count = 5, usefulness_score = 5.0 \
6681 WHERE memory_id = ?1",
6682 rusqlite::params![&live_id],
6683 )
6684 .expect("seed stats");
6685 }
6686
6687 let opts = TopOptions {
6688 scope: None,
6689 min_uses: 1,
6690 limit: 20,
6691 };
6692 let top = list_memories_top(&root, opts).expect("list_memories_top");
6693
6694 assert!(
6695 !top.iter().any(|r| r.memory_id == superseded_id),
6696 "superseded row must not appear in top"
6697 );
6698 assert!(
6699 top.iter().any(|r| r.memory_id == live_id),
6700 "live row must appear in top"
6701 );
6702
6703 std::fs::remove_dir_all(&root).ok();
6704 });
6705 }
6706
6707 #[test]
6708 fn fix4_prune_excludes_superseded_rows() {
6709 with_user_brain_disabled(|| {
6710 let root = test_root();
6711 init_project(&root, false).expect("init");
6712
6713 let superseded_id = add_memory(
6714 &root,
6715 MemoryScope::Project,
6716 MemoryKind::Fact,
6717 "memory to be superseded with low usefulness",
6718 )
6719 .expect("add");
6720
6721 {
6723 let (_paths, _config, conn) = load_project(&root).expect("load");
6724 conn.execute(
6725 "UPDATE memories \
6726 SET superseded_by = 'fake-survivor', \
6727 use_count = 10, usefulness_score = -99.0 \
6728 WHERE memory_id = ?1",
6729 rusqlite::params![&superseded_id],
6730 )
6731 .expect("stamp");
6732 }
6733
6734 let live_id = add_memory(
6736 &root,
6737 MemoryScope::Project,
6738 MemoryKind::Fact,
6739 "live memory with negative usefulness for prune",
6740 )
6741 .expect("add live");
6742 {
6743 let (_paths, _config, conn) = load_project(&root).expect("load");
6744 conn.execute(
6745 "UPDATE memories SET use_count = 5, usefulness_score = -5.0 \
6746 WHERE memory_id = ?1",
6747 rusqlite::params![&live_id],
6748 )
6749 .expect("seed stats");
6750 }
6751
6752 let opts = PruneOptions {
6753 scope: None,
6754 min_uses: 1,
6755 max_ratio: -0.1,
6756 apply: false,
6757 };
6758 let summary = prune_low_usefulness(&root, opts).expect("prune");
6759
6760 assert!(
6761 !summary
6762 .candidates
6763 .iter()
6764 .any(|c| c.memory_id == superseded_id),
6765 "superseded row must not appear in prune candidates"
6766 );
6767 assert!(
6768 summary.candidates.iter().any(|c| c.memory_id == live_id),
6769 "live negative-score row must appear in prune candidates"
6770 );
6771
6772 std::fs::remove_dir_all(&root).ok();
6773 });
6774 }
6775
6776 #[test]
6789 fn add_memories_batch_present_retrievable_rebuild_safe() {
6790 with_user_brain_disabled(|| {
6791 let root = test_root();
6792 fs::create_dir_all(&root).expect("create temp project");
6793 init_project(&root, false).expect("init project");
6794
6795 let entries: Vec<BatchMemoryEntry> = (1..=5)
6797 .map(|i| BatchMemoryEntry {
6798 text: format!(
6799 "batch memory entry number {i} unique text for semantic distance"
6800 ),
6801 scope: kimetsu_core::memory::MemoryScope::Project,
6802 kind: kimetsu_core::memory::MemoryKind::Fact,
6803 valid_from: None,
6804 valid_to: None,
6805 })
6806 .collect();
6807
6808 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
6809
6810 assert_eq!(ids.len(), 5, "expected 5 ids back; got {:?}", ids);
6812 for id in &ids {
6814 assert!(!id.is_empty(), "id must not be empty");
6815 }
6816
6817 let memories = list_memories(&root).expect("list_memories after batch");
6819 assert_eq!(
6820 memories.len(),
6821 5,
6822 "list_memories should return 5; got {:?}",
6823 memories.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
6824 );
6825 let stored_ids: Vec<_> = memories.iter().map(|m| m.memory_id.clone()).collect();
6826 for id in &ids {
6827 assert!(stored_ids.contains(id), "id {id} must be in list_memories");
6828 }
6829
6830 let ref_id = add_memory(
6836 &root,
6837 kimetsu_core::memory::MemoryScope::Project,
6838 kimetsu_core::memory::MemoryKind::Fact,
6839 "single-add reference for embedding consistency check",
6840 )
6841 .expect("single add ref");
6842 {
6843 let (_paths, _config, conn) = load_project(&root).expect("load project");
6844 let ref_model: Option<String> = conn
6845 .query_row(
6846 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
6847 rusqlite::params![ref_id],
6848 |r| r.get(0),
6849 )
6850 .expect("query ref embedding_model");
6851
6852 for id in &ids {
6854 let bm: Option<String> = conn
6855 .query_row(
6856 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
6857 rusqlite::params![id],
6858 |r| r.get(0),
6859 )
6860 .expect("query batch embedding_model");
6861 assert_eq!(
6862 bm, ref_model,
6863 "batch memory {id} embedding_model ({bm:?}) must match single-add ref ({ref_model:?})"
6864 );
6865 }
6866 }
6867
6868 {
6871 let (_paths, _config, conn) = load_project(&root).expect("load for rebuild");
6872 crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
6873 let after_count: i64 = conn
6874 .query_row(
6875 "SELECT COUNT(*) FROM memories \
6876 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
6877 [],
6878 |r| r.get(0),
6879 )
6880 .expect("count after rebuild");
6881 assert_eq!(
6882 after_count, 6,
6883 "all 6 memories (5 batch + 1 single) must survive rebuild_in_place; got {after_count}"
6884 );
6885 let rebuilt_ids: Vec<String> = {
6886 let mut stmt = conn
6887 .prepare(
6888 "SELECT memory_id FROM memories \
6889 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
6890 )
6891 .expect("prepare");
6892 stmt.query_map([], |r| r.get(0))
6893 .expect("query")
6894 .map(|r| r.expect("row"))
6895 .collect()
6896 };
6897 for id in &ids {
6898 assert!(
6899 rebuilt_ids.contains(id),
6900 "id {id} must survive rebuild_in_place"
6901 );
6902 }
6903 }
6904
6905 let temporal_entries = vec![BatchMemoryEntry {
6907 text: "batch temporal test this fact expires soon".to_string(),
6908 scope: kimetsu_core::memory::MemoryScope::Project,
6909 kind: kimetsu_core::memory::MemoryKind::Fact,
6910 valid_from: Some("2025-01-01T00:00:00Z".to_string()),
6911 valid_to: Some("2099-12-31T00:00:00Z".to_string()),
6912 }];
6913 let temporal_ids =
6914 add_memories_batch(&root, temporal_entries).expect("add_memories_batch temporal");
6915 assert_eq!(temporal_ids.len(), 1);
6916 let temporal_id = &temporal_ids[0];
6917 {
6918 let (_paths, _config, conn) = load_project(&root).expect("load for temporal check");
6919 let (vf, vt): (Option<String>, Option<String>) = conn
6920 .query_row(
6921 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
6922 rusqlite::params![temporal_id],
6923 |r| Ok((r.get(0)?, r.get(1)?)),
6924 )
6925 .expect("query valid_from/valid_to");
6926 assert!(
6927 vf.is_some(),
6928 "valid_from must be set for temporal batch entry"
6929 );
6930 assert!(
6931 vt.is_some(),
6932 "valid_to must be set for temporal batch entry"
6933 );
6934 crate::projector::rebuild_in_place(&conn).expect("rebuild temporal");
6936 let (vf2, vt2): (Option<String>, Option<String>) = conn
6937 .query_row(
6938 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
6939 rusqlite::params![temporal_id],
6940 |r| Ok((r.get(0)?, r.get(1)?)),
6941 )
6942 .expect("query after rebuild");
6943 assert_eq!(vf, vf2, "valid_from must survive rebuild");
6944 assert_eq!(vt, vt2, "valid_to must survive rebuild");
6945 }
6946
6947 fs::remove_dir_all(&root).ok();
6948 });
6949 }
6950
6951 #[test]
6954 fn add_memories_batch_deduplicates() {
6955 with_user_brain_disabled(|| {
6956 let root = test_root();
6957 fs::create_dir_all(&root).expect("create temp project");
6958 init_project(&root, false).expect("init project");
6959
6960 let text = "batch dedup test unique entry";
6961 let entries = vec![
6962 BatchMemoryEntry {
6963 text: text.to_string(),
6964 scope: kimetsu_core::memory::MemoryScope::Project,
6965 kind: kimetsu_core::memory::MemoryKind::Fact,
6966 valid_from: None,
6967 valid_to: None,
6968 },
6969 BatchMemoryEntry {
6970 text: text.to_string(),
6971 scope: kimetsu_core::memory::MemoryScope::Project,
6972 kind: kimetsu_core::memory::MemoryKind::Fact,
6973 valid_from: None,
6974 valid_to: None,
6975 },
6976 ];
6977
6978 let ids = add_memories_batch(&root, entries).expect("add_memories_batch dedup");
6979 assert_eq!(ids.len(), 2);
6980 assert_eq!(
6981 ids[0], ids[1],
6982 "duplicate text must return the same memory_id"
6983 );
6984
6985 let memories = list_memories(&root).expect("list");
6987 assert_eq!(
6988 memories.len(),
6989 1,
6990 "deduped batch must produce exactly 1 DB row; got {}",
6991 memories.len()
6992 );
6993
6994 fs::remove_dir_all(&root).ok();
6995 });
6996 }
6997
6998 #[test]
7008 fn add_memories_batch_all_entries_same_embedding_model() {
7009 with_user_brain_disabled(|| {
7010 let root = test_root();
7011 fs::create_dir_all(&root).expect("create temp project");
7012 init_project(&root, false).expect("init project");
7013
7014 let n = 8_usize;
7015 let entries: Vec<BatchMemoryEntry> = (0..n)
7016 .map(|i| BatchMemoryEntry {
7017 text: format!(
7018 "embedding model consistency test memory {i} distinct content here"
7019 ),
7020 scope: kimetsu_core::memory::MemoryScope::Project,
7021 kind: kimetsu_core::memory::MemoryKind::Convention,
7022 valid_from: None,
7023 valid_to: None,
7024 })
7025 .collect();
7026
7027 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
7028 assert_eq!(ids.len(), n);
7029
7030 let (_paths, _config, conn) = load_project(&root).expect("load project");
7031 let model_id_rows: Vec<Option<String>> = {
7032 let mut stmt = conn
7033 .prepare("SELECT embedding_model FROM memories ORDER BY created_at")
7034 .expect("prepare");
7035 stmt.query_map([], |r| r.get(0))
7036 .expect("query")
7037 .map(|r| r.expect("row"))
7038 .collect()
7039 };
7040 assert_eq!(model_id_rows.len(), n, "expected {n} rows");
7041 let first = &model_id_rows[0];
7043 for (i, model_id) in model_id_rows.iter().enumerate() {
7044 assert_eq!(
7045 model_id, first,
7046 "memory {i} embedding_model ({model_id:?}) must match first ({first:?})"
7047 );
7048 }
7049
7050 fs::remove_dir_all(&root).ok();
7051 });
7052 }
7053}