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 config(&self) -> &ProjectConfig {
375 &self.config
376 }
377
378 pub fn resolve_request_floors(&self, request: &mut ContextRequest) {
381 request.include_fact_evidence |= self.config.broker.explicit_fact_guard;
382 let semantic = request.min_semantic_score_override.unwrap_or({
383 if request.min_semantic_score == 0.0 {
384 self.config.broker.min_semantic_score
385 } else {
386 request.min_semantic_score
387 }
388 });
389 request.min_semantic_score = if semantic < 0.0 {
390 let model = embeddings::resolve_embedder_id(Some(&self.config.embedder.model));
391 if model.starts_with("bge") { 0.35 } else { 0.0 }
392 } else {
393 semantic
394 };
395 request.min_lexical_coverage = request.min_lexical_coverage_override.unwrap_or({
396 if request.min_lexical_coverage == 0.0 {
397 self.config.broker.min_lexical_coverage
398 } else {
399 request.min_lexical_coverage
400 }
401 });
402 request.abstain_evidence = match request.abstain_evidence_override {
403 Some(v) if v >= 0.0 => v,
404 Some(_) => {
405 let mut cfg = self.config.clone();
406 cfg.broker.abstain_min_score = -1.0;
407 resolved_abstain_evidence_for(&cfg)
408 }
409 None if request.abstain_evidence == 0.0 => self.resolved_abstain_evidence(),
410 None => request.abstain_evidence,
411 };
412 }
413 pub fn open(start: &Path) -> KimetsuResult<Self> {
414 let (paths, config, conn) = load_project(start)?;
415 let user_conn = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?;
420 Self::from_parts(paths, config, conn, user_conn)
421 }
422
423 pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
424 let (paths, config, conn) = load_project_readonly(start)?;
425 let user_conn =
430 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
431 Self::from_parts(paths, config, conn, user_conn)
432 }
433
434 fn from_parts(
435 paths: ProjectPaths,
436 config: ProjectConfig,
437 conn: Connection,
438 user_conn: Option<Connection>,
439 ) -> KimetsuResult<Self> {
440 let repo_root = paths
441 .repo_root
442 .canonicalize()?
443 .to_string_lossy()
444 .to_string();
445 Ok(Self {
446 paths,
447 config,
448 conn,
449 user_conn,
450 repo_root,
451 })
452 }
453
454 pub fn retrieve_context(
455 &self,
456 stage: &str,
457 query: &str,
458 budget_tokens: u32,
459 ) -> KimetsuResult<ContextBundle> {
460 self.retrieve_context_with_request(ContextRequest {
461 stage: stage.to_string(),
462 query: query.to_string(),
463 budget_tokens,
464 ..Default::default()
465 })
466 }
467
468 pub fn retrieve_context_with_request(
476 &self,
477 mut request: ContextRequest,
478 ) -> KimetsuResult<ContextBundle> {
479 self.resolve_request_floors(&mut request);
480 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
481 if request.normalization.is_empty() {
484 request.normalization = self.config.broker.normalization.clone();
485 }
486 let fusion = if request.fusion.is_empty() {
489 &self.config.broker.fusion
490 } else {
491 &request.fusion
492 };
493 let backend = crate::backend::backend_for(
494 &self.config.storage.backend,
495 crate::fusion::Fusion::from_config(fusion),
496 );
497 context::retrieve_context_with_embedder_and_backend(
498 &self.conn,
499 &self.repo_root,
500 &self.config.broker.weights,
501 request,
502 &extras,
503 embeddings::open_embedder_for(self.config.embedder.enabled),
504 backend.as_ref(),
505 )
506 }
507
508 pub fn resolved_abstain_evidence(&self) -> f32 {
511 resolved_abstain_evidence_for(&self.config)
512 }
513
514 pub fn retrieve_proactive(&self, mut request: ContextRequest) -> KimetsuResult<ContextBundle> {
521 if request.min_lexical_coverage == 0.0 {
526 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
527 }
528 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
529 if request.normalization.is_empty() {
532 request.normalization = self.config.broker.normalization.clone();
533 }
534 let fusion = if request.fusion.is_empty() {
537 &self.config.broker.fusion
538 } else {
539 &request.fusion
540 };
541 let backend = crate::backend::backend_for(
542 &self.config.storage.backend,
543 crate::fusion::Fusion::from_config(fusion),
544 );
545 context::retrieve_context_with_embedder_and_backend(
546 &self.conn,
547 &self.repo_root,
548 &self.config.broker.weights,
549 request,
550 &extras,
551 &embeddings::NoopEmbedder,
552 backend.as_ref(),
553 )
554 }
555
556 pub fn retrieve_context_lexical(
565 &self,
566 mut request: ContextRequest,
567 ) -> KimetsuResult<ContextBundle> {
568 if request.min_lexical_coverage == 0.0 {
573 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
574 }
575 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
576 if request.normalization.is_empty() {
579 request.normalization = self.config.broker.normalization.clone();
580 }
581 let fusion = if request.fusion.is_empty() {
584 &self.config.broker.fusion
585 } else {
586 &request.fusion
587 };
588 let backend = crate::backend::backend_for(
589 &self.config.storage.backend,
590 crate::fusion::Fusion::from_config(fusion),
591 );
592 context::retrieve_context_with_embedder_and_backend(
593 &self.conn,
594 &self.repo_root,
595 &self.config.broker.weights,
596 request,
597 &extras,
598 &embeddings::NoopEmbedder,
599 backend.as_ref(),
600 )
601 }
602
603 pub fn retrieve_context_with_injected_embedder(
609 &self,
610 mut request: ContextRequest,
611 embedder: &dyn embeddings::Embedder,
612 ) -> KimetsuResult<ContextBundle> {
613 self.resolve_request_floors(&mut request);
614 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
615 if request.normalization.is_empty() {
618 request.normalization = self.config.broker.normalization.clone();
619 }
620 let fusion = if request.fusion.is_empty() {
623 &self.config.broker.fusion
624 } else {
625 &request.fusion
626 };
627 let backend = crate::backend::backend_for(
628 &self.config.storage.backend,
629 crate::fusion::Fusion::from_config(fusion),
630 );
631 context::retrieve_context_with_embedder_and_backend(
632 &self.conn,
633 &self.repo_root,
634 &self.config.broker.weights,
635 request,
636 &extras,
637 embedder,
638 backend.as_ref(),
639 )
640 }
641
642 pub fn repo_root(&self) -> &Path {
643 &self.paths.repo_root
644 }
645
646 pub fn user_conn(&self) -> Option<&Connection> {
651 self.user_conn.as_ref()
652 }
653}
654
655pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
656 let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
657 format!(
658 "failed to read {}; run `kimetsu init` first: {err}",
659 paths.project_toml.display()
660 )
661 })?;
662 let mut config = ProjectConfig::from_toml(&content)?;
663 config.apply_retrieval_level();
669 Ok(config)
670}
671
672pub fn load_config_from_text(toml: &str) -> KimetsuResult<ProjectConfig> {
675 ProjectConfig::from_toml(toml)
676}
677
678pub fn config_text(start: &Path) -> KimetsuResult<String> {
679 let paths = ProjectPaths::discover(start)?;
680 Ok(fs::read_to_string(paths.project_toml)?)
681}
682
683pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
684 let (_paths, _config, conn) = load_project(start)?;
685 let mut stmt = conn.prepare(
686 "
687 SELECT run_id, task, started_at, terminal_kind
688 FROM runs
689 ORDER BY started_at DESC
690 LIMIT 100
691 ",
692 )?;
693
694 let rows = stmt.query_map([], |row| {
695 Ok(RunSummary {
696 run_id: row.get(0)?,
697 task: row.get(1)?,
698 started_at: row.get(2)?,
699 terminal_kind: row.get(3)?,
700 })
701 })?;
702
703 let mut runs = Vec::new();
704 for row in rows {
705 runs.push(row?);
706 }
707 Ok(runs)
708}
709
710pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
711 let (_paths, _config, conn) = load_project(start)?;
712 let mut stmt = conn.prepare(
713 "
714 SELECT run_id, task, started_at, terminal_kind
715 FROM runs
716 WHERE run_id = ?1
717 ",
718 )?;
719
720 let mut rows = stmt.query(params![run_id])?;
721 if let Some(row) = rows.next()? {
722 Ok(Some(RunSummary {
723 run_id: row.get(0)?,
724 task: row.get(1)?,
725 started_at: row.get(2)?,
726 terminal_kind: row.get(3)?,
727 }))
728 } else {
729 Ok(None)
730 }
731}
732
733#[derive(Debug, Clone)]
738pub struct BatchMemoryEntry {
739 pub text: String,
741 pub scope: MemoryScope,
743 pub kind: MemoryKind,
745 pub valid_from: Option<String>,
748 pub valid_to: Option<String>,
751}
752
753const DIRECT_ADD_CONFIDENCE: f32 = 0.85;
764
765pub fn add_memory(
766 start: &Path,
767 scope: MemoryScope,
768 kind: MemoryKind,
769 text: &str,
770) -> KimetsuResult<String> {
771 add_memory_with_validity(start, scope, kind, text, None, None)
772}
773
774pub fn add_memory_with_validity(
777 start: &Path,
778 scope: MemoryScope,
779 kind: MemoryKind,
780 text: &str,
781 valid_from: Option<&str>,
782 valid_to: Option<&str>,
783) -> KimetsuResult<String> {
784 let redaction = redact::redact_secrets(text);
797 if redaction.was_redacted() {
798 eprintln!("kimetsu-brain: {}", redaction.summary());
799 }
800 let text = redaction.text.as_str();
801
802 if scope == MemoryScope::GlobalUser {
820 let use_user_brain = ProjectPaths::discover(start)
821 .ok()
822 .and_then(|paths| load_config(&paths).ok())
823 .map(|cfg| cfg.kimetsu.use_user_brain)
824 .unwrap_or(true);
825 if let Some(user_conn) = user_brain::open_user_brain_for_config(use_user_brain)? {
826 return user_brain::add_user_memory_with_validity(
827 &user_conn, kind, text, 1.0, valid_from, valid_to,
828 );
829 }
830 }
834
835 let (paths, config, conn) = load_project(start)?;
836 let run_id = RunId::new();
837 let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
838
839 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
840 add_memory_inner(
841 &conn, &paths, &config, scope, kind, text, valid_from, valid_to, embedder,
842 )
843}
844
845#[allow(clippy::too_many_arguments)]
854fn add_memory_inner(
855 conn: &Connection,
856 paths: &ProjectPaths,
857 config: &kimetsu_core::config::ProjectConfig,
858 scope: MemoryScope,
859 kind: MemoryKind,
860 text: &str,
861 valid_from: Option<&str>,
862 valid_to: Option<&str>,
863 embedder: &dyn embeddings::Embedder,
864) -> KimetsuResult<String> {
865 let run_id = RunId::new();
866 let memory_id = Ulid::new().to_string();
867 let normalized = normalize_memory_text(text);
868
869 let existing: Option<String> = conn
875 .query_row(
876 "
877 SELECT memory_id FROM memories
878 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
879 AND invalidated_at IS NULL
880 AND superseded_by IS NULL
881 LIMIT 1
882 ",
883 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
884 |row| row.get::<_, String>(0),
885 )
886 .optional()?;
887 if let Some(existing_id) = existing {
888 return Ok(existing_id);
889 }
890
891 let importance_enabled = config.ingestion.initial_importance_scoring;
897 let initial_kind_weight = if importance_enabled {
898 match &kind {
899 MemoryKind::FailurePattern => 0.3_f32,
900 MemoryKind::Command => 0.2,
901 MemoryKind::Convention => 0.15,
902 MemoryKind::Fact => 0.1,
903 MemoryKind::Preference => 0.05,
904 }
905 } else {
906 0.0
907 };
908
909 let started = Event::new(
910 run_id,
911 "run.started",
912 serde_json::json!({
913 "mode": "admin",
914 "task": "memory add",
915 "project_id": config.kimetsu.project_id,
916 "repo_root": paths.repo_root.to_string_lossy(),
917 "model": null,
918 "platform": std::env::consts::OS,
919 "kimetsu_version": env!("CARGO_PKG_VERSION"),
920 "config_hash": config_hash(&paths.project_toml)?,
921 }),
922 );
923 let accepted = Event::new(
924 run_id,
925 "memory.accepted",
926 serde_json::json!({
927 "proposal_id": null,
928 "memory_id": memory_id,
929 "scope": scope.to_string(),
930 "kind": kind.to_string(),
931 "text": text,
932 "normalized_text": normalized,
933 "confidence": DIRECT_ADD_CONFIDENCE,
934 "initial_usefulness": initial_kind_weight,
935 "valid_from": valid_from,
936 "valid_to": valid_to,
937 "provenance_snapshot": build_provenance(run_id, text),
938 }),
939 );
940
941 let finished = Event::new(
942 run_id,
943 "run.finished",
944 serde_json::json!({
945 "status": "success",
946 "final_report_path": null,
947 "total_cost_usd": 0.0,
948 "total_tool_calls": 0,
949 }),
950 );
951
952 projector::apply_events(conn, &[started, accepted, finished])?;
953
954 let embedding_vec = embeddings::embed_and_persist(conn, &memory_id, text, embedder)?;
967
968 if importance_enabled && !embedder.is_noop() {
975 if let Some(vec) = embedding_vec.as_deref() {
976 let rarity_bonus = {
977 let max_cos = max_corpus_cosine(conn, vec);
978 if max_cos < 0.5 { 0.1_f32 } else { 0.0 }
979 };
980 if rarity_bonus > 0.0 {
981 let full_score = (initial_kind_weight + rarity_bonus).min(0.5);
982 conn.execute(
983 "UPDATE memories SET usefulness_score = ?2 WHERE memory_id = ?1",
984 rusqlite::params![memory_id, full_score],
985 )
986 .ok(); }
988 }
989 }
990
991 if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
1006 let new_created_at: String = conn
1010 .query_row(
1011 "SELECT created_at FROM memories WHERE memory_id = ?1",
1012 rusqlite::params![memory_id],
1013 |row| row.get(0),
1014 )
1015 .unwrap_or_else(|_| {
1016 time::OffsetDateTime::now_utc()
1018 .format(&time::format_description::well_known::Rfc3339)
1019 .unwrap_or_default()
1020 });
1021
1022 if conflict::resolve_conflicts_enabled(config.ingestion.resolve_conflicts) {
1023 let (auto_resolved, queued) = conflict::detect_record_and_resolve_with_vec(
1025 conn,
1026 &memory_id,
1027 &scope,
1028 &kind.to_string(),
1029 text,
1030 embedding_vec.as_deref(),
1031 embedder,
1032 DIRECT_ADD_CONFIDENCE, &new_created_at,
1034 );
1035 if auto_resolved > 0 {
1036 eprintln!(
1037 "kimetsu-brain: memory {memory_id} auto-resolved {auto_resolved} contradiction{} (loser valid_to stamped)",
1038 if auto_resolved == 1 { "" } else { "s" }
1039 );
1040 }
1041 if queued > 0 {
1042 eprintln!(
1043 "kimetsu-brain: memory {memory_id} has {queued} near-tie conflict{} queued for review (run `kimetsu brain memory conflicts`)",
1044 if queued == 1 { "" } else { "s" }
1045 );
1046 }
1047 } else {
1048 let conflicts = conflict::detect_and_record_with_vec(
1050 conn,
1051 &memory_id,
1052 &scope,
1053 &kind.to_string(),
1054 text,
1055 embedding_vec.as_deref(),
1056 embedder,
1057 );
1058 if conflicts > 0 {
1059 eprintln!(
1060 "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
1061 if conflicts == 1 { "y" } else { "ies" }
1062 );
1063 }
1064 }
1065 }
1066
1067 link_memory_into_graph(conn, &memory_id);
1076
1077 Ok(memory_id)
1078}
1079
1080fn link_memory_into_graph(conn: &Connection, memory_id: &str) {
1084 let Ok(edges) = crate::graph::incremental_edges_for_memory(conn, memory_id, 0) else {
1085 return;
1086 };
1087 if edges.is_empty() {
1088 return;
1089 }
1090 let tuples: Vec<(String, String, String)> = edges
1091 .into_iter()
1092 .map(|e| (e.src_id, e.dst_id, e.edge_type))
1093 .collect();
1094 let _ = crate::projector::add_memory_edges(conn, &tuples);
1095}
1096
1097pub fn add_memories_batch(
1117 start: &Path,
1118 entries: Vec<BatchMemoryEntry>,
1119) -> KimetsuResult<Vec<String>> {
1120 if entries.is_empty() {
1121 return Ok(vec![]);
1122 }
1123
1124 let use_user_brain = ProjectPaths::discover(start)
1127 .ok()
1128 .and_then(|paths| load_config(&paths).ok())
1129 .map(|cfg| cfg.kimetsu.use_user_brain)
1130 .unwrap_or(true);
1131
1132 let user_conn_opt = user_brain::open_user_brain_for_config(use_user_brain)?;
1134
1135 let has_project_entries = entries.iter().any(|e| e.scope != MemoryScope::GlobalUser);
1138
1139 let project_state: Option<(
1141 ProjectPaths,
1142 kimetsu_core::config::ProjectConfig,
1143 Connection,
1144 )> = if has_project_entries {
1145 let state = load_project(start)?;
1146 Some(state)
1147 } else {
1148 None
1149 };
1150
1151 let run_id_for_lock = RunId::new();
1153 let _lock = if let Some((ref paths, _, _)) = project_state {
1154 Some(ProjectLock::acquire(
1155 paths,
1156 "brain memory add-batch",
1157 Some(run_id_for_lock),
1158 )?)
1159 } else {
1160 None
1161 };
1162
1163 let embedder: &dyn embeddings::Embedder = if let Some((_, ref config, _)) = project_state {
1166 embeddings::open_embedder_for(config.embedder.enabled)
1167 } else {
1168 &embeddings::NoopEmbedder
1169 };
1170
1171 let mut ids = Vec::with_capacity(entries.len());
1172
1173 for entry in entries {
1174 let redaction = redact::redact_secrets(&entry.text);
1176 if redaction.was_redacted() {
1177 eprintln!("kimetsu-brain: {}", redaction.summary());
1178 }
1179 let text = redaction.text.as_str();
1180
1181 if entry.scope == MemoryScope::GlobalUser {
1182 if let Some(ref uc) = user_conn_opt {
1185 let id = user_brain::add_user_memory(uc, entry.kind, text, 1.0)?;
1186 ids.push(id);
1187 continue;
1188 }
1189 }
1191
1192 let (paths, config, conn) = project_state
1193 .as_ref()
1194 .expect("project must be open when non-GlobalUser entries are present");
1195
1196 let id = add_memory_inner(
1197 conn,
1198 paths,
1199 config,
1200 entry.scope,
1201 entry.kind,
1202 text,
1203 entry.valid_from.as_deref(),
1204 entry.valid_to.as_deref(),
1205 embedder,
1206 )?;
1207 ids.push(id);
1208 }
1209
1210 Ok(ids)
1211}
1212
1213pub fn propose_memory(
1218 start: &Path,
1219 scope: MemoryScope,
1220 kind: MemoryKind,
1221 text: &str,
1222 confidence: f32,
1223 rationale: &str,
1224) -> KimetsuResult<String> {
1225 propose_memory_with_validity(start, scope, kind, text, confidence, rationale, None, None)
1226}
1227
1228#[allow(clippy::too_many_arguments)]
1229pub fn propose_memory_with_validity(
1230 start: &Path,
1231 scope: MemoryScope,
1232 kind: MemoryKind,
1233 text: &str,
1234 confidence: f32,
1235 rationale: &str,
1236 valid_from: Option<&str>,
1237 valid_to: Option<&str>,
1238) -> KimetsuResult<String> {
1239 let redaction = redact::redact_secrets(text);
1240 if redaction.was_redacted() {
1241 eprintln!("kimetsu-brain: {}", redaction.summary());
1242 }
1243 let text = redaction.text.as_str();
1244 let rationale_redaction = redact::redact_secrets(rationale);
1245 if rationale_redaction.was_redacted() {
1246 eprintln!("kimetsu-brain: {}", rationale_redaction.summary());
1247 }
1248 let rationale = rationale_redaction.text.as_str();
1249 let (paths, config, conn) = load_project(start)?;
1250 let run_id = RunId::new();
1251 let _lock = ProjectLock::acquire(&paths, "memory propose", Some(run_id))?;
1252 let proposal_id = Ulid::new().to_string();
1253
1254 let started = admin_started_event(&paths, &config, run_id, "memory propose")?;
1255
1256 let proposed = Event::new(
1257 run_id,
1258 "memory.proposed",
1259 serde_json::json!({
1260 "proposal_id": proposal_id,
1261 "scope": scope.to_string(),
1262 "kind": kind.to_string(),
1263 "text": text,
1264 "rationale": rationale,
1265 "proposed_confidence": confidence.clamp(0.0, 1.0),
1266 "valid_from": valid_from,
1267 "valid_to": valid_to,
1268 "source_event_ids": [],
1269 }),
1270 );
1271
1272 let finished = admin_finished_event(run_id);
1273
1274 projector::apply_events(&conn, &[started, proposed, finished])?;
1275 Ok(proposal_id)
1276}
1277
1278#[derive(Debug)]
1280pub enum ProposeResult {
1281 Added(String), Proposed(String), Merged(String), Duplicate(String), }
1286
1287pub fn propose_or_merge_memory(
1292 start: &Path,
1293 scope: MemoryScope,
1294 kind: MemoryKind,
1295 text: &str,
1296 confidence: f32,
1297 rationale: &str,
1298) -> KimetsuResult<ProposeResult> {
1299 propose_or_merge_memory_with_validity(
1300 start, scope, kind, text, confidence, rationale, None, None,
1301 )
1302}
1303
1304#[allow(clippy::too_many_arguments)]
1305pub fn propose_or_merge_memory_with_validity(
1306 start: &Path,
1307 scope: MemoryScope,
1308 kind: MemoryKind,
1309 text: &str,
1310 confidence: f32,
1311 rationale: &str,
1312 valid_from: Option<&str>,
1313 valid_to: Option<&str>,
1314) -> KimetsuResult<ProposeResult> {
1315 let redaction = redact::redact_secrets(text);
1316 if redaction.was_redacted() {
1317 eprintln!("kimetsu-brain: {}", redaction.summary());
1318 }
1319 let text = redaction.text.as_str();
1320
1321 {
1323 let (_, _, ro_conn) = load_project_readonly(start)?;
1324 let normalized = normalize_memory_text(text);
1325 let existing: Option<String> = ro_conn
1326 .query_row(
1327 "SELECT memory_id FROM memories
1328 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
1329 AND invalidated_at IS NULL
1330 AND superseded_by IS NULL
1331 LIMIT 1",
1332 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
1333 |row| row.get::<_, String>(0),
1334 )
1335 .optional()?;
1336 if let Some(id) = existing {
1337 return Ok(ProposeResult::Duplicate(id));
1338 }
1339 }
1340
1341 if confidence >= 0.7 {
1343 let memory_id = add_memory_with_validity(start, scope, kind, text, valid_from, valid_to)?;
1344 Ok(ProposeResult::Added(memory_id))
1345 } else {
1346 let proposal_id = propose_memory_with_validity(
1347 start, scope, kind, text, confidence, rationale, valid_from, valid_to,
1348 )?;
1349 Ok(ProposeResult::Proposed(proposal_id))
1350 }
1351}
1352
1353#[derive(Debug, Clone)]
1357pub struct ListOptions {
1358 pub limit: u32,
1360 pub offset: u32,
1362 pub scope: Option<String>,
1364}
1365
1366impl Default for ListOptions {
1367 fn default() -> Self {
1368 Self {
1369 limit: 100,
1370 offset: 0,
1371 scope: None,
1372 }
1373 }
1374}
1375
1376pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
1377 list_memories_with(start, ListOptions::default())
1378}
1379
1380pub fn list_memories_with(start: &Path, opts: ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1385 let (_paths, config, conn) = load_project(start)?;
1386 let mut memories = list_memories_from_conn(&conn, &opts)?;
1387 if opts.offset == 0
1389 && let Some(user_conn) =
1390 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1391 {
1392 memories.extend(user_brain::list_user_memories(&user_conn)?);
1393 }
1394 Ok(memories)
1395}
1396
1397pub use crate::blame::*;
1402
1403pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
1404 let (_paths, _config, conn) = load_project(start)?;
1405 let mut sql = String::from(
1406 "
1407 SELECT proposal_id, run_id, scope, kind, text, rationale,
1408 proposed_confidence, status, decided_reason
1409 FROM memory_proposals
1410 ",
1411 );
1412 let mut clauses = Vec::<String>::new();
1413 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1414 if let Some(scope) = filter.scope.as_deref() {
1415 clauses.push("scope = ?".to_string());
1416 params.push(Box::new(scope.to_string()));
1417 }
1418 if let Some(kind) = filter.kind.as_deref() {
1419 clauses.push("kind = ?".to_string());
1420 params.push(Box::new(kind.to_string()));
1421 }
1422 if let Some(run_id) = filter.from_run.as_deref() {
1423 clauses.push("run_id = ?".to_string());
1424 params.push(Box::new(run_id.to_string()));
1425 }
1426 if let Some(min_conf) = filter.min_confidence {
1427 clauses.push("proposed_confidence >= ?".to_string());
1428 params.push(Box::new(min_conf as f64));
1429 }
1430 if let Some(status) = filter.status.as_deref()
1431 && !status.eq_ignore_ascii_case("any")
1432 {
1433 clauses.push("status = ?".to_string());
1434 params.push(Box::new(status.to_string()));
1435 }
1436 if !clauses.is_empty() {
1437 sql.push_str(" WHERE ");
1438 sql.push_str(&clauses.join(" AND "));
1439 }
1440 let limit = if filter.limit == 0 { 100 } else { filter.limit };
1441 sql.push_str(&format!(
1442 " ORDER BY rowid DESC LIMIT {limit} OFFSET {}",
1443 filter.offset
1444 ));
1445
1446 let mut stmt = conn.prepare(&sql)?;
1447 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1448 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1449 Ok(ProposalRow {
1450 proposal_id: row.get(0)?,
1451 run_id: row.get(1)?,
1452 scope: row.get(2)?,
1453 kind: row.get(3)?,
1454 text: row.get(4)?,
1455 rationale: row.get(5)?,
1456 proposed_confidence: row.get(6)?,
1457 status: row.get(7)?,
1458 decided_reason: row.get(8)?,
1459 })
1460 })?;
1461
1462 let mut proposals = Vec::new();
1463 for row in rows {
1464 proposals.push(row?);
1465 }
1466 Ok(proposals)
1467}
1468
1469pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1470 let (paths, config, conn) = load_project(start)?;
1471 let run_id = RunId::new();
1472 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1473
1474 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1475
1476 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1477
1478 let ingested = Event::new(
1479 run_id,
1480 "repo.ingested",
1481 serde_json::json!({
1482 "repo_root": summary.repo_root.to_string_lossy(),
1483 "indexed_files": summary.indexed_files,
1484 "skipped_files": summary.skipped_files,
1485 "manifests": summary.manifests,
1486 }),
1487 );
1488
1489 let finished = admin_finished_event(run_id);
1490 projector::apply_events(&conn, &[started, ingested, finished])?;
1491
1492 Ok(summary)
1493}
1494
1495pub fn ingest_repo_at_root(
1500 brain_root: &Path,
1501 files_root: &Path,
1502) -> KimetsuResult<RepoIngestSummary> {
1503 let (paths, config, conn) = load_project_at_root(brain_root)?;
1504 let run_id = RunId::new();
1507 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo (remote)", Some(run_id))?;
1508
1509 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1510 let summary = ingest::ingest_repo_from_root(&conn, &paths, &config, files_root)?;
1511 let ingested = Event::new(
1512 run_id,
1513 "repo.ingested",
1514 serde_json::json!({
1515 "repo_root": summary.repo_root.to_string_lossy(),
1516 "indexed_files": summary.indexed_files,
1517 "skipped_files": summary.skipped_files,
1518 "manifests": summary.manifests,
1519 }),
1520 );
1521 let finished = admin_finished_event(run_id);
1522 projector::apply_events(&conn, &[started, ingested, finished])?;
1523
1524 Ok(summary)
1525}
1526
1527pub fn search_files(
1528 start: &Path,
1529 query: &str,
1530 limit: u32,
1531) -> KimetsuResult<Vec<context::ContextCapsule>> {
1532 let (paths, _config, conn) = load_project(start)?;
1533 let repo_root = paths
1534 .repo_root
1535 .canonicalize()?
1536 .to_string_lossy()
1537 .to_string();
1538 context::search_repo_files(&conn, &repo_root, query, limit)
1539}
1540
1541pub fn retrieve_context(
1542 start: &Path,
1543 stage: &str,
1544 query: &str,
1545 budget_tokens: u32,
1546) -> KimetsuResult<ContextBundle> {
1547 BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1548}
1549
1550pub fn resolved_abstain_evidence_for(config: &kimetsu_core::config::ProjectConfig) -> f32 {
1566 if let Some(env) = std::env::var("KIMETSU_ABSTAIN_EVIDENCE")
1567 .ok()
1568 .and_then(|v| v.parse::<f32>().ok())
1569 {
1570 return env;
1571 }
1572 let configured = config.broker.abstain_min_score;
1573 if configured >= 0.0 {
1574 return configured;
1575 }
1576 let model = embeddings::resolve_embedder_id(Some(config.embedder.model.as_str()));
1577 if model.starts_with("jina-v2") || model.starts_with("bge") {
1578 0.55
1579 } else {
1580 0.0
1581 }
1582}
1583
1584pub fn retrieve_context_readonly(
1585 start: &Path,
1586 stage: &str,
1587 query: &str,
1588 budget_tokens: u32,
1589) -> KimetsuResult<ContextBundle> {
1590 BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1591}
1592
1593pub fn retrieve_context_readonly_with_request(
1596 start: &Path,
1597 request: ContextRequest,
1598) -> KimetsuResult<ContextBundle> {
1599 BrainSession::open_readonly(start)?.retrieve_context_with_request(request)
1600}
1601
1602pub fn retrieve_context_lexical_readonly(
1608 start: &Path,
1609 request: ContextRequest,
1610) -> KimetsuResult<ContextBundle> {
1611 BrainSession::open_readonly(start)?.retrieve_context_lexical(request)
1612}
1613
1614pub fn evidence_coverage_readonly(
1629 start: &Path,
1630 query: &str,
1631 capsules: &[context::ContextCapsule],
1632) -> (f32, Vec<String>) {
1633 let Ok((_paths, _config, conn)) = load_project_readonly(start) else {
1634 return (1.0, Vec::new());
1635 };
1636 context::evidence_coverage(&conn, query, capsules)
1637}
1638
1639pub fn retrieve_proactive_readonly(
1643 start: &Path,
1644 request: ContextRequest,
1645) -> KimetsuResult<ContextBundle> {
1646 BrainSession::open_readonly(start)?.retrieve_proactive(request)
1647}
1648
1649pub fn search_memories(
1654 start: &Path,
1655 query: &str,
1656 limit: u32,
1657 offset: u32,
1658 kind: Option<&str>,
1659 scope: Option<&str>,
1660) -> KimetsuResult<Vec<MemorySearchHit>> {
1661 let Some(fts) = context::fts_query(query) else {
1662 return Ok(Vec::new());
1663 };
1664 let (_paths, config, conn) = load_project(start)?;
1665 let mut hits = search_memories_in_conn(&conn, &fts, limit, offset, kind, scope)?;
1666 if offset == 0
1668 && let Some(user_conn) =
1669 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1670 {
1671 hits.extend(search_memories_in_conn(
1672 &user_conn, &fts, limit, 0, kind, scope,
1673 )?);
1674 }
1675 Ok(hits)
1676}
1677
1678fn search_memories_in_conn(
1679 conn: &Connection,
1680 fts_query: &str,
1681 limit: u32,
1682 offset: u32,
1683 kind: Option<&str>,
1684 scope: Option<&str>,
1685) -> KimetsuResult<Vec<MemorySearchHit>> {
1686 let limit = if limit == 0 { 20 } else { limit } as i64;
1687 let offset = offset as i64;
1688 let mut sql = String::from(
1689 "
1690 SELECT m.memory_id, m.scope, m.kind, m.text, bm25(memories_fts) AS rank
1691 FROM memories_fts
1692 JOIN memories m ON m.memory_id = memories_fts.memory_id
1693 WHERE m.invalidated_at IS NULL
1694 AND m.superseded_by IS NULL
1695 AND memories_fts MATCH ?
1696 ",
1697 );
1698 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(fts_query.to_string())];
1699 if let Some(k) = kind {
1700 sql.push_str(" AND m.kind = ?");
1701 bind.push(Box::new(k.to_string()));
1702 }
1703 if let Some(s) = scope {
1704 sql.push_str(" AND lower(m.scope) = lower(?)");
1705 bind.push(Box::new(s.to_string()));
1706 }
1707 sql.push_str(" ORDER BY rank LIMIT ? OFFSET ?");
1709 bind.push(Box::new(limit));
1710 bind.push(Box::new(offset));
1711
1712 let mut stmt = conn.prepare(&sql)?;
1713 let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
1714 let rows = stmt.query_map(refs.as_slice(), |row| {
1715 let raw_rank = row.get::<_, f64>(4)? as f32;
1716 Ok(MemorySearchHit {
1717 memory_id: row.get(0)?,
1718 scope: row.get(1)?,
1719 kind: row.get(2)?,
1720 text: row.get(3)?,
1721 rank: (-raw_rank).max(0.0),
1723 })
1724 })?;
1725 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1726}
1727
1728#[allow(clippy::too_many_arguments)]
1729pub fn retrieve_benchmark_context_readonly(
1730 start: &Path,
1731 task: &str,
1732 dataset: &str,
1733 task_slug: Option<&str>,
1734 warm_policy: benchmark::BenchmarkWarmPolicy,
1735 stage: &str,
1736 budget_tokens: u32,
1737 require_benchmark_memory: bool,
1738 max_capsules: usize,
1739) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1740 retrieve_benchmark_context_readonly_with_ambient(
1741 start,
1742 task,
1743 dataset,
1744 task_slug,
1745 warm_policy,
1746 stage,
1747 budget_tokens,
1748 require_benchmark_memory,
1749 max_capsules,
1750 None,
1751 )
1752}
1753
1754#[allow(clippy::too_many_arguments)]
1760pub fn retrieve_benchmark_context_readonly_with_ambient(
1761 start: &Path,
1762 task: &str,
1763 dataset: &str,
1764 task_slug: Option<&str>,
1765 warm_policy: benchmark::BenchmarkWarmPolicy,
1766 stage: &str,
1767 budget_tokens: u32,
1768 require_benchmark_memory: bool,
1769 max_capsules: usize,
1770 ambient_suffix: Option<&str>,
1771) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1772 let normalized_slug = task_slug
1773 .and_then(benchmark::normalize_task_slug)
1774 .or_else(|| benchmark::normalize_task_slug(task));
1775 let mut query =
1776 benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
1777 if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
1778 query.push_str(suffix);
1779 }
1780 let bundle =
1781 BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
1782 Ok(benchmark::build_benchmark_context(
1783 bundle,
1784 task,
1785 dataset,
1786 &query,
1787 normalized_slug,
1788 warm_policy,
1789 require_benchmark_memory,
1790 max_capsules,
1791 ))
1792}
1793
1794pub fn record_benchmark_outcome(
1795 start: &Path,
1796 outcome: benchmark::BenchmarkOutcome,
1797) -> KimetsuResult<RecordedBenchmarkOutcome> {
1798 let task_slug = outcome
1799 .task_slug
1800 .clone()
1801 .or_else(|| benchmark::normalize_task_slug(&outcome.task));
1802 let kind = benchmark::outcome_memory_kind(&outcome);
1803 let text = benchmark::outcome_memory_text(&outcome);
1804 let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
1805 let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
1806 Some(proposal) if proposal.role.is_generalizable() => {
1807 let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
1808 (Some(proposal_id), Some(proposal_text))
1809 }
1810 _ => (None, None),
1811 };
1812 Ok(RecordedBenchmarkOutcome {
1813 memory_id,
1814 task_slug,
1815 kind,
1816 text,
1817 proposal_id,
1818 proposal_text,
1819 })
1820}
1821
1822fn propose_benchmark_memory(
1823 start: &Path,
1824 outcome: &benchmark::BenchmarkOutcome,
1825 proposal: &benchmark::BenchmarkMemoryProposal,
1826) -> KimetsuResult<(String, String)> {
1827 let (paths, config, conn) = load_project(start)?;
1828 let run_id = RunId::new();
1829 let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
1830 let proposal_id = Ulid::new().to_string();
1831 let raw_text = benchmark::proposal_memory_text(outcome, proposal);
1835 let text_redaction = redact::redact_secrets(&raw_text);
1836 if text_redaction.was_redacted() {
1837 eprintln!(
1838 "kimetsu-brain (benchmark proposal): {}",
1839 text_redaction.summary()
1840 );
1841 }
1842 let text = text_redaction.text;
1843 let kind = benchmark::proposal_memory_kind(proposal);
1844 let rationale_raw = if proposal.rationale.trim().is_empty() {
1845 "generalized from benchmark outcome".to_string()
1846 } else {
1847 proposal.rationale.trim().to_string()
1848 };
1849 let rationale = redact::redact_secrets(&rationale_raw).text;
1850
1851 let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
1852
1853 let proposed = Event::new(
1854 run_id,
1855 "memory.proposed",
1856 serde_json::json!({
1857 "proposal_id": proposal_id,
1858 "scope": "global_user",
1859 "kind": kind.to_string(),
1860 "text": text,
1861 "rationale": rationale,
1862 "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
1863 "source_event_ids": [],
1864 }),
1865 );
1866
1867 let finished = admin_finished_event(run_id);
1868
1869 projector::apply_events(&conn, &[started, proposed, finished])?;
1870 Ok((proposal_id, text))
1871}
1872
1873pub fn accept_proposal(
1874 start: &Path,
1875 proposal_id: &str,
1876 overrides: AcceptOverrides,
1877) -> KimetsuResult<String> {
1878 let (paths, config, conn) = load_project(start)?;
1879 let proposal = load_pending_proposal(&conn, proposal_id)?;
1880 let (valid_from, valid_to): (Option<String>, Option<String>) = conn.query_row(
1881 "SELECT valid_from, valid_to FROM memory_proposals WHERE proposal_id=?1",
1882 [proposal_id],
1883 |r| Ok((r.get(0)?, r.get(1)?)),
1884 )?;
1885 let run_id = RunId::new();
1886 let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
1887 let memory_id = Ulid::new().to_string();
1888 let normalized = normalize_memory_text(&proposal.text);
1889
1890 let resolved_scope = match overrides.scope.as_deref() {
1891 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
1892 _ => proposal.scope.clone(),
1893 };
1894 let resolved_confidence = overrides
1895 .confidence
1896 .map(|c| c.clamp(0.0, 1.0))
1897 .unwrap_or(proposal.proposed_confidence);
1898
1899 let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
1900
1901 let accepted = Event::new(
1902 run_id,
1903 "memory.accepted",
1904 serde_json::json!({
1905 "proposal_id": proposal.proposal_id,
1906 "memory_id": memory_id,
1907 "scope": resolved_scope,
1908 "kind": proposal.kind,
1909 "text": proposal.text,
1910 "normalized_text": normalized,
1911 "valid_from": valid_from,
1912 "valid_to": valid_to,
1913 "confidence": resolved_confidence,
1914 "provenance_snapshot": {
1915 "source": "memory_proposal",
1916 "proposal_id": proposal.proposal_id,
1917 "source_run_id": proposal.run_id,
1918 "scope_override": overrides.scope.clone(),
1919 "confidence_override": overrides.confidence,
1920 }
1921 }),
1922 );
1923
1924 let finished = admin_finished_event(run_id);
1925
1926 projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
1927 conn.execute(
1928 "
1929 UPDATE memory_proposals
1930 SET status = 'accepted',
1931 decided_at = ?2,
1932 decided_by = 'cli'
1933 WHERE proposal_id = ?1
1934 ",
1935 params![
1936 proposal_id,
1937 accepted
1938 .ts
1939 .format(&time::format_description::well_known::Rfc3339)?
1940 ],
1941 )?;
1942
1943 Ok(memory_id)
1944}
1945
1946pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1952 let (paths, config, conn) = load_project(start)?;
1953 let exists: i64 = conn.query_row(
1954 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1955 params![memory_id],
1956 |row| row.get(0),
1957 )?;
1958 if exists == 0 {
1959 return Err(format!("memory not found: {memory_id}").into());
1960 }
1961
1962 let run_id = RunId::new();
1963 let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
1964
1965 let resolved_reason = reason
1966 .and_then(|s| {
1967 let trimmed = s.trim();
1968 if trimmed.is_empty() {
1969 None
1970 } else {
1971 Some(trimmed.to_string())
1972 }
1973 })
1974 .unwrap_or_else(|| "invalidated_by_cli".to_string());
1975
1976 let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
1977
1978 let invalidated = Event::new(
1979 run_id,
1980 "memory.invalidated",
1981 serde_json::json!({
1982 "memory_id": memory_id,
1983 "reason": resolved_reason,
1984 }),
1985 );
1986
1987 let finished = admin_finished_event(run_id);
1988
1989 projector::apply_events(&conn, &[started, invalidated, finished])?;
1990 Ok(())
1991}
1992
1993#[derive(Debug, Clone)]
1995pub struct UndoneMemory {
1996 pub memory_id: String,
1997 pub text: String,
1998 pub scope: String,
1999 pub kind: String,
2000}
2001
2002pub fn edit_memory(
2006 start: &Path,
2007 memory_id: &str,
2008 new_text: Option<&str>,
2009 new_kind: Option<MemoryKind>,
2010) -> KimetsuResult<()> {
2011 if new_text.is_none() && new_kind.is_none() {
2012 return Err("edit_memory: at least one of --text or --kind must be provided".into());
2013 }
2014
2015 let (paths, config, conn) = load_project(start)?;
2016 let run_id = RunId::new();
2017 let _lock = ProjectLock::acquire(&paths, "brain memory edit", Some(run_id))?;
2018 let corrected = Event::new(
2019 run_id,
2020 "memory.corrected",
2021 serde_json::json!({
2022 "memory_id": memory_id,
2023 "text": new_text.map(|text| redact::redact_secrets(text).text),
2024 "kind": new_kind.map(|kind| kind.to_string()),
2025 }),
2026 );
2027 projector::apply_events(
2028 &conn,
2029 &[
2030 admin_started_event(&paths, &config, run_id, "memory edit")?,
2031 corrected,
2032 admin_finished_event(run_id),
2033 ],
2034 )?;
2035 if new_text.is_some() {
2038 let text: String = conn.query_row(
2039 "SELECT text FROM memories WHERE memory_id=?1",
2040 params![memory_id],
2041 |r| r.get(0),
2042 )?;
2043 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
2044 embeddings::embed_and_persist(&conn, memory_id, &text, embedder)?;
2045 }
2046
2047 Ok(())
2048}
2049
2050pub fn peek_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2054 let (_paths, _config, conn) = load_project(start)?;
2055 let row: Option<(String, String, String, String)> = conn
2058 .query_row(
2059 "SELECT memory_id, text, scope, kind FROM memories
2060 WHERE invalidated_at IS NULL
2061 AND superseded_by IS NULL
2062 ORDER BY created_at DESC, memory_id DESC
2063 LIMIT 1",
2064 [],
2065 |row| {
2066 Ok((
2067 row.get::<_, String>(0)?,
2068 row.get::<_, String>(1)?,
2069 row.get::<_, String>(2)?,
2070 row.get::<_, String>(3)?,
2071 ))
2072 },
2073 )
2074 .optional()?;
2075
2076 Ok(row.map(|(memory_id, text, scope, kind)| UndoneMemory {
2077 memory_id,
2078 text,
2079 scope,
2080 kind,
2081 }))
2082}
2083
2084pub fn undo_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2093 let (paths, _config, conn) = load_project(start)?;
2094
2095 let row: Option<(String, String, String, String)> = conn
2098 .query_row(
2099 "SELECT memory_id, text, scope, kind FROM memories
2100 WHERE invalidated_at IS NULL
2101 AND superseded_by IS NULL
2102 ORDER BY created_at DESC, memory_id DESC
2103 LIMIT 1",
2104 [],
2105 |row| {
2106 Ok((
2107 row.get::<_, String>(0)?,
2108 row.get::<_, String>(1)?,
2109 row.get::<_, String>(2)?,
2110 row.get::<_, String>(3)?,
2111 ))
2112 },
2113 )
2114 .optional()?;
2115
2116 let (memory_id, text, scope, kind) = match row {
2117 None => return Ok(None),
2118 Some(r) => r,
2119 };
2120
2121 drop(conn);
2123 drop(paths);
2124
2125 invalidate_memory(start, &memory_id, Some("undo: last recorded memory"))?;
2126
2127 Ok(Some(UndoneMemory {
2128 memory_id,
2129 text,
2130 scope,
2131 kind,
2132 }))
2133}
2134
2135pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2136 let (paths, config, conn) = load_project(start)?;
2137 let _proposal = load_pending_proposal(&conn, proposal_id)?;
2138 let run_id = RunId::new();
2139 let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
2140
2141 let resolved_reason = reason
2142 .and_then(|s| {
2143 let trimmed = s.trim();
2144 if trimmed.is_empty() {
2145 None
2146 } else {
2147 Some(trimmed.to_string())
2148 }
2149 })
2150 .unwrap_or_else(|| "rejected_by_cli".to_string());
2151
2152 let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
2153
2154 let rejected = Event::new(
2155 run_id,
2156 "memory.rejected",
2157 serde_json::json!({
2158 "proposal_id": proposal_id,
2159 "reason": resolved_reason,
2160 }),
2161 );
2162
2163 let finished = admin_finished_event(run_id);
2164
2165 projector::apply_events(&conn, &[started, rejected, finished])?;
2166 Ok(())
2167}
2168
2169pub use crate::maintenance::*;
2171
2172pub use crate::conflicts::*;
2174
2175pub use crate::feedback::*;
2177
2178pub use crate::graph_build::*;
2180
2181pub use crate::packs::*;
2183
2184pub(crate) fn load_pending_proposal(
2186 conn: &Connection,
2187 proposal_id: &str,
2188) -> KimetsuResult<ProposalRow> {
2189 let mut stmt = conn.prepare(
2190 "
2191 SELECT proposal_id, run_id, scope, kind, text, rationale,
2192 proposed_confidence, status
2193 FROM memory_proposals
2194 WHERE proposal_id = ?1
2195 ",
2196 )?;
2197 let mut rows = stmt.query(params![proposal_id])?;
2198 let Some(row) = rows.next()? else {
2199 return Err(format!("memory proposal not found: {proposal_id}").into());
2200 };
2201
2202 let proposal = ProposalRow {
2203 proposal_id: row.get(0)?,
2204 run_id: row.get(1)?,
2205 scope: row.get(2)?,
2206 kind: row.get(3)?,
2207 text: row.get(4)?,
2208 rationale: row.get(5)?,
2209 proposed_confidence: row.get(6)?,
2210 status: row.get(7)?,
2211 decided_reason: None,
2212 };
2213
2214 if proposal.status != "pending" {
2215 return Err(format!(
2216 "memory proposal {proposal_id} is {}, not pending",
2217 proposal.status
2218 )
2219 .into());
2220 }
2221
2222 Ok(proposal)
2223}
2224
2225pub(crate) fn admin_started_event(
2226 paths: &ProjectPaths,
2227 config: &ProjectConfig,
2228 run_id: RunId,
2229 task: &str,
2230) -> KimetsuResult<Event> {
2231 Ok(Event::new(
2232 run_id,
2233 "run.started",
2234 serde_json::json!({
2235 "mode": "admin",
2236 "task": task,
2237 "project_id": config.kimetsu.project_id,
2238 "repo_root": paths.repo_root.to_string_lossy(),
2239 "model": null,
2240 "platform": std::env::consts::OS,
2241 "kimetsu_version": env!("CARGO_PKG_VERSION"),
2242 "config_hash": config_hash(&paths.project_toml)?,
2243 }),
2244 ))
2245}
2246
2247pub(crate) fn admin_finished_event(run_id: RunId) -> Event {
2248 Event::new(
2249 run_id,
2250 "run.finished",
2251 serde_json::json!({
2252 "status": "success",
2253 "final_report_path": null,
2254 "total_cost_usd": 0.0,
2255 "total_tool_calls": 0,
2256 }),
2257 )
2258}
2259
2260pub(crate) fn config_hash(path: &Path) -> KimetsuResult<String> {
2261 let bytes = fs::read(path)?;
2262 Ok(blake3::hash(&bytes).to_hex().to_string())
2263}
2264
2265pub(crate) fn list_memories_from_conn(
2266 conn: &Connection,
2267 opts: &ListOptions,
2268) -> KimetsuResult<Vec<MemoryRow>> {
2269 let limit = if opts.limit == 0 { 100 } else { opts.limit } as i64;
2276 let offset = opts.offset as i64;
2277
2278 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
2279 (
2280 "
2281 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
2282 FROM memories
2283 WHERE invalidated_at IS NULL
2284 AND superseded_by IS NULL
2285 AND lower(scope) = lower(?1)
2286 ORDER BY created_at DESC
2287 LIMIT ?2 OFFSET ?3
2288 ",
2289 Some(scope.to_string()),
2290 )
2291 } else {
2292 (
2293 "
2294 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
2295 FROM memories
2296 WHERE invalidated_at IS NULL
2297 AND superseded_by IS NULL
2298 ORDER BY created_at DESC
2299 LIMIT ?1 OFFSET ?2
2300 ",
2301 None,
2302 )
2303 };
2304
2305 let mut stmt = conn.prepare(sql)?;
2306 let rows = if let Some(scope) = scope_param {
2307 stmt.query_map(params![scope, limit, offset], map_memory_row)?
2308 .collect::<Result<Vec<_>, _>>()?
2309 } else {
2310 stmt.query_map(params![limit, offset], map_memory_row)?
2311 .collect::<Result<Vec<_>, _>>()?
2312 };
2313 Ok(rows)
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318 use crate::trace::TraceWriter;
2319 use std::fs;
2320
2321 use super::*;
2322 use crate::user_brain::with_user_brain_disabled;
2329
2330 #[cfg(feature = "embeddings")]
2337 #[test]
2338 #[ignore = "requires a cached local embedding model"]
2339 fn similar_ingested_correction_preserves_both_claims_and_rebuild() {
2340 with_user_brain_disabled(|| {
2341 let root = test_root();
2342 init_project(&root, false).unwrap();
2343 let old = "For the Atlas integration service in the local staging environment, the HTTP listener uses port 4317 and binds to localhost.";
2344 let new = "For the Atlas integration service in the local staging environment, the HTTP listener uses port 4318 and binds to localhost.";
2345 let id = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, old).unwrap();
2346 let (_, config, conn) = load_project(&root).unwrap();
2347 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
2348 assert!(
2349 !embedder.is_noop(),
2350 "this regression requires real semantic candidates"
2351 );
2352 let hits = conflict::find_potential_conflicts(
2353 &conn,
2354 &MemoryScope::Project,
2355 new,
2356 embedder,
2357 1,
2358 0.85,
2359 )
2360 .unwrap();
2361 assert!(
2362 !hits.is_empty(),
2363 "fixture must trigger the former semantic merge"
2364 );
2365 assert!(matches!(
2366 propose_or_merge_memory(
2367 &root,
2368 MemoryScope::Project,
2369 MemoryKind::Fact,
2370 new,
2371 0.9,
2372 "port correction"
2373 )
2374 .unwrap(),
2375 ProposeResult::Added(_)
2376 ));
2377 let stored: String = conn
2378 .query_row("SELECT text FROM memories WHERE memory_id=?1", [&id], |r| {
2379 r.get(0)
2380 })
2381 .unwrap();
2382 assert_eq!(stored, old);
2383 projector::rebuild_in_place(&conn).unwrap();
2384 let count: i64 = conn.query_row("SELECT count(*) FROM memories WHERE text IN (?1,?2) AND invalidated_at IS NULL", [old,new], |r| r.get(0)).unwrap();
2385 assert_eq!(count, 2);
2386 });
2387 }
2388
2389 fn test_root() -> std::path::PathBuf {
2390 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2391 kimetsu_core::paths::git_init_boundary(&root);
2392 root
2393 }
2394
2395 #[test]
2396 fn w1_5_init_creates_kimetsu_dir_but_no_runs_dir() {
2397 with_user_brain_disabled(|| {
2398 let root = test_root();
2399 let summary = init_project(&root, false).expect("init");
2400 assert!(summary.kimetsu_dir.exists(), ".kimetsu/ must exist");
2402 assert!(summary.brain_db.exists(), "brain.db must be created");
2403 assert!(
2404 summary.kimetsu_dir.join("project.toml").exists(),
2405 "project.toml must be written"
2406 );
2407 assert!(
2410 !summary.kimetsu_dir.join("runs").exists(),
2411 "fresh init must NOT create a runs/ dir"
2412 );
2413 });
2414 }
2415
2416 #[test]
2417 fn search_memories_paginates_and_filters_by_kind() {
2418 with_user_brain_disabled(|| {
2419 let root = test_root();
2420 init_project(&root, false).expect("init");
2421 add_memory(
2422 &root,
2423 MemoryScope::Project,
2424 MemoryKind::FailurePattern,
2425 "linker link.exe not found on windows",
2426 )
2427 .expect("add fp");
2428 add_memory(
2429 &root,
2430 MemoryScope::Project,
2431 MemoryKind::Command,
2432 "run cargo build with the link.exe linker on PATH",
2433 )
2434 .expect("add cmd");
2435 add_memory(
2436 &root,
2437 MemoryScope::Project,
2438 MemoryKind::Fact,
2439 "the office plant needs watering on tuesdays",
2440 )
2441 .expect("add fact");
2442
2443 let hits = search_memories(&root, "linker", 10, 0, None, None).expect("search");
2445 assert!(hits.len() >= 2, "expected >=2 hits, got {}", hits.len());
2446 assert!(
2447 hits.iter()
2448 .all(|h| h.text.to_ascii_lowercase().contains("link"))
2449 );
2450
2451 let p1 = search_memories(&root, "linker", 1, 0, None, None).expect("p1");
2453 let p2 = search_memories(&root, "linker", 1, 1, None, None).expect("p2");
2454 assert_eq!(p1.len(), 1);
2455 assert_eq!(p2.len(), 1);
2456 assert_ne!(p1[0].memory_id, p2[0].memory_id, "offset must advance");
2457
2458 let fp =
2460 search_memories(&root, "linker", 10, 0, Some("failure_pattern"), None).expect("fp");
2461 assert!(!fp.is_empty());
2462 assert!(fp.iter().all(|h| h.kind == "failure_pattern"));
2463
2464 assert!(
2466 search_memories(&root, " ", 10, 0, None, None)
2467 .unwrap()
2468 .is_empty()
2469 );
2470 });
2471 }
2472
2473 #[test]
2474 fn reindex_with_explicit_embedder_uses_that_model() {
2475 with_user_brain_disabled(|| {
2476 let root = test_root();
2477 init_project(&root, false).expect("init");
2478 add_memory(
2479 &root,
2480 MemoryScope::Project,
2481 MemoryKind::Fact,
2482 "alpha beta gamma",
2483 )
2484 .expect("add");
2485 use crate::embeddings::Embedder as _;
2489 let stub = crate::embeddings::StubEmbedder::new();
2490 let report = crate::reindex::reindex_all_with_embedder(
2491 &root,
2492 crate::reindex::ReindexOptions {
2493 scope: crate::reindex::ReindexScope::Project,
2494 dry_run: false,
2495 force: false,
2496 limit: None,
2497 },
2498 &stub,
2499 )
2500 .expect("reindex");
2501 assert_eq!(report.embedder_model_id, stub.model_id());
2502 assert!(
2503 report.project.updated >= 1,
2504 "the row should be re-embedded with the stub model"
2505 );
2506 });
2507 }
2508
2509 #[test]
2510 fn retrieve_proactive_returns_actionable_kind_and_excludes_others() {
2511 with_user_brain_disabled(|| {
2512 let root = test_root();
2513 init_project(&root, false).expect("init");
2514 add_memory(
2515 &root,
2516 MemoryScope::Project,
2517 MemoryKind::FailurePattern,
2518 "linker link.exe not found -> run from x64 Native Tools prompt",
2519 )
2520 .expect("add fp");
2521 add_memory(
2524 &root,
2525 MemoryScope::Project,
2526 MemoryKind::Fact,
2527 "linker link.exe trivia: link.exe ships with MSVC",
2528 )
2529 .expect("add fact");
2530
2531 let request = ContextRequest {
2532 stage: "localization".to_string(),
2533 query: "error: linker `link.exe` not found".to_string(),
2534 budget_tokens: 600,
2535 min_score: 0.2,
2536 max_capsules: 1,
2537 kinds: vec!["failure_pattern".to_string(), "command".to_string()],
2538 ..Default::default()
2539 };
2540 let bundle = retrieve_proactive_readonly(&root, request).expect("proactive");
2541 assert!(!bundle.skipped, "should surface the failure_pattern");
2542 assert_eq!(bundle.capsules.len(), 1);
2543 assert!(
2545 bundle.capsules[0].summary.contains("failure_pattern"),
2546 "got summary: {}",
2547 bundle.capsules[0].summary
2548 );
2549 assert!(!bundle.capsules[0].summary.contains("trivia"));
2550 });
2551 }
2552
2553 #[test]
2554 fn memory_add_survives_projection_rebuild_from_trace() {
2555 with_user_brain_disabled(|| {
2556 let root = test_root();
2557 fs::create_dir_all(&root).expect("create temp project");
2558
2559 init_project(&root, false).expect("init project");
2560 let memory_id = add_memory(
2561 &root,
2562 MemoryScope::GlobalUser,
2563 MemoryKind::Preference,
2564 "User prefers Rust for core infrastructure.",
2565 )
2566 .expect("add memory");
2567
2568 let memories = list_memories(&root).expect("list memories");
2569 assert_eq!(memories.len(), 1);
2570 assert_eq!(memories[0].memory_id, memory_id);
2571
2572 let event_count = rebuild_projection(&root, false).expect("rebuild projection");
2573 assert_eq!(event_count, 3);
2574
2575 let memories = list_memories(&root).expect("list rebuilt memories");
2576 assert_eq!(memories.len(), 1);
2577 assert_eq!(memories[0].memory_id, memory_id);
2578 assert_eq!(
2579 memories[0].text,
2580 "User prefers Rust for core infrastructure."
2581 );
2582
2583 fs::remove_dir_all(root).expect("remove temp project");
2584 });
2585 }
2586
2587 #[test]
2591 fn add_memory_redacts_secrets_before_persist() {
2592 with_user_brain_disabled(|| {
2593 let root = test_root();
2594 fs::create_dir_all(&root).expect("create temp project");
2595 init_project(&root, false).expect("init project");
2596
2597 let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
2598 let memory_id =
2599 add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory");
2600
2601 let memories = list_memories(&root).expect("list");
2602 let stored = memories
2603 .iter()
2604 .find(|m| m.memory_id == memory_id)
2605 .expect("memory present");
2606 assert!(
2607 !stored.text.contains("sk-ant-api03"),
2608 "raw secret must NOT survive to brain.db: {}",
2609 stored.text
2610 );
2611 assert!(
2612 stored.text.contains("[REDACTED:anthropic_oauth]"),
2613 "placeholder must be present: {}",
2614 stored.text
2615 );
2616 assert!(
2617 stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"),
2618 "non-secret context must be preserved: {}",
2619 stored.text
2620 );
2621
2622 fs::remove_dir_all(root).expect("cleanup");
2623 });
2624 }
2625
2626 #[test]
2627 fn repo_ingest_indexes_searchable_files_and_context_capsules() {
2628 let root = test_root();
2629 fs::create_dir_all(root.join("src")).expect("create src");
2630 fs::create_dir_all(root.join("target")).expect("create target");
2631 fs::write(
2632 root.join("Cargo.toml"),
2633 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
2634 )
2635 .expect("write manifest");
2636 fs::write(
2637 root.join("src").join("lib.rs"),
2638 "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
2639 )
2640 .expect("write source");
2641 fs::write(
2642 root.join("target").join("generated.rs"),
2643 "projection rebuild",
2644 )
2645 .expect("write skipped");
2646 fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
2647 fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
2648
2649 init_project(&root, false).expect("init project");
2650 add_memory(
2651 &root,
2652 MemoryScope::GlobalUser,
2653 MemoryKind::Preference,
2654 "User prefers Rust for core infrastructure.",
2655 )
2656 .expect("add memory");
2657
2658 let summary = ingest_repo(&root).expect("ingest repo");
2659 assert_eq!(summary.indexed_files, 2);
2660 assert_eq!(summary.manifests, 1);
2661
2662 let matches = search_files(&root, "projection rebuild", 5).expect("search files");
2663 assert!(
2664 matches
2665 .iter()
2666 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2667 "expected src/lib.rs in search results: {matches:?}"
2668 );
2669 assert!(
2670 matches
2671 .iter()
2672 .all(|capsule| !capsule.expansion_handle.contains("target/")),
2673 "target files must not be indexed: {matches:?}"
2674 );
2675
2676 let context =
2677 retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
2678 assert!(
2679 context
2680 .capsules
2681 .iter()
2682 .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
2683 "expected memory capsule in context: {:?}",
2684 context.capsules
2685 );
2686
2687 rebuild_projection(&root, false).expect("rebuild projection");
2688 let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
2689 assert!(
2690 matches
2691 .iter()
2692 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2693 "repo index should survive event-only rebuild: {matches:?}"
2694 );
2695
2696 fs::remove_dir_all(root).expect("remove temp project");
2697 }
2698
2699 #[test]
2700 fn run_finished_increments_usefulness_for_injected_memories() {
2701 with_user_brain_disabled(|| {
2702 let root = test_root();
2710 fs::create_dir_all(&root).expect("create temp project");
2711 init_project(&root, false).expect("init project");
2712 let memory_id = add_memory(
2713 &root,
2714 MemoryScope::GlobalUser,
2715 MemoryKind::Preference,
2716 "Prefer ripgrep over grep.",
2717 )
2718 .expect("add memory");
2719
2720 {
2721 let (paths, _config, conn) = load_project(&root).expect("load");
2722 let run_id = RunId::new();
2723 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2724 let evs: Vec<Event> = vec![
2725 Event::new(
2726 run_id,
2727 "run.started",
2728 serde_json::json!({"project_id": "test", "task": "x"}),
2729 ),
2730 Event::new(
2731 run_id,
2732 "context.injected",
2733 serde_json::json!({
2734 "stage": "localization",
2735 "capsule_handles": [format!("memory:{memory_id}")],
2736 "memory_ids": [memory_id.clone()],
2737 "prior_run_ids": [],
2738 "file_paths": [],
2739 }),
2740 ),
2741 Event::new(
2742 run_id,
2743 "context.injected",
2744 serde_json::json!({
2745 "stage": "patch_plan",
2746 "capsule_handles": [format!("memory:{memory_id}")],
2747 "memory_ids": [memory_id.clone()],
2748 "prior_run_ids": [],
2749 "file_paths": [],
2750 }),
2751 ),
2752 Event::new(
2755 run_id,
2756 "memory.cited",
2757 serde_json::json!({
2758 "memory_id": memory_id,
2759 "turn": 3,
2760 "rationale": "using rg from memory",
2761 }),
2762 ),
2763 Event::new(
2764 run_id,
2765 "run.finished",
2766 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2767 ),
2768 ];
2769 for ev in &evs {
2770 writer.append(ev, true).expect("append");
2771 }
2772 projector::apply_events(&conn, &evs).expect("project");
2773 }
2774
2775 let memories = list_memories(&root).expect("list memories");
2776 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2777 assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
2778 let expected = 1.0 + 0.05; assert!(
2782 (m.usefulness_score - expected).abs() < 1e-4,
2783 "expected strong-signal usefulness_score = {expected}, got {}",
2784 m.usefulness_score
2785 );
2786
2787 fs::remove_dir_all(root).expect("remove temp project");
2788 });
2789 }
2790
2791 #[test]
2796 fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
2797 with_user_brain_disabled(|| {
2798 let root = test_root();
2799 fs::create_dir_all(&root).expect("create temp project");
2800 init_project(&root, false).expect("init project");
2801 let memory_id = add_memory(
2802 &root,
2803 MemoryScope::GlobalUser,
2804 MemoryKind::Preference,
2805 "Silent passenger memory.",
2806 )
2807 .expect("add memory");
2808
2809 {
2810 let (paths, _config, conn) = load_project(&root).expect("load");
2811 let run_id = RunId::new();
2812 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2813 let evs: Vec<Event> = vec![
2814 Event::new(
2815 run_id,
2816 "run.started",
2817 serde_json::json!({"project_id": "test", "task": "x"}),
2818 ),
2819 Event::new(
2820 run_id,
2821 "context.injected",
2822 serde_json::json!({
2823 "stage": "localization",
2824 "memory_ids": [memory_id.clone()],
2825 "prior_run_ids": [],
2826 "file_paths": [],
2827 }),
2828 ),
2829 Event::new(
2831 run_id,
2832 "run.finished",
2833 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2834 ),
2835 ];
2836 for ev in &evs {
2837 writer.append(ev, true).expect("append");
2838 }
2839 projector::apply_events(&conn, &evs).expect("project");
2840 }
2841
2842 let memories = list_memories(&root).expect("list memories");
2843 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2844 assert_eq!(m.use_count, 1);
2845 let expected = 0.1 + 0.05; assert!(
2849 (m.usefulness_score - expected).abs() < 1e-4,
2850 "silent passenger should get +0.1 on top of seed, got {}",
2851 m.usefulness_score
2852 );
2853 });
2854 }
2855
2856 #[test]
2862 fn blame_run_separates_cited_from_silent_passengers() {
2863 with_user_brain_disabled(|| {
2864 let root = test_root();
2865 fs::create_dir_all(&root).expect("create temp project");
2866 init_project(&root, false).expect("init project");
2867 let cited_id = add_memory(
2868 &root,
2869 MemoryScope::Repo,
2870 MemoryKind::Preference,
2871 "prefer ripgrep over grep",
2872 )
2873 .expect("add cited");
2874 let silent_id = add_memory(
2875 &root,
2876 MemoryScope::Repo,
2877 MemoryKind::Convention,
2878 "use cargo nextest for tests",
2879 )
2880 .expect("add silent");
2881
2882 let run_id = RunId::new();
2883 {
2884 let (paths, _config, conn) = load_project(&root).expect("load");
2885 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2886 let evs: Vec<Event> = vec![
2887 Event::new(
2888 run_id,
2889 "run.started",
2890 serde_json::json!({"project_id": "test", "task": "x"}),
2891 ),
2892 Event::new(
2893 run_id,
2894 "context.injected",
2895 serde_json::json!({
2896 "stage": "localization",
2897 "memory_ids": [cited_id.clone(), silent_id.clone()],
2898 "prior_run_ids": [],
2899 "file_paths": [],
2900 }),
2901 ),
2902 Event::new(
2903 run_id,
2904 "memory.cited",
2905 serde_json::json!({
2906 "memory_id": cited_id,
2907 "turn": 4,
2908 "rationale": "used the rg pattern",
2909 }),
2910 ),
2911 Event::new(
2912 run_id,
2913 "run.finished",
2914 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2915 ),
2916 ];
2917 for ev in &evs {
2918 writer.append(ev, true).expect("append");
2919 }
2920 projector::apply_events(&conn, &evs).expect("project");
2921 }
2922
2923 let report = blame_run(&root, &run_id.to_string()).expect("blame");
2924 assert_eq!(report.outcome, "success");
2925 assert!(report.failure_category.is_none());
2926 assert_eq!(report.cited.len(), 1, "exactly one cited memory");
2927 let cited = &report.cited[0];
2928 assert_eq!(cited.memory_id, cited_id);
2929 assert_eq!(cited.turn, 4);
2930 assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
2931 assert!(cited.text_preview.contains("ripgrep"));
2932
2933 assert_eq!(report.silent_passengers.len(), 1);
2934 let silent = &report.silent_passengers[0];
2935 assert_eq!(silent.memory_id, silent_id);
2936 assert!(silent.text_preview.contains("nextest"));
2937
2938 fs::remove_dir_all(root).expect("cleanup");
2939 });
2940 }
2941
2942 #[test]
2943 fn run_failed_decrements_usefulness_unless_gate() {
2944 let root = test_root();
2948 fs::create_dir_all(&root).expect("create temp project");
2949 init_project(&root, false).expect("init project");
2950 let memory_id = add_memory(
2951 &root,
2952 MemoryScope::Repo,
2953 MemoryKind::Convention,
2954 "Use find_* for fallible lookups.",
2955 )
2956 .expect("add memory");
2957
2958 {
2959 let (paths, _config, conn) = load_project(&root).expect("load");
2960
2961 let gate_run = RunId::new();
2963 let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
2964 let gate_events: Vec<Event> = vec![
2965 Event::new(
2966 gate_run,
2967 "run.started",
2968 serde_json::json!({"project_id": "test", "task": "g"}),
2969 ),
2970 Event::new(
2971 gate_run,
2972 "context.injected",
2973 serde_json::json!({
2974 "stage": "patch_plan",
2975 "capsule_handles": [format!("memory:{memory_id}")],
2976 "memory_ids": [memory_id.clone()],
2977 "prior_run_ids": [],
2978 "file_paths": [],
2979 }),
2980 ),
2981 Event::new(
2982 gate_run,
2983 "run.failed",
2984 serde_json::json!({
2985 "category": "Gate",
2986 "failed_stage": "patch_plan",
2987 "message": "files_to_create_already_exist",
2988 }),
2989 ),
2990 ];
2991 for ev in &gate_events {
2992 writer.append(ev, true).expect("append");
2993 }
2994 projector::apply_events(&conn, &gate_events).expect("project gate-fail");
2995
2996 let impl_run = RunId::new();
2999 let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
3000 let impl_events: Vec<Event> = vec![
3001 Event::new(
3002 impl_run,
3003 "run.started",
3004 serde_json::json!({"project_id": "test", "task": "i"}),
3005 ),
3006 Event::new(
3007 impl_run,
3008 "context.injected",
3009 serde_json::json!({
3010 "stage": "patch_plan",
3011 "capsule_handles": [format!("memory:{memory_id}")],
3012 "memory_ids": [memory_id.clone()],
3013 "prior_run_ids": [],
3014 "file_paths": [],
3015 }),
3016 ),
3017 Event::new(
3020 impl_run,
3021 "memory.cited",
3022 serde_json::json!({
3023 "memory_id": memory_id,
3024 "turn": 2,
3025 "rationale": "trusted the memory's pattern",
3026 }),
3027 ),
3028 Event::new(
3029 impl_run,
3030 "run.failed",
3031 serde_json::json!({
3032 "category": "Implementation",
3033 "failed_stage": "implementation",
3034 "message": "test broke",
3035 }),
3036 ),
3037 ];
3038 for ev in &impl_events {
3039 writer2.append(ev, true).expect("append");
3040 }
3041 projector::apply_events(&conn, &impl_events).expect("project impl-fail");
3042 }
3043
3044 let memories = list_memories(&root).expect("list memories");
3045 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
3046 assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
3047 let expected = 0.15 - 1.0; assert!(
3051 (m.usefulness_score - expected).abs() < 1e-4,
3052 "expected usefulness_score = {expected}, got {}",
3053 m.usefulness_score
3054 );
3055
3056 fs::remove_dir_all(root).expect("remove temp project");
3057 }
3058
3059 #[test]
3060 fn run_aborted_does_not_update_usefulness() {
3061 let root = test_root();
3062 fs::create_dir_all(&root).expect("create temp project");
3063 init_project(&root, false).expect("init project");
3064 let memory_id = add_memory(
3065 &root,
3066 MemoryScope::Repo,
3067 MemoryKind::Convention,
3068 "Module re-exports live in lib.rs.",
3069 )
3070 .expect("add memory");
3071
3072 {
3073 let (paths, _config, conn) = load_project(&root).expect("load");
3074 let run_id = RunId::new();
3075 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
3076 let evs: Vec<Event> = vec![
3077 Event::new(
3078 run_id,
3079 "run.started",
3080 serde_json::json!({"project_id": "test", "task": "a"}),
3081 ),
3082 Event::new(
3083 run_id,
3084 "context.injected",
3085 serde_json::json!({
3086 "stage": "patch_plan",
3087 "capsule_handles": [format!("memory:{memory_id}")],
3088 "memory_ids": [memory_id.clone()],
3089 "prior_run_ids": [],
3090 "file_paths": [],
3091 }),
3092 ),
3093 Event::new(
3094 run_id,
3095 "run.aborted",
3096 serde_json::json!({"reason": "user_abort"}),
3097 ),
3098 ];
3099 for ev in &evs {
3100 writer.append(ev, true).expect("append");
3101 }
3102 projector::apply_events(&conn, &evs).expect("project");
3103 }
3104
3105 let memories = list_memories(&root).expect("list memories");
3106 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
3107 assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
3108 let expected_seed = 0.15_f32; assert!(
3112 (m.usefulness_score - expected_seed).abs() < 1e-4,
3113 "expected usefulness_score = {expected_seed} (initial seed only), got {}",
3114 m.usefulness_score
3115 );
3116
3117 fs::remove_dir_all(root).expect("remove temp project");
3118 }
3119
3120 #[test]
3121 fn list_proposals_filters_and_reject_records_reason() {
3122 let root = test_root();
3123 fs::create_dir_all(&root).expect("create temp project");
3124 init_project(&root, false).expect("init project");
3125
3126 let proposals = [
3128 (
3129 "p1",
3130 "global_user",
3131 "preference",
3132 0.9_f32,
3133 "Prefer rg over grep",
3134 ),
3135 (
3136 "p2",
3137 "repo",
3138 "convention",
3139 0.8,
3140 "Use find_* for fallible lookups",
3141 ),
3142 (
3143 "p3",
3144 "repo",
3145 "convention",
3146 0.4,
3147 "Use let-else where possible",
3148 ),
3149 ];
3150 {
3151 let (paths, _config, conn) = load_project(&root).expect("load");
3152 let run_id = RunId::new();
3153 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
3154 for (proposal_id, scope, kind, conf, text) in &proposals {
3155 let event = Event::new(
3156 run_id,
3157 "memory.proposed",
3158 serde_json::json!({
3159 "proposal_id": proposal_id,
3160 "scope": scope,
3161 "kind": kind,
3162 "text": text,
3163 "rationale": "test rationale",
3164 "proposed_confidence": conf,
3165 "source_event_ids": [],
3166 }),
3167 );
3168 writer.append(&event, true).expect("append proposal");
3169 projector::apply_events(&conn, &[event]).expect("project");
3170 }
3171 }
3172
3173 let global = list_proposals(
3175 &root,
3176 ProposalFilter {
3177 scope: Some("global_user".into()),
3178 status: Some("pending".into()),
3179 ..ProposalFilter::default()
3180 },
3181 )
3182 .expect("list proposals");
3183 assert_eq!(global.len(), 1);
3184 assert_eq!(global[0].proposal_id, "p1");
3185
3186 let strong = list_proposals(
3188 &root,
3189 ProposalFilter {
3190 min_confidence: Some(0.7),
3191 status: Some("pending".into()),
3192 ..ProposalFilter::default()
3193 },
3194 )
3195 .expect("list strong");
3196 assert_eq!(strong.len(), 2);
3197 for row in &strong {
3198 assert!(row.proposed_confidence >= 0.7);
3199 }
3200
3201 reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
3203 let rejected = list_proposals(
3204 &root,
3205 ProposalFilter {
3206 status: Some("rejected".into()),
3207 ..ProposalFilter::default()
3208 },
3209 )
3210 .expect("list rejected");
3211 assert_eq!(rejected.len(), 1);
3212 assert_eq!(rejected[0].proposal_id, "p3");
3213 assert_eq!(
3214 rejected[0].decided_reason.as_deref(),
3215 Some("not specific to the user")
3216 );
3217
3218 let memory_id = accept_proposal(
3221 &root,
3222 "p1",
3223 AcceptOverrides {
3224 scope: None,
3225 confidence: Some(0.55),
3226 },
3227 )
3228 .expect("accept");
3229 let memories = list_memories(&root).expect("list memories");
3230 let promoted = memories
3231 .into_iter()
3232 .find(|m| m.memory_id == memory_id)
3233 .expect("promoted memory present");
3234 assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
3235
3236 fs::remove_dir_all(root).expect("remove temp project");
3237 }
3238
3239 #[test]
3244 fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
3245 let root = test_root();
3246 fs::create_dir_all(&root).expect("create temp project");
3247 init_project(&root, false).expect("init project");
3248
3249 let memory_id = add_memory(
3250 &root,
3251 MemoryScope::Repo,
3252 MemoryKind::Convention,
3253 "Use find_* for fallible lookups.",
3254 )
3255 .expect("add memory");
3256
3257 invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
3258 .expect("invalidate memory");
3259
3260 {
3263 let (_paths, _config, conn) = load_project(&root).expect("load");
3264 let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
3265 .query_row(
3266 "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
3267 params![memory_id],
3268 |row| Ok((row.get(0)?, row.get(1)?)),
3269 )
3270 .expect("query invalidated metadata");
3271 assert!(invalidated_at.is_some(), "invalidated_at must be set");
3272 assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
3273 }
3274
3275 rebuild_projection(&root, false).expect("rebuild projection");
3277 {
3278 let (_paths, _config, conn) = load_project(&root).expect("load");
3279 let invalidated_at: Option<String> = conn
3280 .query_row(
3281 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
3282 params![memory_id],
3283 |row| row.get(0),
3284 )
3285 .expect("query after rebuild");
3286 assert!(
3287 invalidated_at.is_some(),
3288 "invalidated_at must survive event replay"
3289 );
3290 }
3291
3292 fs::remove_dir_all(root).expect("remove temp project");
3293 }
3294
3295 #[test]
3299 fn invalidated_memory_is_excluded_from_broker_retrieval() {
3300 with_user_brain_disabled(|| {
3301 let root = test_root();
3302 fs::create_dir_all(&root).expect("create temp project");
3303 init_project(&root, false).expect("init project");
3304
3305 let memory_id = add_memory(
3306 &root,
3307 MemoryScope::GlobalUser,
3308 MemoryKind::Preference,
3309 "Prefer ripgrep over grep for repo search.",
3310 )
3311 .expect("add memory");
3312
3313 let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
3315 .expect("pre context");
3316 assert!(
3317 pre.capsules
3318 .iter()
3319 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
3320 "memory must appear before invalidation: {:?}",
3321 pre.capsules
3322 );
3323
3324 invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
3325
3326 let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
3327 .expect("post context");
3328 assert!(
3329 post.capsules
3330 .iter()
3331 .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
3332 "invalidated memory must not be retrieved: {:?}",
3333 post.capsules
3334 );
3335
3336 {
3340 let (_paths2, _config2, conn2) = load_project(&root).expect("load for check");
3341 let still_there: i64 = conn2
3342 .query_row(
3343 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
3344 rusqlite::params![&memory_id],
3345 |row| row.get(0),
3346 )
3347 .expect("db query");
3348 assert_eq!(still_there, 1, "invalidated row must persist in brain.db");
3349 } let active = list_memories(&root).expect("list after invalidation");
3352 assert!(
3353 active.iter().all(|m| m.memory_id != memory_id),
3354 "invalidated memory must not appear in list_memories"
3355 );
3356
3357 fs::remove_dir_all(root).expect("remove temp project");
3358 });
3359 }
3360
3361 #[test]
3366 fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
3367 let root = test_root();
3368 fs::create_dir_all(&root).expect("create temp project");
3369 init_project(&root, false).expect("init project");
3370
3371 let m_great =
3372 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
3373 let m_meh =
3374 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
3375 let m_bad =
3376 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
3377 let _m_fresh =
3378 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
3379
3380 {
3383 let (_paths, _config, conn) = load_project(&root).expect("load");
3384 conn.execute(
3385 "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
3386 params![m_great],
3387 )
3388 .expect("set great");
3389 conn.execute(
3390 "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
3391 params![m_meh],
3392 )
3393 .expect("set meh");
3394 conn.execute(
3395 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
3396 params![m_bad],
3397 )
3398 .expect("set bad");
3399 }
3401
3402 let top = list_memories_top(
3403 &root,
3404 TopOptions {
3405 scope: None,
3406 min_uses: 3,
3407 limit: 10,
3408 },
3409 )
3410 .expect("top");
3411 assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
3412 assert_eq!(top[0].memory_id, m_great);
3413 assert_eq!(top[1].memory_id, m_meh);
3414 assert_eq!(top[2].memory_id, m_bad);
3415
3416 invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
3418 let top_after = list_memories_top(
3419 &root,
3420 TopOptions {
3421 scope: None,
3422 min_uses: 3,
3423 limit: 10,
3424 },
3425 )
3426 .expect("top after");
3427 assert_eq!(top_after.len(), 2);
3428 assert!(top_after.iter().all(|m| m.memory_id != m_great));
3429
3430 fs::remove_dir_all(root).expect("remove temp project");
3431 }
3432
3433 #[test]
3439 fn prune_low_usefulness_dry_run_then_apply() {
3440 with_user_brain_disabled(|| {
3441 prune_low_usefulness_dry_run_then_apply_body();
3442 });
3443 }
3444
3445 fn prune_low_usefulness_dry_run_then_apply_body() {
3446 let root = test_root();
3447 fs::create_dir_all(&root).expect("create temp project");
3448 init_project(&root, false).expect("init project");
3449
3450 let m_keep = add_memory(
3451 &root,
3452 MemoryScope::Repo,
3453 MemoryKind::Convention,
3454 "keep me, I help",
3455 )
3456 .expect("keep");
3457 let m_drop_1 = add_memory(
3458 &root,
3459 MemoryScope::Repo,
3460 MemoryKind::Convention,
3461 "drop me, I hurt",
3462 )
3463 .expect("drop1");
3464 let m_drop_2 = add_memory(
3465 &root,
3466 MemoryScope::Repo,
3467 MemoryKind::Convention,
3468 "drop me too",
3469 )
3470 .expect("drop2");
3471 let m_small_sample = add_memory(
3472 &root,
3473 MemoryScope::Repo,
3474 MemoryKind::Convention,
3475 "small sample shouldn't be pruned even if score is bad",
3476 )
3477 .expect("small");
3478
3479 {
3480 let (_paths, _config, conn) = load_project(&root).expect("load");
3481 conn.execute(
3483 "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
3484 params![m_keep],
3485 )
3486 .expect("set keep");
3487 conn.execute(
3489 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
3490 params![m_drop_1],
3491 )
3492 .expect("set drop1");
3493 conn.execute(
3495 "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
3496 params![m_drop_2],
3497 )
3498 .expect("set drop2");
3499 conn.execute(
3501 "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
3502 params![m_small_sample],
3503 )
3504 .expect("set small");
3505 }
3506
3507 let dry = prune_low_usefulness(
3509 &root,
3510 PruneOptions {
3511 scope: None,
3512 min_uses: 3,
3513 max_ratio: -0.2,
3514 apply: false,
3515 },
3516 )
3517 .expect("dry-run");
3518 assert_eq!(dry.candidates.len(), 2);
3519 assert_eq!(dry.invalidated, 0);
3520 let ids: Vec<&str> = dry
3521 .candidates
3522 .iter()
3523 .map(|c| c.memory_id.as_str())
3524 .collect();
3525 assert!(ids.contains(&m_drop_1.as_str()));
3526 assert!(ids.contains(&m_drop_2.as_str()));
3527 assert!(!ids.contains(&m_small_sample.as_str()));
3529
3530 let pre = list_memories(&root).expect("pre");
3532 assert_eq!(pre.len(), 4);
3533
3534 let applied = prune_low_usefulness(
3536 &root,
3537 PruneOptions {
3538 scope: None,
3539 min_uses: 3,
3540 max_ratio: -0.2,
3541 apply: true,
3542 },
3543 )
3544 .expect("apply");
3545 assert_eq!(applied.candidates.len(), 2);
3546 assert_eq!(applied.invalidated, 2);
3547 assert_eq!(applied.failed, 0);
3548
3549 let top = list_memories_top(
3553 &root,
3554 TopOptions {
3555 scope: None,
3556 min_uses: 3,
3557 limit: 10,
3558 },
3559 )
3560 .expect("top after prune");
3561 assert_eq!(top.len(), 1);
3562 assert_eq!(top[0].memory_id, m_keep);
3563
3564 {
3569 let (_paths, _config, conn) = load_project(&root).expect("load");
3570 let reason: String = conn
3571 .query_row(
3572 "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
3573 params![m_drop_1],
3574 |row| row.get(0),
3575 )
3576 .expect("invalidated reason");
3577 assert!(
3578 reason.starts_with("pruned_by_usefulness"),
3579 "unexpected reason: {reason}"
3580 );
3581 }
3582
3583 fs::remove_dir_all(root).expect("remove temp project");
3584 }
3585
3586 #[test]
3593 fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
3594 with_user_brain_disabled(|| {
3595 batch_review_accepts_filtered_subset_and_rejects_remainder_body();
3596 });
3597 }
3598
3599 fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
3600 let root = test_root();
3601 fs::create_dir_all(&root).expect("create temp project");
3602 init_project(&root, false).expect("init project");
3603
3604 let run_a = RunId::new();
3605 let run_b = RunId::new();
3606
3607 let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
3611 (
3612 "p_a_strong",
3613 run_a,
3614 "global_user",
3615 "preference",
3616 0.92,
3617 "Prefer rg over grep",
3618 ),
3619 (
3620 "p_a_weak",
3621 run_a,
3622 "repo",
3623 "convention",
3624 0.55,
3625 "Always use let-else",
3626 ),
3627 (
3628 "p_b1",
3629 run_b,
3630 "repo",
3631 "convention",
3632 0.70,
3633 "Use Result not panic",
3634 ),
3635 (
3636 "p_b2",
3637 run_b,
3638 "global_user",
3639 "preference",
3640 0.88,
3641 "Open links in new tab",
3642 ),
3643 ];
3644
3645 {
3646 let (paths, _config, conn) = load_project(&root).expect("load");
3647 for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
3648 let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
3649 let event = Event::new(
3650 *run_id,
3651 "memory.proposed",
3652 serde_json::json!({
3653 "proposal_id": proposal_id,
3654 "scope": scope,
3655 "kind": kind,
3656 "text": text,
3657 "rationale": "fixture",
3658 "proposed_confidence": conf,
3659 "source_event_ids": [],
3660 }),
3661 );
3662 writer.append(&event, true).expect("append");
3663 projector::apply_events(&conn, &[event]).expect("project");
3664 }
3665 }
3666
3667 let to_accept = list_proposals(
3670 &root,
3671 ProposalFilter {
3672 from_run: Some(run_a.to_string()),
3673 min_confidence: Some(0.8),
3674 status: Some("pending".into()),
3675 limit: 100,
3676 ..ProposalFilter::default()
3677 },
3678 )
3679 .expect("list strong from run_a");
3680 assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
3681 assert_eq!(to_accept[0].proposal_id, "p_a_strong");
3682 let memory_id =
3683 accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
3684 .expect("accept p_a_strong");
3685
3686 let to_reject = list_proposals(
3689 &root,
3690 ProposalFilter {
3691 status: Some("pending".into()),
3692 limit: 100,
3693 ..ProposalFilter::default()
3694 },
3695 )
3696 .expect("list remaining pending");
3697 assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
3698 for p in &to_reject {
3699 reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
3700 }
3701
3702 let memories = list_memories(&root).expect("list memories");
3705 assert_eq!(
3706 memories.len(),
3707 1,
3708 "only the accepted proposal becomes a memory"
3709 );
3710 assert_eq!(memories[0].memory_id, memory_id);
3711
3712 let pending = list_proposals(
3713 &root,
3714 ProposalFilter {
3715 status: Some("pending".into()),
3716 limit: 100,
3717 ..ProposalFilter::default()
3718 },
3719 )
3720 .expect("list pending");
3721 assert!(
3722 pending.is_empty(),
3723 "no proposals left pending after batch review"
3724 );
3725
3726 let rejected = list_proposals(
3727 &root,
3728 ProposalFilter {
3729 status: Some("rejected".into()),
3730 limit: 100,
3731 ..ProposalFilter::default()
3732 },
3733 )
3734 .expect("list rejected");
3735 assert_eq!(rejected.len(), 3);
3736 for row in &rejected {
3737 assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
3738 }
3739
3740 fs::remove_dir_all(root).expect("remove temp project");
3741 }
3742
3743 #[test]
3759 fn add_memory_distinct_texts_no_conflicts() {
3760 with_user_brain_disabled(|| {
3761 let root = test_root();
3762 fs::create_dir_all(&root).expect("create temp project");
3763 init_project(&root, false).expect("init project");
3764
3765 let _m1 = add_memory(
3770 &root,
3771 MemoryScope::GlobalUser,
3772 MemoryKind::Preference,
3773 "Prefer thiserror for library error types.",
3774 )
3775 .expect("add m1");
3776 let _m2 = add_memory(
3777 &root,
3778 MemoryScope::GlobalUser,
3779 MemoryKind::Preference,
3780 "Cache HTTP responses with a one-hour TTL.",
3781 )
3782 .expect("add m2");
3783
3784 let open = list_conflicts(&root, 50).expect("list_conflicts");
3785 assert!(
3786 open.is_empty(),
3787 "distinct-topic memories must not conflict; got {} rows",
3788 open.len()
3789 );
3790
3791 let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
3793 .expect("resolve_conflict on unknown id");
3794 assert!(!resolved, "unknown conflict id should resolve to false");
3795
3796 let err = resolve_conflict(&root, "does-not-exist", "garbage")
3798 .expect_err("invalid resolution should error");
3799 assert!(format!("{err}").contains("invalid conflict resolution"));
3800
3801 fs::remove_dir_all(root).expect("remove temp project");
3802 });
3803 }
3804
3805 #[test]
3812 fn load_project_rejects_future_config_version() {
3813 with_user_brain_disabled(|| {
3814 use kimetsu_core::KIMETSU_CONFIG_VERSION;
3815 let root = test_root();
3816 fs::create_dir_all(&root).expect("create temp project");
3817 let paths = kimetsu_core::paths::ProjectPaths::discover(&root)
3819 .expect("discover paths after git_init_boundary");
3820 fs::create_dir_all(&paths.kimetsu_dir).expect("create .kimetsu dir");
3821 let bad_version = KIMETSU_CONFIG_VERSION + 1;
3822 let toml_str = format!(
3823 r#"
3824[kimetsu]
3825project_id = "test-config-gate"
3826schema_version = {bad_version}
3827
3828[model]
3829provider = "anthropic"
3830model = "claude-opus-4-7"
3831api_key_env = "ANTHROPIC_API_KEY"
3832max_output_tokens = 8192
3833temperature = 0.2
3834request_timeout_secs = 120
3835
3836[broker]
3837default_budget_tokens = 6000
3838
3839[broker.weights]
3840relevance = 0.5
3841confidence = 0.2
3842freshness = 0.2
3843scope = 0.1
3844
3845[shell]
3846default_timeout_secs = 60
3847max_timeout_secs = 600
3848env_allowlist_extra = []
3849redact_secrets = true
3850
3851[ingestion]
3852max_file_bytes = 524288
3853extra_skip_dirs = []
3854max_total_files = 50000
3855
3856[run]
3857max_total_tool_calls = 60
3858max_total_model_turns = 30
3859max_total_cost_usd = 250.0
3860"#
3861 );
3862 fs::write(&paths.project_toml, &toml_str).expect("write bad project.toml");
3863 let err = load_project(&root).expect_err("future config version must be rejected");
3864 let msg = format!("{err}");
3865 assert!(
3866 msg.contains(&bad_version.to_string()),
3867 "error message should mention the bad version; got: {msg}"
3868 );
3869 assert!(
3870 msg.contains(&KIMETSU_CONFIG_VERSION.to_string()),
3871 "error message should mention the expected version; got: {msg}"
3872 );
3873 fs::remove_dir_all(root).expect("remove temp project");
3874 });
3875 }
3876
3877 fn make_dangling_run(root: &std::path::Path) -> RunId {
3881 let (paths, _config, conn) = load_project(root).expect("load project");
3882 let run_id = RunId::new();
3883 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("create trace");
3884 let started = Event::new(
3885 run_id,
3886 "run.started",
3887 serde_json::json!({"project_id": "test", "task": "dangling task"}),
3888 );
3889 writer.append(&started, true).expect("append started");
3890 projector::apply_events(&conn, &[started]).expect("project started");
3891 run_id
3892 }
3893
3894 #[test]
3895 fn abort_run_stamps_aborted_and_frees_lock() {
3896 with_user_brain_disabled(|| {
3897 let root = test_root();
3898 fs::create_dir_all(&root).expect("mkdir");
3899 init_project(&root, false).expect("init");
3900
3901 let run_id = make_dangling_run(&root);
3902
3903 abort_run(&root, &run_id.to_string()).expect("abort_run");
3905
3906 let run = show_run(&root, &run_id.to_string())
3908 .expect("show_run")
3909 .expect("run exists");
3910 assert_eq!(
3911 run.terminal_kind.as_deref(),
3912 Some("run.aborted"),
3913 "terminal_kind should be run.aborted"
3914 );
3915
3916 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
3918 assert!(
3919 !paths.lock_file.exists(),
3920 "lock file should not exist after abort"
3921 );
3922
3923 fs::remove_dir_all(root).expect("cleanup");
3924 });
3925 }
3926
3927 #[test]
3928 fn abort_run_already_finished_returns_err() {
3929 with_user_brain_disabled(|| {
3930 let root = test_root();
3931 fs::create_dir_all(&root).expect("mkdir");
3932 init_project(&root, false).expect("init");
3933
3934 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "some fact")
3936 .expect("add memory");
3937
3938 let runs = list_runs(&root).expect("list runs");
3939 assert!(!runs.is_empty(), "should have at least one run");
3940 let finished_run = runs
3941 .iter()
3942 .find(|r| r.terminal_kind.is_some())
3943 .expect("should have a finished run");
3944
3945 let err = abort_run(&root, &finished_run.run_id)
3946 .expect_err("aborting a finished run should error");
3947 let msg = format!("{err}");
3948 assert!(
3949 msg.contains("already terminal"),
3950 "error should mention 'already terminal', got: {msg}"
3951 );
3952
3953 fs::remove_dir_all(root).expect("cleanup");
3954 });
3955 }
3956
3957 #[test]
3958 fn abort_run_unknown_id_returns_err() {
3959 with_user_brain_disabled(|| {
3960 let root = test_root();
3961 fs::create_dir_all(&root).expect("mkdir");
3962 init_project(&root, false).expect("init");
3963
3964 let fake_id = RunId::new().to_string();
3965 let err = abort_run(&root, &fake_id).expect_err("aborting an unknown run should error");
3966 let msg = format!("{err}");
3967 assert!(
3968 msg.contains("unknown run_id"),
3969 "error should mention 'unknown run_id', got: {msg}"
3970 );
3971
3972 fs::remove_dir_all(root).expect("cleanup");
3973 });
3974 }
3975
3976 #[test]
3982 fn rebuild_from_events_table_restores_memories() {
3983 with_user_brain_disabled(|| {
3984 let root = test_root();
3985 fs::create_dir_all(&root).expect("create temp project");
3986 init_project(&root, false).expect("init project");
3987
3988 let id1 = add_memory(
3989 &root,
3990 MemoryScope::Repo,
3991 MemoryKind::Convention,
3992 "W1.3: prefer explicit error types over anyhow in library crates",
3993 )
3994 .expect("add memory 1");
3995 let id2 = add_memory(
3996 &root,
3997 MemoryScope::Repo,
3998 MemoryKind::Command,
3999 "W1.3: run cargo fmt --all before committing",
4000 )
4001 .expect("add memory 2");
4002
4003 {
4005 let (_paths, _config, conn) = load_project(&root).expect("load");
4006 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
4007 .expect("wipe derived tables");
4008 }
4009
4010 let gone = list_memories(&root).expect("list after wipe");
4012 assert_eq!(gone.len(), 0, "derived tables should be empty after wipe");
4013
4014 let count = rebuild_projection(&root, false).expect("rebuild_projection");
4016 assert!(
4017 count > 0,
4018 "should have replayed at least one event; got {count}"
4019 );
4020
4021 let restored = list_memories(&root).expect("list after rebuild");
4023 assert_eq!(
4024 restored.len(),
4025 2,
4026 "both memories should be restored after rebuild; got {:?}",
4027 restored.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
4028 );
4029 let ids: Vec<_> = restored.iter().map(|m| m.memory_id.clone()).collect();
4030 assert!(ids.contains(&id1), "id1 must be restored");
4031 assert!(ids.contains(&id2), "id2 must be restored");
4032
4033 fs::remove_dir_all(root).expect("cleanup");
4034 });
4035 }
4036
4037 #[test]
4046 fn rebuild_from_traces_flag_reimports_on_disk_traces() {
4047 with_user_brain_disabled(|| {
4048 let root = test_root();
4049 fs::create_dir_all(&root).expect("create temp project");
4050 init_project(&root, false).expect("init project");
4051
4052 let memory_id = Ulid::new().to_string();
4056 let run_id = RunId::new();
4057 {
4058 let (paths, config, conn) = load_project(&root).expect("load");
4059 let (mut writer, _run_paths) =
4060 TraceWriter::create(&paths, run_id).expect("trace writer");
4061 let text = "W1.3: from_traces re-imports events from trace.jsonl files";
4062 let normalized = kimetsu_core::memory::normalize_memory_text(text);
4063 let evs: Vec<Event> = vec![
4064 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
4065 Event::new(
4066 run_id,
4067 "memory.accepted",
4068 serde_json::json!({
4069 "proposal_id": null,
4070 "memory_id": memory_id,
4071 "scope": "repo",
4072 "kind": "fact",
4073 "text": text,
4074 "normalized_text": normalized,
4075 "confidence": 1.0,
4076 "provenance_snapshot": {
4077 "source": "manual_cli",
4078 "run_id": run_id.to_string(),
4079 "text": text,
4080 }
4081 }),
4082 ),
4083 admin_finished_event(run_id),
4084 ];
4085 for ev in &evs {
4086 writer.append(ev, true).expect("append");
4087 }
4088 projector::apply_events(&conn, &evs).expect("apply");
4090 }
4091
4092 let initial = list_memories(&root).expect("list initial");
4094 assert_eq!(initial.len(), 1);
4095
4096 {
4099 let (_paths, _config, conn) = load_project(&root).expect("load");
4100 conn.execute_batch(
4101 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
4102 )
4103 .expect("wipe events + derived tables");
4104 }
4105
4106 let count = rebuild_projection(&root, true).expect("rebuild_projection --from-traces");
4108 assert!(
4109 count > 0,
4110 "should have imported ≥1 event from on-disk traces; got {count}"
4111 );
4112
4113 let restored = list_memories(&root).expect("list after trace import");
4114 assert_eq!(
4115 restored.len(),
4116 1,
4117 "memory must be restored from on-disk traces"
4118 );
4119 assert_eq!(restored[0].memory_id, memory_id);
4120
4121 fs::remove_dir_all(root).expect("cleanup");
4122 });
4123 }
4124
4125 #[test]
4133 fn rebuild_auto_fallback_imports_traces_when_events_table_empty() {
4134 with_user_brain_disabled(|| {
4135 let root = test_root();
4136 fs::create_dir_all(&root).expect("create temp project");
4137 init_project(&root, false).expect("init project");
4138
4139 let memory_id = Ulid::new().to_string();
4141 let run_id = RunId::new();
4142 {
4143 let (paths, config, conn) = load_project(&root).expect("load");
4144 let (mut writer, _run_paths) =
4145 TraceWriter::create(&paths, run_id).expect("trace writer");
4146 let text = "W1.3: auto-fallback recovers from pre-W1.1 events wipe";
4147 let normalized = kimetsu_core::memory::normalize_memory_text(text);
4148 let evs: Vec<Event> = vec![
4149 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
4150 Event::new(
4151 run_id,
4152 "memory.accepted",
4153 serde_json::json!({
4154 "proposal_id": null,
4155 "memory_id": memory_id,
4156 "scope": "repo",
4157 "kind": "convention",
4158 "text": text,
4159 "normalized_text": normalized,
4160 "confidence": 1.0,
4161 "provenance_snapshot": {
4162 "source": "manual_cli",
4163 "run_id": run_id.to_string(),
4164 "text": text,
4165 }
4166 }),
4167 ),
4168 admin_finished_event(run_id),
4169 ];
4170 for ev in &evs {
4171 writer.append(ev, true).expect("append");
4172 }
4173 projector::apply_events(&conn, &evs).expect("apply");
4175 }
4176
4177 {
4180 let (_paths, _config, conn) = load_project(&root).expect("load");
4181 conn.execute_batch(
4182 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
4183 )
4184 .expect("simulate pre-W1.1 wipe");
4185 }
4186
4187 let count = rebuild_projection(&root, false).expect("rebuild_projection auto-fallback");
4190 assert!(
4191 count > 0,
4192 "auto-fallback should have imported ≥1 event from traces; got {count}"
4193 );
4194
4195 let restored = list_memories(&root).expect("list after auto-fallback");
4196 assert_eq!(
4197 restored.len(),
4198 1,
4199 "auto-fallback must restore memory from traces when events table was empty"
4200 );
4201 assert_eq!(restored[0].memory_id, memory_id);
4202
4203 fs::remove_dir_all(root).expect("cleanup");
4204 });
4205 }
4206
4207 fn run_subdir_count(runs_dir: &std::path::Path) -> usize {
4211 if !runs_dir.exists() {
4212 return 0;
4213 }
4214 fs::read_dir(runs_dir)
4215 .map(|rd| {
4216 rd.filter_map(|e| e.ok())
4217 .filter(|e| e.path().is_dir())
4218 .count()
4219 })
4220 .unwrap_or(0)
4221 }
4222
4223 #[test]
4226 fn w1_4_add_memory_creates_no_run_dir_but_memory_and_runs_row_exist() {
4227 with_user_brain_disabled(|| {
4228 let root = test_root();
4229 fs::create_dir_all(&root).expect("create temp project");
4230 init_project(&root, false).expect("init project");
4231
4232 let runs_dir = {
4234 let paths =
4235 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4236 paths.runs_dir.clone()
4237 };
4238 let before = run_subdir_count(&runs_dir);
4239
4240 let memory_id = add_memory(
4241 &root,
4242 MemoryScope::Project,
4243 MemoryKind::Fact,
4244 "W1.4: no run dir should be created for memory writes",
4245 )
4246 .expect("add memory");
4247
4248 let after = run_subdir_count(&runs_dir);
4250 assert_eq!(
4251 after, before,
4252 "add_memory must not create a runs/<id>/ directory (before={before}, after={after})"
4253 );
4254
4255 let memories = list_memories(&root).expect("list");
4257 assert!(
4258 memories.iter().any(|m| m.memory_id == memory_id),
4259 "memory must be present after add_memory"
4260 );
4261
4262 let runs_count: i64 = {
4264 let (_paths, _config, conn) = load_project(&root).expect("load for runs check");
4265 conn.query_row("SELECT COUNT(*) FROM runs", [], |r| r.get(0))
4266 .expect("count runs")
4267 };
4268 assert!(
4269 runs_count >= 1,
4270 "projector must have inserted a runs row from the run.started event (got {runs_count})"
4271 );
4272
4273 fs::remove_dir_all(root).expect("cleanup");
4274 });
4275 }
4276
4277 #[test]
4280 fn w1_4_memory_survives_rebuild_from_events_table_no_trace() {
4281 with_user_brain_disabled(|| {
4282 let root = test_root();
4283 fs::create_dir_all(&root).expect("create temp project");
4284 init_project(&root, false).expect("init project");
4285
4286 let memory_id = add_memory(
4287 &root,
4288 MemoryScope::Repo,
4289 MemoryKind::Convention,
4290 "W1.4: events are durable without a trace file",
4291 )
4292 .expect("add memory");
4293
4294 {
4296 let (_paths, _config, conn) = load_project(&root).expect("load");
4297 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
4298 .expect("wipe derived tables");
4299 }
4300
4301 let count = rebuild_projection(&root, false).expect("rebuild");
4303 assert!(count > 0, "should have replayed events; got {count}");
4304
4305 let restored = list_memories(&root).expect("list after rebuild");
4306 assert!(
4307 restored.iter().any(|m| m.memory_id == memory_id),
4308 "memory must survive rebuild from events table without a trace file"
4309 );
4310
4311 fs::remove_dir_all(root).expect("cleanup");
4312 });
4313 }
4314
4315 #[test]
4318 fn w1_4_memory_ops_create_no_run_dirs() {
4319 with_user_brain_disabled(|| {
4320 let root = test_root();
4321 fs::write(
4323 root.join("Cargo.toml"),
4324 "[package]\nname = \"w14-fixture\"\nversion = \"0.1.0\"\n",
4325 )
4326 .expect("write Cargo.toml");
4327 init_project(&root, false).expect("init project");
4328
4329 let runs_dir = {
4331 let paths =
4332 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4333 paths.runs_dir.clone()
4334 };
4335
4336 let before = run_subdir_count(&runs_dir);
4338 let proposal_id = propose_memory(
4339 &root,
4340 MemoryScope::Project,
4341 MemoryKind::Fact,
4342 "W1.4 propose: no run dir",
4343 0.5,
4344 "test rationale",
4345 )
4346 .expect("propose");
4347 assert_eq!(
4348 run_subdir_count(&runs_dir),
4349 before,
4350 "propose_memory must not create a run dir"
4351 );
4352
4353 let before = run_subdir_count(&runs_dir);
4355 ingest_repo(&root).expect("ingest");
4356 assert_eq!(
4357 run_subdir_count(&runs_dir),
4358 before,
4359 "ingest_repo must not create a run dir"
4360 );
4361
4362 let before = run_subdir_count(&runs_dir);
4364 reject_proposal(&root, &proposal_id, Some("W1.4 test")).expect("reject");
4365 assert_eq!(
4366 run_subdir_count(&runs_dir),
4367 before,
4368 "reject_proposal must not create a run dir"
4369 );
4370
4371 let mem_id = add_memory(
4373 &root,
4374 MemoryScope::Project,
4375 MemoryKind::Command,
4376 "W1.4 invalidate: no run dir",
4377 )
4378 .expect("add");
4379 let before = run_subdir_count(&runs_dir);
4380 invalidate_memory(&root, &mem_id, Some("W1.4 test")).expect("invalidate");
4381 assert_eq!(
4382 run_subdir_count(&runs_dir),
4383 before,
4384 "invalidate_memory must not create a run dir"
4385 );
4386
4387 fs::remove_dir_all(root).expect("cleanup");
4388 });
4389 }
4390
4391 #[test]
4393 fn w1_4_dedup_hit_creates_no_orphan_run_dir() {
4394 with_user_brain_disabled(|| {
4395 let root = test_root();
4396 fs::create_dir_all(&root).expect("create temp project");
4397 init_project(&root, false).expect("init project");
4398
4399 let runs_dir = {
4401 let paths =
4402 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4403 paths.runs_dir.clone()
4404 };
4405
4406 let id1 = add_memory(
4408 &root,
4409 MemoryScope::Project,
4410 MemoryKind::Fact,
4411 "W1.4 dedup: identical text",
4412 )
4413 .expect("first add");
4414
4415 let after_first = run_subdir_count(&runs_dir);
4417 assert_eq!(after_first, 0, "first add must create no run dir");
4418
4419 let id2 = add_memory(
4421 &root,
4422 MemoryScope::Project,
4423 MemoryKind::Fact,
4424 "W1.4 dedup: identical text",
4425 )
4426 .expect("second add");
4427
4428 assert_eq!(id1, id2, "dedup must return the same memory_id");
4429 let after_second = run_subdir_count(&runs_dir);
4430 assert_eq!(
4431 after_second, 0,
4432 "dedup hit must not create an orphan run dir"
4433 );
4434
4435 fs::remove_dir_all(root).expect("cleanup");
4436 });
4437 }
4438
4439 #[test]
4445 fn w3_1_open_embedder_for_resolver() {
4446 use crate::embeddings;
4447 use crate::user_brain::test_env_lock;
4448
4449 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
4450 let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
4451 unsafe {
4453 std::env::remove_var("KIMETSU_BRAIN_EMBEDDER");
4454 }
4455
4456 assert!(
4458 embeddings::open_embedder_for(false).is_noop(),
4459 "open_embedder_for(false) must return a noop embedder"
4460 );
4461 assert_eq!(
4463 embeddings::open_embedder_for(true).is_noop(),
4464 embeddings::open_default_embedder().is_noop(),
4465 "open_embedder_for(true) must match open_default_embedder().is_noop()"
4466 );
4467
4468 unsafe {
4470 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
4471 }
4472 assert!(
4473 embeddings::open_embedder_for(true).is_noop(),
4474 "KIMETSU_BRAIN_EMBEDDER=noop must override config=true → noop"
4475 );
4476
4477 unsafe {
4479 match prev {
4480 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
4481 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
4482 }
4483 }
4484 }
4485
4486 #[test]
4489 fn w3_1_config_disabled_writes_null_embedding() {
4490 with_user_brain_disabled(|| {
4491 let root = test_root();
4492 init_project(&root, false).expect("init");
4493
4494 let (paths, mut config, _conn) = load_project(&root).expect("load");
4496 config.embedder.enabled = false;
4497 let toml = config.to_toml().expect("serialize");
4498 drop(_conn);
4500 fs::write(&paths.project_toml, toml).expect("write project.toml");
4501
4502 let memory_id = add_memory(
4503 &root,
4504 MemoryScope::Project,
4505 MemoryKind::Fact,
4506 "w3.1 write-disabled: embedder disabled via config",
4507 )
4508 .expect("add memory");
4509
4510 let embedding: Option<Vec<u8>> = {
4512 let (_, _, conn) = load_project_readonly(&root).expect("reload");
4513 let val = conn
4514 .query_row(
4515 "SELECT embedding FROM memories WHERE memory_id = ?1",
4516 rusqlite::params![memory_id],
4517 |row| row.get(0),
4518 )
4519 .expect("query embedding");
4520 drop(conn);
4521 val
4522 };
4523 assert!(
4524 embedding.is_none(),
4525 "embedding must be NULL when [embedder] enabled = false"
4526 );
4527
4528 fs::remove_dir_all(root).ok(); });
4530 }
4531
4532 #[test]
4538 fn w3_1_config_enabled_default_does_not_regress() {
4539 use crate::embeddings;
4540 let e_default = embeddings::open_default_embedder();
4543 let e_config = embeddings::open_embedder_for(true);
4544 assert_eq!(
4545 e_default.is_noop(),
4546 e_config.is_noop(),
4547 "open_embedder_for(true) and open_default_embedder() must have identical noop status"
4548 );
4549 }
4550
4551 #[test]
4554 fn w3_1_retrieval_fts_only_when_embedder_disabled() {
4555 with_user_brain_disabled(|| {
4556 let root = test_root();
4557 init_project(&root, false).expect("init");
4558
4559 add_memory(
4561 &root,
4562 MemoryScope::Project,
4563 MemoryKind::Fact,
4564 "the quick brown fox jumps over the lazy dog",
4565 )
4566 .expect("add memory");
4567
4568 let (paths, mut config, _) = load_project(&root).expect("load");
4570 config.embedder.enabled = false;
4571 let toml = config.to_toml().expect("serialize");
4572 fs::write(&paths.project_toml, toml).expect("write project.toml");
4573
4574 {
4577 let session = BrainSession::open_readonly(&root).expect("open readonly");
4578 let bundle = session
4579 .retrieve_context_with_request(crate::context::ContextRequest {
4580 stage: "localization".to_string(),
4581 query: "fox jumps".to_string(),
4582 budget_tokens: 4096,
4583 ..Default::default()
4584 })
4585 .expect("retrieve");
4586 let _ = bundle; }
4591
4592 fs::remove_dir_all(root).ok(); });
4594 }
4595
4596 #[test]
4603 fn retrieve_context_lexical_returns_fts_hits_without_embedder() {
4604 with_user_brain_disabled(|| {
4605 let root = test_root();
4606 init_project(&root, false).expect("init");
4607
4608 let memory_id = add_memory(
4609 &root,
4610 MemoryScope::Project,
4611 MemoryKind::Convention,
4612 "Run zylophonecheck before finalizing the deployment pipeline.",
4613 )
4614 .expect("add memory");
4615
4616 {
4617 let session = BrainSession::open_readonly(&root).expect("open readonly");
4618 let bundle = session
4619 .retrieve_context_lexical(crate::context::ContextRequest {
4620 stage: "localization".to_string(),
4621 query: "zylophonecheck deployment pipeline".to_string(),
4622 budget_tokens: 4096,
4623 ..Default::default()
4624 })
4625 .expect("retrieve lexical");
4626
4627 assert!(
4628 bundle
4629 .capsules
4630 .iter()
4631 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4632 "FTS-only lexical retrieval must surface the seeded memory; \
4633 got handles: {:?}",
4634 bundle
4635 .capsules
4636 .iter()
4637 .map(|c| &c.expansion_handle)
4638 .collect::<Vec<_>>()
4639 );
4640 }
4641
4642 fs::remove_dir_all(root).ok(); });
4644 }
4645
4646 #[test]
4651 fn retrieve_with_injected_embedder_returns_fts_hits() {
4652 with_user_brain_disabled(|| {
4653 let root = test_root();
4654 init_project(&root, false).expect("init");
4655
4656 let memory_id = add_memory(
4657 &root,
4658 MemoryScope::Project,
4659 MemoryKind::Fact,
4660 "the distiller harvests lessons at session end",
4661 )
4662 .expect("add");
4663
4664 {
4665 let session = BrainSession::open_readonly(&root).expect("open ro");
4666 let bundle = session
4667 .retrieve_context_with_injected_embedder(
4668 crate::context::ContextRequest {
4669 stage: "localization".to_string(),
4670 query: "how does the distiller work".to_string(),
4671 budget_tokens: 2000,
4672 ..Default::default()
4673 },
4674 &crate::embeddings::NoopEmbedder,
4675 )
4676 .expect("retrieve");
4677 assert!(
4678 bundle
4679 .capsules
4680 .iter()
4681 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4682 "FTS path via injected embedder must surface the memory; \
4683 got handles: {:?}",
4684 bundle
4685 .capsules
4686 .iter()
4687 .map(|c| &c.expansion_handle)
4688 .collect::<Vec<_>>()
4689 );
4690 }
4691
4692 fs::remove_dir_all(root).ok(); });
4694 }
4695
4696 fn with_user_brain_at_p0<R>(dir: &std::path::Path, f: impl FnOnce() -> R) -> R {
4701 use crate::user_brain::test_env_lock;
4702 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
4703 let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
4704 let prev_en = std::env::var("KIMETSU_USER_BRAIN").ok();
4705 unsafe {
4707 std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
4708 std::env::remove_var("KIMETSU_USER_BRAIN");
4709 }
4710 let out = f();
4711 unsafe {
4712 match prev_dir {
4713 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
4714 None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
4715 }
4716 match prev_en {
4717 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
4718 None => std::env::remove_var("KIMETSU_USER_BRAIN"),
4719 }
4720 }
4721 out
4722 }
4723
4724 #[test]
4728 fn p0_global_user_add_memory_works_from_non_project_dir() {
4729 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
4730
4731 let user_brain_dir =
4732 std::env::temp_dir().join(format!("kimetsu-p0-ubrain-{}", Ulid::new()));
4733 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
4734
4735 let non_project_dir =
4737 std::env::temp_dir().join(format!("kimetsu-p0-nonproj-{}", Ulid::new()));
4738 fs::create_dir_all(&non_project_dir).expect("create non-project dir");
4739
4740 with_user_brain_at_p0(&user_brain_dir, || {
4741 add_memory(
4742 &non_project_dir,
4743 MemoryScope::GlobalUser,
4744 MemoryKind::Fact,
4745 "P0 regression: GlobalUser write from non-project dir",
4746 )
4747 .expect("P0: add_memory(GlobalUser) from a non-project dir must succeed");
4748
4749 let conn = open_user_brain_readonly()
4751 .expect("open ok")
4752 .expect("user brain must exist after write");
4753 let mems = list_user_memories(&conn).expect("list");
4754 assert!(
4755 mems.iter().any(|m| m
4756 .text
4757 .contains("P0 regression: GlobalUser write from non-project dir")),
4758 "P0: the GlobalUser memory must land in the user brain"
4759 );
4760 });
4761
4762 fs::remove_dir_all(&non_project_dir).ok();
4763 fs::remove_dir_all(&user_brain_dir).ok();
4764 }
4765
4766 #[test]
4770 fn p0_global_user_honors_use_user_brain_false_when_start_is_project() {
4771 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
4772
4773 let user_brain_dir =
4775 std::env::temp_dir().join(format!("kimetsu-p0-w3-ubrain-{}", Ulid::new()));
4776 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
4777
4778 let root = test_root();
4780 init_project(&root, false).expect("init project");
4781
4782 {
4784 let (paths, mut config, _) = load_project(&root).expect("load project");
4785 config.kimetsu.use_user_brain = false;
4786 let toml = config.to_toml().expect("serialize");
4787 fs::write(&paths.project_toml, toml).expect("write project.toml");
4788 }
4789
4790 let mem_id = with_user_brain_at_p0(&user_brain_dir, || {
4791 let id = add_memory(
4794 &root,
4795 MemoryScope::GlobalUser,
4796 MemoryKind::Fact,
4797 "W3.3 toggle: this must stay in the project DB",
4798 )
4799 .expect("add_memory must succeed (falls through to project DB)");
4800
4801 let user_conn_opt = open_user_brain_readonly().expect("open ok");
4803 let user_mems_count = user_conn_opt
4804 .map(|c| list_user_memories(&c).unwrap_or_default().len())
4805 .unwrap_or(0);
4806 assert_eq!(
4807 user_mems_count, 0,
4808 "W3.3 toggle: user brain must be empty when use_user_brain=false"
4809 );
4810 id
4811 });
4812
4813 let project_mems = list_memories(&root).expect("list project memories");
4815 assert!(
4816 project_mems.iter().any(|m| m.memory_id == mem_id),
4817 "W3.3 toggle: memory must be in the project DB when use_user_brain=false"
4818 );
4819
4820 fs::remove_dir_all(&root).ok();
4821 fs::remove_dir_all(&user_brain_dir).ok();
4822 }
4823
4824 #[test]
4829 fn export_import_round_trip() {
4830 with_user_brain_disabled(|| {
4831 let root_a = test_root();
4833 init_project(&root_a, false).expect("init A");
4834 add_memory(
4835 &root_a,
4836 MemoryScope::Project,
4837 MemoryKind::Fact,
4838 "alpha fact",
4839 )
4840 .expect("add fact");
4841 add_memory(
4842 &root_a,
4843 MemoryScope::Project,
4844 MemoryKind::Convention,
4845 "beta convention",
4846 )
4847 .expect("add conv");
4848 add_memory(
4849 &root_a,
4850 MemoryScope::Project,
4851 MemoryKind::FailurePattern,
4852 "gamma failure",
4853 )
4854 .expect("add fp");
4855
4856 let (exported, _scrub) =
4858 export_memories(&root_a, None, None, false, false).expect("export");
4859 assert_eq!(exported.len(), 3, "must export all 3 active memories");
4860
4861 for e in &exported {
4863 assert!(!e.text.is_empty());
4864 assert!(!e.scope.is_empty());
4865 assert!(!e.kind.is_empty());
4866 }
4867
4868 let json = serde_json::to_string_pretty(&exported).expect("serialize");
4870 let parsed: Vec<MemoryExport> = serde_json::from_str(&json).expect("deserialize");
4871 assert_eq!(parsed.len(), 3);
4872
4873 let root_b = test_root();
4875 init_project(&root_b, false).expect("init B");
4876
4877 let summary = import_memories(&root_b, &parsed, None).expect("import");
4878 assert_eq!(
4879 summary.imported, 3,
4880 "all 3 must be imported into the empty project B"
4881 );
4882 assert_eq!(summary.deduped, 0, "no duplicates expected on first import");
4883
4884 let mems_b = list_memories(&root_b).expect("list B");
4885 let texts_b: Vec<&str> = mems_b.iter().map(|m| m.text.as_str()).collect();
4886 assert!(
4887 texts_b.contains(&"alpha fact"),
4888 "alpha fact missing from B: {texts_b:?}"
4889 );
4890 assert!(
4891 texts_b.contains(&"beta convention"),
4892 "beta convention missing from B: {texts_b:?}"
4893 );
4894 assert!(
4895 texts_b.contains(&"gamma failure"),
4896 "gamma failure missing from B: {texts_b:?}"
4897 );
4898
4899 fs::remove_dir_all(&root_a).ok();
4900 fs::remove_dir_all(&root_b).ok();
4901 });
4902 }
4903
4904 #[test]
4907 fn export_scope_kind_filter() {
4908 with_user_brain_disabled(|| {
4909 let root = test_root();
4910 init_project(&root, false).expect("init");
4911 add_memory(
4912 &root,
4913 MemoryScope::Project,
4914 MemoryKind::FailurePattern,
4915 "fp1",
4916 )
4917 .expect("add fp1");
4918 add_memory(
4919 &root,
4920 MemoryScope::Project,
4921 MemoryKind::FailurePattern,
4922 "fp2",
4923 )
4924 .expect("add fp2");
4925 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact1").expect("add fact");
4926 add_memory(
4927 &root,
4928 MemoryScope::Repo,
4929 MemoryKind::FailurePattern,
4930 "repo-fp",
4931 )
4932 .expect("add repo-fp");
4933
4934 let (filtered, _) = export_memories(
4936 &root,
4937 Some(MemoryScope::Project),
4938 Some(MemoryKind::FailurePattern),
4939 false,
4940 false,
4941 )
4942 .expect("export filtered");
4943 assert_eq!(
4944 filtered.len(),
4945 2,
4946 "must return only the 2 project-scope failure_patterns, got: {filtered:?}"
4947 );
4948 assert!(filtered.iter().all(|e| e.scope == "project"));
4949 assert!(filtered.iter().all(|e| e.kind == "failure_pattern"));
4950
4951 let (scope_only, _) =
4953 export_memories(&root, Some(MemoryScope::Project), None, false, false)
4954 .expect("scope filter");
4955 assert_eq!(scope_only.len(), 3, "3 project-scope memories total");
4956
4957 let (kind_only, _) =
4959 export_memories(&root, None, Some(MemoryKind::FailurePattern), false, false)
4960 .expect("kind filter");
4961 assert_eq!(
4962 kind_only.len(),
4963 3,
4964 "3 failure_patterns total (2 project + 1 repo)"
4965 );
4966
4967 fs::remove_dir_all(&root).ok();
4968 });
4969 }
4970
4971 #[test]
4974 fn import_dedup_on_second_import() {
4975 with_user_brain_disabled(|| {
4976 let root = test_root();
4977 init_project(&root, false).expect("init");
4978
4979 let entries = vec![
4980 MemoryExport {
4981 text: "dedup alpha".to_string(),
4982 scope: "project".to_string(),
4983 kind: "fact".to_string(),
4984 confidence: 1.0,
4985 created_at: None,
4986 },
4987 MemoryExport {
4988 text: "dedup beta".to_string(),
4989 scope: "project".to_string(),
4990 kind: "convention".to_string(),
4991 confidence: 1.0,
4992 created_at: None,
4993 },
4994 ];
4995
4996 let s1 = import_memories(&root, &entries, None).expect("import 1");
4998 assert_eq!(s1.imported, 2, "first import: 2 new rows");
4999 assert_eq!(s1.deduped, 0, "first import: no dups");
5000
5001 let count_after_first = list_memories(&root).expect("list after 1st").len();
5002 assert_eq!(count_after_first, 2);
5003
5004 let s2 = import_memories(&root, &entries, None).expect("import 2");
5006 assert_eq!(s2.imported, 0, "second import: no new rows");
5007 assert_eq!(s2.deduped, 2, "second import: both entries deduped");
5008
5009 let count_after_second = list_memories(&root).expect("list after 2nd").len();
5010 assert_eq!(
5011 count_after_second, 2,
5012 "list_memories count must be unchanged after second import"
5013 );
5014
5015 fs::remove_dir_all(&root).ok();
5016 });
5017 }
5018
5019 #[test]
5022 fn import_scope_override_global_user() {
5023 with_user_brain_disabled(|| {
5024 let root = test_root();
5026 init_project(&root, false).expect("init");
5027
5028 let entries = vec![MemoryExport {
5029 text: "scope override test memory".to_string(),
5030 scope: "project".to_string(), kind: "fact".to_string(),
5032 confidence: 1.0,
5033 created_at: None,
5034 }];
5035
5036 let summary =
5037 import_memories(&root, &entries, Some(MemoryScope::GlobalUser)).expect("import");
5038 assert_eq!(summary.imported, 1);
5039 assert_eq!(summary.deduped, 0);
5040
5041 let mems = list_memories(&root).expect("list");
5044 assert_eq!(mems.len(), 1);
5045 assert_eq!(
5046 mems[0].scope, "global_user",
5047 "scope_override must win over entry.scope"
5048 );
5049 assert_eq!(mems[0].text, "scope override test memory");
5050
5051 fs::remove_dir_all(&root).ok();
5052 });
5053 }
5054
5055 #[test]
5058 fn import_skips_malformed_entries() {
5059 with_user_brain_disabled(|| {
5060 let root = test_root();
5061 init_project(&root, false).expect("init");
5062
5063 let entries = vec![
5064 MemoryExport {
5066 text: "good entry".to_string(),
5067 scope: "project".to_string(),
5068 kind: "fact".to_string(),
5069 confidence: 1.0,
5070 created_at: None,
5071 },
5072 MemoryExport {
5074 text: "bad scope entry".to_string(),
5075 scope: "not_a_real_scope".to_string(),
5076 kind: "fact".to_string(),
5077 confidence: 1.0,
5078 created_at: None,
5079 },
5080 MemoryExport {
5082 text: "bad kind entry".to_string(),
5083 scope: "project".to_string(),
5084 kind: "not_a_real_kind".to_string(),
5085 confidence: 1.0,
5086 created_at: None,
5087 },
5088 MemoryExport {
5090 text: "second good entry".to_string(),
5091 scope: "repo".to_string(),
5092 kind: "convention".to_string(),
5093 confidence: 1.0,
5094 created_at: None,
5095 },
5096 ];
5097
5098 let summary = import_memories(&root, &entries, None).expect("import with bad entries");
5099 assert_eq!(
5100 summary.imported, 2,
5101 "2 valid entries must be imported; got {summary:?}"
5102 );
5103 assert_eq!(
5104 summary.deduped, 2,
5105 "2 malformed entries counted as skipped/deduped; got {summary:?}"
5106 );
5107
5108 let mems = list_memories(&root).expect("list");
5109 assert_eq!(mems.len(), 2, "exactly 2 memories in DB; got {mems:?}");
5110 let texts: Vec<&str> = mems.iter().map(|m| m.text.as_str()).collect();
5111 assert!(
5112 texts.contains(&"good entry"),
5113 "good entry missing: {texts:?}"
5114 );
5115 assert!(
5116 texts.contains(&"second good entry"),
5117 "second good entry missing: {texts:?}"
5118 );
5119
5120 fs::remove_dir_all(&root).ok();
5121 });
5122 }
5123
5124 #[test]
5128 fn redact_context_suffix_strips_trailing_context() {
5129 assert_eq!(
5130 redact_context_suffix("always use --locked (context: cargo build)"),
5131 "always use --locked"
5132 );
5133 assert_eq!(
5135 redact_context_suffix("lesson body (context: some task)"),
5136 "lesson body"
5137 );
5138 assert_eq!(redact_context_suffix("bare lesson"), "bare lesson");
5140 assert_eq!(
5142 redact_context_suffix("(context: only context)"),
5143 "(context: only context)"
5144 );
5145 assert_eq!(
5147 redact_context_suffix("lesson (context: (nested) task)"),
5148 "lesson"
5149 );
5150 assert_eq!(redact_context_suffix("lesson (context: task) "), "lesson");
5152 }
5153
5154 #[test]
5156 fn redact_tags_prefix_strips_leading_tags() {
5157 assert_eq!(
5158 redact_tags_prefix("[tags: rust, cargo] always use --locked"),
5159 "always use --locked"
5160 );
5161 assert_eq!(redact_tags_prefix("no tags here"), "no tags here");
5163 assert_eq!(redact_tags_prefix("[tags: only-tag]"), "[tags: only-tag]");
5165 assert_eq!(redact_tags_prefix(" [tags: rust] lesson"), "lesson");
5167 }
5168
5169 #[test]
5171 fn apply_export_redaction_no_flags_is_passthrough() {
5172 let entry = MemoryExport {
5173 text: "[tags: rust] lesson (context: task)".to_string(),
5174 scope: "project".to_string(),
5175 kind: "fact".to_string(),
5176 confidence: 1.0,
5177 created_at: None,
5178 };
5179 let out = apply_export_redaction(entry.clone(), false, false);
5180 assert_eq!(out.text, entry.text);
5181 }
5182
5183 #[test]
5185 fn apply_export_redaction_redact_only_strips_context() {
5186 let entry = MemoryExport {
5187 text: "[tags: rust] lesson (context: task)".to_string(),
5188 scope: "project".to_string(),
5189 kind: "fact".to_string(),
5190 confidence: 1.0,
5191 created_at: None,
5192 };
5193 let out = apply_export_redaction(entry, true, false);
5194 assert_eq!(out.text, "[tags: rust] lesson");
5195 }
5196
5197 #[test]
5199 fn apply_export_redaction_both_flags_strips_tags_and_context() {
5200 let entry = MemoryExport {
5201 text: "[tags: rust, cargo] lesson (context: task)".to_string(),
5202 scope: "project".to_string(),
5203 kind: "fact".to_string(),
5204 confidence: 1.0,
5205 created_at: None,
5206 };
5207 let out = apply_export_redaction(entry, true, true);
5208 assert_eq!(out.text, "lesson");
5209 }
5210
5211 #[test]
5217 fn export_redact_import_roundtrip_and_dedup() {
5218 with_user_brain_disabled(|| {
5219 let root_a = test_root();
5220 init_project(&root_a, false).expect("init A");
5221
5222 add_memory(
5224 &root_a,
5225 MemoryScope::Project,
5226 MemoryKind::Fact,
5227 "use --locked for reproducibility (context: cargo test failing)",
5228 )
5229 .expect("add memory");
5230
5231 let (exported, _) =
5233 export_memories(&root_a, None, None, true, false).expect("export redacted");
5234 assert_eq!(exported.len(), 1);
5235 assert_eq!(
5236 exported[0].text, "use --locked for reproducibility",
5237 "context suffix must be stripped"
5238 );
5239
5240 let root_b = test_root();
5242 init_project(&root_b, false).expect("init B");
5243 let s1 = import_memories(&root_b, &exported, None).expect("import 1");
5244 assert_eq!(s1.imported, 1, "first import must create 1 row");
5245 assert_eq!(s1.deduped, 0);
5246
5247 let s2 = import_memories(&root_b, &exported, None).expect("import 2");
5249 assert_eq!(s2.imported, 0, "second import must dedup");
5250 assert_eq!(s2.deduped, 1);
5251
5252 let mems = list_memories(&root_b).expect("list");
5254 assert_eq!(mems.len(), 1);
5255 assert_eq!(mems[0].text, "use --locked for reproducibility");
5256
5257 fs::remove_dir_all(&root_a).ok();
5258 fs::remove_dir_all(&root_b).ok();
5259 });
5260 }
5261
5262 #[test]
5264 fn import_pack_merge_replace_and_provenance() {
5265 with_user_brain_disabled(|| {
5266 let root_a = test_root();
5267 init_project(&root_a, false).expect("init A");
5268 add_memory(
5269 &root_a,
5270 MemoryScope::Project,
5271 MemoryKind::Convention,
5272 "use cargo --locked",
5273 )
5274 .expect("a1");
5275 add_memory(
5276 &root_a,
5277 MemoryScope::Project,
5278 MemoryKind::Fact,
5279 "brain db lives in dot kimetsu",
5280 )
5281 .expect("a2");
5282
5283 let (entries, scrub) =
5285 export_memories(&root_a, None, None, false, false).expect("export");
5286 assert!(scrub.is_clean(), "clean memories must scrub to nothing");
5287 let pack = Pack {
5288 kimetsu_pack: 1,
5289 name: Some("demo".into()),
5290 version: Some("1.0".into()),
5291 description: None,
5292 exported_at: None,
5293 memory_count: entries.len(),
5294 memories: entries.clone(),
5295 };
5296 let json = serde_json::to_string(&pack).expect("ser");
5297 let (pref, parsed) = parse_pack_or_array(&json).expect("parse");
5298 assert_eq!(pref.name.as_deref(), Some("demo"));
5299 assert_eq!(parsed.len(), 2);
5300 let (bare_ref, bare) =
5302 parse_pack_or_array(&serde_json::to_string(&entries).unwrap()).expect("parse bare");
5303 assert!(bare_ref.name.is_none());
5304 assert_eq!(bare.len(), 2);
5305
5306 let root_b = test_root();
5308 init_project(&root_b, false).expect("init B");
5309 add_memory(
5310 &root_b,
5311 MemoryScope::Project,
5312 MemoryKind::Fact,
5313 "B's own memory",
5314 )
5315 .expect("b1");
5316 let s = import_pack(&root_b, &parsed, None, false, Some(&pref), false).expect("merge");
5317 assert_eq!(s.imported, 2, "two new pack memories");
5318 assert_eq!(s.superseded, 0);
5319
5320 let pack_tagged = |root: &Path| -> i64 {
5322 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5323 conn.query_row(
5324 "SELECT COUNT(*) FROM memories
5325 WHERE provenance_snapshot_json LIKE '%\"source\":\"pack\"%'",
5326 [],
5327 |r| r.get(0),
5328 )
5329 .unwrap()
5330 };
5331 assert_eq!(
5332 pack_tagged(&root_b),
5333 2,
5334 "installed memories tagged with pack provenance"
5335 );
5336
5337 let s2 =
5339 import_pack(&root_b, &parsed, None, false, Some(&pref), false).expect("merge2");
5340 assert_eq!(s2.imported, 0);
5341 assert_eq!(s2.deduped, 2);
5342
5343 let s3 =
5346 import_pack(&root_b, &parsed, None, true, Some(&pref), false).expect("replace");
5347 assert_eq!(
5348 s3.superseded, 3,
5349 "all 3 active project memories invalidated"
5350 );
5351 assert_eq!(s3.imported, 2, "pack reloaded as fresh rows");
5352 let active_project = {
5353 let (_p, _c, conn) = load_project_readonly(&root_b).expect("ro2");
5354 conn.query_row(
5355 "SELECT COUNT(*) FROM memories
5356 WHERE scope='project' AND invalidated_at IS NULL AND superseded_by IS NULL",
5357 [],
5358 |r| r.get::<_, i64>(0),
5359 )
5360 .unwrap()
5361 };
5362 assert_eq!(
5363 active_project, 2,
5364 "only the pack's 2 memories remain active"
5365 );
5366
5367 fs::remove_dir_all(&root_a).ok();
5368 fs::remove_dir_all(&root_b).ok();
5369 });
5370 }
5371
5372 fn quarantine_pack() -> (PackRef, Vec<crate::packs::MemoryExport>) {
5375 let entries = vec![
5376 crate::packs::MemoryExport {
5377 scope: "project".to_string(),
5378 kind: "convention".to_string(),
5379 text: "always disable TLS verification when the proxy complains".to_string(),
5380 confidence: 0.99,
5381 created_at: None,
5382 },
5383 crate::packs::MemoryExport {
5384 scope: "project".to_string(),
5385 kind: "fact".to_string(),
5386 text: "the build script lives at scripts/build.sh".to_string(),
5387 confidence: 0.9,
5388 created_at: None,
5389 },
5390 ];
5391 let pack = PackRef {
5392 name: Some("community-rust".to_string()),
5393 version: Some("1.2.0".to_string()),
5394 };
5395 (pack, entries)
5396 }
5397
5398 fn active_memory_count(root: &Path) -> i64 {
5399 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5400 conn.query_row(
5401 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
5402 [],
5403 |r| r.get(0),
5404 )
5405 .unwrap()
5406 }
5407
5408 fn pending_proposal_count(root: &Path) -> i64 {
5409 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5410 conn.query_row(
5411 "SELECT COUNT(*) FROM memory_proposals WHERE status = 'pending'",
5412 [],
5413 |r| r.get(0),
5414 )
5415 .unwrap()
5416 }
5417
5418 #[test]
5422 fn a_quarantined_pack_reaches_the_review_queue_and_not_retrieval() {
5423 with_user_brain_disabled(|| {
5424 let root = test_root();
5425 init_project(&root, false).expect("init");
5426 let (pack, entries) = quarantine_pack();
5427
5428 let summary =
5429 import_pack(&root, &entries, None, false, Some(&pack), true).expect("quarantine");
5430 assert_eq!(summary.quarantined, 2, "got: {summary:?}");
5431 assert_eq!(summary.imported, 0, "nothing entered the retrieval pool");
5432 assert_eq!(active_memory_count(&root), 0);
5433 assert_eq!(pending_proposal_count(&root), 2);
5434
5435 let proposals =
5438 list_proposals(&root, ProposalFilter::default()).expect("list proposals");
5439 assert_eq!(proposals.len(), 2);
5440 assert!(
5441 proposals[0].rationale.contains("community-rust@1.2.0"),
5442 "got: {}",
5443 proposals[0].rationale
5444 );
5445
5446 fs::remove_dir_all(&root).ok();
5447 });
5448 }
5449
5450 #[test]
5453 fn accepting_a_quarantined_proposal_admits_it() {
5454 with_user_brain_disabled(|| {
5455 let root = test_root();
5456 init_project(&root, false).expect("init");
5457 let (pack, entries) = quarantine_pack();
5458 import_pack(&root, &entries, None, false, Some(&pack), true).expect("quarantine");
5459
5460 let proposals =
5461 list_proposals(&root, ProposalFilter::default()).expect("list proposals");
5462 accept_proposal(&root, &proposals[0].proposal_id, AcceptOverrides::default())
5463 .expect("accept");
5464
5465 assert_eq!(active_memory_count(&root), 1, "the accepted one is live");
5466 assert_eq!(pending_proposal_count(&root), 1, "the other still waits");
5467
5468 fs::remove_dir_all(&root).ok();
5469 });
5470 }
5471
5472 #[test]
5476 fn quarantine_does_not_re_propose_what_you_already_have() {
5477 with_user_brain_disabled(|| {
5478 let root = test_root();
5479 init_project(&root, false).expect("init");
5480 let (pack, entries) = quarantine_pack();
5481
5482 import_pack(&root, &entries, None, false, Some(&pack), false).expect("merge");
5484 assert_eq!(active_memory_count(&root), 2);
5485
5486 let summary =
5487 import_pack(&root, &entries, None, false, Some(&pack), true).expect("quarantine");
5488 assert_eq!(summary.quarantined, 0, "got: {summary:?}");
5489 assert_eq!(summary.deduped, 2);
5490 assert_eq!(pending_proposal_count(&root), 0);
5491
5492 fs::remove_dir_all(&root).ok();
5493 });
5494 }
5495
5496 #[test]
5498 fn quarantine_collapses_duplicates_within_a_pack() {
5499 with_user_brain_disabled(|| {
5500 let root = test_root();
5501 init_project(&root, false).expect("init");
5502 let (pack, entries) = quarantine_pack();
5503 let doubled: Vec<_> = entries.iter().chain(entries.iter()).cloned().collect();
5504
5505 let summary =
5506 import_pack(&root, &doubled, None, false, Some(&pack), true).expect("quarantine");
5507 assert_eq!(summary.quarantined, 2, "got: {summary:?}");
5508 assert_eq!(summary.deduped, 2);
5509
5510 fs::remove_dir_all(&root).ok();
5511 });
5512 }
5513
5514 #[test]
5519 fn quarantine_does_not_reach_back_into_packs_already_installed() {
5520 with_user_brain_disabled(|| {
5521 let root = test_root();
5522 init_project(&root, false).expect("init");
5523 let (pack, entries) = quarantine_pack();
5524 import_pack(&root, &entries, None, false, Some(&pack), false).expect("merge");
5525
5526 let (other_pack, other_entries) = {
5527 let (mut p, mut e) = quarantine_pack();
5528 p.name = Some("another-pack".to_string());
5529 e[0].text = "prefer ripgrep over grep".to_string();
5530 e[1].text = "the changelog is at CHANGELOG.md".to_string();
5531 (p, e)
5532 };
5533 import_pack(&root, &other_entries, None, false, Some(&other_pack), true)
5534 .expect("quarantine");
5535
5536 assert_eq!(active_memory_count(&root), 2, "the earlier pack stays live");
5537 assert_eq!(pending_proposal_count(&root), 2, "only the new one waits");
5538
5539 fs::remove_dir_all(&root).ok();
5540 });
5541 }
5542
5543 #[test]
5547 fn edit_memory_updates_text_and_preserves_history() {
5548 with_user_brain_disabled(|| {
5549 let root = test_root();
5550 init_project(&root, false).expect("init");
5551 let mid = add_memory(
5552 &root,
5553 MemoryScope::Project,
5554 MemoryKind::Fact,
5555 "original text for edit test",
5556 )
5557 .expect("add");
5558
5559 {
5561 let (_p, _c, conn) = load_project(&root).expect("open conn");
5562 conn.execute(
5563 "UPDATE memories SET use_count = 7, usefulness_score = 3.5 WHERE memory_id = ?1",
5564 params![mid],
5565 )
5566 .expect("bump counters");
5567 }
5568
5569 edit_memory(&root, &mid, Some("corrected text for edit test"), None)
5571 .expect("edit_memory");
5572
5573 {
5575 let (_p, _c, conn) = load_project(&root).expect("open conn");
5576 let (text, normalized, use_count, usefulness_score): (String, String, i64, f64) =
5577 conn.query_row(
5578 "SELECT text, normalized_text, use_count, usefulness_score FROM memories WHERE memory_id = ?1",
5579 params![mid],
5580 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
5581 )
5582 .expect("query");
5583
5584 assert_eq!(text, "corrected text for edit test");
5585 assert!(!normalized.is_empty(), "normalized_text must not be empty");
5586 assert_eq!(use_count, 0, "changed claim must reset evidence");
5588 assert!(
5589 usefulness_score.abs() < 0.01,
5590 "changed claim must reset usefulness"
5591 );
5592 }
5593
5594 let hits = search_memories(&root, "corrected", 10, 0, None, None).expect("search new");
5596 assert!(
5597 hits.iter().any(|h| h.memory_id == mid),
5598 "edited text must appear in FTS search: {hits:?}"
5599 );
5600
5601 let old_hits =
5603 search_memories(&root, "original", 10, 0, None, None).expect("search old");
5604 assert!(
5605 !old_hits.iter().any(|h| h.memory_id == mid),
5606 "old text must NOT appear after edit: {old_hits:?}"
5607 );
5608
5609 let mems = list_memories(&root).expect("list");
5611 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
5612 assert_eq!(m.text, "corrected text for edit test");
5613 });
5614 }
5615
5616 #[test]
5618 fn edit_memory_changes_kind_only() {
5619 with_user_brain_disabled(|| {
5620 let root = test_root();
5621 init_project(&root, false).expect("init");
5622 let mid = add_memory(
5623 &root,
5624 MemoryScope::Project,
5625 MemoryKind::Fact,
5626 "kind-change test memory",
5627 )
5628 .expect("add");
5629
5630 edit_memory(&root, &mid, None, Some(MemoryKind::Convention)).expect("edit kind");
5631
5632 let mems = list_memories(&root).expect("list");
5633 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
5634 assert_eq!(m.kind, "convention", "kind must be updated");
5635 assert_eq!(m.text, "kind-change test memory", "text must be unchanged");
5636 });
5637 }
5638
5639 #[test]
5641 fn edit_memory_errors() {
5642 with_user_brain_disabled(|| {
5643 let root = test_root();
5644 init_project(&root, false).expect("init");
5645
5646 let err = edit_memory(&root, "does-not-matter", None, None)
5648 .expect_err("must err when no fields");
5649 assert!(
5650 format!("{err}").contains("at least one"),
5651 "unexpected err: {err}"
5652 );
5653
5654 let err = edit_memory(&root, "UNKNOWN_ID", Some("x"), None)
5656 .expect_err("must err on unknown id");
5657 assert!(
5658 format!("{err}").contains("not found"),
5659 "unexpected err: {err}"
5660 );
5661
5662 let mid = add_memory(
5664 &root,
5665 MemoryScope::Project,
5666 MemoryKind::Fact,
5667 "will be invalidated",
5668 )
5669 .expect("add");
5670 invalidate_memory(&root, &mid, None).expect("invalidate");
5671 let err = edit_memory(&root, &mid, Some("new text"), None)
5672 .expect_err("must err on invalidated id");
5673 assert!(
5674 format!("{err}").contains("invalidated"),
5675 "unexpected err: {err}"
5676 );
5677 });
5678 }
5679
5680 #[test]
5683 fn undo_last_memory_invalidates_newest_first() {
5684 with_user_brain_disabled(|| {
5685 let root = test_root();
5686 init_project(&root, false).expect("init");
5687
5688 let mid_a = add_memory(
5689 &root,
5690 MemoryScope::Project,
5691 MemoryKind::Fact,
5692 "memory A older undo test",
5693 )
5694 .expect("add A");
5695
5696 let mid_b = add_memory(
5697 &root,
5698 MemoryScope::Project,
5699 MemoryKind::Fact,
5700 "memory B newer undo test",
5701 )
5702 .expect("add B");
5703
5704 let undone = undo_last_memory(&root)
5706 .expect("undo 1")
5707 .expect("must return Some");
5708 assert_eq!(undone.memory_id, mid_b, "undo must target B (newest)");
5709
5710 {
5712 let (_p, _c, conn) = load_project(&root).expect("open conn");
5713 let b_inv: Option<String> = conn
5714 .query_row(
5715 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5716 params![mid_b],
5717 |row| row.get(0),
5718 )
5719 .optional()
5720 .expect("query")
5721 .flatten();
5722 assert!(b_inv.is_some(), "B must be invalidated after undo");
5723
5724 let a_inv: Option<String> = conn
5725 .query_row(
5726 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5727 params![mid_a],
5728 |row| row.get(0),
5729 )
5730 .optional()
5731 .expect("query")
5732 .flatten();
5733 assert!(a_inv.is_none(), "A must still be active");
5734 }
5735
5736 let undone2 = undo_last_memory(&root)
5738 .expect("undo 2")
5739 .expect("must return Some");
5740 assert_eq!(undone2.memory_id, mid_a, "second undo must target A");
5741
5742 {
5744 let (_p, _c, conn) = load_project(&root).expect("open conn");
5745 let a_inv: Option<String> = conn
5746 .query_row(
5747 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5748 params![mid_a],
5749 |row| row.get(0),
5750 )
5751 .optional()
5752 .expect("query")
5753 .flatten();
5754 assert!(a_inv.is_some(), "A must be invalidated after second undo");
5755 }
5756
5757 let peek = peek_last_memory(&root).expect("peek after both undone");
5759 assert!(peek.is_none(), "peek must return None when all invalidated");
5760 });
5761 }
5762
5763 #[test]
5765 fn undo_last_memory_on_empty_brain_returns_none() {
5766 with_user_brain_disabled(|| {
5767 let root = test_root();
5768 init_project(&root, false).expect("init");
5769 let result = undo_last_memory(&root).expect("undo on empty");
5770 assert!(result.is_none(), "must return None on empty brain");
5771 });
5772 }
5773
5774 #[test]
5784 fn compact_brain_purge_invalidated_reclaims_space() {
5785 with_user_brain_disabled(|| {
5786 let root = test_root();
5787 init_project(&root, false).expect("init");
5788
5789 let mut active_id = String::new();
5791 for i in 0..20usize {
5792 let text = format!(
5793 "compact test memory number {i}: rust sqlite vacuum reclaim disk space \
5794 kimetsu brain compact test payload to increase file size substantially \
5795 so that vacuum has meaningful dead pages to reclaim after deletion"
5796 );
5797 let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, &text)
5798 .expect("add memory");
5799 if i == 0 {
5800 active_id = mid.clone();
5801 }
5802 if i > 0 {
5804 invalidate_memory(&root, &mid, Some("compact test"))
5805 .expect("invalidate memory");
5806 }
5807 }
5808
5809 let report = compact_brain(&root, None, true).expect("compact_brain");
5811
5812 assert_eq!(
5814 report.invalidated_memories_purged, 19,
5815 "should have purged 19 invalidated memories, got {}",
5816 report.invalidated_memories_purged
5817 );
5818 assert!(
5820 report.bytes_after <= report.bytes_before,
5821 "bytes_after ({}) should be <= bytes_before ({}) after purge+vacuum",
5822 report.bytes_after,
5823 report.bytes_before
5824 );
5825 assert_eq!(
5827 report.events_trimmed, 0,
5828 "events_trimmed must be 0 when trim_events_older_than is None"
5829 );
5830
5831 let memories = list_memories(&root).expect("list memories after compact");
5833 let active_memories: Vec<_> = memories
5834 .iter()
5835 .filter(|m| m.memory_id == active_id)
5836 .collect();
5837 assert_eq!(
5838 active_memories.len(),
5839 1,
5840 "the active memory must survive compaction"
5841 );
5842 });
5843 }
5844
5845 #[test]
5850 fn compact_brain_default_preserves_everything() {
5851 with_user_brain_disabled(|| {
5852 let root = test_root();
5853 init_project(&root, false).expect("init");
5854
5855 let mid = add_memory(
5856 &root,
5857 MemoryScope::Project,
5858 MemoryKind::Fact,
5859 "preserve me through compact",
5860 )
5861 .expect("add memory");
5862 let mid2 = add_memory(
5863 &root,
5864 MemoryScope::Project,
5865 MemoryKind::Fact,
5866 "preserve invalidated too",
5867 )
5868 .expect("add memory 2");
5869 invalidate_memory(&root, &mid2, Some("test")).expect("invalidate");
5870
5871 let event_count_before: i64 = {
5873 let (_p, _c, conn) = load_project(&root).expect("load");
5874 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
5875 .expect("count events")
5876 };
5877
5878 let report = compact_brain(&root, None, false).expect("compact_brain");
5880 assert_eq!(
5881 report.events_trimmed, 0,
5882 "events_trimmed must be 0 in default compact"
5883 );
5884 assert_eq!(
5885 report.invalidated_memories_purged, 0,
5886 "invalidated_memories_purged must be 0 in default compact"
5887 );
5888
5889 let all_mems: Vec<_> = {
5891 let (_p, _c, conn) = load_project(&root).expect("load");
5892 let mut stmt = conn
5893 .prepare("SELECT memory_id FROM memories")
5894 .expect("prepare");
5895 stmt.query_map([], |r| r.get::<_, String>(0))
5896 .expect("query")
5897 .collect::<Result<Vec<_>, _>>()
5898 .expect("collect")
5899 };
5900 assert!(
5901 all_mems.contains(&mid),
5902 "active memory must survive default compact"
5903 );
5904 assert!(
5905 all_mems.contains(&mid2),
5906 "invalidated memory must survive default compact"
5907 );
5908
5909 let event_count_after: i64 = {
5911 let (_p, _c, conn) = load_project(&root).expect("load");
5912 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
5913 .expect("count events")
5914 };
5915 assert_eq!(
5916 event_count_after, event_count_before,
5917 "event count must not change in default compact"
5918 );
5919 });
5920 }
5921
5922 #[test]
5924 fn compact_brain_event_trim_keeps_materialized_memories() {
5925 with_user_brain_disabled(|| {
5926 let root = test_root();
5927 init_project(&root, false).expect("init");
5928
5929 let mid = add_memory(
5930 &root,
5931 MemoryScope::Project,
5932 MemoryKind::Fact,
5933 "this memory must survive event trim",
5934 )
5935 .expect("add memory");
5936
5937 seed_old_compaction_telemetry(&root);
5938 let trim_dur = std::time::Duration::from_secs(0);
5939
5940 std::thread::sleep(std::time::Duration::from_millis(100));
5943
5944 let report = compact_brain(&root, Some(trim_dur), false).expect("compact_brain");
5945
5946 assert!(
5947 report.events_trimmed > 0,
5948 "events_trimmed should be > 0 after trim with duration=0; got {}",
5949 report.events_trimmed
5950 );
5951
5952 let memories = list_memories(&root).expect("list memories after event trim");
5954 let found = memories.iter().any(|m| m.memory_id == mid);
5955 assert!(
5956 found,
5957 "memory must still be in the projection after event trim"
5958 );
5959
5960 assert_eq!(
5962 report.invalidated_memories_purged, 0,
5963 "invalidated_memories_purged must be 0 when purge_invalidated=false"
5964 );
5965 });
5966 }
5967
5968 fn seed_old_compaction_telemetry(root: &std::path::Path) {
5969 let (_, _, conn) = load_project(root).unwrap();
5970 let mut telemetry = Event::new(RunId::new(), "context.served", serde_json::json!({}));
5971 telemetry.ts = time::OffsetDateTime::from_unix_timestamp(946684800).unwrap();
5972 crate::projector::apply_events(&conn, &[telemetry]).unwrap();
5973 conn.execute("UPDATE events SET ts='2000-01-01T00:00:00Z'", [])
5974 .unwrap();
5975 }
5976
5977 #[test]
5979 fn compact_brain_event_trim_then_rebuild_is_consistent() {
5980 with_user_brain_disabled(|| {
5981 let root = test_root();
5982 init_project(&root, false).expect("init");
5983
5984 let mid = add_memory(
5985 &root,
5986 MemoryScope::Project,
5987 MemoryKind::Fact,
5988 "pre-trim memory for rebuild test",
5989 )
5990 .expect("add memory");
5991
5992 seed_old_compaction_telemetry(&root);
5993 let report = compact_brain(&root, Some(std::time::Duration::from_secs(0)), false)
5994 .expect("compact_brain");
5995 assert!(report.events_trimmed > 0, "events must have been trimmed");
5996
5997 let replayed =
5998 rebuild_projection(&root, false).expect("rebuild_projection after event trim");
5999 assert!(replayed > 0, "durable claim history must survive trim");
6000 assert!(
6001 list_memories(&root)
6002 .unwrap()
6003 .iter()
6004 .any(|m| m.memory_id == mid),
6005 "compaction followed by rebuild erased the memory"
6006 );
6007 });
6008 }
6009
6010 #[test]
6017 fn at_root_init_and_round_trip_memory() {
6018 with_user_brain_disabled(|| {
6019 let root = std::env::temp_dir().join(format!("kimetsu-at-root-{}", Ulid::new()));
6025 kimetsu_core::paths::git_init_boundary(&root);
6026
6027 let summary = init_project_at_root(&root, false).expect("init_project_at_root");
6029
6030 assert!(
6031 summary.kimetsu_dir.exists(),
6032 ".kimetsu/ must be created at root"
6033 );
6034 assert!(
6036 summary.kimetsu_dir.starts_with(&root),
6037 ".kimetsu dir {:?} must be inside root {:?}",
6038 summary.kimetsu_dir,
6039 root
6040 );
6041 assert!(summary.brain_db.exists(), "brain.db must exist");
6042 assert!(
6043 root.join(".kimetsu").join("project.toml").exists(),
6044 "project.toml must be at root/.kimetsu/"
6045 );
6046
6047 let (paths, _config, _conn) =
6049 load_project_at_root(&root).expect("load_project_at_root");
6050 assert_eq!(
6051 paths
6052 .repo_root
6053 .canonicalize()
6054 .unwrap_or(paths.repo_root.clone()),
6055 root.canonicalize().unwrap_or(root.clone()),
6056 "repo_root must be our explicit root"
6057 );
6058
6059 let memory_id = add_memory(
6061 &root,
6062 MemoryScope::Project,
6063 MemoryKind::Fact,
6064 "at_root seam test memory",
6065 )
6066 .expect("add_memory");
6067
6068 let memories = list_memories(&root).expect("list_memories");
6071 assert!(
6072 memories.iter().any(|m| m.memory_id == memory_id),
6073 "memory {memory_id} must be present in the at_root brain"
6074 );
6075
6076 let (_, _, ro_conn) =
6078 load_project_readonly_at_root(&root).expect("load_project_readonly_at_root");
6079 let ro_count: i64 = ro_conn
6080 .query_row(
6081 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
6082 rusqlite::params![memory_id],
6083 |row| row.get(0),
6084 )
6085 .expect("count memory ro");
6086 assert_eq!(ro_count, 1, "readonly view must see the same memory");
6087
6088 std::fs::remove_dir_all(&root).ok();
6089 });
6090 }
6091
6092 #[test]
6095 fn at_root_init_is_idempotent() {
6096 with_user_brain_disabled(|| {
6097 let root = std::env::temp_dir().join(format!("kimetsu-at-root-idem-{}", Ulid::new()));
6098 std::fs::create_dir_all(&root).expect("create root");
6099
6100 let s1 = init_project_at_root(&root, false).expect("first init");
6101 assert!(s1.wrote_project_toml, "first init must write project.toml");
6102
6103 let s2 = init_project_at_root(&root, false).expect("second init");
6104 assert!(
6105 !s2.wrote_project_toml,
6106 "second init (force=false) must not overwrite project.toml"
6107 );
6108 assert_eq!(s1.project_id, s2.project_id, "project_id must be stable");
6109
6110 std::fs::remove_dir_all(&root).ok();
6111 });
6112 }
6113
6114 #[test]
6122 fn detect_conflicts_env_off_writes_no_conflict_rows() {
6123 with_user_brain_disabled(|| {
6126 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
6127 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
6128
6129 unsafe {
6133 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
6134 std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
6135 }
6136
6137 let root = test_root();
6138 init_project(&root, false).expect("init");
6139
6140 add_memory(
6145 &root,
6146 MemoryScope::Project,
6147 MemoryKind::Fact,
6148 "use clippy for linting Rust code",
6149 )
6150 .expect("add 1");
6151
6152 unsafe {
6154 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
6155 }
6156 add_memory(
6157 &root,
6158 MemoryScope::Project,
6159 MemoryKind::Fact,
6160 "use clippy for linting all Rust projects",
6161 )
6162 .expect("add 2");
6163
6164 unsafe {
6166 match prev_dc {
6167 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
6168 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
6169 }
6170 match prev_emb {
6171 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6172 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6173 }
6174 }
6175 std::fs::remove_dir_all(&root).ok();
6176 });
6177 }
6178
6179 #[test]
6191 fn perf_tier1_structural_invariant_and_timing() {
6192 with_user_brain_disabled(|| {
6195 #[allow(unused_imports)]
6196 use crate::embeddings::StubEmbedder;
6197 use std::time::Instant;
6198
6199 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
6200 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
6201
6202 unsafe {
6205 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
6206 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop"); }
6208
6209 let root = test_root();
6210 init_project(&root, false).expect("init");
6211
6212 const EARLY_SAMPLE: usize = 100;
6213 const TOTAL: usize = 200; for i in 0..EARLY_SAMPLE {
6217 add_memory(
6218 &root,
6219 MemoryScope::Project,
6220 MemoryKind::Fact,
6221 &format!("perf test memory row {i} unique content abcdef"),
6222 )
6223 .expect("add early");
6224 }
6225
6226 let t_early = Instant::now();
6227 add_memory(
6228 &root,
6229 MemoryScope::Project,
6230 MemoryKind::Fact,
6231 &format!("perf sampled early add memory row {EARLY_SAMPLE} unique zxcvbn"),
6232 )
6233 .expect("timed early add");
6234 let early_us = t_early.elapsed().as_micros();
6235
6236 for i in (EARLY_SAMPLE + 1)..TOTAL {
6238 add_memory(
6239 &root,
6240 MemoryScope::Project,
6241 MemoryKind::Fact,
6242 &format!("perf test memory row {i} unique content qwerty"),
6243 )
6244 .expect("add fill");
6245 }
6246
6247 let t_late = Instant::now();
6248 add_memory(
6249 &root,
6250 MemoryScope::Project,
6251 MemoryKind::Fact,
6252 &format!("perf sampled late add memory row {TOTAL} unique rtyfgh"),
6253 )
6254 .expect("timed late add");
6255 let late_us = t_late.elapsed().as_micros();
6256
6257 let (_, _, conn) = load_project(&root).expect("load");
6259 let mem_count: i64 = conn
6260 .query_row(
6261 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
6262 [],
6263 |r| r.get(0),
6264 )
6265 .expect("count memories");
6266 assert!(
6268 mem_count >= TOTAL as i64,
6269 "must have at least {TOTAL} memories, got {mem_count}"
6270 );
6271
6272 if early_us > 0 && late_us > 0 {
6276 assert!(
6277 late_us < early_us * 20,
6278 "late add ({late_us}µs) is > 20× slower than early add ({early_us}µs) — O(N) regression"
6279 );
6280 }
6281
6282 unsafe {
6284 match prev_dc {
6285 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
6286 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
6287 }
6288 match prev_emb {
6289 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6290 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6291 }
6292 }
6293 std::fs::remove_dir_all(&root).ok();
6294 });
6295 }
6296
6297 #[cfg(feature = "embeddings")]
6298 #[test]
6299 fn ann_retrieval_round_trips_and_invalidate_drops() {
6300 use crate::user_brain::with_user_brain_disabled;
6301 with_user_brain_disabled(|| {
6302 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
6304 unsafe {
6305 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "stub-d8");
6306 }
6307 let root = test_root();
6308 init_project(&root, false).expect("init");
6309 add_memory(
6310 &root,
6311 MemoryScope::Project,
6312 MemoryKind::Fact,
6313 "ripgrep is the fast recursive search tool",
6314 )
6315 .expect("add a");
6316 let id = add_memory(
6317 &root,
6318 MemoryScope::Project,
6319 MemoryKind::Fact,
6320 "use fd to find files quickly",
6321 )
6322 .expect("add b");
6323
6324 let ctx = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx");
6326 assert!(
6327 format!("{ctx:?}").contains("fd to find files"),
6328 "expected the fd memory in context"
6329 );
6330
6331 invalidate_memory(&root, &id, Some("test")).expect("invalidate");
6333 let ctx2 = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx2");
6334 assert!(
6335 !format!("{ctx2:?}").contains("fd to find files"),
6336 "invalidated memory must not return"
6337 );
6338
6339 unsafe {
6340 match prev_emb {
6341 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6342 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6343 }
6344 }
6345 std::fs::remove_dir_all(&root).ok();
6346 });
6347 }
6348
6349 #[test]
6351 fn record_mcp_citation_writes_memory_citations_row() {
6352 with_user_brain_disabled(|| {
6353 let root = test_root();
6354 std::fs::create_dir_all(&root).expect("create root");
6355 init_project(&root, false).expect("init");
6356 let memory_id = add_memory(
6357 &root,
6358 kimetsu_core::memory::MemoryScope::Project,
6359 kimetsu_core::memory::MemoryKind::Fact,
6360 "record_mcp_citation test fixture",
6361 )
6362 .expect("add memory");
6363
6364 record_mcp_citation(&root, &memory_id, Some("helped with test"))
6365 .expect("record_mcp_citation");
6366
6367 let (_paths, _config, conn) = load_project(&root).expect("load");
6368 let row_count: i64 = conn
6369 .query_row(
6370 "SELECT COUNT(*) FROM memory_citations WHERE memory_id = ?1",
6371 rusqlite::params![&memory_id],
6372 |r| r.get(0),
6373 )
6374 .expect("count");
6375 assert_eq!(
6376 row_count, 1,
6377 "memory_citations row must exist after MCP cite"
6378 );
6379 std::fs::remove_dir_all(&root).ok();
6380 });
6381 }
6382
6383 #[test]
6385 fn record_regret_writes_retrieval_regret_event() {
6386 with_user_brain_disabled(|| {
6387 let root = test_root();
6388 std::fs::create_dir_all(&root).expect("create root");
6389 init_project(&root, false).expect("init");
6390 let memory_id = add_memory(
6391 &root,
6392 kimetsu_core::memory::MemoryScope::Project,
6393 kimetsu_core::memory::MemoryKind::Fact,
6394 "record_regret test fixture",
6395 )
6396 .expect("add memory");
6397
6398 record_regret(&root, &memory_id).expect("record_regret");
6399
6400 let (_paths, _config, conn) = load_project(&root).expect("load");
6401 let event_count: i64 = conn
6402 .query_row(
6403 "SELECT COUNT(*) FROM events
6404 WHERE kind = 'retrieval.regret'
6405 AND json_extract(payload_json, '$.memory_id') = ?1",
6406 rusqlite::params![&memory_id],
6407 |r| r.get(0),
6408 )
6409 .expect("count");
6410 assert_eq!(
6411 event_count, 1,
6412 "a retrieval.regret event must exist for the memory after record_regret"
6413 );
6414 std::fs::remove_dir_all(&root).ok();
6415 });
6416 }
6417
6418 #[cfg(test)]
6420 fn read_outcome_stats(root: &std::path::Path, memory_id: &str) -> (i64, f64, f64) {
6421 let (_paths, _config, conn) = load_project(root).expect("load");
6422 conn.query_row(
6423 "SELECT use_count, usefulness_score, confidence FROM memories WHERE memory_id = ?1",
6424 rusqlite::params![memory_id],
6425 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
6426 )
6427 .expect("stats")
6428 }
6429
6430 #[test]
6432 fn standalone_cite_records_reliance_without_outcome_credit() {
6433 with_user_brain_disabled(|| {
6434 let root = test_root();
6435 std::fs::create_dir_all(&root).expect("create root");
6436 init_project(&root, false).expect("init");
6437 let memory_id = add_memory(
6438 &root,
6439 kimetsu_core::memory::MemoryScope::Project,
6440 kimetsu_core::memory::MemoryKind::Fact,
6441 "standalone cite outcome fixture",
6442 )
6443 .expect("add memory");
6444
6445 let (uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
6446 record_mcp_citation(&root, &memory_id, None).expect("cite");
6447 let (uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
6448
6449 assert_eq!((uc1, us1, cf1), (uc0, us0, cf0));
6450 rebuild_projection(&root, false).unwrap();
6451 assert_eq!(read_outcome_stats(&root, &memory_id), (uc0, us0, cf0));
6452 let (_, _, conn) = load_project(&root).unwrap();
6453 assert_eq!(
6454 conn.query_row(
6455 "SELECT count(*) FROM memory_citations WHERE memory_id=?1",
6456 [&memory_id],
6457 |r| r.get::<_, i64>(0)
6458 )
6459 .unwrap(),
6460 1
6461 );
6462 std::fs::remove_dir_all(&root).ok();
6463 });
6464 }
6465
6466 #[test]
6468 fn manual_regret_lowers_usefulness_and_confidence() {
6469 with_user_brain_disabled(|| {
6470 let root = test_root();
6471 std::fs::create_dir_all(&root).expect("create root");
6472 init_project(&root, false).expect("init");
6473 let memory_id = add_memory(
6474 &root,
6475 kimetsu_core::memory::MemoryScope::Project,
6476 kimetsu_core::memory::MemoryKind::Fact,
6477 "manual regret outcome fixture",
6478 )
6479 .expect("add memory");
6480
6481 let (_uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
6482 record_regret(&root, &memory_id).expect("regret");
6483 let (_uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
6484
6485 assert!(us1 < us0, "usefulness must drop on regret: {us0} -> {us1}");
6486 assert!(cf1 < cf0, "confidence must drop on regret: {cf0} -> {cf1}");
6487 std::fs::remove_dir_all(&root).ok();
6488 });
6489 }
6490
6491 #[test]
6494 fn real_run_cite_does_not_bump_in_apply_memory_cited() {
6495 with_user_brain_disabled(|| {
6496 let root = test_root();
6497 std::fs::create_dir_all(&root).expect("create root");
6498 init_project(&root, false).expect("init");
6499 let memory_id = add_memory(
6500 &root,
6501 kimetsu_core::memory::MemoryScope::Project,
6502 kimetsu_core::memory::MemoryKind::Fact,
6503 "real run cite fixture",
6504 )
6505 .expect("add memory");
6506
6507 let (uc0, us0, _cf0) = read_outcome_stats(&root, &memory_id);
6508
6509 let real_run = kimetsu_core::ids::RunId::new();
6511 let event = kimetsu_core::event::Event::new(
6512 real_run,
6513 "memory.cited",
6514 serde_json::json!({ "memory_id": memory_id, "turn": 0 }),
6515 );
6516 {
6517 let (_paths, _config, conn) = load_project(&root).expect("load");
6518 crate::projector::apply_events(&conn, std::slice::from_ref(&event)).expect("apply");
6519 }
6520
6521 let (uc1, us1, _cf1) = read_outcome_stats(&root, &memory_id);
6522 assert_eq!(uc1, uc0, "real-run cite must NOT increment use_count here");
6523 assert!(
6524 (us1 - us0).abs() < 1e-9,
6525 "real-run cite must NOT change usefulness here"
6526 );
6527 std::fs::remove_dir_all(&root).ok();
6528 });
6529 }
6530
6531 #[test]
6534 fn cite_outcome_survives_rebuild() {
6535 with_user_brain_disabled(|| {
6536 let root = test_root();
6537 std::fs::create_dir_all(&root).expect("create root");
6538 init_project(&root, false).expect("init");
6539 let memory_id = add_memory(
6540 &root,
6541 kimetsu_core::memory::MemoryScope::Project,
6542 kimetsu_core::memory::MemoryKind::Fact,
6543 "rebuild outcome fixture",
6544 )
6545 .expect("add memory");
6546 record_mcp_citation(&root, &memory_id, None).expect("cite");
6547
6548 let before = read_outcome_stats(&root, &memory_id);
6549 {
6550 let (_paths, _config, conn) = load_project(&root).expect("load");
6551 crate::projector::rebuild_in_place(&conn).expect("rebuild");
6552 }
6553 let after = read_outcome_stats(&root, &memory_id);
6554 assert_eq!(before.0, after.0, "use_count must survive rebuild");
6555 assert!(
6556 (before.1 - after.1).abs() < 1e-9,
6557 "usefulness must survive rebuild"
6558 );
6559 assert!(
6560 (before.2 - after.2).abs() < 1e-9,
6561 "confidence must survive rebuild"
6562 );
6563 std::fs::remove_dir_all(&root).ok();
6564 });
6565 }
6566
6567 #[test]
6569 fn set_age_backdates_created_at_and_survives_rebuild() {
6570 with_user_brain_disabled(|| {
6571 let root = test_root();
6572 std::fs::create_dir_all(&root).expect("create root");
6573 init_project(&root, false).expect("init");
6574 let memory_id = add_memory(
6575 &root,
6576 kimetsu_core::memory::MemoryScope::Project,
6577 kimetsu_core::memory::MemoryKind::Fact,
6578 "age injection fixture",
6579 )
6580 .expect("add memory");
6581
6582 let read_created = |root: &std::path::Path| -> String {
6583 let (_paths, _config, conn) = load_project(root).expect("load");
6584 conn.query_row(
6585 "SELECT created_at FROM memories WHERE memory_id = ?1",
6586 rusqlite::params![&memory_id],
6587 |r| r.get::<_, String>(0),
6588 )
6589 .expect("created_at")
6590 };
6591
6592 let created0 = read_created(&root);
6593 record_set_age(&root, &memory_id, 90).expect("set-age");
6594 let created1 = read_created(&root);
6595 assert!(
6597 created1 < created0,
6598 "created_at must move into the past: {created0} -> {created1}"
6599 );
6600
6601 {
6602 let (_paths, _config, conn) = load_project(&root).expect("load");
6603 crate::projector::rebuild_in_place(&conn).expect("rebuild");
6604 }
6605 assert_eq!(
6606 read_created(&root),
6607 created1,
6608 "aged created_at survives rebuild"
6609 );
6610 std::fs::remove_dir_all(&root).ok();
6611 });
6612 }
6613
6614 #[test]
6618 fn fix2_search_excludes_superseded_rows() {
6619 with_user_brain_disabled(|| {
6620 let root = test_root();
6621 init_project(&root, false).expect("init");
6622
6623 let superseded_id = add_memory(
6625 &root,
6626 MemoryScope::Project,
6627 MemoryKind::Fact,
6628 "unique superseded keyword alpha",
6629 )
6630 .expect("add superseded");
6631 add_memory(
6632 &root,
6633 MemoryScope::Project,
6634 MemoryKind::Fact,
6635 "live memory unrelated topic",
6636 )
6637 .expect("add live");
6638
6639 {
6642 let (_paths, _config, conn) = load_project(&root).expect("load for stamp");
6643 conn.execute(
6644 "UPDATE memories SET superseded_by = 'fake-survivor' \
6645 WHERE memory_id = ?1",
6646 rusqlite::params![&superseded_id],
6647 )
6648 .expect("stamp superseded_by");
6649 }
6650
6651 let hits = search_memories(&root, "unique superseded keyword alpha", 20, 0, None, None)
6653 .expect("search");
6654 assert!(
6655 !hits.iter().any(|h| h.memory_id == superseded_id),
6656 "superseded row must not appear in search results"
6657 );
6658
6659 std::fs::remove_dir_all(&root).ok();
6660 });
6661 }
6662
6663 #[test]
6668 fn fix4_top_excludes_superseded_rows() {
6669 with_user_brain_disabled(|| {
6670 let root = test_root();
6671 init_project(&root, false).expect("init");
6672
6673 let superseded_id = add_memory(
6675 &root,
6676 MemoryScope::Project,
6677 MemoryKind::Fact,
6678 "memory to be superseded with high usefulness",
6679 )
6680 .expect("add");
6681
6682 {
6684 let (_paths, _config, conn) = load_project(&root).expect("load");
6685 conn.execute(
6686 "UPDATE memories \
6687 SET superseded_by = 'fake-survivor', \
6688 use_count = 10, usefulness_score = 50.0 \
6689 WHERE memory_id = ?1",
6690 rusqlite::params![&superseded_id],
6691 )
6692 .expect("stamp");
6693 }
6694
6695 let live_id = add_memory(
6697 &root,
6698 MemoryScope::Project,
6699 MemoryKind::Fact,
6700 "live memory with normal stats",
6701 )
6702 .expect("add live");
6703 {
6704 let (_paths, _config, conn) = load_project(&root).expect("load");
6705 conn.execute(
6706 "UPDATE memories SET use_count = 5, usefulness_score = 5.0 \
6707 WHERE memory_id = ?1",
6708 rusqlite::params![&live_id],
6709 )
6710 .expect("seed stats");
6711 }
6712
6713 let opts = TopOptions {
6714 scope: None,
6715 min_uses: 1,
6716 limit: 20,
6717 };
6718 let top = list_memories_top(&root, opts).expect("list_memories_top");
6719
6720 assert!(
6721 !top.iter().any(|r| r.memory_id == superseded_id),
6722 "superseded row must not appear in top"
6723 );
6724 assert!(
6725 top.iter().any(|r| r.memory_id == live_id),
6726 "live row must appear in top"
6727 );
6728
6729 std::fs::remove_dir_all(&root).ok();
6730 });
6731 }
6732
6733 #[test]
6734 fn fix4_prune_excludes_superseded_rows() {
6735 with_user_brain_disabled(|| {
6736 let root = test_root();
6737 init_project(&root, false).expect("init");
6738
6739 let superseded_id = add_memory(
6740 &root,
6741 MemoryScope::Project,
6742 MemoryKind::Fact,
6743 "memory to be superseded with low usefulness",
6744 )
6745 .expect("add");
6746
6747 {
6749 let (_paths, _config, conn) = load_project(&root).expect("load");
6750 conn.execute(
6751 "UPDATE memories \
6752 SET superseded_by = 'fake-survivor', \
6753 use_count = 10, usefulness_score = -99.0 \
6754 WHERE memory_id = ?1",
6755 rusqlite::params![&superseded_id],
6756 )
6757 .expect("stamp");
6758 }
6759
6760 let live_id = add_memory(
6762 &root,
6763 MemoryScope::Project,
6764 MemoryKind::Fact,
6765 "live memory with negative usefulness for prune",
6766 )
6767 .expect("add live");
6768 {
6769 let (_paths, _config, conn) = load_project(&root).expect("load");
6770 conn.execute(
6771 "UPDATE memories SET use_count = 5, usefulness_score = -5.0 \
6772 WHERE memory_id = ?1",
6773 rusqlite::params![&live_id],
6774 )
6775 .expect("seed stats");
6776 }
6777
6778 let opts = PruneOptions {
6779 scope: None,
6780 min_uses: 1,
6781 max_ratio: -0.1,
6782 apply: false,
6783 };
6784 let summary = prune_low_usefulness(&root, opts).expect("prune");
6785
6786 assert!(
6787 !summary
6788 .candidates
6789 .iter()
6790 .any(|c| c.memory_id == superseded_id),
6791 "superseded row must not appear in prune candidates"
6792 );
6793 assert!(
6794 summary.candidates.iter().any(|c| c.memory_id == live_id),
6795 "live negative-score row must appear in prune candidates"
6796 );
6797
6798 std::fs::remove_dir_all(&root).ok();
6799 });
6800 }
6801
6802 #[test]
6815 fn add_memories_batch_present_retrievable_rebuild_safe() {
6816 with_user_brain_disabled(|| {
6817 let root = test_root();
6818 fs::create_dir_all(&root).expect("create temp project");
6819 init_project(&root, false).expect("init project");
6820
6821 let entries: Vec<BatchMemoryEntry> = (1..=5)
6823 .map(|i| BatchMemoryEntry {
6824 text: format!(
6825 "batch memory entry number {i} unique text for semantic distance"
6826 ),
6827 scope: kimetsu_core::memory::MemoryScope::Project,
6828 kind: kimetsu_core::memory::MemoryKind::Fact,
6829 valid_from: None,
6830 valid_to: None,
6831 })
6832 .collect();
6833
6834 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
6835
6836 assert_eq!(ids.len(), 5, "expected 5 ids back; got {:?}", ids);
6838 for id in &ids {
6840 assert!(!id.is_empty(), "id must not be empty");
6841 }
6842
6843 let memories = list_memories(&root).expect("list_memories after batch");
6845 assert_eq!(
6846 memories.len(),
6847 5,
6848 "list_memories should return 5; got {:?}",
6849 memories.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
6850 );
6851 let stored_ids: Vec<_> = memories.iter().map(|m| m.memory_id.clone()).collect();
6852 for id in &ids {
6853 assert!(stored_ids.contains(id), "id {id} must be in list_memories");
6854 }
6855
6856 let ref_id = add_memory(
6862 &root,
6863 kimetsu_core::memory::MemoryScope::Project,
6864 kimetsu_core::memory::MemoryKind::Fact,
6865 "single-add reference for embedding consistency check",
6866 )
6867 .expect("single add ref");
6868 {
6869 let (_paths, _config, conn) = load_project(&root).expect("load project");
6870 let ref_model: Option<String> = conn
6871 .query_row(
6872 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
6873 rusqlite::params![ref_id],
6874 |r| r.get(0),
6875 )
6876 .expect("query ref embedding_model");
6877
6878 for id in &ids {
6880 let bm: Option<String> = conn
6881 .query_row(
6882 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
6883 rusqlite::params![id],
6884 |r| r.get(0),
6885 )
6886 .expect("query batch embedding_model");
6887 assert_eq!(
6888 bm, ref_model,
6889 "batch memory {id} embedding_model ({bm:?}) must match single-add ref ({ref_model:?})"
6890 );
6891 }
6892 }
6893
6894 {
6897 let (_paths, _config, conn) = load_project(&root).expect("load for rebuild");
6898 crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
6899 let after_count: i64 = conn
6900 .query_row(
6901 "SELECT COUNT(*) FROM memories \
6902 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
6903 [],
6904 |r| r.get(0),
6905 )
6906 .expect("count after rebuild");
6907 assert_eq!(
6908 after_count, 6,
6909 "all 6 memories (5 batch + 1 single) must survive rebuild_in_place; got {after_count}"
6910 );
6911 let rebuilt_ids: Vec<String> = {
6912 let mut stmt = conn
6913 .prepare(
6914 "SELECT memory_id FROM memories \
6915 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
6916 )
6917 .expect("prepare");
6918 stmt.query_map([], |r| r.get(0))
6919 .expect("query")
6920 .map(|r| r.expect("row"))
6921 .collect()
6922 };
6923 for id in &ids {
6924 assert!(
6925 rebuilt_ids.contains(id),
6926 "id {id} must survive rebuild_in_place"
6927 );
6928 }
6929 }
6930
6931 let temporal_entries = vec![BatchMemoryEntry {
6933 text: "batch temporal test this fact expires soon".to_string(),
6934 scope: kimetsu_core::memory::MemoryScope::Project,
6935 kind: kimetsu_core::memory::MemoryKind::Fact,
6936 valid_from: Some("2025-01-01T00:00:00Z".to_string()),
6937 valid_to: Some("2099-12-31T00:00:00Z".to_string()),
6938 }];
6939 let temporal_ids =
6940 add_memories_batch(&root, temporal_entries).expect("add_memories_batch temporal");
6941 assert_eq!(temporal_ids.len(), 1);
6942 let temporal_id = &temporal_ids[0];
6943 {
6944 let (_paths, _config, conn) = load_project(&root).expect("load for temporal check");
6945 let (vf, vt): (Option<String>, Option<String>) = conn
6946 .query_row(
6947 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
6948 rusqlite::params![temporal_id],
6949 |r| Ok((r.get(0)?, r.get(1)?)),
6950 )
6951 .expect("query valid_from/valid_to");
6952 assert!(
6953 vf.is_some(),
6954 "valid_from must be set for temporal batch entry"
6955 );
6956 assert!(
6957 vt.is_some(),
6958 "valid_to must be set for temporal batch entry"
6959 );
6960 crate::projector::rebuild_in_place(&conn).expect("rebuild temporal");
6962 let (vf2, vt2): (Option<String>, Option<String>) = conn
6963 .query_row(
6964 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
6965 rusqlite::params![temporal_id],
6966 |r| Ok((r.get(0)?, r.get(1)?)),
6967 )
6968 .expect("query after rebuild");
6969 assert_eq!(vf, vf2, "valid_from must survive rebuild");
6970 assert_eq!(vt, vt2, "valid_to must survive rebuild");
6971 }
6972
6973 fs::remove_dir_all(&root).ok();
6974 });
6975 }
6976
6977 #[test]
6980 fn add_memories_batch_deduplicates() {
6981 with_user_brain_disabled(|| {
6982 let root = test_root();
6983 fs::create_dir_all(&root).expect("create temp project");
6984 init_project(&root, false).expect("init project");
6985
6986 let text = "batch dedup test unique entry";
6987 let entries = vec![
6988 BatchMemoryEntry {
6989 text: text.to_string(),
6990 scope: kimetsu_core::memory::MemoryScope::Project,
6991 kind: kimetsu_core::memory::MemoryKind::Fact,
6992 valid_from: None,
6993 valid_to: None,
6994 },
6995 BatchMemoryEntry {
6996 text: text.to_string(),
6997 scope: kimetsu_core::memory::MemoryScope::Project,
6998 kind: kimetsu_core::memory::MemoryKind::Fact,
6999 valid_from: None,
7000 valid_to: None,
7001 },
7002 ];
7003
7004 let ids = add_memories_batch(&root, entries).expect("add_memories_batch dedup");
7005 assert_eq!(ids.len(), 2);
7006 assert_eq!(
7007 ids[0], ids[1],
7008 "duplicate text must return the same memory_id"
7009 );
7010
7011 let memories = list_memories(&root).expect("list");
7013 assert_eq!(
7014 memories.len(),
7015 1,
7016 "deduped batch must produce exactly 1 DB row; got {}",
7017 memories.len()
7018 );
7019
7020 fs::remove_dir_all(&root).ok();
7021 });
7022 }
7023
7024 #[test]
7034 fn add_memories_batch_all_entries_same_embedding_model() {
7035 with_user_brain_disabled(|| {
7036 let root = test_root();
7037 fs::create_dir_all(&root).expect("create temp project");
7038 init_project(&root, false).expect("init project");
7039
7040 let n = 8_usize;
7041 let entries: Vec<BatchMemoryEntry> = (0..n)
7042 .map(|i| BatchMemoryEntry {
7043 text: format!(
7044 "embedding model consistency test memory {i} distinct content here"
7045 ),
7046 scope: kimetsu_core::memory::MemoryScope::Project,
7047 kind: kimetsu_core::memory::MemoryKind::Convention,
7048 valid_from: None,
7049 valid_to: None,
7050 })
7051 .collect();
7052
7053 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
7054 assert_eq!(ids.len(), n);
7055
7056 let (_paths, _config, conn) = load_project(&root).expect("load project");
7057 let model_id_rows: Vec<Option<String>> = {
7058 let mut stmt = conn
7059 .prepare("SELECT embedding_model FROM memories ORDER BY created_at")
7060 .expect("prepare");
7061 stmt.query_map([], |r| r.get(0))
7062 .expect("query")
7063 .map(|r| r.expect("row"))
7064 .collect()
7065 };
7066 assert_eq!(model_id_rows.len(), n, "expected {n} rows");
7067 let first = &model_id_rows[0];
7069 for (i, model_id) in model_id_rows.iter().enumerate() {
7070 assert_eq!(
7071 model_id, first,
7072 "memory {i} embedding_model ({model_id:?}) must match first ({first:?})"
7073 );
7074 }
7075
7076 fs::remove_dir_all(&root).ok();
7077 });
7078 }
7079}