1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kimetsu_core::config::ProjectConfig;
5use kimetsu_core::env_file::resolve_env_value;
6use kimetsu_core::event::Event;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::memory::{MemoryKind, MemoryScope, normalize_memory_text};
9use kimetsu_core::paths::{ProjectPaths, default_project_id};
10use kimetsu_core::{KIMETSU_CONFIG_VERSION, KimetsuResult};
11use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
12use ulid::Ulid;
13
14use crate::benchmark;
15use crate::conflict;
16use crate::context::{self, ContextBundle, ContextRequest};
17use crate::embeddings;
18use crate::ingest::{self, RepoIngestSummary};
19use crate::lock::ProjectLock;
20use crate::projector;
21use crate::redact;
22use crate::schema;
23use crate::user_brain;
24
25fn max_corpus_cosine(conn: &Connection, query_vec: &[f32]) -> f32 {
32 let mut stmt = match conn.prepare(
33 "SELECT embedding FROM memories
34 WHERE invalidated_at IS NULL
35 AND superseded_by IS NULL
36 AND embedding IS NOT NULL
37 ORDER BY created_at DESC
38 LIMIT 200",
39 ) {
40 Ok(s) => s,
41 Err(_) => return 0.0,
42 };
43 let rows = match stmt.query_map([], |row| row.get::<_, Vec<u8>>(0)) {
44 Ok(r) => r,
45 Err(_) => return 0.0,
46 };
47 let mut max_cos: f32 = 0.0;
48 for row in rows.flatten() {
49 if let Ok(vec) = embeddings::decode_embedding(&row, None) {
50 if vec.len() == query_vec.len() {
51 let cos = cosine_sim(query_vec, &vec);
52 if cos > max_cos {
53 max_cos = cos;
54 }
55 }
56 }
57 }
58 max_cos
59}
60
61fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
62 if a.len() != b.len() || a.is_empty() {
63 return 0.0;
64 }
65 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
66 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
67 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
68 if na < f32::EPSILON || nb < f32::EPSILON {
69 return 0.0;
70 }
71 (dot / (na * nb)).clamp(-1.0, 1.0)
72}
73
74#[derive(Debug, Clone)]
75pub struct InitSummary {
76 pub project_id: String,
77 pub repo_root: PathBuf,
78 pub kimetsu_dir: PathBuf,
79 pub brain_db: PathBuf,
80 pub model: String,
81 pub api_key_env: String,
82 pub api_key_present: bool,
83 pub wrote_project_toml: bool,
84}
85
86#[derive(Debug, Clone)]
87pub struct RunSummary {
88 pub run_id: String,
89 pub task: String,
90 pub started_at: String,
91 pub terminal_kind: Option<String>,
92}
93
94#[derive(Debug, Clone)]
95pub struct MemoryRow {
96 pub memory_id: String,
97 pub scope: String,
98 pub kind: String,
99 pub text: String,
100 pub confidence: f32,
101 pub use_count: u32,
102 pub usefulness_score: f32,
107}
108
109#[derive(Debug, Clone)]
113pub struct MemorySearchHit {
114 pub memory_id: String,
115 pub scope: String,
116 pub kind: String,
117 pub text: String,
118 pub rank: f32,
119}
120
121#[derive(Debug, Clone)]
122pub struct ProposalRow {
123 pub proposal_id: String,
124 pub run_id: String,
125 pub scope: String,
126 pub kind: String,
127 pub text: String,
128 pub rationale: String,
129 pub proposed_confidence: f32,
130 pub status: String,
131 pub decided_reason: Option<String>,
132}
133
134#[derive(Debug, Clone, Default)]
135pub struct ProposalFilter {
136 pub scope: Option<String>,
137 pub kind: Option<String>,
138 pub from_run: Option<String>,
139 pub min_confidence: Option<f32>,
140 pub status: Option<String>,
141 pub limit: u32,
142 pub offset: u32,
145}
146
147#[derive(Debug, Clone, Default)]
148pub struct AcceptOverrides {
149 pub scope: Option<String>,
150 pub confidence: Option<f32>,
151}
152
153#[derive(Debug, Clone)]
154pub struct RecordedBenchmarkOutcome {
155 pub memory_id: String,
156 pub task_slug: Option<String>,
157 pub kind: MemoryKind,
158 pub text: String,
159 pub proposal_id: Option<String>,
160 pub proposal_text: Option<String>,
161}
162
163pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
164 let paths = ProjectPaths::discover(start)?;
165 paths.validate_state_dir()?;
166 fs::create_dir_all(&paths.kimetsu_dir)?;
171
172 let project_id = default_project_id(&paths.repo_root);
173 let config = ProjectConfig::default_for_project(project_id);
174 let wrote_project_toml = if force || !paths.project_toml.exists() {
175 fs::write(&paths.project_toml, config.to_toml()?)?;
176 true
177 } else {
178 false
179 };
180
181 let config = load_config(&paths)?;
182 let conn = Connection::open(&paths.brain_db)?;
183 schema::initialize(&conn)?;
184
185 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
186
187 Ok(InitSummary {
188 project_id: config.kimetsu.project_id,
189 repo_root: paths.repo_root,
190 kimetsu_dir: paths.kimetsu_dir,
191 brain_db: paths.brain_db,
192 model: format!("{}/{}", config.model.provider, config.model.model),
193 api_key_env: config.model.api_key_env,
194 api_key_present,
195 wrote_project_toml,
196 })
197}
198
199pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
200 let paths = ProjectPaths::discover(start)?;
201 paths.validate_state_dir()?;
202 let config = load_config(&paths)?;
203 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
204 return Err(format!(
210 "project.toml schema version {} does not match expected {} (file: {}). \
211 If this is not the project you meant, run from inside a git \
212 repository or pass --workspace to pin the project root.",
213 config.kimetsu.schema_version,
214 KIMETSU_CONFIG_VERSION,
215 paths.project_toml.display()
216 )
217 .into());
218 }
219
220 let conn = Connection::open(&paths.brain_db)?;
221 schema::initialize(&conn)?;
222 Ok((paths, config, conn))
223}
224
225pub fn init_project_at_root(root: &Path, force: bool) -> KimetsuResult<InitSummary> {
230 let paths = ProjectPaths::at_root(root);
231 paths.validate_state_dir()?;
232 fs::create_dir_all(&paths.kimetsu_dir)?;
233
234 let project_id = default_project_id(&paths.repo_root);
235 let config = ProjectConfig::default_for_project(project_id);
236 let wrote_project_toml = if force || !paths.project_toml.exists() {
237 fs::write(&paths.project_toml, config.to_toml()?)?;
238 true
239 } else {
240 false
241 };
242
243 let config = load_config(&paths)?;
244 let conn = Connection::open(&paths.brain_db)?;
245 schema::initialize(&conn)?;
246
247 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
248
249 Ok(InitSummary {
250 project_id: config.kimetsu.project_id,
251 repo_root: paths.repo_root,
252 kimetsu_dir: paths.kimetsu_dir,
253 brain_db: paths.brain_db,
254 model: format!("{}/{}", config.model.provider, config.model.model),
255 api_key_env: config.model.api_key_env,
256 api_key_present,
257 wrote_project_toml,
258 })
259}
260
261pub fn load_project_at_root(
264 root: &Path,
265) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
266 let paths = ProjectPaths::at_root(root);
267 paths.validate_state_dir()?;
268 let config = load_config(&paths)?;
269 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
270 return Err(format!(
276 "project.toml schema version {} does not match expected {} (file: {}). \
277 If this is not the project you meant, run from inside a git \
278 repository or pass --workspace to pin the project root.",
279 config.kimetsu.schema_version,
280 KIMETSU_CONFIG_VERSION,
281 paths.project_toml.display()
282 )
283 .into());
284 }
285
286 let conn = Connection::open(&paths.brain_db)?;
287 schema::initialize(&conn)?;
288 Ok((paths, config, conn))
289}
290
291pub fn load_project_readonly_at_root(
294 root: &Path,
295) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
296 let paths = ProjectPaths::at_root(root);
297 paths.validate_state_dir()?;
298 let config = load_config(&paths)?;
299 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
300 return Err(format!(
306 "project.toml schema version {} does not match expected {} (file: {}). \
307 If this is not the project you meant, run from inside a git \
308 repository or pass --workspace to pin the project root.",
309 config.kimetsu.schema_version,
310 KIMETSU_CONFIG_VERSION,
311 paths.project_toml.display()
312 )
313 .into());
314 }
315
316 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
317 schema::validate(&conn)?;
318 Ok((paths, config, conn))
319}
320
321pub fn schema_version(start: &Path) -> KimetsuResult<i64> {
326 let (_, _, conn) = load_project(start)?;
327 crate::migrate::current_version(&conn)
328}
329
330pub fn load_project_readonly(
331 start: &Path,
332) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
333 let paths = ProjectPaths::discover(start)?;
334 paths.validate_state_dir()?;
335 let config = load_config(&paths)?;
336 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
337 return Err(format!(
343 "project.toml schema version {} does not match expected {} (file: {}). \
344 If this is not the project you meant, run from inside a git \
345 repository or pass --workspace to pin the project root.",
346 config.kimetsu.schema_version,
347 KIMETSU_CONFIG_VERSION,
348 paths.project_toml.display()
349 )
350 .into());
351 }
352
353 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
354 schema::validate(&conn)?;
355 Ok((paths, config, conn))
356}
357
358pub struct BrainSession {
359 paths: ProjectPaths,
360 config: ProjectConfig,
361 conn: Connection,
362 user_conn: Option<Connection>,
370 repo_root: String,
371}
372
373impl BrainSession {
374 pub fn open(start: &Path) -> KimetsuResult<Self> {
375 let (paths, config, conn) = load_project(start)?;
376 let user_conn = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?;
381 Self::from_parts(paths, config, conn, user_conn)
382 }
383
384 pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
385 let (paths, config, conn) = load_project_readonly(start)?;
386 let user_conn =
391 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
392 Self::from_parts(paths, config, conn, user_conn)
393 }
394
395 fn from_parts(
396 paths: ProjectPaths,
397 config: ProjectConfig,
398 conn: Connection,
399 user_conn: Option<Connection>,
400 ) -> KimetsuResult<Self> {
401 let repo_root = paths
402 .repo_root
403 .canonicalize()?
404 .to_string_lossy()
405 .to_string();
406 Ok(Self {
407 paths,
408 config,
409 conn,
410 user_conn,
411 repo_root,
412 })
413 }
414
415 pub fn retrieve_context(
416 &self,
417 stage: &str,
418 query: &str,
419 budget_tokens: u32,
420 ) -> KimetsuResult<ContextBundle> {
421 self.retrieve_context_with_request(ContextRequest {
422 stage: stage.to_string(),
423 query: query.to_string(),
424 budget_tokens,
425 ..Default::default()
426 })
427 }
428
429 pub fn retrieve_context_with_request(
437 &self,
438 mut request: ContextRequest,
439 ) -> KimetsuResult<ContextBundle> {
440 if request.min_lexical_coverage == 0.0 {
443 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
444 }
445 if request.min_semantic_score == 0.0 {
446 request.min_semantic_score = self.resolved_min_semantic_score();
447 }
448 if request.min_score == 0.0 {
452 request.min_score = self.config.broker.abstain_min_score;
453 }
454 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
455 let backend = crate::backend::backend_for(&self.config.storage.backend);
456 context::retrieve_context_with_embedder_and_backend(
457 &self.conn,
458 &self.repo_root,
459 &self.config.broker.weights,
460 request,
461 &extras,
462 embeddings::open_embedder_for(self.config.embedder.enabled),
463 backend.as_ref(),
464 )
465 }
466
467 fn resolved_min_semantic_score(&self) -> f32 {
476 let configured = self.config.broker.min_semantic_score;
477 if configured >= 0.0 {
478 return configured;
479 }
480 let model = embeddings::resolve_embedder_id(Some(self.config.embedder.model.as_str()));
481 if model.starts_with("bge") { 0.35 } else { 0.0 }
482 }
483
484 pub fn retrieve_proactive(&self, mut request: ContextRequest) -> KimetsuResult<ContextBundle> {
491 if request.min_lexical_coverage == 0.0 {
496 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
497 }
498 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
499 let backend = crate::backend::backend_for(&self.config.storage.backend);
500 context::retrieve_context_with_embedder_and_backend(
501 &self.conn,
502 &self.repo_root,
503 &self.config.broker.weights,
504 request,
505 &extras,
506 &embeddings::NoopEmbedder,
507 backend.as_ref(),
508 )
509 }
510
511 pub fn retrieve_context_lexical(
520 &self,
521 mut request: ContextRequest,
522 ) -> KimetsuResult<ContextBundle> {
523 if request.min_lexical_coverage == 0.0 {
528 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
529 }
530 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
531 let backend = crate::backend::backend_for(&self.config.storage.backend);
532 context::retrieve_context_with_embedder_and_backend(
533 &self.conn,
534 &self.repo_root,
535 &self.config.broker.weights,
536 request,
537 &extras,
538 &embeddings::NoopEmbedder,
539 backend.as_ref(),
540 )
541 }
542
543 pub fn retrieve_context_with_injected_embedder(
549 &self,
550 mut request: ContextRequest,
551 embedder: &dyn embeddings::Embedder,
552 ) -> KimetsuResult<ContextBundle> {
553 if request.min_lexical_coverage == 0.0 {
554 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
555 }
556 if request.min_semantic_score == 0.0 {
559 request.min_semantic_score = self.resolved_min_semantic_score();
560 }
561 if request.min_score == 0.0 {
565 request.min_score = self.config.broker.abstain_min_score;
566 }
567 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
568 let backend = crate::backend::backend_for(&self.config.storage.backend);
569 context::retrieve_context_with_embedder_and_backend(
570 &self.conn,
571 &self.repo_root,
572 &self.config.broker.weights,
573 request,
574 &extras,
575 embedder,
576 backend.as_ref(),
577 )
578 }
579
580 pub fn repo_root(&self) -> &Path {
581 &self.paths.repo_root
582 }
583
584 pub fn user_conn(&self) -> Option<&Connection> {
589 self.user_conn.as_ref()
590 }
591}
592
593pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
594 let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
595 format!(
596 "failed to read {}; run `kimetsu init` first: {err}",
597 paths.project_toml.display()
598 )
599 })?;
600 let mut config = ProjectConfig::from_toml(&content)?;
601 config.apply_retrieval_level();
607 Ok(config)
608}
609
610pub fn load_config_from_text(toml: &str) -> KimetsuResult<ProjectConfig> {
613 ProjectConfig::from_toml(toml)
614}
615
616pub fn config_text(start: &Path) -> KimetsuResult<String> {
617 let paths = ProjectPaths::discover(start)?;
618 Ok(fs::read_to_string(paths.project_toml)?)
619}
620
621pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
622 let (_paths, _config, conn) = load_project(start)?;
623 let mut stmt = conn.prepare(
624 "
625 SELECT run_id, task, started_at, terminal_kind
626 FROM runs
627 ORDER BY started_at DESC
628 LIMIT 100
629 ",
630 )?;
631
632 let rows = stmt.query_map([], |row| {
633 Ok(RunSummary {
634 run_id: row.get(0)?,
635 task: row.get(1)?,
636 started_at: row.get(2)?,
637 terminal_kind: row.get(3)?,
638 })
639 })?;
640
641 let mut runs = Vec::new();
642 for row in rows {
643 runs.push(row?);
644 }
645 Ok(runs)
646}
647
648pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
649 let (_paths, _config, conn) = load_project(start)?;
650 let mut stmt = conn.prepare(
651 "
652 SELECT run_id, task, started_at, terminal_kind
653 FROM runs
654 WHERE run_id = ?1
655 ",
656 )?;
657
658 let mut rows = stmt.query(params![run_id])?;
659 if let Some(row) = rows.next()? {
660 Ok(Some(RunSummary {
661 run_id: row.get(0)?,
662 task: row.get(1)?,
663 started_at: row.get(2)?,
664 terminal_kind: row.get(3)?,
665 }))
666 } else {
667 Ok(None)
668 }
669}
670
671#[derive(Debug, Clone)]
676pub struct BatchMemoryEntry {
677 pub text: String,
679 pub scope: MemoryScope,
681 pub kind: MemoryKind,
683 pub valid_from: Option<String>,
686 pub valid_to: Option<String>,
689}
690
691const DIRECT_ADD_CONFIDENCE: f32 = 0.85;
702
703pub fn add_memory(
704 start: &Path,
705 scope: MemoryScope,
706 kind: MemoryKind,
707 text: &str,
708) -> KimetsuResult<String> {
709 let redaction = redact::redact_secrets(text);
722 if redaction.was_redacted() {
723 eprintln!("kimetsu-brain: {}", redaction.summary());
724 }
725 let text = redaction.text.as_str();
726
727 if scope == MemoryScope::GlobalUser {
745 let use_user_brain = ProjectPaths::discover(start)
746 .ok()
747 .and_then(|paths| load_config(&paths).ok())
748 .map(|cfg| cfg.kimetsu.use_user_brain)
749 .unwrap_or(true);
750 if let Some(user_conn) = user_brain::open_user_brain_for_config(use_user_brain)? {
751 return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
752 }
753 }
757
758 let (paths, config, conn) = load_project(start)?;
759 let run_id = RunId::new();
760 let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
761
762 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
763 add_memory_inner(
764 &conn, &paths, &config, scope, kind, text, None, None, embedder,
765 )
766}
767
768#[allow(clippy::too_many_arguments)]
777fn add_memory_inner(
778 conn: &Connection,
779 paths: &ProjectPaths,
780 config: &kimetsu_core::config::ProjectConfig,
781 scope: MemoryScope,
782 kind: MemoryKind,
783 text: &str,
784 valid_from: Option<&str>,
785 valid_to: Option<&str>,
786 embedder: &dyn embeddings::Embedder,
787) -> KimetsuResult<String> {
788 let run_id = RunId::new();
789 let memory_id = Ulid::new().to_string();
790 let normalized = normalize_memory_text(text);
791
792 let existing: Option<String> = conn
798 .query_row(
799 "
800 SELECT memory_id FROM memories
801 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
802 AND invalidated_at IS NULL
803 AND superseded_by IS NULL
804 LIMIT 1
805 ",
806 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
807 |row| row.get::<_, String>(0),
808 )
809 .optional()?;
810 if let Some(existing_id) = existing {
811 return Ok(existing_id);
812 }
813
814 let importance_enabled = config.ingestion.initial_importance_scoring;
820 let initial_kind_weight = if importance_enabled {
821 match &kind {
822 MemoryKind::FailurePattern => 0.3_f32,
823 MemoryKind::Command => 0.2,
824 MemoryKind::Convention => 0.15,
825 MemoryKind::Fact => 0.1,
826 MemoryKind::Preference => 0.05,
827 }
828 } else {
829 0.0
830 };
831
832 let started = Event::new(
833 run_id,
834 "run.started",
835 serde_json::json!({
836 "mode": "admin",
837 "task": "memory add",
838 "project_id": config.kimetsu.project_id,
839 "repo_root": paths.repo_root.to_string_lossy(),
840 "model": null,
841 "platform": std::env::consts::OS,
842 "kimetsu_version": env!("CARGO_PKG_VERSION"),
843 "config_hash": config_hash(&paths.project_toml)?,
844 }),
845 );
846 let accepted = Event::new(
847 run_id,
848 "memory.accepted",
849 serde_json::json!({
850 "proposal_id": null,
851 "memory_id": memory_id,
852 "scope": scope.to_string(),
853 "kind": kind.to_string(),
854 "text": text,
855 "normalized_text": normalized,
856 "confidence": DIRECT_ADD_CONFIDENCE,
857 "initial_usefulness": initial_kind_weight,
858 "provenance_snapshot": build_provenance(run_id, text),
859 }),
860 );
861
862 let finished = Event::new(
863 run_id,
864 "run.finished",
865 serde_json::json!({
866 "status": "success",
867 "final_report_path": null,
868 "total_cost_usd": 0.0,
869 "total_tool_calls": 0,
870 }),
871 );
872
873 projector::apply_events(conn, &[started, accepted, finished])?;
874
875 if valid_from.is_some() || valid_to.is_some() {
878 projector::mark_memory_temporal(conn, &memory_id, valid_from, valid_to)?;
879 }
880
881 let embedding_vec = embeddings::embed_and_persist(conn, &memory_id, text, embedder)?;
894
895 if importance_enabled && !embedder.is_noop() {
902 if let Some(vec) = embedding_vec.as_deref() {
903 let rarity_bonus = {
904 let max_cos = max_corpus_cosine(conn, vec);
905 if max_cos < 0.5 { 0.1_f32 } else { 0.0 }
906 };
907 if rarity_bonus > 0.0 {
908 let full_score = (initial_kind_weight + rarity_bonus).min(0.5);
909 conn.execute(
910 "UPDATE memories SET usefulness_score = ?2 WHERE memory_id = ?1",
911 rusqlite::params![memory_id, full_score],
912 )
913 .ok(); }
915 }
916 }
917
918 if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
933 let new_created_at: String = conn
937 .query_row(
938 "SELECT created_at FROM memories WHERE memory_id = ?1",
939 rusqlite::params![memory_id],
940 |row| row.get(0),
941 )
942 .unwrap_or_else(|_| {
943 time::OffsetDateTime::now_utc()
945 .format(&time::format_description::well_known::Rfc3339)
946 .unwrap_or_default()
947 });
948
949 if conflict::resolve_conflicts_enabled(config.ingestion.resolve_conflicts) {
950 let (auto_resolved, queued) = conflict::detect_record_and_resolve_with_vec(
952 conn,
953 &memory_id,
954 &scope,
955 &kind.to_string(),
956 text,
957 embedding_vec.as_deref(),
958 embedder,
959 DIRECT_ADD_CONFIDENCE, &new_created_at,
961 );
962 if auto_resolved > 0 {
963 eprintln!(
964 "kimetsu-brain: memory {memory_id} auto-resolved {auto_resolved} contradiction{} (loser valid_to stamped)",
965 if auto_resolved == 1 { "" } else { "s" }
966 );
967 }
968 if queued > 0 {
969 eprintln!(
970 "kimetsu-brain: memory {memory_id} has {queued} near-tie conflict{} queued for review (run `kimetsu brain memory conflicts`)",
971 if queued == 1 { "" } else { "s" }
972 );
973 }
974 } else {
975 let conflicts = conflict::detect_and_record_with_vec(
977 conn,
978 &memory_id,
979 &scope,
980 &kind.to_string(),
981 text,
982 embedding_vec.as_deref(),
983 embedder,
984 );
985 if conflicts > 0 {
986 eprintln!(
987 "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
988 if conflicts == 1 { "y" } else { "ies" }
989 );
990 }
991 }
992 }
993
994 Ok(memory_id)
995}
996
997pub fn add_memories_batch(
1017 start: &Path,
1018 entries: Vec<BatchMemoryEntry>,
1019) -> KimetsuResult<Vec<String>> {
1020 if entries.is_empty() {
1021 return Ok(vec![]);
1022 }
1023
1024 let use_user_brain = ProjectPaths::discover(start)
1027 .ok()
1028 .and_then(|paths| load_config(&paths).ok())
1029 .map(|cfg| cfg.kimetsu.use_user_brain)
1030 .unwrap_or(true);
1031
1032 let user_conn_opt = user_brain::open_user_brain_for_config(use_user_brain)?;
1034
1035 let has_project_entries = entries.iter().any(|e| e.scope != MemoryScope::GlobalUser);
1038
1039 let project_state: Option<(
1041 ProjectPaths,
1042 kimetsu_core::config::ProjectConfig,
1043 Connection,
1044 )> = if has_project_entries {
1045 let state = load_project(start)?;
1046 Some(state)
1047 } else {
1048 None
1049 };
1050
1051 let run_id_for_lock = RunId::new();
1053 let _lock = if let Some((ref paths, _, _)) = project_state {
1054 Some(ProjectLock::acquire(
1055 paths,
1056 "brain memory add-batch",
1057 Some(run_id_for_lock),
1058 )?)
1059 } else {
1060 None
1061 };
1062
1063 let embedder: &dyn embeddings::Embedder = if let Some((_, ref config, _)) = project_state {
1066 embeddings::open_embedder_for(config.embedder.enabled)
1067 } else {
1068 &embeddings::NoopEmbedder
1069 };
1070
1071 let mut ids = Vec::with_capacity(entries.len());
1072
1073 for entry in entries {
1074 let redaction = redact::redact_secrets(&entry.text);
1076 if redaction.was_redacted() {
1077 eprintln!("kimetsu-brain: {}", redaction.summary());
1078 }
1079 let text = redaction.text.as_str();
1080
1081 if entry.scope == MemoryScope::GlobalUser {
1082 if let Some(ref uc) = user_conn_opt {
1085 let id = user_brain::add_user_memory(uc, entry.kind, text, 1.0)?;
1086 ids.push(id);
1087 continue;
1088 }
1089 }
1091
1092 let (paths, config, conn) = project_state
1093 .as_ref()
1094 .expect("project must be open when non-GlobalUser entries are present");
1095
1096 let id = add_memory_inner(
1097 conn,
1098 paths,
1099 config,
1100 entry.scope,
1101 entry.kind,
1102 text,
1103 entry.valid_from.as_deref(),
1104 entry.valid_to.as_deref(),
1105 embedder,
1106 )?;
1107 ids.push(id);
1108 }
1109
1110 Ok(ids)
1111}
1112
1113pub fn propose_memory(
1118 start: &Path,
1119 scope: MemoryScope,
1120 kind: MemoryKind,
1121 text: &str,
1122 confidence: f32,
1123 rationale: &str,
1124) -> KimetsuResult<String> {
1125 let redaction = redact::redact_secrets(text);
1126 if redaction.was_redacted() {
1127 eprintln!("kimetsu-brain: {}", redaction.summary());
1128 }
1129 let text = redaction.text.as_str();
1130 let rationale_redaction = redact::redact_secrets(rationale);
1131 if rationale_redaction.was_redacted() {
1132 eprintln!("kimetsu-brain: {}", rationale_redaction.summary());
1133 }
1134 let rationale = rationale_redaction.text.as_str();
1135 let (paths, config, conn) = load_project(start)?;
1136 let run_id = RunId::new();
1137 let _lock = ProjectLock::acquire(&paths, "memory propose", Some(run_id))?;
1138 let proposal_id = Ulid::new().to_string();
1139
1140 let started = admin_started_event(&paths, &config, run_id, "memory propose")?;
1141
1142 let proposed = Event::new(
1143 run_id,
1144 "memory.proposed",
1145 serde_json::json!({
1146 "proposal_id": proposal_id,
1147 "scope": scope.to_string(),
1148 "kind": kind.to_string(),
1149 "text": text,
1150 "rationale": rationale,
1151 "proposed_confidence": confidence.clamp(0.0, 1.0),
1152 "source_event_ids": [],
1153 }),
1154 );
1155
1156 let finished = admin_finished_event(run_id);
1157
1158 projector::apply_events(&conn, &[started, proposed, finished])?;
1159 Ok(proposal_id)
1160}
1161
1162#[derive(Debug)]
1164pub enum ProposeResult {
1165 Added(String), Proposed(String), Merged(String), Duplicate(String), }
1170
1171pub fn propose_or_merge_memory(
1182 start: &Path,
1183 scope: MemoryScope,
1184 kind: MemoryKind,
1185 text: &str,
1186 confidence: f32,
1187 rationale: &str,
1188) -> KimetsuResult<ProposeResult> {
1189 let redaction = redact::redact_secrets(text);
1190 if redaction.was_redacted() {
1191 eprintln!("kimetsu-brain: {}", redaction.summary());
1192 }
1193 let text = redaction.text.as_str();
1194
1195 let (_, config, _) = {
1198 let (paths, config, ro_conn) = load_project_readonly(start)?;
1199 let normalized = normalize_memory_text(text);
1200 let existing: Option<String> = ro_conn
1201 .query_row(
1202 "SELECT memory_id FROM memories
1203 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
1204 AND invalidated_at IS NULL
1205 AND superseded_by IS NULL
1206 LIMIT 1",
1207 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
1208 |row| row.get::<_, String>(0),
1209 )
1210 .optional()?;
1211 if let Some(id) = existing {
1212 return Ok(ProposeResult::Duplicate(id));
1213 }
1214 (paths, config, ro_conn)
1215 };
1216
1217 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
1223 {
1224 let (_, _, ro_conn) = load_project_readonly(start)?;
1225 let conflicts = if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
1226 conflict::find_potential_conflicts(&ro_conn, &scope, text, embedder, 1, 0.85)?
1227 } else {
1228 Vec::new()
1229 };
1230 if let Some(hit) = conflicts.into_iter().next() {
1231 let (paths, _config, conn) = load_project(start)?;
1233 let run_id = RunId::new();
1234 let _lock = ProjectLock::acquire(&paths, "memory merge", Some(run_id))?;
1235 let merged_text = format!("{}\n\nAlso: {text}", hit.existing_text);
1236 let new_normalized = normalize_memory_text(&merged_text);
1237 conn.execute(
1238 "UPDATE memories
1239 SET text = ?1, normalized_text = ?2, use_count = use_count + 1
1240 WHERE memory_id = ?3",
1241 rusqlite::params![merged_text, new_normalized, hit.existing_memory_id],
1242 )?;
1243 embeddings::embed_and_persist(&conn, &hit.existing_memory_id, &merged_text, embedder)?;
1245 return Ok(ProposeResult::Merged(hit.existing_memory_id));
1246 }
1247 }
1248
1249 if confidence >= 0.7 {
1251 let memory_id = add_memory(start, scope, kind, text)?;
1252 Ok(ProposeResult::Added(memory_id))
1253 } else {
1254 let proposal_id = propose_memory(start, scope, kind, text, confidence, rationale)?;
1255 Ok(ProposeResult::Proposed(proposal_id))
1256 }
1257}
1258
1259#[derive(Debug, Clone)]
1263pub struct ListOptions {
1264 pub limit: u32,
1266 pub offset: u32,
1268 pub scope: Option<String>,
1270}
1271
1272impl Default for ListOptions {
1273 fn default() -> Self {
1274 Self {
1275 limit: 100,
1276 offset: 0,
1277 scope: None,
1278 }
1279 }
1280}
1281
1282pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
1283 list_memories_with(start, ListOptions::default())
1284}
1285
1286pub fn list_memories_with(start: &Path, opts: ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1291 let (_paths, config, conn) = load_project(start)?;
1292 let mut memories = list_memories_from_conn(&conn, &opts)?;
1293 if opts.offset == 0
1295 && let Some(user_conn) =
1296 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1297 {
1298 memories.extend(user_brain::list_user_memories(&user_conn)?);
1299 }
1300 Ok(memories)
1301}
1302
1303pub use crate::blame::*;
1308
1309pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
1310 let (_paths, _config, conn) = load_project(start)?;
1311 let mut sql = String::from(
1312 "
1313 SELECT proposal_id, run_id, scope, kind, text, rationale,
1314 proposed_confidence, status, decided_reason
1315 FROM memory_proposals
1316 ",
1317 );
1318 let mut clauses = Vec::<String>::new();
1319 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1320 if let Some(scope) = filter.scope.as_deref() {
1321 clauses.push("scope = ?".to_string());
1322 params.push(Box::new(scope.to_string()));
1323 }
1324 if let Some(kind) = filter.kind.as_deref() {
1325 clauses.push("kind = ?".to_string());
1326 params.push(Box::new(kind.to_string()));
1327 }
1328 if let Some(run_id) = filter.from_run.as_deref() {
1329 clauses.push("run_id = ?".to_string());
1330 params.push(Box::new(run_id.to_string()));
1331 }
1332 if let Some(min_conf) = filter.min_confidence {
1333 clauses.push("proposed_confidence >= ?".to_string());
1334 params.push(Box::new(min_conf as f64));
1335 }
1336 if let Some(status) = filter.status.as_deref()
1337 && !status.eq_ignore_ascii_case("any")
1338 {
1339 clauses.push("status = ?".to_string());
1340 params.push(Box::new(status.to_string()));
1341 }
1342 if !clauses.is_empty() {
1343 sql.push_str(" WHERE ");
1344 sql.push_str(&clauses.join(" AND "));
1345 }
1346 let limit = if filter.limit == 0 { 100 } else { filter.limit };
1347 sql.push_str(&format!(
1348 " ORDER BY rowid DESC LIMIT {limit} OFFSET {}",
1349 filter.offset
1350 ));
1351
1352 let mut stmt = conn.prepare(&sql)?;
1353 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1354 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1355 Ok(ProposalRow {
1356 proposal_id: row.get(0)?,
1357 run_id: row.get(1)?,
1358 scope: row.get(2)?,
1359 kind: row.get(3)?,
1360 text: row.get(4)?,
1361 rationale: row.get(5)?,
1362 proposed_confidence: row.get(6)?,
1363 status: row.get(7)?,
1364 decided_reason: row.get(8)?,
1365 })
1366 })?;
1367
1368 let mut proposals = Vec::new();
1369 for row in rows {
1370 proposals.push(row?);
1371 }
1372 Ok(proposals)
1373}
1374
1375pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1376 let (paths, config, conn) = load_project(start)?;
1377 let run_id = RunId::new();
1378 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1379
1380 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1381
1382 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1383
1384 let ingested = Event::new(
1385 run_id,
1386 "repo.ingested",
1387 serde_json::json!({
1388 "repo_root": summary.repo_root.to_string_lossy(),
1389 "indexed_files": summary.indexed_files,
1390 "skipped_files": summary.skipped_files,
1391 "manifests": summary.manifests,
1392 }),
1393 );
1394
1395 let finished = admin_finished_event(run_id);
1396 projector::apply_events(&conn, &[started, ingested, finished])?;
1397
1398 Ok(summary)
1399}
1400
1401pub fn ingest_repo_at_root(
1406 brain_root: &Path,
1407 files_root: &Path,
1408) -> KimetsuResult<RepoIngestSummary> {
1409 let (mut paths, config, conn) = load_project_at_root(brain_root)?;
1410 paths.repo_root = files_root
1412 .canonicalize()
1413 .unwrap_or_else(|_| files_root.to_path_buf());
1414 let run_id = RunId::new();
1415 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo (remote)", Some(run_id))?;
1416
1417 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1418 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1419 let ingested = Event::new(
1420 run_id,
1421 "repo.ingested",
1422 serde_json::json!({
1423 "repo_root": summary.repo_root.to_string_lossy(),
1424 "indexed_files": summary.indexed_files,
1425 "skipped_files": summary.skipped_files,
1426 "manifests": summary.manifests,
1427 }),
1428 );
1429 let finished = admin_finished_event(run_id);
1430 projector::apply_events(&conn, &[started, ingested, finished])?;
1431
1432 Ok(summary)
1433}
1434
1435pub fn search_files(
1436 start: &Path,
1437 query: &str,
1438 limit: u32,
1439) -> KimetsuResult<Vec<context::ContextCapsule>> {
1440 let (paths, _config, conn) = load_project(start)?;
1441 let repo_root = paths
1442 .repo_root
1443 .canonicalize()?
1444 .to_string_lossy()
1445 .to_string();
1446 context::search_repo_files(&conn, &repo_root, query, limit)
1447}
1448
1449pub fn retrieve_context(
1450 start: &Path,
1451 stage: &str,
1452 query: &str,
1453 budget_tokens: u32,
1454) -> KimetsuResult<ContextBundle> {
1455 BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1456}
1457
1458pub fn retrieve_context_readonly(
1459 start: &Path,
1460 stage: &str,
1461 query: &str,
1462 budget_tokens: u32,
1463) -> KimetsuResult<ContextBundle> {
1464 BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1465}
1466
1467pub fn retrieve_context_readonly_with_request(
1470 start: &Path,
1471 request: ContextRequest,
1472) -> KimetsuResult<ContextBundle> {
1473 BrainSession::open_readonly(start)?.retrieve_context_with_request(request)
1474}
1475
1476pub fn retrieve_context_lexical_readonly(
1482 start: &Path,
1483 request: ContextRequest,
1484) -> KimetsuResult<ContextBundle> {
1485 BrainSession::open_readonly(start)?.retrieve_context_lexical(request)
1486}
1487
1488pub fn retrieve_proactive_readonly(
1492 start: &Path,
1493 request: ContextRequest,
1494) -> KimetsuResult<ContextBundle> {
1495 BrainSession::open_readonly(start)?.retrieve_proactive(request)
1496}
1497
1498pub fn search_memories(
1503 start: &Path,
1504 query: &str,
1505 limit: u32,
1506 offset: u32,
1507 kind: Option<&str>,
1508 scope: Option<&str>,
1509) -> KimetsuResult<Vec<MemorySearchHit>> {
1510 let Some(fts) = context::fts_query(query) else {
1511 return Ok(Vec::new());
1512 };
1513 let (_paths, config, conn) = load_project(start)?;
1514 let mut hits = search_memories_in_conn(&conn, &fts, limit, offset, kind, scope)?;
1515 if offset == 0
1517 && let Some(user_conn) =
1518 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1519 {
1520 hits.extend(search_memories_in_conn(
1521 &user_conn, &fts, limit, 0, kind, scope,
1522 )?);
1523 }
1524 Ok(hits)
1525}
1526
1527fn search_memories_in_conn(
1528 conn: &Connection,
1529 fts_query: &str,
1530 limit: u32,
1531 offset: u32,
1532 kind: Option<&str>,
1533 scope: Option<&str>,
1534) -> KimetsuResult<Vec<MemorySearchHit>> {
1535 let limit = if limit == 0 { 20 } else { limit } as i64;
1536 let offset = offset as i64;
1537 let mut sql = String::from(
1538 "
1539 SELECT m.memory_id, m.scope, m.kind, m.text, bm25(memories_fts) AS rank
1540 FROM memories_fts
1541 JOIN memories m ON m.memory_id = memories_fts.memory_id
1542 WHERE m.invalidated_at IS NULL
1543 AND m.superseded_by IS NULL
1544 AND memories_fts MATCH ?
1545 ",
1546 );
1547 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(fts_query.to_string())];
1548 if let Some(k) = kind {
1549 sql.push_str(" AND m.kind = ?");
1550 bind.push(Box::new(k.to_string()));
1551 }
1552 if let Some(s) = scope {
1553 sql.push_str(" AND lower(m.scope) = lower(?)");
1554 bind.push(Box::new(s.to_string()));
1555 }
1556 sql.push_str(" ORDER BY rank LIMIT ? OFFSET ?");
1558 bind.push(Box::new(limit));
1559 bind.push(Box::new(offset));
1560
1561 let mut stmt = conn.prepare(&sql)?;
1562 let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
1563 let rows = stmt.query_map(refs.as_slice(), |row| {
1564 let raw_rank = row.get::<_, f64>(4)? as f32;
1565 Ok(MemorySearchHit {
1566 memory_id: row.get(0)?,
1567 scope: row.get(1)?,
1568 kind: row.get(2)?,
1569 text: row.get(3)?,
1570 rank: (-raw_rank).max(0.0),
1572 })
1573 })?;
1574 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1575}
1576
1577#[allow(clippy::too_many_arguments)]
1578pub fn retrieve_benchmark_context_readonly(
1579 start: &Path,
1580 task: &str,
1581 dataset: &str,
1582 task_slug: Option<&str>,
1583 warm_policy: benchmark::BenchmarkWarmPolicy,
1584 stage: &str,
1585 budget_tokens: u32,
1586 require_benchmark_memory: bool,
1587 max_capsules: usize,
1588) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1589 retrieve_benchmark_context_readonly_with_ambient(
1590 start,
1591 task,
1592 dataset,
1593 task_slug,
1594 warm_policy,
1595 stage,
1596 budget_tokens,
1597 require_benchmark_memory,
1598 max_capsules,
1599 None,
1600 )
1601}
1602
1603#[allow(clippy::too_many_arguments)]
1609pub fn retrieve_benchmark_context_readonly_with_ambient(
1610 start: &Path,
1611 task: &str,
1612 dataset: &str,
1613 task_slug: Option<&str>,
1614 warm_policy: benchmark::BenchmarkWarmPolicy,
1615 stage: &str,
1616 budget_tokens: u32,
1617 require_benchmark_memory: bool,
1618 max_capsules: usize,
1619 ambient_suffix: Option<&str>,
1620) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1621 let normalized_slug = task_slug
1622 .and_then(benchmark::normalize_task_slug)
1623 .or_else(|| benchmark::normalize_task_slug(task));
1624 let mut query =
1625 benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
1626 if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
1627 query.push_str(suffix);
1628 }
1629 let bundle =
1630 BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
1631 Ok(benchmark::build_benchmark_context(
1632 bundle,
1633 task,
1634 dataset,
1635 &query,
1636 normalized_slug,
1637 warm_policy,
1638 require_benchmark_memory,
1639 max_capsules,
1640 ))
1641}
1642
1643pub fn record_benchmark_outcome(
1644 start: &Path,
1645 outcome: benchmark::BenchmarkOutcome,
1646) -> KimetsuResult<RecordedBenchmarkOutcome> {
1647 let task_slug = outcome
1648 .task_slug
1649 .clone()
1650 .or_else(|| benchmark::normalize_task_slug(&outcome.task));
1651 let kind = benchmark::outcome_memory_kind(&outcome);
1652 let text = benchmark::outcome_memory_text(&outcome);
1653 let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
1654 let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
1655 Some(proposal) if proposal.role.is_generalizable() => {
1656 let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
1657 (Some(proposal_id), Some(proposal_text))
1658 }
1659 _ => (None, None),
1660 };
1661 Ok(RecordedBenchmarkOutcome {
1662 memory_id,
1663 task_slug,
1664 kind,
1665 text,
1666 proposal_id,
1667 proposal_text,
1668 })
1669}
1670
1671fn propose_benchmark_memory(
1672 start: &Path,
1673 outcome: &benchmark::BenchmarkOutcome,
1674 proposal: &benchmark::BenchmarkMemoryProposal,
1675) -> KimetsuResult<(String, String)> {
1676 let (paths, config, conn) = load_project(start)?;
1677 let run_id = RunId::new();
1678 let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
1679 let proposal_id = Ulid::new().to_string();
1680 let raw_text = benchmark::proposal_memory_text(outcome, proposal);
1684 let text_redaction = redact::redact_secrets(&raw_text);
1685 if text_redaction.was_redacted() {
1686 eprintln!(
1687 "kimetsu-brain (benchmark proposal): {}",
1688 text_redaction.summary()
1689 );
1690 }
1691 let text = text_redaction.text;
1692 let kind = benchmark::proposal_memory_kind(proposal);
1693 let rationale_raw = if proposal.rationale.trim().is_empty() {
1694 "generalized from benchmark outcome".to_string()
1695 } else {
1696 proposal.rationale.trim().to_string()
1697 };
1698 let rationale = redact::redact_secrets(&rationale_raw).text;
1699
1700 let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
1701
1702 let proposed = Event::new(
1703 run_id,
1704 "memory.proposed",
1705 serde_json::json!({
1706 "proposal_id": proposal_id,
1707 "scope": "global_user",
1708 "kind": kind.to_string(),
1709 "text": text,
1710 "rationale": rationale,
1711 "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
1712 "source_event_ids": [],
1713 }),
1714 );
1715
1716 let finished = admin_finished_event(run_id);
1717
1718 projector::apply_events(&conn, &[started, proposed, finished])?;
1719 Ok((proposal_id, text))
1720}
1721
1722pub fn accept_proposal(
1723 start: &Path,
1724 proposal_id: &str,
1725 overrides: AcceptOverrides,
1726) -> KimetsuResult<String> {
1727 let (paths, config, conn) = load_project(start)?;
1728 let proposal = load_pending_proposal(&conn, proposal_id)?;
1729 let run_id = RunId::new();
1730 let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
1731 let memory_id = Ulid::new().to_string();
1732 let normalized = normalize_memory_text(&proposal.text);
1733
1734 let resolved_scope = match overrides.scope.as_deref() {
1735 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
1736 _ => proposal.scope.clone(),
1737 };
1738 let resolved_confidence = overrides
1739 .confidence
1740 .map(|c| c.clamp(0.0, 1.0))
1741 .unwrap_or(proposal.proposed_confidence);
1742
1743 let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
1744
1745 let accepted = Event::new(
1746 run_id,
1747 "memory.accepted",
1748 serde_json::json!({
1749 "proposal_id": proposal.proposal_id,
1750 "memory_id": memory_id,
1751 "scope": resolved_scope,
1752 "kind": proposal.kind,
1753 "text": proposal.text,
1754 "normalized_text": normalized,
1755 "confidence": resolved_confidence,
1756 "provenance_snapshot": {
1757 "source": "memory_proposal",
1758 "proposal_id": proposal.proposal_id,
1759 "source_run_id": proposal.run_id,
1760 "scope_override": overrides.scope.clone(),
1761 "confidence_override": overrides.confidence,
1762 }
1763 }),
1764 );
1765
1766 let finished = admin_finished_event(run_id);
1767
1768 projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
1769 conn.execute(
1770 "
1771 UPDATE memory_proposals
1772 SET status = 'accepted',
1773 decided_at = ?2,
1774 decided_by = 'cli'
1775 WHERE proposal_id = ?1
1776 ",
1777 params![
1778 proposal_id,
1779 accepted
1780 .ts
1781 .format(&time::format_description::well_known::Rfc3339)?
1782 ],
1783 )?;
1784
1785 Ok(memory_id)
1786}
1787
1788pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1794 let (paths, config, conn) = load_project(start)?;
1795 let exists: i64 = conn.query_row(
1796 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1797 params![memory_id],
1798 |row| row.get(0),
1799 )?;
1800 if exists == 0 {
1801 return Err(format!("memory not found: {memory_id}").into());
1802 }
1803
1804 let run_id = RunId::new();
1805 let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
1806
1807 let resolved_reason = reason
1808 .and_then(|s| {
1809 let trimmed = s.trim();
1810 if trimmed.is_empty() {
1811 None
1812 } else {
1813 Some(trimmed.to_string())
1814 }
1815 })
1816 .unwrap_or_else(|| "invalidated_by_cli".to_string());
1817
1818 let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
1819
1820 let invalidated = Event::new(
1821 run_id,
1822 "memory.invalidated",
1823 serde_json::json!({
1824 "memory_id": memory_id,
1825 "reason": resolved_reason,
1826 }),
1827 );
1828
1829 let finished = admin_finished_event(run_id);
1830
1831 projector::apply_events(&conn, &[started, invalidated, finished])?;
1832 Ok(())
1833}
1834
1835#[derive(Debug, Clone)]
1837pub struct UndoneMemory {
1838 pub memory_id: String,
1839 pub text: String,
1840 pub scope: String,
1841 pub kind: String,
1842}
1843
1844pub fn edit_memory(
1859 start: &Path,
1860 memory_id: &str,
1861 new_text: Option<&str>,
1862 new_kind: Option<MemoryKind>,
1863) -> KimetsuResult<()> {
1864 if new_text.is_none() && new_kind.is_none() {
1865 return Err("edit_memory: at least one of --text or --kind must be provided".into());
1866 }
1867
1868 let (paths, config, conn) = load_project(start)?;
1869
1870 let row: Option<(String, String, String)> = conn
1872 .query_row(
1873 "SELECT scope, kind, invalidated_at FROM memories WHERE memory_id = ?1",
1874 params![memory_id],
1875 |row| {
1876 Ok((
1877 row.get::<_, String>(0)?,
1878 row.get::<_, String>(1)?,
1879 row.get::<_, String>(2).unwrap_or_default(),
1880 ))
1881 },
1882 )
1883 .optional()?;
1884
1885 let (scope, current_kind, invalidated_at) = match row {
1886 None => return Err(format!("memory not found: {memory_id}").into()),
1887 Some(r) => r,
1888 };
1889 if !invalidated_at.is_empty() {
1890 return Err(format!("memory {memory_id} is already invalidated").into());
1891 }
1892
1893 let run_id = RunId::new();
1894 let _lock = ProjectLock::acquire(&paths, "brain memory edit", Some(run_id))?;
1895
1896 if let Some(raw_text) = new_text {
1898 let redaction = redact::redact_secrets(raw_text);
1899 if redaction.was_redacted() {
1900 eprintln!("kimetsu-brain: {}", redaction.summary());
1901 }
1902 let text = &redaction.text;
1903 let normalized = normalize_memory_text(text);
1904
1905 conn.execute(
1906 "UPDATE memories SET text = ?1, normalized_text = ?2 WHERE memory_id = ?3",
1907 params![text, normalized, memory_id],
1908 )?;
1909
1910 conn.execute(
1912 "DELETE FROM memories_fts WHERE memory_id = ?1",
1913 params![memory_id],
1914 )?;
1915 let kind_for_fts = new_kind
1916 .as_ref()
1917 .map(|k| k.to_string())
1918 .unwrap_or(current_kind.clone());
1919 conn.execute(
1920 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
1921 params![memory_id, text, kind_for_fts, scope],
1922 )?;
1923
1924 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
1926 embeddings::embed_and_persist(&conn, memory_id, text, embedder)?;
1927 }
1929
1930 if let Some(kind) = new_kind {
1932 conn.execute(
1933 "UPDATE memories SET kind = ?1 WHERE memory_id = ?2",
1934 params![kind.to_string(), memory_id],
1935 )?;
1936
1937 if new_text.is_none() {
1939 let current_text: String = conn.query_row(
1942 "SELECT text FROM memories WHERE memory_id = ?1",
1943 params![memory_id],
1944 |row| row.get(0),
1945 )?;
1946 conn.execute(
1947 "DELETE FROM memories_fts WHERE memory_id = ?1",
1948 params![memory_id],
1949 )?;
1950 conn.execute(
1951 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
1952 params![memory_id, current_text, kind.to_string(), scope],
1953 )?;
1954 }
1955 }
1956
1957 Ok(())
1958}
1959
1960pub fn peek_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
1964 let (_paths, _config, conn) = load_project(start)?;
1965 let row: Option<(String, String, String, String)> = conn
1968 .query_row(
1969 "SELECT memory_id, text, scope, kind FROM memories
1970 WHERE invalidated_at IS NULL
1971 AND superseded_by IS NULL
1972 ORDER BY created_at DESC, memory_id DESC
1973 LIMIT 1",
1974 [],
1975 |row| {
1976 Ok((
1977 row.get::<_, String>(0)?,
1978 row.get::<_, String>(1)?,
1979 row.get::<_, String>(2)?,
1980 row.get::<_, String>(3)?,
1981 ))
1982 },
1983 )
1984 .optional()?;
1985
1986 Ok(row.map(|(memory_id, text, scope, kind)| UndoneMemory {
1987 memory_id,
1988 text,
1989 scope,
1990 kind,
1991 }))
1992}
1993
1994pub fn undo_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2003 let (paths, _config, conn) = load_project(start)?;
2004
2005 let row: Option<(String, String, String, String)> = conn
2008 .query_row(
2009 "SELECT memory_id, text, scope, kind FROM memories
2010 WHERE invalidated_at IS NULL
2011 AND superseded_by IS NULL
2012 ORDER BY created_at DESC, memory_id DESC
2013 LIMIT 1",
2014 [],
2015 |row| {
2016 Ok((
2017 row.get::<_, String>(0)?,
2018 row.get::<_, String>(1)?,
2019 row.get::<_, String>(2)?,
2020 row.get::<_, String>(3)?,
2021 ))
2022 },
2023 )
2024 .optional()?;
2025
2026 let (memory_id, text, scope, kind) = match row {
2027 None => return Ok(None),
2028 Some(r) => r,
2029 };
2030
2031 drop(conn);
2033 drop(paths);
2034
2035 invalidate_memory(start, &memory_id, Some("undo: last recorded memory"))?;
2036
2037 Ok(Some(UndoneMemory {
2038 memory_id,
2039 text,
2040 scope,
2041 kind,
2042 }))
2043}
2044
2045pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2046 let (paths, config, conn) = load_project(start)?;
2047 let _proposal = load_pending_proposal(&conn, proposal_id)?;
2048 let run_id = RunId::new();
2049 let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
2050
2051 let resolved_reason = reason
2052 .and_then(|s| {
2053 let trimmed = s.trim();
2054 if trimmed.is_empty() {
2055 None
2056 } else {
2057 Some(trimmed.to_string())
2058 }
2059 })
2060 .unwrap_or_else(|| "rejected_by_cli".to_string());
2061
2062 let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
2063
2064 let rejected = Event::new(
2065 run_id,
2066 "memory.rejected",
2067 serde_json::json!({
2068 "proposal_id": proposal_id,
2069 "reason": resolved_reason,
2070 }),
2071 );
2072
2073 let finished = admin_finished_event(run_id);
2074
2075 projector::apply_events(&conn, &[started, rejected, finished])?;
2076 Ok(())
2077}
2078
2079pub use crate::maintenance::*;
2081
2082pub use crate::conflicts::*;
2084
2085pub use crate::feedback::*;
2087
2088pub use crate::graph_build::*;
2090
2091pub use crate::packs::*;
2093
2094pub(crate) fn load_pending_proposal(
2096 conn: &Connection,
2097 proposal_id: &str,
2098) -> KimetsuResult<ProposalRow> {
2099 let mut stmt = conn.prepare(
2100 "
2101 SELECT proposal_id, run_id, scope, kind, text, rationale,
2102 proposed_confidence, status
2103 FROM memory_proposals
2104 WHERE proposal_id = ?1
2105 ",
2106 )?;
2107 let mut rows = stmt.query(params![proposal_id])?;
2108 let Some(row) = rows.next()? else {
2109 return Err(format!("memory proposal not found: {proposal_id}").into());
2110 };
2111
2112 let proposal = ProposalRow {
2113 proposal_id: row.get(0)?,
2114 run_id: row.get(1)?,
2115 scope: row.get(2)?,
2116 kind: row.get(3)?,
2117 text: row.get(4)?,
2118 rationale: row.get(5)?,
2119 proposed_confidence: row.get(6)?,
2120 status: row.get(7)?,
2121 decided_reason: None,
2122 };
2123
2124 if proposal.status != "pending" {
2125 return Err(format!(
2126 "memory proposal {proposal_id} is {}, not pending",
2127 proposal.status
2128 )
2129 .into());
2130 }
2131
2132 Ok(proposal)
2133}
2134
2135pub(crate) fn admin_started_event(
2136 paths: &ProjectPaths,
2137 config: &ProjectConfig,
2138 run_id: RunId,
2139 task: &str,
2140) -> KimetsuResult<Event> {
2141 Ok(Event::new(
2142 run_id,
2143 "run.started",
2144 serde_json::json!({
2145 "mode": "admin",
2146 "task": task,
2147 "project_id": config.kimetsu.project_id,
2148 "repo_root": paths.repo_root.to_string_lossy(),
2149 "model": null,
2150 "platform": std::env::consts::OS,
2151 "kimetsu_version": env!("CARGO_PKG_VERSION"),
2152 "config_hash": config_hash(&paths.project_toml)?,
2153 }),
2154 ))
2155}
2156
2157pub(crate) fn admin_finished_event(run_id: RunId) -> Event {
2158 Event::new(
2159 run_id,
2160 "run.finished",
2161 serde_json::json!({
2162 "status": "success",
2163 "final_report_path": null,
2164 "total_cost_usd": 0.0,
2165 "total_tool_calls": 0,
2166 }),
2167 )
2168}
2169
2170pub(crate) fn config_hash(path: &Path) -> KimetsuResult<String> {
2171 let bytes = fs::read(path)?;
2172 Ok(blake3::hash(&bytes).to_hex().to_string())
2173}
2174
2175pub(crate) fn list_memories_from_conn(
2176 conn: &Connection,
2177 opts: &ListOptions,
2178) -> KimetsuResult<Vec<MemoryRow>> {
2179 let limit = if opts.limit == 0 { 100 } else { opts.limit } as i64;
2186 let offset = opts.offset as i64;
2187
2188 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
2189 (
2190 "
2191 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
2192 FROM memories
2193 WHERE invalidated_at IS NULL
2194 AND superseded_by IS NULL
2195 AND lower(scope) = lower(?1)
2196 ORDER BY created_at DESC
2197 LIMIT ?2 OFFSET ?3
2198 ",
2199 Some(scope.to_string()),
2200 )
2201 } else {
2202 (
2203 "
2204 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
2205 FROM memories
2206 WHERE invalidated_at IS NULL
2207 AND superseded_by IS NULL
2208 ORDER BY created_at DESC
2209 LIMIT ?1 OFFSET ?2
2210 ",
2211 None,
2212 )
2213 };
2214
2215 let mut stmt = conn.prepare(sql)?;
2216 let rows = if let Some(scope) = scope_param {
2217 stmt.query_map(params![scope, limit, offset], map_memory_row)?
2218 .collect::<Result<Vec<_>, _>>()?
2219 } else {
2220 stmt.query_map(params![limit, offset], map_memory_row)?
2221 .collect::<Result<Vec<_>, _>>()?
2222 };
2223 Ok(rows)
2224}
2225
2226#[cfg(test)]
2227mod tests {
2228 use crate::trace::TraceWriter;
2229 use std::fs;
2230
2231 use super::*;
2232 use crate::user_brain::with_user_brain_disabled;
2239
2240 fn test_root() -> std::path::PathBuf {
2247 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2248 kimetsu_core::paths::git_init_boundary(&root);
2249 root
2250 }
2251
2252 #[test]
2253 fn w1_5_init_creates_kimetsu_dir_but_no_runs_dir() {
2254 with_user_brain_disabled(|| {
2255 let root = test_root();
2256 let summary = init_project(&root, false).expect("init");
2257 assert!(summary.kimetsu_dir.exists(), ".kimetsu/ must exist");
2259 assert!(summary.brain_db.exists(), "brain.db must be created");
2260 assert!(
2261 summary.kimetsu_dir.join("project.toml").exists(),
2262 "project.toml must be written"
2263 );
2264 assert!(
2267 !summary.kimetsu_dir.join("runs").exists(),
2268 "fresh init must NOT create a runs/ dir"
2269 );
2270 });
2271 }
2272
2273 #[test]
2274 fn search_memories_paginates_and_filters_by_kind() {
2275 with_user_brain_disabled(|| {
2276 let root = test_root();
2277 init_project(&root, false).expect("init");
2278 add_memory(
2279 &root,
2280 MemoryScope::Project,
2281 MemoryKind::FailurePattern,
2282 "linker link.exe not found on windows",
2283 )
2284 .expect("add fp");
2285 add_memory(
2286 &root,
2287 MemoryScope::Project,
2288 MemoryKind::Command,
2289 "run cargo build with the link.exe linker on PATH",
2290 )
2291 .expect("add cmd");
2292 add_memory(
2293 &root,
2294 MemoryScope::Project,
2295 MemoryKind::Fact,
2296 "the office plant needs watering on tuesdays",
2297 )
2298 .expect("add fact");
2299
2300 let hits = search_memories(&root, "linker", 10, 0, None, None).expect("search");
2302 assert!(hits.len() >= 2, "expected >=2 hits, got {}", hits.len());
2303 assert!(
2304 hits.iter()
2305 .all(|h| h.text.to_ascii_lowercase().contains("link"))
2306 );
2307
2308 let p1 = search_memories(&root, "linker", 1, 0, None, None).expect("p1");
2310 let p2 = search_memories(&root, "linker", 1, 1, None, None).expect("p2");
2311 assert_eq!(p1.len(), 1);
2312 assert_eq!(p2.len(), 1);
2313 assert_ne!(p1[0].memory_id, p2[0].memory_id, "offset must advance");
2314
2315 let fp =
2317 search_memories(&root, "linker", 10, 0, Some("failure_pattern"), None).expect("fp");
2318 assert!(!fp.is_empty());
2319 assert!(fp.iter().all(|h| h.kind == "failure_pattern"));
2320
2321 assert!(
2323 search_memories(&root, " ", 10, 0, None, None)
2324 .unwrap()
2325 .is_empty()
2326 );
2327 });
2328 }
2329
2330 #[test]
2331 fn reindex_with_explicit_embedder_uses_that_model() {
2332 with_user_brain_disabled(|| {
2333 let root = test_root();
2334 init_project(&root, false).expect("init");
2335 add_memory(
2336 &root,
2337 MemoryScope::Project,
2338 MemoryKind::Fact,
2339 "alpha beta gamma",
2340 )
2341 .expect("add");
2342 use crate::embeddings::Embedder as _;
2346 let stub = crate::embeddings::StubEmbedder::new();
2347 let report = crate::reindex::reindex_all_with_embedder(
2348 &root,
2349 crate::reindex::ReindexOptions {
2350 scope: crate::reindex::ReindexScope::Project,
2351 dry_run: false,
2352 force: false,
2353 limit: None,
2354 },
2355 &stub,
2356 )
2357 .expect("reindex");
2358 assert_eq!(report.embedder_model_id, stub.model_id());
2359 assert!(
2360 report.project.updated >= 1,
2361 "the row should be re-embedded with the stub model"
2362 );
2363 });
2364 }
2365
2366 #[test]
2367 fn retrieve_proactive_returns_actionable_kind_and_excludes_others() {
2368 with_user_brain_disabled(|| {
2369 let root = test_root();
2370 init_project(&root, false).expect("init");
2371 add_memory(
2372 &root,
2373 MemoryScope::Project,
2374 MemoryKind::FailurePattern,
2375 "linker link.exe not found -> run from x64 Native Tools prompt",
2376 )
2377 .expect("add fp");
2378 add_memory(
2381 &root,
2382 MemoryScope::Project,
2383 MemoryKind::Fact,
2384 "linker link.exe trivia: link.exe ships with MSVC",
2385 )
2386 .expect("add fact");
2387
2388 let request = ContextRequest {
2389 stage: "localization".to_string(),
2390 query: "error: linker `link.exe` not found".to_string(),
2391 budget_tokens: 600,
2392 min_score: 0.2,
2393 max_capsules: 1,
2394 kinds: vec!["failure_pattern".to_string(), "command".to_string()],
2395 ..Default::default()
2396 };
2397 let bundle = retrieve_proactive_readonly(&root, request).expect("proactive");
2398 assert!(!bundle.skipped, "should surface the failure_pattern");
2399 assert_eq!(bundle.capsules.len(), 1);
2400 assert!(
2402 bundle.capsules[0].summary.contains("failure_pattern"),
2403 "got summary: {}",
2404 bundle.capsules[0].summary
2405 );
2406 assert!(!bundle.capsules[0].summary.contains("trivia"));
2407 });
2408 }
2409
2410 #[test]
2411 fn memory_add_survives_projection_rebuild_from_trace() {
2412 with_user_brain_disabled(|| {
2413 let root = test_root();
2414 fs::create_dir_all(&root).expect("create temp project");
2415
2416 init_project(&root, false).expect("init project");
2417 let memory_id = add_memory(
2418 &root,
2419 MemoryScope::GlobalUser,
2420 MemoryKind::Preference,
2421 "User prefers Rust for core infrastructure.",
2422 )
2423 .expect("add memory");
2424
2425 let memories = list_memories(&root).expect("list memories");
2426 assert_eq!(memories.len(), 1);
2427 assert_eq!(memories[0].memory_id, memory_id);
2428
2429 let event_count = rebuild_projection(&root, false).expect("rebuild projection");
2430 assert_eq!(event_count, 3);
2431
2432 let memories = list_memories(&root).expect("list rebuilt memories");
2433 assert_eq!(memories.len(), 1);
2434 assert_eq!(memories[0].memory_id, memory_id);
2435 assert_eq!(
2436 memories[0].text,
2437 "User prefers Rust for core infrastructure."
2438 );
2439
2440 fs::remove_dir_all(root).expect("remove temp project");
2441 });
2442 }
2443
2444 #[test]
2448 fn add_memory_redacts_secrets_before_persist() {
2449 with_user_brain_disabled(|| {
2450 let root = test_root();
2451 fs::create_dir_all(&root).expect("create temp project");
2452 init_project(&root, false).expect("init project");
2453
2454 let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
2455 let memory_id =
2456 add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory");
2457
2458 let memories = list_memories(&root).expect("list");
2459 let stored = memories
2460 .iter()
2461 .find(|m| m.memory_id == memory_id)
2462 .expect("memory present");
2463 assert!(
2464 !stored.text.contains("sk-ant-api03"),
2465 "raw secret must NOT survive to brain.db: {}",
2466 stored.text
2467 );
2468 assert!(
2469 stored.text.contains("[REDACTED:anthropic_oauth]"),
2470 "placeholder must be present: {}",
2471 stored.text
2472 );
2473 assert!(
2474 stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"),
2475 "non-secret context must be preserved: {}",
2476 stored.text
2477 );
2478
2479 fs::remove_dir_all(root).expect("cleanup");
2480 });
2481 }
2482
2483 #[test]
2484 fn repo_ingest_indexes_searchable_files_and_context_capsules() {
2485 let root = test_root();
2486 fs::create_dir_all(root.join("src")).expect("create src");
2487 fs::create_dir_all(root.join("target")).expect("create target");
2488 fs::write(
2489 root.join("Cargo.toml"),
2490 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
2491 )
2492 .expect("write manifest");
2493 fs::write(
2494 root.join("src").join("lib.rs"),
2495 "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
2496 )
2497 .expect("write source");
2498 fs::write(
2499 root.join("target").join("generated.rs"),
2500 "projection rebuild",
2501 )
2502 .expect("write skipped");
2503 fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
2504 fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
2505
2506 init_project(&root, false).expect("init project");
2507 add_memory(
2508 &root,
2509 MemoryScope::GlobalUser,
2510 MemoryKind::Preference,
2511 "User prefers Rust for core infrastructure.",
2512 )
2513 .expect("add memory");
2514
2515 let summary = ingest_repo(&root).expect("ingest repo");
2516 assert_eq!(summary.indexed_files, 2);
2517 assert_eq!(summary.manifests, 1);
2518
2519 let matches = search_files(&root, "projection rebuild", 5).expect("search files");
2520 assert!(
2521 matches
2522 .iter()
2523 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2524 "expected src/lib.rs in search results: {matches:?}"
2525 );
2526 assert!(
2527 matches
2528 .iter()
2529 .all(|capsule| !capsule.expansion_handle.contains("target/")),
2530 "target files must not be indexed: {matches:?}"
2531 );
2532
2533 let context =
2534 retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
2535 assert!(
2536 context
2537 .capsules
2538 .iter()
2539 .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
2540 "expected memory capsule in context: {:?}",
2541 context.capsules
2542 );
2543
2544 rebuild_projection(&root, false).expect("rebuild projection");
2545 let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
2546 assert!(
2547 matches
2548 .iter()
2549 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
2550 "repo index should survive event-only rebuild: {matches:?}"
2551 );
2552
2553 fs::remove_dir_all(root).expect("remove temp project");
2554 }
2555
2556 #[test]
2557 fn run_finished_increments_usefulness_for_injected_memories() {
2558 with_user_brain_disabled(|| {
2559 let root = test_root();
2567 fs::create_dir_all(&root).expect("create temp project");
2568 init_project(&root, false).expect("init project");
2569 let memory_id = add_memory(
2570 &root,
2571 MemoryScope::GlobalUser,
2572 MemoryKind::Preference,
2573 "Prefer ripgrep over grep.",
2574 )
2575 .expect("add memory");
2576
2577 {
2578 let (paths, _config, conn) = load_project(&root).expect("load");
2579 let run_id = RunId::new();
2580 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2581 let evs: Vec<Event> = vec![
2582 Event::new(
2583 run_id,
2584 "run.started",
2585 serde_json::json!({"project_id": "test", "task": "x"}),
2586 ),
2587 Event::new(
2588 run_id,
2589 "context.injected",
2590 serde_json::json!({
2591 "stage": "localization",
2592 "capsule_handles": [format!("memory:{memory_id}")],
2593 "memory_ids": [memory_id.clone()],
2594 "prior_run_ids": [],
2595 "file_paths": [],
2596 }),
2597 ),
2598 Event::new(
2599 run_id,
2600 "context.injected",
2601 serde_json::json!({
2602 "stage": "patch_plan",
2603 "capsule_handles": [format!("memory:{memory_id}")],
2604 "memory_ids": [memory_id.clone()],
2605 "prior_run_ids": [],
2606 "file_paths": [],
2607 }),
2608 ),
2609 Event::new(
2612 run_id,
2613 "memory.cited",
2614 serde_json::json!({
2615 "memory_id": memory_id,
2616 "turn": 3,
2617 "rationale": "using rg from memory",
2618 }),
2619 ),
2620 Event::new(
2621 run_id,
2622 "run.finished",
2623 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2624 ),
2625 ];
2626 for ev in &evs {
2627 writer.append(ev, true).expect("append");
2628 }
2629 projector::apply_events(&conn, &evs).expect("project");
2630 }
2631
2632 let memories = list_memories(&root).expect("list memories");
2633 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2634 assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
2635 let expected = 1.0 + 0.05; assert!(
2639 (m.usefulness_score - expected).abs() < 1e-4,
2640 "expected strong-signal usefulness_score = {expected}, got {}",
2641 m.usefulness_score
2642 );
2643
2644 fs::remove_dir_all(root).expect("remove temp project");
2645 });
2646 }
2647
2648 #[test]
2653 fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
2654 with_user_brain_disabled(|| {
2655 let root = test_root();
2656 fs::create_dir_all(&root).expect("create temp project");
2657 init_project(&root, false).expect("init project");
2658 let memory_id = add_memory(
2659 &root,
2660 MemoryScope::GlobalUser,
2661 MemoryKind::Preference,
2662 "Silent passenger memory.",
2663 )
2664 .expect("add memory");
2665
2666 {
2667 let (paths, _config, conn) = load_project(&root).expect("load");
2668 let run_id = RunId::new();
2669 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2670 let evs: Vec<Event> = vec![
2671 Event::new(
2672 run_id,
2673 "run.started",
2674 serde_json::json!({"project_id": "test", "task": "x"}),
2675 ),
2676 Event::new(
2677 run_id,
2678 "context.injected",
2679 serde_json::json!({
2680 "stage": "localization",
2681 "memory_ids": [memory_id.clone()],
2682 "prior_run_ids": [],
2683 "file_paths": [],
2684 }),
2685 ),
2686 Event::new(
2688 run_id,
2689 "run.finished",
2690 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2691 ),
2692 ];
2693 for ev in &evs {
2694 writer.append(ev, true).expect("append");
2695 }
2696 projector::apply_events(&conn, &evs).expect("project");
2697 }
2698
2699 let memories = list_memories(&root).expect("list memories");
2700 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2701 assert_eq!(m.use_count, 1);
2702 let expected = 0.1 + 0.05; assert!(
2706 (m.usefulness_score - expected).abs() < 1e-4,
2707 "silent passenger should get +0.1 on top of seed, got {}",
2708 m.usefulness_score
2709 );
2710 });
2711 }
2712
2713 #[test]
2719 fn blame_run_separates_cited_from_silent_passengers() {
2720 with_user_brain_disabled(|| {
2721 let root = test_root();
2722 fs::create_dir_all(&root).expect("create temp project");
2723 init_project(&root, false).expect("init project");
2724 let cited_id = add_memory(
2725 &root,
2726 MemoryScope::Repo,
2727 MemoryKind::Preference,
2728 "prefer ripgrep over grep",
2729 )
2730 .expect("add cited");
2731 let silent_id = add_memory(
2732 &root,
2733 MemoryScope::Repo,
2734 MemoryKind::Convention,
2735 "use cargo nextest for tests",
2736 )
2737 .expect("add silent");
2738
2739 let run_id = RunId::new();
2740 {
2741 let (paths, _config, conn) = load_project(&root).expect("load");
2742 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2743 let evs: Vec<Event> = vec![
2744 Event::new(
2745 run_id,
2746 "run.started",
2747 serde_json::json!({"project_id": "test", "task": "x"}),
2748 ),
2749 Event::new(
2750 run_id,
2751 "context.injected",
2752 serde_json::json!({
2753 "stage": "localization",
2754 "memory_ids": [cited_id.clone(), silent_id.clone()],
2755 "prior_run_ids": [],
2756 "file_paths": [],
2757 }),
2758 ),
2759 Event::new(
2760 run_id,
2761 "memory.cited",
2762 serde_json::json!({
2763 "memory_id": cited_id,
2764 "turn": 4,
2765 "rationale": "used the rg pattern",
2766 }),
2767 ),
2768 Event::new(
2769 run_id,
2770 "run.finished",
2771 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
2772 ),
2773 ];
2774 for ev in &evs {
2775 writer.append(ev, true).expect("append");
2776 }
2777 projector::apply_events(&conn, &evs).expect("project");
2778 }
2779
2780 let report = blame_run(&root, &run_id.to_string()).expect("blame");
2781 assert_eq!(report.outcome, "success");
2782 assert!(report.failure_category.is_none());
2783 assert_eq!(report.cited.len(), 1, "exactly one cited memory");
2784 let cited = &report.cited[0];
2785 assert_eq!(cited.memory_id, cited_id);
2786 assert_eq!(cited.turn, 4);
2787 assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
2788 assert!(cited.text_preview.contains("ripgrep"));
2789
2790 assert_eq!(report.silent_passengers.len(), 1);
2791 let silent = &report.silent_passengers[0];
2792 assert_eq!(silent.memory_id, silent_id);
2793 assert!(silent.text_preview.contains("nextest"));
2794
2795 fs::remove_dir_all(root).expect("cleanup");
2796 });
2797 }
2798
2799 #[test]
2800 fn run_failed_decrements_usefulness_unless_gate() {
2801 let root = test_root();
2805 fs::create_dir_all(&root).expect("create temp project");
2806 init_project(&root, false).expect("init project");
2807 let memory_id = add_memory(
2808 &root,
2809 MemoryScope::Repo,
2810 MemoryKind::Convention,
2811 "Use find_* for fallible lookups.",
2812 )
2813 .expect("add memory");
2814
2815 {
2816 let (paths, _config, conn) = load_project(&root).expect("load");
2817
2818 let gate_run = RunId::new();
2820 let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
2821 let gate_events: Vec<Event> = vec![
2822 Event::new(
2823 gate_run,
2824 "run.started",
2825 serde_json::json!({"project_id": "test", "task": "g"}),
2826 ),
2827 Event::new(
2828 gate_run,
2829 "context.injected",
2830 serde_json::json!({
2831 "stage": "patch_plan",
2832 "capsule_handles": [format!("memory:{memory_id}")],
2833 "memory_ids": [memory_id.clone()],
2834 "prior_run_ids": [],
2835 "file_paths": [],
2836 }),
2837 ),
2838 Event::new(
2839 gate_run,
2840 "run.failed",
2841 serde_json::json!({
2842 "category": "Gate",
2843 "failed_stage": "patch_plan",
2844 "message": "files_to_create_already_exist",
2845 }),
2846 ),
2847 ];
2848 for ev in &gate_events {
2849 writer.append(ev, true).expect("append");
2850 }
2851 projector::apply_events(&conn, &gate_events).expect("project gate-fail");
2852
2853 let impl_run = RunId::new();
2856 let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
2857 let impl_events: Vec<Event> = vec![
2858 Event::new(
2859 impl_run,
2860 "run.started",
2861 serde_json::json!({"project_id": "test", "task": "i"}),
2862 ),
2863 Event::new(
2864 impl_run,
2865 "context.injected",
2866 serde_json::json!({
2867 "stage": "patch_plan",
2868 "capsule_handles": [format!("memory:{memory_id}")],
2869 "memory_ids": [memory_id.clone()],
2870 "prior_run_ids": [],
2871 "file_paths": [],
2872 }),
2873 ),
2874 Event::new(
2877 impl_run,
2878 "memory.cited",
2879 serde_json::json!({
2880 "memory_id": memory_id,
2881 "turn": 2,
2882 "rationale": "trusted the memory's pattern",
2883 }),
2884 ),
2885 Event::new(
2886 impl_run,
2887 "run.failed",
2888 serde_json::json!({
2889 "category": "Implementation",
2890 "failed_stage": "implementation",
2891 "message": "test broke",
2892 }),
2893 ),
2894 ];
2895 for ev in &impl_events {
2896 writer2.append(ev, true).expect("append");
2897 }
2898 projector::apply_events(&conn, &impl_events).expect("project impl-fail");
2899 }
2900
2901 let memories = list_memories(&root).expect("list memories");
2902 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2903 assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
2904 let expected = 0.15 - 1.0; assert!(
2908 (m.usefulness_score - expected).abs() < 1e-4,
2909 "expected usefulness_score = {expected}, got {}",
2910 m.usefulness_score
2911 );
2912
2913 fs::remove_dir_all(root).expect("remove temp project");
2914 }
2915
2916 #[test]
2917 fn run_aborted_does_not_update_usefulness() {
2918 let root = test_root();
2919 fs::create_dir_all(&root).expect("create temp project");
2920 init_project(&root, false).expect("init project");
2921 let memory_id = add_memory(
2922 &root,
2923 MemoryScope::Repo,
2924 MemoryKind::Convention,
2925 "Module re-exports live in lib.rs.",
2926 )
2927 .expect("add memory");
2928
2929 {
2930 let (paths, _config, conn) = load_project(&root).expect("load");
2931 let run_id = RunId::new();
2932 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
2933 let evs: Vec<Event> = vec![
2934 Event::new(
2935 run_id,
2936 "run.started",
2937 serde_json::json!({"project_id": "test", "task": "a"}),
2938 ),
2939 Event::new(
2940 run_id,
2941 "context.injected",
2942 serde_json::json!({
2943 "stage": "patch_plan",
2944 "capsule_handles": [format!("memory:{memory_id}")],
2945 "memory_ids": [memory_id.clone()],
2946 "prior_run_ids": [],
2947 "file_paths": [],
2948 }),
2949 ),
2950 Event::new(
2951 run_id,
2952 "run.aborted",
2953 serde_json::json!({"reason": "user_abort"}),
2954 ),
2955 ];
2956 for ev in &evs {
2957 writer.append(ev, true).expect("append");
2958 }
2959 projector::apply_events(&conn, &evs).expect("project");
2960 }
2961
2962 let memories = list_memories(&root).expect("list memories");
2963 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2964 assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
2965 let expected_seed = 0.15_f32; assert!(
2969 (m.usefulness_score - expected_seed).abs() < 1e-4,
2970 "expected usefulness_score = {expected_seed} (initial seed only), got {}",
2971 m.usefulness_score
2972 );
2973
2974 fs::remove_dir_all(root).expect("remove temp project");
2975 }
2976
2977 #[test]
2978 fn list_proposals_filters_and_reject_records_reason() {
2979 let root = test_root();
2980 fs::create_dir_all(&root).expect("create temp project");
2981 init_project(&root, false).expect("init project");
2982
2983 let proposals = [
2985 (
2986 "p1",
2987 "global_user",
2988 "preference",
2989 0.9_f32,
2990 "Prefer rg over grep",
2991 ),
2992 (
2993 "p2",
2994 "repo",
2995 "convention",
2996 0.8,
2997 "Use find_* for fallible lookups",
2998 ),
2999 (
3000 "p3",
3001 "repo",
3002 "convention",
3003 0.4,
3004 "Use let-else where possible",
3005 ),
3006 ];
3007 {
3008 let (paths, _config, conn) = load_project(&root).expect("load");
3009 let run_id = RunId::new();
3010 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
3011 for (proposal_id, scope, kind, conf, text) in &proposals {
3012 let event = Event::new(
3013 run_id,
3014 "memory.proposed",
3015 serde_json::json!({
3016 "proposal_id": proposal_id,
3017 "scope": scope,
3018 "kind": kind,
3019 "text": text,
3020 "rationale": "test rationale",
3021 "proposed_confidence": conf,
3022 "source_event_ids": [],
3023 }),
3024 );
3025 writer.append(&event, true).expect("append proposal");
3026 projector::apply_events(&conn, &[event]).expect("project");
3027 }
3028 }
3029
3030 let global = list_proposals(
3032 &root,
3033 ProposalFilter {
3034 scope: Some("global_user".into()),
3035 status: Some("pending".into()),
3036 ..ProposalFilter::default()
3037 },
3038 )
3039 .expect("list proposals");
3040 assert_eq!(global.len(), 1);
3041 assert_eq!(global[0].proposal_id, "p1");
3042
3043 let strong = list_proposals(
3045 &root,
3046 ProposalFilter {
3047 min_confidence: Some(0.7),
3048 status: Some("pending".into()),
3049 ..ProposalFilter::default()
3050 },
3051 )
3052 .expect("list strong");
3053 assert_eq!(strong.len(), 2);
3054 for row in &strong {
3055 assert!(row.proposed_confidence >= 0.7);
3056 }
3057
3058 reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
3060 let rejected = list_proposals(
3061 &root,
3062 ProposalFilter {
3063 status: Some("rejected".into()),
3064 ..ProposalFilter::default()
3065 },
3066 )
3067 .expect("list rejected");
3068 assert_eq!(rejected.len(), 1);
3069 assert_eq!(rejected[0].proposal_id, "p3");
3070 assert_eq!(
3071 rejected[0].decided_reason.as_deref(),
3072 Some("not specific to the user")
3073 );
3074
3075 let memory_id = accept_proposal(
3078 &root,
3079 "p1",
3080 AcceptOverrides {
3081 scope: None,
3082 confidence: Some(0.55),
3083 },
3084 )
3085 .expect("accept");
3086 let memories = list_memories(&root).expect("list memories");
3087 let promoted = memories
3088 .into_iter()
3089 .find(|m| m.memory_id == memory_id)
3090 .expect("promoted memory present");
3091 assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
3092
3093 fs::remove_dir_all(root).expect("remove temp project");
3094 }
3095
3096 #[test]
3101 fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
3102 let root = test_root();
3103 fs::create_dir_all(&root).expect("create temp project");
3104 init_project(&root, false).expect("init project");
3105
3106 let memory_id = add_memory(
3107 &root,
3108 MemoryScope::Repo,
3109 MemoryKind::Convention,
3110 "Use find_* for fallible lookups.",
3111 )
3112 .expect("add memory");
3113
3114 invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
3115 .expect("invalidate memory");
3116
3117 {
3120 let (_paths, _config, conn) = load_project(&root).expect("load");
3121 let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
3122 .query_row(
3123 "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
3124 params![memory_id],
3125 |row| Ok((row.get(0)?, row.get(1)?)),
3126 )
3127 .expect("query invalidated metadata");
3128 assert!(invalidated_at.is_some(), "invalidated_at must be set");
3129 assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
3130 }
3131
3132 rebuild_projection(&root, false).expect("rebuild projection");
3134 {
3135 let (_paths, _config, conn) = load_project(&root).expect("load");
3136 let invalidated_at: Option<String> = conn
3137 .query_row(
3138 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
3139 params![memory_id],
3140 |row| row.get(0),
3141 )
3142 .expect("query after rebuild");
3143 assert!(
3144 invalidated_at.is_some(),
3145 "invalidated_at must survive event replay"
3146 );
3147 }
3148
3149 fs::remove_dir_all(root).expect("remove temp project");
3150 }
3151
3152 #[test]
3156 fn invalidated_memory_is_excluded_from_broker_retrieval() {
3157 with_user_brain_disabled(|| {
3158 let root = test_root();
3159 fs::create_dir_all(&root).expect("create temp project");
3160 init_project(&root, false).expect("init project");
3161
3162 let memory_id = add_memory(
3163 &root,
3164 MemoryScope::GlobalUser,
3165 MemoryKind::Preference,
3166 "Prefer ripgrep over grep for repo search.",
3167 )
3168 .expect("add memory");
3169
3170 let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
3172 .expect("pre context");
3173 assert!(
3174 pre.capsules
3175 .iter()
3176 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
3177 "memory must appear before invalidation: {:?}",
3178 pre.capsules
3179 );
3180
3181 invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
3182
3183 let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
3184 .expect("post context");
3185 assert!(
3186 post.capsules
3187 .iter()
3188 .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
3189 "invalidated memory must not be retrieved: {:?}",
3190 post.capsules
3191 );
3192
3193 {
3197 let (_paths2, _config2, conn2) = load_project(&root).expect("load for check");
3198 let still_there: i64 = conn2
3199 .query_row(
3200 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
3201 rusqlite::params![&memory_id],
3202 |row| row.get(0),
3203 )
3204 .expect("db query");
3205 assert_eq!(still_there, 1, "invalidated row must persist in brain.db");
3206 } let active = list_memories(&root).expect("list after invalidation");
3209 assert!(
3210 active.iter().all(|m| m.memory_id != memory_id),
3211 "invalidated memory must not appear in list_memories"
3212 );
3213
3214 fs::remove_dir_all(root).expect("remove temp project");
3215 });
3216 }
3217
3218 #[test]
3223 fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
3224 let root = test_root();
3225 fs::create_dir_all(&root).expect("create temp project");
3226 init_project(&root, false).expect("init project");
3227
3228 let m_great =
3229 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
3230 let m_meh =
3231 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
3232 let m_bad =
3233 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
3234 let _m_fresh =
3235 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
3236
3237 {
3240 let (_paths, _config, conn) = load_project(&root).expect("load");
3241 conn.execute(
3242 "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
3243 params![m_great],
3244 )
3245 .expect("set great");
3246 conn.execute(
3247 "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
3248 params![m_meh],
3249 )
3250 .expect("set meh");
3251 conn.execute(
3252 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
3253 params![m_bad],
3254 )
3255 .expect("set bad");
3256 }
3258
3259 let top = list_memories_top(
3260 &root,
3261 TopOptions {
3262 scope: None,
3263 min_uses: 3,
3264 limit: 10,
3265 },
3266 )
3267 .expect("top");
3268 assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
3269 assert_eq!(top[0].memory_id, m_great);
3270 assert_eq!(top[1].memory_id, m_meh);
3271 assert_eq!(top[2].memory_id, m_bad);
3272
3273 invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
3275 let top_after = list_memories_top(
3276 &root,
3277 TopOptions {
3278 scope: None,
3279 min_uses: 3,
3280 limit: 10,
3281 },
3282 )
3283 .expect("top after");
3284 assert_eq!(top_after.len(), 2);
3285 assert!(top_after.iter().all(|m| m.memory_id != m_great));
3286
3287 fs::remove_dir_all(root).expect("remove temp project");
3288 }
3289
3290 #[test]
3296 fn prune_low_usefulness_dry_run_then_apply() {
3297 with_user_brain_disabled(|| {
3298 prune_low_usefulness_dry_run_then_apply_body();
3299 });
3300 }
3301
3302 fn prune_low_usefulness_dry_run_then_apply_body() {
3303 let root = test_root();
3304 fs::create_dir_all(&root).expect("create temp project");
3305 init_project(&root, false).expect("init project");
3306
3307 let m_keep = add_memory(
3308 &root,
3309 MemoryScope::Repo,
3310 MemoryKind::Convention,
3311 "keep me, I help",
3312 )
3313 .expect("keep");
3314 let m_drop_1 = add_memory(
3315 &root,
3316 MemoryScope::Repo,
3317 MemoryKind::Convention,
3318 "drop me, I hurt",
3319 )
3320 .expect("drop1");
3321 let m_drop_2 = add_memory(
3322 &root,
3323 MemoryScope::Repo,
3324 MemoryKind::Convention,
3325 "drop me too",
3326 )
3327 .expect("drop2");
3328 let m_small_sample = add_memory(
3329 &root,
3330 MemoryScope::Repo,
3331 MemoryKind::Convention,
3332 "small sample shouldn't be pruned even if score is bad",
3333 )
3334 .expect("small");
3335
3336 {
3337 let (_paths, _config, conn) = load_project(&root).expect("load");
3338 conn.execute(
3340 "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
3341 params![m_keep],
3342 )
3343 .expect("set keep");
3344 conn.execute(
3346 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
3347 params![m_drop_1],
3348 )
3349 .expect("set drop1");
3350 conn.execute(
3352 "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
3353 params![m_drop_2],
3354 )
3355 .expect("set drop2");
3356 conn.execute(
3358 "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
3359 params![m_small_sample],
3360 )
3361 .expect("set small");
3362 }
3363
3364 let dry = prune_low_usefulness(
3366 &root,
3367 PruneOptions {
3368 scope: None,
3369 min_uses: 3,
3370 max_ratio: -0.2,
3371 apply: false,
3372 },
3373 )
3374 .expect("dry-run");
3375 assert_eq!(dry.candidates.len(), 2);
3376 assert_eq!(dry.invalidated, 0);
3377 let ids: Vec<&str> = dry
3378 .candidates
3379 .iter()
3380 .map(|c| c.memory_id.as_str())
3381 .collect();
3382 assert!(ids.contains(&m_drop_1.as_str()));
3383 assert!(ids.contains(&m_drop_2.as_str()));
3384 assert!(!ids.contains(&m_small_sample.as_str()));
3386
3387 let pre = list_memories(&root).expect("pre");
3389 assert_eq!(pre.len(), 4);
3390
3391 let applied = prune_low_usefulness(
3393 &root,
3394 PruneOptions {
3395 scope: None,
3396 min_uses: 3,
3397 max_ratio: -0.2,
3398 apply: true,
3399 },
3400 )
3401 .expect("apply");
3402 assert_eq!(applied.candidates.len(), 2);
3403 assert_eq!(applied.invalidated, 2);
3404 assert_eq!(applied.failed, 0);
3405
3406 let top = list_memories_top(
3410 &root,
3411 TopOptions {
3412 scope: None,
3413 min_uses: 3,
3414 limit: 10,
3415 },
3416 )
3417 .expect("top after prune");
3418 assert_eq!(top.len(), 1);
3419 assert_eq!(top[0].memory_id, m_keep);
3420
3421 {
3426 let (_paths, _config, conn) = load_project(&root).expect("load");
3427 let reason: String = conn
3428 .query_row(
3429 "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
3430 params![m_drop_1],
3431 |row| row.get(0),
3432 )
3433 .expect("invalidated reason");
3434 assert!(
3435 reason.starts_with("pruned_by_usefulness"),
3436 "unexpected reason: {reason}"
3437 );
3438 }
3439
3440 fs::remove_dir_all(root).expect("remove temp project");
3441 }
3442
3443 #[test]
3450 fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
3451 with_user_brain_disabled(|| {
3452 batch_review_accepts_filtered_subset_and_rejects_remainder_body();
3453 });
3454 }
3455
3456 fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
3457 let root = test_root();
3458 fs::create_dir_all(&root).expect("create temp project");
3459 init_project(&root, false).expect("init project");
3460
3461 let run_a = RunId::new();
3462 let run_b = RunId::new();
3463
3464 let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
3468 (
3469 "p_a_strong",
3470 run_a,
3471 "global_user",
3472 "preference",
3473 0.92,
3474 "Prefer rg over grep",
3475 ),
3476 (
3477 "p_a_weak",
3478 run_a,
3479 "repo",
3480 "convention",
3481 0.55,
3482 "Always use let-else",
3483 ),
3484 (
3485 "p_b1",
3486 run_b,
3487 "repo",
3488 "convention",
3489 0.70,
3490 "Use Result not panic",
3491 ),
3492 (
3493 "p_b2",
3494 run_b,
3495 "global_user",
3496 "preference",
3497 0.88,
3498 "Open links in new tab",
3499 ),
3500 ];
3501
3502 {
3503 let (paths, _config, conn) = load_project(&root).expect("load");
3504 for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
3505 let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
3506 let event = Event::new(
3507 *run_id,
3508 "memory.proposed",
3509 serde_json::json!({
3510 "proposal_id": proposal_id,
3511 "scope": scope,
3512 "kind": kind,
3513 "text": text,
3514 "rationale": "fixture",
3515 "proposed_confidence": conf,
3516 "source_event_ids": [],
3517 }),
3518 );
3519 writer.append(&event, true).expect("append");
3520 projector::apply_events(&conn, &[event]).expect("project");
3521 }
3522 }
3523
3524 let to_accept = list_proposals(
3527 &root,
3528 ProposalFilter {
3529 from_run: Some(run_a.to_string()),
3530 min_confidence: Some(0.8),
3531 status: Some("pending".into()),
3532 limit: 100,
3533 ..ProposalFilter::default()
3534 },
3535 )
3536 .expect("list strong from run_a");
3537 assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
3538 assert_eq!(to_accept[0].proposal_id, "p_a_strong");
3539 let memory_id =
3540 accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
3541 .expect("accept p_a_strong");
3542
3543 let to_reject = list_proposals(
3546 &root,
3547 ProposalFilter {
3548 status: Some("pending".into()),
3549 limit: 100,
3550 ..ProposalFilter::default()
3551 },
3552 )
3553 .expect("list remaining pending");
3554 assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
3555 for p in &to_reject {
3556 reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
3557 }
3558
3559 let memories = list_memories(&root).expect("list memories");
3562 assert_eq!(
3563 memories.len(),
3564 1,
3565 "only the accepted proposal becomes a memory"
3566 );
3567 assert_eq!(memories[0].memory_id, memory_id);
3568
3569 let pending = list_proposals(
3570 &root,
3571 ProposalFilter {
3572 status: Some("pending".into()),
3573 limit: 100,
3574 ..ProposalFilter::default()
3575 },
3576 )
3577 .expect("list pending");
3578 assert!(
3579 pending.is_empty(),
3580 "no proposals left pending after batch review"
3581 );
3582
3583 let rejected = list_proposals(
3584 &root,
3585 ProposalFilter {
3586 status: Some("rejected".into()),
3587 limit: 100,
3588 ..ProposalFilter::default()
3589 },
3590 )
3591 .expect("list rejected");
3592 assert_eq!(rejected.len(), 3);
3593 for row in &rejected {
3594 assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
3595 }
3596
3597 fs::remove_dir_all(root).expect("remove temp project");
3598 }
3599
3600 #[test]
3616 fn add_memory_distinct_texts_no_conflicts() {
3617 with_user_brain_disabled(|| {
3618 let root = test_root();
3619 fs::create_dir_all(&root).expect("create temp project");
3620 init_project(&root, false).expect("init project");
3621
3622 let _m1 = add_memory(
3627 &root,
3628 MemoryScope::GlobalUser,
3629 MemoryKind::Preference,
3630 "Prefer thiserror for library error types.",
3631 )
3632 .expect("add m1");
3633 let _m2 = add_memory(
3634 &root,
3635 MemoryScope::GlobalUser,
3636 MemoryKind::Preference,
3637 "Cache HTTP responses with a one-hour TTL.",
3638 )
3639 .expect("add m2");
3640
3641 let open = list_conflicts(&root, 50).expect("list_conflicts");
3642 assert!(
3643 open.is_empty(),
3644 "distinct-topic memories must not conflict; got {} rows",
3645 open.len()
3646 );
3647
3648 let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
3650 .expect("resolve_conflict on unknown id");
3651 assert!(!resolved, "unknown conflict id should resolve to false");
3652
3653 let err = resolve_conflict(&root, "does-not-exist", "garbage")
3655 .expect_err("invalid resolution should error");
3656 assert!(format!("{err}").contains("invalid conflict resolution"));
3657
3658 fs::remove_dir_all(root).expect("remove temp project");
3659 });
3660 }
3661
3662 #[test]
3669 fn load_project_rejects_future_config_version() {
3670 with_user_brain_disabled(|| {
3671 use kimetsu_core::KIMETSU_CONFIG_VERSION;
3672 let root = test_root();
3673 fs::create_dir_all(&root).expect("create temp project");
3674 let paths = kimetsu_core::paths::ProjectPaths::discover(&root)
3676 .expect("discover paths after git_init_boundary");
3677 fs::create_dir_all(&paths.kimetsu_dir).expect("create .kimetsu dir");
3678 let bad_version = KIMETSU_CONFIG_VERSION + 1;
3679 let toml_str = format!(
3680 r#"
3681[kimetsu]
3682project_id = "test-config-gate"
3683schema_version = {bad_version}
3684
3685[model]
3686provider = "anthropic"
3687model = "claude-opus-4-7"
3688api_key_env = "ANTHROPIC_API_KEY"
3689max_output_tokens = 8192
3690temperature = 0.2
3691request_timeout_secs = 120
3692
3693[broker]
3694default_budget_tokens = 6000
3695
3696[broker.weights]
3697relevance = 0.5
3698confidence = 0.2
3699freshness = 0.2
3700scope = 0.1
3701
3702[shell]
3703default_timeout_secs = 60
3704max_timeout_secs = 600
3705env_allowlist_extra = []
3706redact_secrets = true
3707
3708[ingestion]
3709max_file_bytes = 524288
3710extra_skip_dirs = []
3711max_total_files = 50000
3712
3713[run]
3714max_total_tool_calls = 60
3715max_total_model_turns = 30
3716max_total_cost_usd = 250.0
3717"#
3718 );
3719 fs::write(&paths.project_toml, &toml_str).expect("write bad project.toml");
3720 let err = load_project(&root).expect_err("future config version must be rejected");
3721 let msg = format!("{err}");
3722 assert!(
3723 msg.contains(&bad_version.to_string()),
3724 "error message should mention the bad version; got: {msg}"
3725 );
3726 assert!(
3727 msg.contains(&KIMETSU_CONFIG_VERSION.to_string()),
3728 "error message should mention the expected version; got: {msg}"
3729 );
3730 fs::remove_dir_all(root).expect("remove temp project");
3731 });
3732 }
3733
3734 fn make_dangling_run(root: &std::path::Path) -> RunId {
3738 let (paths, _config, conn) = load_project(root).expect("load project");
3739 let run_id = RunId::new();
3740 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("create trace");
3741 let started = Event::new(
3742 run_id,
3743 "run.started",
3744 serde_json::json!({"project_id": "test", "task": "dangling task"}),
3745 );
3746 writer.append(&started, true).expect("append started");
3747 projector::apply_events(&conn, &[started]).expect("project started");
3748 run_id
3749 }
3750
3751 #[test]
3752 fn abort_run_stamps_aborted_and_frees_lock() {
3753 with_user_brain_disabled(|| {
3754 let root = test_root();
3755 fs::create_dir_all(&root).expect("mkdir");
3756 init_project(&root, false).expect("init");
3757
3758 let run_id = make_dangling_run(&root);
3759
3760 abort_run(&root, &run_id.to_string()).expect("abort_run");
3762
3763 let run = show_run(&root, &run_id.to_string())
3765 .expect("show_run")
3766 .expect("run exists");
3767 assert_eq!(
3768 run.terminal_kind.as_deref(),
3769 Some("run.aborted"),
3770 "terminal_kind should be run.aborted"
3771 );
3772
3773 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
3775 assert!(
3776 !paths.lock_file.exists(),
3777 "lock file should not exist after abort"
3778 );
3779
3780 fs::remove_dir_all(root).expect("cleanup");
3781 });
3782 }
3783
3784 #[test]
3785 fn abort_run_already_finished_returns_err() {
3786 with_user_brain_disabled(|| {
3787 let root = test_root();
3788 fs::create_dir_all(&root).expect("mkdir");
3789 init_project(&root, false).expect("init");
3790
3791 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "some fact")
3793 .expect("add memory");
3794
3795 let runs = list_runs(&root).expect("list runs");
3796 assert!(!runs.is_empty(), "should have at least one run");
3797 let finished_run = runs
3798 .iter()
3799 .find(|r| r.terminal_kind.is_some())
3800 .expect("should have a finished run");
3801
3802 let err = abort_run(&root, &finished_run.run_id)
3803 .expect_err("aborting a finished run should error");
3804 let msg = format!("{err}");
3805 assert!(
3806 msg.contains("already terminal"),
3807 "error should mention 'already terminal', got: {msg}"
3808 );
3809
3810 fs::remove_dir_all(root).expect("cleanup");
3811 });
3812 }
3813
3814 #[test]
3815 fn abort_run_unknown_id_returns_err() {
3816 with_user_brain_disabled(|| {
3817 let root = test_root();
3818 fs::create_dir_all(&root).expect("mkdir");
3819 init_project(&root, false).expect("init");
3820
3821 let fake_id = RunId::new().to_string();
3822 let err = abort_run(&root, &fake_id).expect_err("aborting an unknown run should error");
3823 let msg = format!("{err}");
3824 assert!(
3825 msg.contains("unknown run_id"),
3826 "error should mention 'unknown run_id', got: {msg}"
3827 );
3828
3829 fs::remove_dir_all(root).expect("cleanup");
3830 });
3831 }
3832
3833 #[test]
3839 fn rebuild_from_events_table_restores_memories() {
3840 with_user_brain_disabled(|| {
3841 let root = test_root();
3842 fs::create_dir_all(&root).expect("create temp project");
3843 init_project(&root, false).expect("init project");
3844
3845 let id1 = add_memory(
3846 &root,
3847 MemoryScope::Repo,
3848 MemoryKind::Convention,
3849 "W1.3: prefer explicit error types over anyhow in library crates",
3850 )
3851 .expect("add memory 1");
3852 let id2 = add_memory(
3853 &root,
3854 MemoryScope::Repo,
3855 MemoryKind::Command,
3856 "W1.3: run cargo fmt --all before committing",
3857 )
3858 .expect("add memory 2");
3859
3860 {
3862 let (_paths, _config, conn) = load_project(&root).expect("load");
3863 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
3864 .expect("wipe derived tables");
3865 }
3866
3867 let gone = list_memories(&root).expect("list after wipe");
3869 assert_eq!(gone.len(), 0, "derived tables should be empty after wipe");
3870
3871 let count = rebuild_projection(&root, false).expect("rebuild_projection");
3873 assert!(
3874 count > 0,
3875 "should have replayed at least one event; got {count}"
3876 );
3877
3878 let restored = list_memories(&root).expect("list after rebuild");
3880 assert_eq!(
3881 restored.len(),
3882 2,
3883 "both memories should be restored after rebuild; got {:?}",
3884 restored.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
3885 );
3886 let ids: Vec<_> = restored.iter().map(|m| m.memory_id.clone()).collect();
3887 assert!(ids.contains(&id1), "id1 must be restored");
3888 assert!(ids.contains(&id2), "id2 must be restored");
3889
3890 fs::remove_dir_all(root).expect("cleanup");
3891 });
3892 }
3893
3894 #[test]
3903 fn rebuild_from_traces_flag_reimports_on_disk_traces() {
3904 with_user_brain_disabled(|| {
3905 let root = test_root();
3906 fs::create_dir_all(&root).expect("create temp project");
3907 init_project(&root, false).expect("init project");
3908
3909 let memory_id = Ulid::new().to_string();
3913 let run_id = RunId::new();
3914 {
3915 let (paths, config, conn) = load_project(&root).expect("load");
3916 let (mut writer, _run_paths) =
3917 TraceWriter::create(&paths, run_id).expect("trace writer");
3918 let text = "W1.3: from_traces re-imports events from trace.jsonl files";
3919 let normalized = kimetsu_core::memory::normalize_memory_text(text);
3920 let evs: Vec<Event> = vec![
3921 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
3922 Event::new(
3923 run_id,
3924 "memory.accepted",
3925 serde_json::json!({
3926 "proposal_id": null,
3927 "memory_id": memory_id,
3928 "scope": "repo",
3929 "kind": "fact",
3930 "text": text,
3931 "normalized_text": normalized,
3932 "confidence": 1.0,
3933 "provenance_snapshot": {
3934 "source": "manual_cli",
3935 "run_id": run_id.to_string(),
3936 "text": text,
3937 }
3938 }),
3939 ),
3940 admin_finished_event(run_id),
3941 ];
3942 for ev in &evs {
3943 writer.append(ev, true).expect("append");
3944 }
3945 projector::apply_events(&conn, &evs).expect("apply");
3947 }
3948
3949 let initial = list_memories(&root).expect("list initial");
3951 assert_eq!(initial.len(), 1);
3952
3953 {
3956 let (_paths, _config, conn) = load_project(&root).expect("load");
3957 conn.execute_batch(
3958 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
3959 )
3960 .expect("wipe events + derived tables");
3961 }
3962
3963 let count = rebuild_projection(&root, true).expect("rebuild_projection --from-traces");
3965 assert!(
3966 count > 0,
3967 "should have imported ≥1 event from on-disk traces; got {count}"
3968 );
3969
3970 let restored = list_memories(&root).expect("list after trace import");
3971 assert_eq!(
3972 restored.len(),
3973 1,
3974 "memory must be restored from on-disk traces"
3975 );
3976 assert_eq!(restored[0].memory_id, memory_id);
3977
3978 fs::remove_dir_all(root).expect("cleanup");
3979 });
3980 }
3981
3982 #[test]
3990 fn rebuild_auto_fallback_imports_traces_when_events_table_empty() {
3991 with_user_brain_disabled(|| {
3992 let root = test_root();
3993 fs::create_dir_all(&root).expect("create temp project");
3994 init_project(&root, false).expect("init project");
3995
3996 let memory_id = Ulid::new().to_string();
3998 let run_id = RunId::new();
3999 {
4000 let (paths, config, conn) = load_project(&root).expect("load");
4001 let (mut writer, _run_paths) =
4002 TraceWriter::create(&paths, run_id).expect("trace writer");
4003 let text = "W1.3: auto-fallback recovers from pre-W1.1 events wipe";
4004 let normalized = kimetsu_core::memory::normalize_memory_text(text);
4005 let evs: Vec<Event> = vec![
4006 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
4007 Event::new(
4008 run_id,
4009 "memory.accepted",
4010 serde_json::json!({
4011 "proposal_id": null,
4012 "memory_id": memory_id,
4013 "scope": "repo",
4014 "kind": "convention",
4015 "text": text,
4016 "normalized_text": normalized,
4017 "confidence": 1.0,
4018 "provenance_snapshot": {
4019 "source": "manual_cli",
4020 "run_id": run_id.to_string(),
4021 "text": text,
4022 }
4023 }),
4024 ),
4025 admin_finished_event(run_id),
4026 ];
4027 for ev in &evs {
4028 writer.append(ev, true).expect("append");
4029 }
4030 projector::apply_events(&conn, &evs).expect("apply");
4032 }
4033
4034 {
4037 let (_paths, _config, conn) = load_project(&root).expect("load");
4038 conn.execute_batch(
4039 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
4040 )
4041 .expect("simulate pre-W1.1 wipe");
4042 }
4043
4044 let count = rebuild_projection(&root, false).expect("rebuild_projection auto-fallback");
4047 assert!(
4048 count > 0,
4049 "auto-fallback should have imported ≥1 event from traces; got {count}"
4050 );
4051
4052 let restored = list_memories(&root).expect("list after auto-fallback");
4053 assert_eq!(
4054 restored.len(),
4055 1,
4056 "auto-fallback must restore memory from traces when events table was empty"
4057 );
4058 assert_eq!(restored[0].memory_id, memory_id);
4059
4060 fs::remove_dir_all(root).expect("cleanup");
4061 });
4062 }
4063
4064 fn run_subdir_count(runs_dir: &std::path::Path) -> usize {
4068 if !runs_dir.exists() {
4069 return 0;
4070 }
4071 fs::read_dir(runs_dir)
4072 .map(|rd| {
4073 rd.filter_map(|e| e.ok())
4074 .filter(|e| e.path().is_dir())
4075 .count()
4076 })
4077 .unwrap_or(0)
4078 }
4079
4080 #[test]
4083 fn w1_4_add_memory_creates_no_run_dir_but_memory_and_runs_row_exist() {
4084 with_user_brain_disabled(|| {
4085 let root = test_root();
4086 fs::create_dir_all(&root).expect("create temp project");
4087 init_project(&root, false).expect("init project");
4088
4089 let runs_dir = {
4091 let paths =
4092 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4093 paths.runs_dir.clone()
4094 };
4095 let before = run_subdir_count(&runs_dir);
4096
4097 let memory_id = add_memory(
4098 &root,
4099 MemoryScope::Project,
4100 MemoryKind::Fact,
4101 "W1.4: no run dir should be created for memory writes",
4102 )
4103 .expect("add memory");
4104
4105 let after = run_subdir_count(&runs_dir);
4107 assert_eq!(
4108 after, before,
4109 "add_memory must not create a runs/<id>/ directory (before={before}, after={after})"
4110 );
4111
4112 let memories = list_memories(&root).expect("list");
4114 assert!(
4115 memories.iter().any(|m| m.memory_id == memory_id),
4116 "memory must be present after add_memory"
4117 );
4118
4119 let runs_count: i64 = {
4121 let (_paths, _config, conn) = load_project(&root).expect("load for runs check");
4122 conn.query_row("SELECT COUNT(*) FROM runs", [], |r| r.get(0))
4123 .expect("count runs")
4124 };
4125 assert!(
4126 runs_count >= 1,
4127 "projector must have inserted a runs row from the run.started event (got {runs_count})"
4128 );
4129
4130 fs::remove_dir_all(root).expect("cleanup");
4131 });
4132 }
4133
4134 #[test]
4137 fn w1_4_memory_survives_rebuild_from_events_table_no_trace() {
4138 with_user_brain_disabled(|| {
4139 let root = test_root();
4140 fs::create_dir_all(&root).expect("create temp project");
4141 init_project(&root, false).expect("init project");
4142
4143 let memory_id = add_memory(
4144 &root,
4145 MemoryScope::Repo,
4146 MemoryKind::Convention,
4147 "W1.4: events are durable without a trace file",
4148 )
4149 .expect("add memory");
4150
4151 {
4153 let (_paths, _config, conn) = load_project(&root).expect("load");
4154 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
4155 .expect("wipe derived tables");
4156 }
4157
4158 let count = rebuild_projection(&root, false).expect("rebuild");
4160 assert!(count > 0, "should have replayed events; got {count}");
4161
4162 let restored = list_memories(&root).expect("list after rebuild");
4163 assert!(
4164 restored.iter().any(|m| m.memory_id == memory_id),
4165 "memory must survive rebuild from events table without a trace file"
4166 );
4167
4168 fs::remove_dir_all(root).expect("cleanup");
4169 });
4170 }
4171
4172 #[test]
4175 fn w1_4_memory_ops_create_no_run_dirs() {
4176 with_user_brain_disabled(|| {
4177 let root = test_root();
4178 fs::write(
4180 root.join("Cargo.toml"),
4181 "[package]\nname = \"w14-fixture\"\nversion = \"0.1.0\"\n",
4182 )
4183 .expect("write Cargo.toml");
4184 init_project(&root, false).expect("init project");
4185
4186 let runs_dir = {
4188 let paths =
4189 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4190 paths.runs_dir.clone()
4191 };
4192
4193 let before = run_subdir_count(&runs_dir);
4195 let proposal_id = propose_memory(
4196 &root,
4197 MemoryScope::Project,
4198 MemoryKind::Fact,
4199 "W1.4 propose: no run dir",
4200 0.5,
4201 "test rationale",
4202 )
4203 .expect("propose");
4204 assert_eq!(
4205 run_subdir_count(&runs_dir),
4206 before,
4207 "propose_memory must not create a run dir"
4208 );
4209
4210 let before = run_subdir_count(&runs_dir);
4212 ingest_repo(&root).expect("ingest");
4213 assert_eq!(
4214 run_subdir_count(&runs_dir),
4215 before,
4216 "ingest_repo must not create a run dir"
4217 );
4218
4219 let before = run_subdir_count(&runs_dir);
4221 reject_proposal(&root, &proposal_id, Some("W1.4 test")).expect("reject");
4222 assert_eq!(
4223 run_subdir_count(&runs_dir),
4224 before,
4225 "reject_proposal must not create a run dir"
4226 );
4227
4228 let mem_id = add_memory(
4230 &root,
4231 MemoryScope::Project,
4232 MemoryKind::Command,
4233 "W1.4 invalidate: no run dir",
4234 )
4235 .expect("add");
4236 let before = run_subdir_count(&runs_dir);
4237 invalidate_memory(&root, &mem_id, Some("W1.4 test")).expect("invalidate");
4238 assert_eq!(
4239 run_subdir_count(&runs_dir),
4240 before,
4241 "invalidate_memory must not create a run dir"
4242 );
4243
4244 fs::remove_dir_all(root).expect("cleanup");
4245 });
4246 }
4247
4248 #[test]
4250 fn w1_4_dedup_hit_creates_no_orphan_run_dir() {
4251 with_user_brain_disabled(|| {
4252 let root = test_root();
4253 fs::create_dir_all(&root).expect("create temp project");
4254 init_project(&root, false).expect("init project");
4255
4256 let runs_dir = {
4258 let paths =
4259 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
4260 paths.runs_dir.clone()
4261 };
4262
4263 let id1 = add_memory(
4265 &root,
4266 MemoryScope::Project,
4267 MemoryKind::Fact,
4268 "W1.4 dedup: identical text",
4269 )
4270 .expect("first add");
4271
4272 let after_first = run_subdir_count(&runs_dir);
4274 assert_eq!(after_first, 0, "first add must create no run dir");
4275
4276 let id2 = add_memory(
4278 &root,
4279 MemoryScope::Project,
4280 MemoryKind::Fact,
4281 "W1.4 dedup: identical text",
4282 )
4283 .expect("second add");
4284
4285 assert_eq!(id1, id2, "dedup must return the same memory_id");
4286 let after_second = run_subdir_count(&runs_dir);
4287 assert_eq!(
4288 after_second, 0,
4289 "dedup hit must not create an orphan run dir"
4290 );
4291
4292 fs::remove_dir_all(root).expect("cleanup");
4293 });
4294 }
4295
4296 #[test]
4302 fn w3_1_open_embedder_for_resolver() {
4303 use crate::embeddings;
4304 use crate::user_brain::test_env_lock;
4305
4306 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
4307 let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
4308 unsafe {
4310 std::env::remove_var("KIMETSU_BRAIN_EMBEDDER");
4311 }
4312
4313 assert!(
4315 embeddings::open_embedder_for(false).is_noop(),
4316 "open_embedder_for(false) must return a noop embedder"
4317 );
4318 assert_eq!(
4320 embeddings::open_embedder_for(true).is_noop(),
4321 embeddings::open_default_embedder().is_noop(),
4322 "open_embedder_for(true) must match open_default_embedder().is_noop()"
4323 );
4324
4325 unsafe {
4327 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
4328 }
4329 assert!(
4330 embeddings::open_embedder_for(true).is_noop(),
4331 "KIMETSU_BRAIN_EMBEDDER=noop must override config=true → noop"
4332 );
4333
4334 unsafe {
4336 match prev {
4337 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
4338 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
4339 }
4340 }
4341 }
4342
4343 #[test]
4346 fn w3_1_config_disabled_writes_null_embedding() {
4347 with_user_brain_disabled(|| {
4348 let root = test_root();
4349 init_project(&root, false).expect("init");
4350
4351 let (paths, mut config, _conn) = load_project(&root).expect("load");
4353 config.embedder.enabled = false;
4354 let toml = config.to_toml().expect("serialize");
4355 drop(_conn);
4357 fs::write(&paths.project_toml, toml).expect("write project.toml");
4358
4359 let memory_id = add_memory(
4360 &root,
4361 MemoryScope::Project,
4362 MemoryKind::Fact,
4363 "w3.1 write-disabled: embedder disabled via config",
4364 )
4365 .expect("add memory");
4366
4367 let embedding: Option<Vec<u8>> = {
4369 let (_, _, conn) = load_project_readonly(&root).expect("reload");
4370 let val = conn
4371 .query_row(
4372 "SELECT embedding FROM memories WHERE memory_id = ?1",
4373 rusqlite::params![memory_id],
4374 |row| row.get(0),
4375 )
4376 .expect("query embedding");
4377 drop(conn);
4378 val
4379 };
4380 assert!(
4381 embedding.is_none(),
4382 "embedding must be NULL when [embedder] enabled = false"
4383 );
4384
4385 fs::remove_dir_all(root).ok(); });
4387 }
4388
4389 #[test]
4395 fn w3_1_config_enabled_default_does_not_regress() {
4396 use crate::embeddings;
4397 let e_default = embeddings::open_default_embedder();
4400 let e_config = embeddings::open_embedder_for(true);
4401 assert_eq!(
4402 e_default.is_noop(),
4403 e_config.is_noop(),
4404 "open_embedder_for(true) and open_default_embedder() must have identical noop status"
4405 );
4406 }
4407
4408 #[test]
4411 fn w3_1_retrieval_fts_only_when_embedder_disabled() {
4412 with_user_brain_disabled(|| {
4413 let root = test_root();
4414 init_project(&root, false).expect("init");
4415
4416 add_memory(
4418 &root,
4419 MemoryScope::Project,
4420 MemoryKind::Fact,
4421 "the quick brown fox jumps over the lazy dog",
4422 )
4423 .expect("add memory");
4424
4425 let (paths, mut config, _) = load_project(&root).expect("load");
4427 config.embedder.enabled = false;
4428 let toml = config.to_toml().expect("serialize");
4429 fs::write(&paths.project_toml, toml).expect("write project.toml");
4430
4431 {
4434 let session = BrainSession::open_readonly(&root).expect("open readonly");
4435 let bundle = session
4436 .retrieve_context_with_request(crate::context::ContextRequest {
4437 stage: "localization".to_string(),
4438 query: "fox jumps".to_string(),
4439 budget_tokens: 4096,
4440 ..Default::default()
4441 })
4442 .expect("retrieve");
4443 let _ = bundle; }
4448
4449 fs::remove_dir_all(root).ok(); });
4451 }
4452
4453 #[test]
4460 fn retrieve_context_lexical_returns_fts_hits_without_embedder() {
4461 with_user_brain_disabled(|| {
4462 let root = test_root();
4463 init_project(&root, false).expect("init");
4464
4465 let memory_id = add_memory(
4466 &root,
4467 MemoryScope::Project,
4468 MemoryKind::Convention,
4469 "Run zylophonecheck before finalizing the deployment pipeline.",
4470 )
4471 .expect("add memory");
4472
4473 {
4474 let session = BrainSession::open_readonly(&root).expect("open readonly");
4475 let bundle = session
4476 .retrieve_context_lexical(crate::context::ContextRequest {
4477 stage: "localization".to_string(),
4478 query: "zylophonecheck deployment pipeline".to_string(),
4479 budget_tokens: 4096,
4480 ..Default::default()
4481 })
4482 .expect("retrieve lexical");
4483
4484 assert!(
4485 bundle
4486 .capsules
4487 .iter()
4488 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4489 "FTS-only lexical retrieval must surface the seeded memory; \
4490 got handles: {:?}",
4491 bundle
4492 .capsules
4493 .iter()
4494 .map(|c| &c.expansion_handle)
4495 .collect::<Vec<_>>()
4496 );
4497 }
4498
4499 fs::remove_dir_all(root).ok(); });
4501 }
4502
4503 #[test]
4508 fn retrieve_with_injected_embedder_returns_fts_hits() {
4509 with_user_brain_disabled(|| {
4510 let root = test_root();
4511 init_project(&root, false).expect("init");
4512
4513 let memory_id = add_memory(
4514 &root,
4515 MemoryScope::Project,
4516 MemoryKind::Fact,
4517 "the distiller harvests lessons at session end",
4518 )
4519 .expect("add");
4520
4521 {
4522 let session = BrainSession::open_readonly(&root).expect("open ro");
4523 let bundle = session
4524 .retrieve_context_with_injected_embedder(
4525 crate::context::ContextRequest {
4526 stage: "localization".to_string(),
4527 query: "how does the distiller work".to_string(),
4528 budget_tokens: 2000,
4529 ..Default::default()
4530 },
4531 &crate::embeddings::NoopEmbedder,
4532 )
4533 .expect("retrieve");
4534 assert!(
4535 bundle
4536 .capsules
4537 .iter()
4538 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4539 "FTS path via injected embedder must surface the memory; \
4540 got handles: {:?}",
4541 bundle
4542 .capsules
4543 .iter()
4544 .map(|c| &c.expansion_handle)
4545 .collect::<Vec<_>>()
4546 );
4547 }
4548
4549 fs::remove_dir_all(root).ok(); });
4551 }
4552
4553 fn with_user_brain_at_p0<R>(dir: &std::path::Path, f: impl FnOnce() -> R) -> R {
4558 use crate::user_brain::test_env_lock;
4559 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
4560 let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
4561 let prev_en = std::env::var("KIMETSU_USER_BRAIN").ok();
4562 unsafe {
4564 std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
4565 std::env::remove_var("KIMETSU_USER_BRAIN");
4566 }
4567 let out = f();
4568 unsafe {
4569 match prev_dir {
4570 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
4571 None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
4572 }
4573 match prev_en {
4574 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
4575 None => std::env::remove_var("KIMETSU_USER_BRAIN"),
4576 }
4577 }
4578 out
4579 }
4580
4581 #[test]
4585 fn p0_global_user_add_memory_works_from_non_project_dir() {
4586 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
4587
4588 let user_brain_dir =
4589 std::env::temp_dir().join(format!("kimetsu-p0-ubrain-{}", Ulid::new()));
4590 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
4591
4592 let non_project_dir =
4594 std::env::temp_dir().join(format!("kimetsu-p0-nonproj-{}", Ulid::new()));
4595 fs::create_dir_all(&non_project_dir).expect("create non-project dir");
4596
4597 with_user_brain_at_p0(&user_brain_dir, || {
4598 add_memory(
4599 &non_project_dir,
4600 MemoryScope::GlobalUser,
4601 MemoryKind::Fact,
4602 "P0 regression: GlobalUser write from non-project dir",
4603 )
4604 .expect("P0: add_memory(GlobalUser) from a non-project dir must succeed");
4605
4606 let conn = open_user_brain_readonly()
4608 .expect("open ok")
4609 .expect("user brain must exist after write");
4610 let mems = list_user_memories(&conn).expect("list");
4611 assert!(
4612 mems.iter().any(|m| m
4613 .text
4614 .contains("P0 regression: GlobalUser write from non-project dir")),
4615 "P0: the GlobalUser memory must land in the user brain"
4616 );
4617 });
4618
4619 fs::remove_dir_all(&non_project_dir).ok();
4620 fs::remove_dir_all(&user_brain_dir).ok();
4621 }
4622
4623 #[test]
4627 fn p0_global_user_honors_use_user_brain_false_when_start_is_project() {
4628 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
4629
4630 let user_brain_dir =
4632 std::env::temp_dir().join(format!("kimetsu-p0-w3-ubrain-{}", Ulid::new()));
4633 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
4634
4635 let root = test_root();
4637 init_project(&root, false).expect("init project");
4638
4639 {
4641 let (paths, mut config, _) = load_project(&root).expect("load project");
4642 config.kimetsu.use_user_brain = false;
4643 let toml = config.to_toml().expect("serialize");
4644 fs::write(&paths.project_toml, toml).expect("write project.toml");
4645 }
4646
4647 let mem_id = with_user_brain_at_p0(&user_brain_dir, || {
4648 let id = add_memory(
4651 &root,
4652 MemoryScope::GlobalUser,
4653 MemoryKind::Fact,
4654 "W3.3 toggle: this must stay in the project DB",
4655 )
4656 .expect("add_memory must succeed (falls through to project DB)");
4657
4658 let user_conn_opt = open_user_brain_readonly().expect("open ok");
4660 let user_mems_count = user_conn_opt
4661 .map(|c| list_user_memories(&c).unwrap_or_default().len())
4662 .unwrap_or(0);
4663 assert_eq!(
4664 user_mems_count, 0,
4665 "W3.3 toggle: user brain must be empty when use_user_brain=false"
4666 );
4667 id
4668 });
4669
4670 let project_mems = list_memories(&root).expect("list project memories");
4672 assert!(
4673 project_mems.iter().any(|m| m.memory_id == mem_id),
4674 "W3.3 toggle: memory must be in the project DB when use_user_brain=false"
4675 );
4676
4677 fs::remove_dir_all(&root).ok();
4678 fs::remove_dir_all(&user_brain_dir).ok();
4679 }
4680
4681 #[test]
4686 fn export_import_round_trip() {
4687 with_user_brain_disabled(|| {
4688 let root_a = test_root();
4690 init_project(&root_a, false).expect("init A");
4691 add_memory(
4692 &root_a,
4693 MemoryScope::Project,
4694 MemoryKind::Fact,
4695 "alpha fact",
4696 )
4697 .expect("add fact");
4698 add_memory(
4699 &root_a,
4700 MemoryScope::Project,
4701 MemoryKind::Convention,
4702 "beta convention",
4703 )
4704 .expect("add conv");
4705 add_memory(
4706 &root_a,
4707 MemoryScope::Project,
4708 MemoryKind::FailurePattern,
4709 "gamma failure",
4710 )
4711 .expect("add fp");
4712
4713 let (exported, _scrub) =
4715 export_memories(&root_a, None, None, false, false).expect("export");
4716 assert_eq!(exported.len(), 3, "must export all 3 active memories");
4717
4718 for e in &exported {
4720 assert!(!e.text.is_empty());
4721 assert!(!e.scope.is_empty());
4722 assert!(!e.kind.is_empty());
4723 }
4724
4725 let json = serde_json::to_string_pretty(&exported).expect("serialize");
4727 let parsed: Vec<MemoryExport> = serde_json::from_str(&json).expect("deserialize");
4728 assert_eq!(parsed.len(), 3);
4729
4730 let root_b = test_root();
4732 init_project(&root_b, false).expect("init B");
4733
4734 let summary = import_memories(&root_b, &parsed, None).expect("import");
4735 assert_eq!(
4736 summary.imported, 3,
4737 "all 3 must be imported into the empty project B"
4738 );
4739 assert_eq!(summary.deduped, 0, "no duplicates expected on first import");
4740
4741 let mems_b = list_memories(&root_b).expect("list B");
4742 let texts_b: Vec<&str> = mems_b.iter().map(|m| m.text.as_str()).collect();
4743 assert!(
4744 texts_b.contains(&"alpha fact"),
4745 "alpha fact missing from B: {texts_b:?}"
4746 );
4747 assert!(
4748 texts_b.contains(&"beta convention"),
4749 "beta convention missing from B: {texts_b:?}"
4750 );
4751 assert!(
4752 texts_b.contains(&"gamma failure"),
4753 "gamma failure missing from B: {texts_b:?}"
4754 );
4755
4756 fs::remove_dir_all(&root_a).ok();
4757 fs::remove_dir_all(&root_b).ok();
4758 });
4759 }
4760
4761 #[test]
4764 fn export_scope_kind_filter() {
4765 with_user_brain_disabled(|| {
4766 let root = test_root();
4767 init_project(&root, false).expect("init");
4768 add_memory(
4769 &root,
4770 MemoryScope::Project,
4771 MemoryKind::FailurePattern,
4772 "fp1",
4773 )
4774 .expect("add fp1");
4775 add_memory(
4776 &root,
4777 MemoryScope::Project,
4778 MemoryKind::FailurePattern,
4779 "fp2",
4780 )
4781 .expect("add fp2");
4782 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact1").expect("add fact");
4783 add_memory(
4784 &root,
4785 MemoryScope::Repo,
4786 MemoryKind::FailurePattern,
4787 "repo-fp",
4788 )
4789 .expect("add repo-fp");
4790
4791 let (filtered, _) = export_memories(
4793 &root,
4794 Some(MemoryScope::Project),
4795 Some(MemoryKind::FailurePattern),
4796 false,
4797 false,
4798 )
4799 .expect("export filtered");
4800 assert_eq!(
4801 filtered.len(),
4802 2,
4803 "must return only the 2 project-scope failure_patterns, got: {filtered:?}"
4804 );
4805 assert!(filtered.iter().all(|e| e.scope == "project"));
4806 assert!(filtered.iter().all(|e| e.kind == "failure_pattern"));
4807
4808 let (scope_only, _) =
4810 export_memories(&root, Some(MemoryScope::Project), None, false, false)
4811 .expect("scope filter");
4812 assert_eq!(scope_only.len(), 3, "3 project-scope memories total");
4813
4814 let (kind_only, _) =
4816 export_memories(&root, None, Some(MemoryKind::FailurePattern), false, false)
4817 .expect("kind filter");
4818 assert_eq!(
4819 kind_only.len(),
4820 3,
4821 "3 failure_patterns total (2 project + 1 repo)"
4822 );
4823
4824 fs::remove_dir_all(&root).ok();
4825 });
4826 }
4827
4828 #[test]
4831 fn import_dedup_on_second_import() {
4832 with_user_brain_disabled(|| {
4833 let root = test_root();
4834 init_project(&root, false).expect("init");
4835
4836 let entries = vec![
4837 MemoryExport {
4838 text: "dedup alpha".to_string(),
4839 scope: "project".to_string(),
4840 kind: "fact".to_string(),
4841 confidence: 1.0,
4842 created_at: None,
4843 },
4844 MemoryExport {
4845 text: "dedup beta".to_string(),
4846 scope: "project".to_string(),
4847 kind: "convention".to_string(),
4848 confidence: 1.0,
4849 created_at: None,
4850 },
4851 ];
4852
4853 let s1 = import_memories(&root, &entries, None).expect("import 1");
4855 assert_eq!(s1.imported, 2, "first import: 2 new rows");
4856 assert_eq!(s1.deduped, 0, "first import: no dups");
4857
4858 let count_after_first = list_memories(&root).expect("list after 1st").len();
4859 assert_eq!(count_after_first, 2);
4860
4861 let s2 = import_memories(&root, &entries, None).expect("import 2");
4863 assert_eq!(s2.imported, 0, "second import: no new rows");
4864 assert_eq!(s2.deduped, 2, "second import: both entries deduped");
4865
4866 let count_after_second = list_memories(&root).expect("list after 2nd").len();
4867 assert_eq!(
4868 count_after_second, 2,
4869 "list_memories count must be unchanged after second import"
4870 );
4871
4872 fs::remove_dir_all(&root).ok();
4873 });
4874 }
4875
4876 #[test]
4879 fn import_scope_override_global_user() {
4880 with_user_brain_disabled(|| {
4881 let root = test_root();
4883 init_project(&root, false).expect("init");
4884
4885 let entries = vec![MemoryExport {
4886 text: "scope override test memory".to_string(),
4887 scope: "project".to_string(), kind: "fact".to_string(),
4889 confidence: 1.0,
4890 created_at: None,
4891 }];
4892
4893 let summary =
4894 import_memories(&root, &entries, Some(MemoryScope::GlobalUser)).expect("import");
4895 assert_eq!(summary.imported, 1);
4896 assert_eq!(summary.deduped, 0);
4897
4898 let mems = list_memories(&root).expect("list");
4901 assert_eq!(mems.len(), 1);
4902 assert_eq!(
4903 mems[0].scope, "global_user",
4904 "scope_override must win over entry.scope"
4905 );
4906 assert_eq!(mems[0].text, "scope override test memory");
4907
4908 fs::remove_dir_all(&root).ok();
4909 });
4910 }
4911
4912 #[test]
4915 fn import_skips_malformed_entries() {
4916 with_user_brain_disabled(|| {
4917 let root = test_root();
4918 init_project(&root, false).expect("init");
4919
4920 let entries = vec![
4921 MemoryExport {
4923 text: "good entry".to_string(),
4924 scope: "project".to_string(),
4925 kind: "fact".to_string(),
4926 confidence: 1.0,
4927 created_at: None,
4928 },
4929 MemoryExport {
4931 text: "bad scope entry".to_string(),
4932 scope: "not_a_real_scope".to_string(),
4933 kind: "fact".to_string(),
4934 confidence: 1.0,
4935 created_at: None,
4936 },
4937 MemoryExport {
4939 text: "bad kind entry".to_string(),
4940 scope: "project".to_string(),
4941 kind: "not_a_real_kind".to_string(),
4942 confidence: 1.0,
4943 created_at: None,
4944 },
4945 MemoryExport {
4947 text: "second good entry".to_string(),
4948 scope: "repo".to_string(),
4949 kind: "convention".to_string(),
4950 confidence: 1.0,
4951 created_at: None,
4952 },
4953 ];
4954
4955 let summary = import_memories(&root, &entries, None).expect("import with bad entries");
4956 assert_eq!(
4957 summary.imported, 2,
4958 "2 valid entries must be imported; got {summary:?}"
4959 );
4960 assert_eq!(
4961 summary.deduped, 2,
4962 "2 malformed entries counted as skipped/deduped; got {summary:?}"
4963 );
4964
4965 let mems = list_memories(&root).expect("list");
4966 assert_eq!(mems.len(), 2, "exactly 2 memories in DB; got {mems:?}");
4967 let texts: Vec<&str> = mems.iter().map(|m| m.text.as_str()).collect();
4968 assert!(
4969 texts.contains(&"good entry"),
4970 "good entry missing: {texts:?}"
4971 );
4972 assert!(
4973 texts.contains(&"second good entry"),
4974 "second good entry missing: {texts:?}"
4975 );
4976
4977 fs::remove_dir_all(&root).ok();
4978 });
4979 }
4980
4981 #[test]
4985 fn redact_context_suffix_strips_trailing_context() {
4986 assert_eq!(
4987 redact_context_suffix("always use --locked (context: cargo build)"),
4988 "always use --locked"
4989 );
4990 assert_eq!(
4992 redact_context_suffix("lesson body (context: some task)"),
4993 "lesson body"
4994 );
4995 assert_eq!(redact_context_suffix("bare lesson"), "bare lesson");
4997 assert_eq!(
4999 redact_context_suffix("(context: only context)"),
5000 "(context: only context)"
5001 );
5002 assert_eq!(
5004 redact_context_suffix("lesson (context: (nested) task)"),
5005 "lesson"
5006 );
5007 assert_eq!(redact_context_suffix("lesson (context: task) "), "lesson");
5009 }
5010
5011 #[test]
5013 fn redact_tags_prefix_strips_leading_tags() {
5014 assert_eq!(
5015 redact_tags_prefix("[tags: rust, cargo] always use --locked"),
5016 "always use --locked"
5017 );
5018 assert_eq!(redact_tags_prefix("no tags here"), "no tags here");
5020 assert_eq!(redact_tags_prefix("[tags: only-tag]"), "[tags: only-tag]");
5022 assert_eq!(redact_tags_prefix(" [tags: rust] lesson"), "lesson");
5024 }
5025
5026 #[test]
5028 fn apply_export_redaction_no_flags_is_passthrough() {
5029 let entry = MemoryExport {
5030 text: "[tags: rust] lesson (context: task)".to_string(),
5031 scope: "project".to_string(),
5032 kind: "fact".to_string(),
5033 confidence: 1.0,
5034 created_at: None,
5035 };
5036 let out = apply_export_redaction(entry.clone(), false, false);
5037 assert_eq!(out.text, entry.text);
5038 }
5039
5040 #[test]
5042 fn apply_export_redaction_redact_only_strips_context() {
5043 let entry = MemoryExport {
5044 text: "[tags: rust] lesson (context: task)".to_string(),
5045 scope: "project".to_string(),
5046 kind: "fact".to_string(),
5047 confidence: 1.0,
5048 created_at: None,
5049 };
5050 let out = apply_export_redaction(entry, true, false);
5051 assert_eq!(out.text, "[tags: rust] lesson");
5052 }
5053
5054 #[test]
5056 fn apply_export_redaction_both_flags_strips_tags_and_context() {
5057 let entry = MemoryExport {
5058 text: "[tags: rust, cargo] lesson (context: task)".to_string(),
5059 scope: "project".to_string(),
5060 kind: "fact".to_string(),
5061 confidence: 1.0,
5062 created_at: None,
5063 };
5064 let out = apply_export_redaction(entry, true, true);
5065 assert_eq!(out.text, "lesson");
5066 }
5067
5068 #[test]
5074 fn export_redact_import_roundtrip_and_dedup() {
5075 with_user_brain_disabled(|| {
5076 let root_a = test_root();
5077 init_project(&root_a, false).expect("init A");
5078
5079 add_memory(
5081 &root_a,
5082 MemoryScope::Project,
5083 MemoryKind::Fact,
5084 "use --locked for reproducibility (context: cargo test failing)",
5085 )
5086 .expect("add memory");
5087
5088 let (exported, _) =
5090 export_memories(&root_a, None, None, true, false).expect("export redacted");
5091 assert_eq!(exported.len(), 1);
5092 assert_eq!(
5093 exported[0].text, "use --locked for reproducibility",
5094 "context suffix must be stripped"
5095 );
5096
5097 let root_b = test_root();
5099 init_project(&root_b, false).expect("init B");
5100 let s1 = import_memories(&root_b, &exported, None).expect("import 1");
5101 assert_eq!(s1.imported, 1, "first import must create 1 row");
5102 assert_eq!(s1.deduped, 0);
5103
5104 let s2 = import_memories(&root_b, &exported, None).expect("import 2");
5106 assert_eq!(s2.imported, 0, "second import must dedup");
5107 assert_eq!(s2.deduped, 1);
5108
5109 let mems = list_memories(&root_b).expect("list");
5111 assert_eq!(mems.len(), 1);
5112 assert_eq!(mems[0].text, "use --locked for reproducibility");
5113
5114 fs::remove_dir_all(&root_a).ok();
5115 fs::remove_dir_all(&root_b).ok();
5116 });
5117 }
5118
5119 #[test]
5121 fn import_pack_merge_replace_and_provenance() {
5122 with_user_brain_disabled(|| {
5123 let root_a = test_root();
5124 init_project(&root_a, false).expect("init A");
5125 add_memory(
5126 &root_a,
5127 MemoryScope::Project,
5128 MemoryKind::Convention,
5129 "use cargo --locked",
5130 )
5131 .expect("a1");
5132 add_memory(
5133 &root_a,
5134 MemoryScope::Project,
5135 MemoryKind::Fact,
5136 "brain db lives in dot kimetsu",
5137 )
5138 .expect("a2");
5139
5140 let (entries, scrub) =
5142 export_memories(&root_a, None, None, false, false).expect("export");
5143 assert!(scrub.is_clean(), "clean memories must scrub to nothing");
5144 let pack = Pack {
5145 kimetsu_pack: 1,
5146 name: Some("demo".into()),
5147 version: Some("1.0".into()),
5148 description: None,
5149 exported_at: None,
5150 memory_count: entries.len(),
5151 memories: entries.clone(),
5152 };
5153 let json = serde_json::to_string(&pack).expect("ser");
5154 let (pref, parsed) = parse_pack_or_array(&json).expect("parse");
5155 assert_eq!(pref.name.as_deref(), Some("demo"));
5156 assert_eq!(parsed.len(), 2);
5157 let (bare_ref, bare) =
5159 parse_pack_or_array(&serde_json::to_string(&entries).unwrap()).expect("parse bare");
5160 assert!(bare_ref.name.is_none());
5161 assert_eq!(bare.len(), 2);
5162
5163 let root_b = test_root();
5165 init_project(&root_b, false).expect("init B");
5166 add_memory(
5167 &root_b,
5168 MemoryScope::Project,
5169 MemoryKind::Fact,
5170 "B's own memory",
5171 )
5172 .expect("b1");
5173 let s = import_pack(&root_b, &parsed, None, false, Some(&pref)).expect("merge");
5174 assert_eq!(s.imported, 2, "two new pack memories");
5175 assert_eq!(s.superseded, 0);
5176
5177 let pack_tagged = |root: &Path| -> i64 {
5179 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
5180 conn.query_row(
5181 "SELECT COUNT(*) FROM memories
5182 WHERE provenance_snapshot_json LIKE '%\"source\":\"pack\"%'",
5183 [],
5184 |r| r.get(0),
5185 )
5186 .unwrap()
5187 };
5188 assert_eq!(
5189 pack_tagged(&root_b),
5190 2,
5191 "installed memories tagged with pack provenance"
5192 );
5193
5194 let s2 = import_pack(&root_b, &parsed, None, false, Some(&pref)).expect("merge2");
5196 assert_eq!(s2.imported, 0);
5197 assert_eq!(s2.deduped, 2);
5198
5199 let s3 = import_pack(&root_b, &parsed, None, true, Some(&pref)).expect("replace");
5202 assert_eq!(
5203 s3.superseded, 3,
5204 "all 3 active project memories invalidated"
5205 );
5206 assert_eq!(s3.imported, 2, "pack reloaded as fresh rows");
5207 let active_project = {
5208 let (_p, _c, conn) = load_project_readonly(&root_b).expect("ro2");
5209 conn.query_row(
5210 "SELECT COUNT(*) FROM memories
5211 WHERE scope='project' AND invalidated_at IS NULL AND superseded_by IS NULL",
5212 [],
5213 |r| r.get::<_, i64>(0),
5214 )
5215 .unwrap()
5216 };
5217 assert_eq!(
5218 active_project, 2,
5219 "only the pack's 2 memories remain active"
5220 );
5221
5222 fs::remove_dir_all(&root_a).ok();
5223 fs::remove_dir_all(&root_b).ok();
5224 });
5225 }
5226
5227 #[test]
5231 fn edit_memory_updates_text_and_preserves_history() {
5232 with_user_brain_disabled(|| {
5233 let root = test_root();
5234 init_project(&root, false).expect("init");
5235 let mid = add_memory(
5236 &root,
5237 MemoryScope::Project,
5238 MemoryKind::Fact,
5239 "original text for edit test",
5240 )
5241 .expect("add");
5242
5243 {
5245 let (_p, _c, conn) = load_project(&root).expect("open conn");
5246 conn.execute(
5247 "UPDATE memories SET use_count = 7, usefulness_score = 3.5 WHERE memory_id = ?1",
5248 params![mid],
5249 )
5250 .expect("bump counters");
5251 }
5252
5253 edit_memory(&root, &mid, Some("corrected text for edit test"), None)
5255 .expect("edit_memory");
5256
5257 {
5259 let (_p, _c, conn) = load_project(&root).expect("open conn");
5260 let (text, normalized, use_count, usefulness_score): (String, String, i64, f64) =
5261 conn.query_row(
5262 "SELECT text, normalized_text, use_count, usefulness_score FROM memories WHERE memory_id = ?1",
5263 params![mid],
5264 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
5265 )
5266 .expect("query");
5267
5268 assert_eq!(text, "corrected text for edit test");
5269 assert!(!normalized.is_empty(), "normalized_text must not be empty");
5270 assert_eq!(use_count, 7, "use_count must not be reset");
5272 assert!(
5273 (usefulness_score - 3.5).abs() < 0.01,
5274 "usefulness_score must not be reset"
5275 );
5276 }
5277
5278 let hits = search_memories(&root, "corrected", 10, 0, None, None).expect("search new");
5280 assert!(
5281 hits.iter().any(|h| h.memory_id == mid),
5282 "edited text must appear in FTS search: {hits:?}"
5283 );
5284
5285 let old_hits =
5287 search_memories(&root, "original", 10, 0, None, None).expect("search old");
5288 assert!(
5289 !old_hits.iter().any(|h| h.memory_id == mid),
5290 "old text must NOT appear after edit: {old_hits:?}"
5291 );
5292
5293 let mems = list_memories(&root).expect("list");
5295 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
5296 assert_eq!(m.text, "corrected text for edit test");
5297 });
5298 }
5299
5300 #[test]
5302 fn edit_memory_changes_kind_only() {
5303 with_user_brain_disabled(|| {
5304 let root = test_root();
5305 init_project(&root, false).expect("init");
5306 let mid = add_memory(
5307 &root,
5308 MemoryScope::Project,
5309 MemoryKind::Fact,
5310 "kind-change test memory",
5311 )
5312 .expect("add");
5313
5314 edit_memory(&root, &mid, None, Some(MemoryKind::Convention)).expect("edit kind");
5315
5316 let mems = list_memories(&root).expect("list");
5317 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
5318 assert_eq!(m.kind, "convention", "kind must be updated");
5319 assert_eq!(m.text, "kind-change test memory", "text must be unchanged");
5320 });
5321 }
5322
5323 #[test]
5325 fn edit_memory_errors() {
5326 with_user_brain_disabled(|| {
5327 let root = test_root();
5328 init_project(&root, false).expect("init");
5329
5330 let err = edit_memory(&root, "does-not-matter", None, None)
5332 .expect_err("must err when no fields");
5333 assert!(
5334 format!("{err}").contains("at least one"),
5335 "unexpected err: {err}"
5336 );
5337
5338 let err = edit_memory(&root, "UNKNOWN_ID", Some("x"), None)
5340 .expect_err("must err on unknown id");
5341 assert!(
5342 format!("{err}").contains("not found"),
5343 "unexpected err: {err}"
5344 );
5345
5346 let mid = add_memory(
5348 &root,
5349 MemoryScope::Project,
5350 MemoryKind::Fact,
5351 "will be invalidated",
5352 )
5353 .expect("add");
5354 invalidate_memory(&root, &mid, None).expect("invalidate");
5355 let err = edit_memory(&root, &mid, Some("new text"), None)
5356 .expect_err("must err on invalidated id");
5357 assert!(
5358 format!("{err}").contains("invalidated"),
5359 "unexpected err: {err}"
5360 );
5361 });
5362 }
5363
5364 #[test]
5367 fn undo_last_memory_invalidates_newest_first() {
5368 with_user_brain_disabled(|| {
5369 let root = test_root();
5370 init_project(&root, false).expect("init");
5371
5372 let mid_a = add_memory(
5373 &root,
5374 MemoryScope::Project,
5375 MemoryKind::Fact,
5376 "memory A older undo test",
5377 )
5378 .expect("add A");
5379
5380 let mid_b = add_memory(
5381 &root,
5382 MemoryScope::Project,
5383 MemoryKind::Fact,
5384 "memory B newer undo test",
5385 )
5386 .expect("add B");
5387
5388 let undone = undo_last_memory(&root)
5390 .expect("undo 1")
5391 .expect("must return Some");
5392 assert_eq!(undone.memory_id, mid_b, "undo must target B (newest)");
5393
5394 {
5396 let (_p, _c, conn) = load_project(&root).expect("open conn");
5397 let b_inv: Option<String> = conn
5398 .query_row(
5399 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5400 params![mid_b],
5401 |row| row.get(0),
5402 )
5403 .optional()
5404 .expect("query")
5405 .flatten();
5406 assert!(b_inv.is_some(), "B must be invalidated after undo");
5407
5408 let a_inv: Option<String> = conn
5409 .query_row(
5410 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5411 params![mid_a],
5412 |row| row.get(0),
5413 )
5414 .optional()
5415 .expect("query")
5416 .flatten();
5417 assert!(a_inv.is_none(), "A must still be active");
5418 }
5419
5420 let undone2 = undo_last_memory(&root)
5422 .expect("undo 2")
5423 .expect("must return Some");
5424 assert_eq!(undone2.memory_id, mid_a, "second undo must target A");
5425
5426 {
5428 let (_p, _c, conn) = load_project(&root).expect("open conn");
5429 let a_inv: Option<String> = conn
5430 .query_row(
5431 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
5432 params![mid_a],
5433 |row| row.get(0),
5434 )
5435 .optional()
5436 .expect("query")
5437 .flatten();
5438 assert!(a_inv.is_some(), "A must be invalidated after second undo");
5439 }
5440
5441 let peek = peek_last_memory(&root).expect("peek after both undone");
5443 assert!(peek.is_none(), "peek must return None when all invalidated");
5444 });
5445 }
5446
5447 #[test]
5449 fn undo_last_memory_on_empty_brain_returns_none() {
5450 with_user_brain_disabled(|| {
5451 let root = test_root();
5452 init_project(&root, false).expect("init");
5453 let result = undo_last_memory(&root).expect("undo on empty");
5454 assert!(result.is_none(), "must return None on empty brain");
5455 });
5456 }
5457
5458 #[test]
5468 fn compact_brain_purge_invalidated_reclaims_space() {
5469 with_user_brain_disabled(|| {
5470 let root = test_root();
5471 init_project(&root, false).expect("init");
5472
5473 let mut active_id = String::new();
5475 for i in 0..20usize {
5476 let text = format!(
5477 "compact test memory number {i}: rust sqlite vacuum reclaim disk space \
5478 kimetsu brain compact test payload to increase file size substantially \
5479 so that vacuum has meaningful dead pages to reclaim after deletion"
5480 );
5481 let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, &text)
5482 .expect("add memory");
5483 if i == 0 {
5484 active_id = mid.clone();
5485 }
5486 if i > 0 {
5488 invalidate_memory(&root, &mid, Some("compact test"))
5489 .expect("invalidate memory");
5490 }
5491 }
5492
5493 let report = compact_brain(&root, None, true).expect("compact_brain");
5495
5496 assert_eq!(
5498 report.invalidated_memories_purged, 19,
5499 "should have purged 19 invalidated memories, got {}",
5500 report.invalidated_memories_purged
5501 );
5502 assert!(
5504 report.bytes_after <= report.bytes_before,
5505 "bytes_after ({}) should be <= bytes_before ({}) after purge+vacuum",
5506 report.bytes_after,
5507 report.bytes_before
5508 );
5509 assert_eq!(
5511 report.events_trimmed, 0,
5512 "events_trimmed must be 0 when trim_events_older_than is None"
5513 );
5514
5515 let memories = list_memories(&root).expect("list memories after compact");
5517 let active_memories: Vec<_> = memories
5518 .iter()
5519 .filter(|m| m.memory_id == active_id)
5520 .collect();
5521 assert_eq!(
5522 active_memories.len(),
5523 1,
5524 "the active memory must survive compaction"
5525 );
5526 });
5527 }
5528
5529 #[test]
5534 fn compact_brain_default_preserves_everything() {
5535 with_user_brain_disabled(|| {
5536 let root = test_root();
5537 init_project(&root, false).expect("init");
5538
5539 let mid = add_memory(
5540 &root,
5541 MemoryScope::Project,
5542 MemoryKind::Fact,
5543 "preserve me through compact",
5544 )
5545 .expect("add memory");
5546 let mid2 = add_memory(
5547 &root,
5548 MemoryScope::Project,
5549 MemoryKind::Fact,
5550 "preserve invalidated too",
5551 )
5552 .expect("add memory 2");
5553 invalidate_memory(&root, &mid2, Some("test")).expect("invalidate");
5554
5555 let event_count_before: i64 = {
5557 let (_p, _c, conn) = load_project(&root).expect("load");
5558 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
5559 .expect("count events")
5560 };
5561
5562 let report = compact_brain(&root, None, false).expect("compact_brain");
5564 assert_eq!(
5565 report.events_trimmed, 0,
5566 "events_trimmed must be 0 in default compact"
5567 );
5568 assert_eq!(
5569 report.invalidated_memories_purged, 0,
5570 "invalidated_memories_purged must be 0 in default compact"
5571 );
5572
5573 let all_mems: Vec<_> = {
5575 let (_p, _c, conn) = load_project(&root).expect("load");
5576 let mut stmt = conn
5577 .prepare("SELECT memory_id FROM memories")
5578 .expect("prepare");
5579 stmt.query_map([], |r| r.get::<_, String>(0))
5580 .expect("query")
5581 .collect::<Result<Vec<_>, _>>()
5582 .expect("collect")
5583 };
5584 assert!(
5585 all_mems.contains(&mid),
5586 "active memory must survive default compact"
5587 );
5588 assert!(
5589 all_mems.contains(&mid2),
5590 "invalidated memory must survive default compact"
5591 );
5592
5593 let event_count_after: i64 = {
5595 let (_p, _c, conn) = load_project(&root).expect("load");
5596 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
5597 .expect("count events")
5598 };
5599 assert_eq!(
5600 event_count_after, event_count_before,
5601 "event count must not change in default compact"
5602 );
5603 });
5604 }
5605
5606 #[test]
5614 fn compact_brain_event_trim_keeps_materialized_memories() {
5615 with_user_brain_disabled(|| {
5616 let root = test_root();
5617 init_project(&root, false).expect("init");
5618
5619 let mid = add_memory(
5620 &root,
5621 MemoryScope::Project,
5622 MemoryKind::Fact,
5623 "this memory must survive event trim",
5624 )
5625 .expect("add memory");
5626
5627 let trim_dur = std::time::Duration::from_secs(0);
5632
5633 std::thread::sleep(std::time::Duration::from_millis(100));
5636
5637 let report = compact_brain(&root, Some(trim_dur), false).expect("compact_brain");
5638
5639 assert!(
5640 report.events_trimmed > 0,
5641 "events_trimmed should be > 0 after trim with duration=0; got {}",
5642 report.events_trimmed
5643 );
5644
5645 let memories = list_memories(&root).expect("list memories after event trim");
5647 let found = memories.iter().any(|m| m.memory_id == mid);
5648 assert!(
5649 found,
5650 "memory must still be in the projection after event trim"
5651 );
5652
5653 assert_eq!(
5655 report.invalidated_memories_purged, 0,
5656 "invalidated_memories_purged must be 0 when purge_invalidated=false"
5657 );
5658 });
5659 }
5660
5661 #[test]
5666 fn compact_brain_event_trim_then_rebuild_is_consistent() {
5667 with_user_brain_disabled(|| {
5668 let root = test_root();
5669 init_project(&root, false).expect("init");
5670
5671 add_memory(
5672 &root,
5673 MemoryScope::Project,
5674 MemoryKind::Fact,
5675 "pre-trim memory for rebuild test",
5676 )
5677 .expect("add memory");
5678
5679 std::thread::sleep(std::time::Duration::from_millis(100));
5681 let report = compact_brain(&root, Some(std::time::Duration::from_secs(0)), false)
5682 .expect("compact_brain");
5683 assert!(report.events_trimmed > 0, "events must have been trimmed");
5684
5685 let replayed =
5687 rebuild_projection(&root, false).expect("rebuild_projection after event trim");
5688 assert_eq!(
5690 replayed, 0,
5691 "replayed should be 0 after all events are trimmed"
5692 );
5693 });
5694 }
5695
5696 #[test]
5703 fn at_root_init_and_round_trip_memory() {
5704 with_user_brain_disabled(|| {
5705 let root = std::env::temp_dir().join(format!("kimetsu-at-root-{}", Ulid::new()));
5711 kimetsu_core::paths::git_init_boundary(&root);
5712
5713 let summary = init_project_at_root(&root, false).expect("init_project_at_root");
5715
5716 assert!(
5717 summary.kimetsu_dir.exists(),
5718 ".kimetsu/ must be created at root"
5719 );
5720 assert!(
5722 summary.kimetsu_dir.starts_with(&root),
5723 ".kimetsu dir {:?} must be inside root {:?}",
5724 summary.kimetsu_dir,
5725 root
5726 );
5727 assert!(summary.brain_db.exists(), "brain.db must exist");
5728 assert!(
5729 root.join(".kimetsu").join("project.toml").exists(),
5730 "project.toml must be at root/.kimetsu/"
5731 );
5732
5733 let (paths, _config, _conn) =
5735 load_project_at_root(&root).expect("load_project_at_root");
5736 assert_eq!(
5737 paths
5738 .repo_root
5739 .canonicalize()
5740 .unwrap_or(paths.repo_root.clone()),
5741 root.canonicalize().unwrap_or(root.clone()),
5742 "repo_root must be our explicit root"
5743 );
5744
5745 let memory_id = add_memory(
5747 &root,
5748 MemoryScope::Project,
5749 MemoryKind::Fact,
5750 "at_root seam test memory",
5751 )
5752 .expect("add_memory");
5753
5754 let memories = list_memories(&root).expect("list_memories");
5757 assert!(
5758 memories.iter().any(|m| m.memory_id == memory_id),
5759 "memory {memory_id} must be present in the at_root brain"
5760 );
5761
5762 let (_, _, ro_conn) =
5764 load_project_readonly_at_root(&root).expect("load_project_readonly_at_root");
5765 let ro_count: i64 = ro_conn
5766 .query_row(
5767 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
5768 rusqlite::params![memory_id],
5769 |row| row.get(0),
5770 )
5771 .expect("count memory ro");
5772 assert_eq!(ro_count, 1, "readonly view must see the same memory");
5773
5774 std::fs::remove_dir_all(&root).ok();
5775 });
5776 }
5777
5778 #[test]
5781 fn at_root_init_is_idempotent() {
5782 with_user_brain_disabled(|| {
5783 let root = std::env::temp_dir().join(format!("kimetsu-at-root-idem-{}", Ulid::new()));
5784 std::fs::create_dir_all(&root).expect("create root");
5785
5786 let s1 = init_project_at_root(&root, false).expect("first init");
5787 assert!(s1.wrote_project_toml, "first init must write project.toml");
5788
5789 let s2 = init_project_at_root(&root, false).expect("second init");
5790 assert!(
5791 !s2.wrote_project_toml,
5792 "second init (force=false) must not overwrite project.toml"
5793 );
5794 assert_eq!(s1.project_id, s2.project_id, "project_id must be stable");
5795
5796 std::fs::remove_dir_all(&root).ok();
5797 });
5798 }
5799
5800 #[test]
5808 fn detect_conflicts_env_off_writes_no_conflict_rows() {
5809 with_user_brain_disabled(|| {
5812 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
5813 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
5814
5815 unsafe {
5819 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
5820 std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
5821 }
5822
5823 let root = test_root();
5824 init_project(&root, false).expect("init");
5825
5826 add_memory(
5831 &root,
5832 MemoryScope::Project,
5833 MemoryKind::Fact,
5834 "use clippy for linting Rust code",
5835 )
5836 .expect("add 1");
5837
5838 unsafe {
5840 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
5841 }
5842 add_memory(
5843 &root,
5844 MemoryScope::Project,
5845 MemoryKind::Fact,
5846 "use clippy for linting all Rust projects",
5847 )
5848 .expect("add 2");
5849
5850 unsafe {
5852 match prev_dc {
5853 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
5854 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
5855 }
5856 match prev_emb {
5857 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
5858 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
5859 }
5860 }
5861 std::fs::remove_dir_all(&root).ok();
5862 });
5863 }
5864
5865 #[test]
5877 fn perf_tier1_structural_invariant_and_timing() {
5878 with_user_brain_disabled(|| {
5881 #[allow(unused_imports)]
5882 use crate::embeddings::StubEmbedder;
5883 use std::time::Instant;
5884
5885 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
5886 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
5887
5888 unsafe {
5891 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
5892 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop"); }
5894
5895 let root = test_root();
5896 init_project(&root, false).expect("init");
5897
5898 const EARLY_SAMPLE: usize = 100;
5899 const TOTAL: usize = 200; for i in 0..EARLY_SAMPLE {
5903 add_memory(
5904 &root,
5905 MemoryScope::Project,
5906 MemoryKind::Fact,
5907 &format!("perf test memory row {i} unique content abcdef"),
5908 )
5909 .expect("add early");
5910 }
5911
5912 let t_early = Instant::now();
5913 add_memory(
5914 &root,
5915 MemoryScope::Project,
5916 MemoryKind::Fact,
5917 &format!("perf sampled early add memory row {EARLY_SAMPLE} unique zxcvbn"),
5918 )
5919 .expect("timed early add");
5920 let early_us = t_early.elapsed().as_micros();
5921
5922 for i in (EARLY_SAMPLE + 1)..TOTAL {
5924 add_memory(
5925 &root,
5926 MemoryScope::Project,
5927 MemoryKind::Fact,
5928 &format!("perf test memory row {i} unique content qwerty"),
5929 )
5930 .expect("add fill");
5931 }
5932
5933 let t_late = Instant::now();
5934 add_memory(
5935 &root,
5936 MemoryScope::Project,
5937 MemoryKind::Fact,
5938 &format!("perf sampled late add memory row {TOTAL} unique rtyfgh"),
5939 )
5940 .expect("timed late add");
5941 let late_us = t_late.elapsed().as_micros();
5942
5943 let (_, _, conn) = load_project(&root).expect("load");
5945 let mem_count: i64 = conn
5946 .query_row(
5947 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
5948 [],
5949 |r| r.get(0),
5950 )
5951 .expect("count memories");
5952 assert!(
5954 mem_count >= TOTAL as i64,
5955 "must have at least {TOTAL} memories, got {mem_count}"
5956 );
5957
5958 if early_us > 0 && late_us > 0 {
5962 assert!(
5963 late_us < early_us * 20,
5964 "late add ({late_us}µs) is > 20× slower than early add ({early_us}µs) — O(N) regression"
5965 );
5966 }
5967
5968 unsafe {
5970 match prev_dc {
5971 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
5972 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
5973 }
5974 match prev_emb {
5975 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
5976 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
5977 }
5978 }
5979 std::fs::remove_dir_all(&root).ok();
5980 });
5981 }
5982
5983 #[cfg(feature = "embeddings")]
5984 #[test]
5985 fn ann_retrieval_round_trips_and_invalidate_drops() {
5986 use crate::user_brain::with_user_brain_disabled;
5987 with_user_brain_disabled(|| {
5988 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
5990 unsafe {
5991 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "stub-d8");
5992 }
5993 let root = test_root();
5994 init_project(&root, false).expect("init");
5995 add_memory(
5996 &root,
5997 MemoryScope::Project,
5998 MemoryKind::Fact,
5999 "ripgrep is the fast recursive search tool",
6000 )
6001 .expect("add a");
6002 let id = add_memory(
6003 &root,
6004 MemoryScope::Project,
6005 MemoryKind::Fact,
6006 "use fd to find files quickly",
6007 )
6008 .expect("add b");
6009
6010 let ctx = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx");
6012 assert!(
6013 format!("{ctx:?}").contains("fd to find files"),
6014 "expected the fd memory in context"
6015 );
6016
6017 invalidate_memory(&root, &id, Some("test")).expect("invalidate");
6019 let ctx2 = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx2");
6020 assert!(
6021 !format!("{ctx2:?}").contains("fd to find files"),
6022 "invalidated memory must not return"
6023 );
6024
6025 unsafe {
6026 match prev_emb {
6027 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
6028 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
6029 }
6030 }
6031 std::fs::remove_dir_all(&root).ok();
6032 });
6033 }
6034
6035 #[test]
6037 fn record_mcp_citation_writes_memory_citations_row() {
6038 with_user_brain_disabled(|| {
6039 let root = test_root();
6040 std::fs::create_dir_all(&root).expect("create root");
6041 init_project(&root, false).expect("init");
6042 let memory_id = add_memory(
6043 &root,
6044 kimetsu_core::memory::MemoryScope::Project,
6045 kimetsu_core::memory::MemoryKind::Fact,
6046 "record_mcp_citation test fixture",
6047 )
6048 .expect("add memory");
6049
6050 record_mcp_citation(&root, &memory_id, Some("helped with test"))
6051 .expect("record_mcp_citation");
6052
6053 let (_paths, _config, conn) = load_project(&root).expect("load");
6054 let row_count: i64 = conn
6055 .query_row(
6056 "SELECT COUNT(*) FROM memory_citations WHERE memory_id = ?1",
6057 rusqlite::params![&memory_id],
6058 |r| r.get(0),
6059 )
6060 .expect("count");
6061 assert_eq!(
6062 row_count, 1,
6063 "memory_citations row must exist after MCP cite"
6064 );
6065 std::fs::remove_dir_all(&root).ok();
6066 });
6067 }
6068
6069 #[test]
6071 fn record_regret_writes_retrieval_regret_event() {
6072 with_user_brain_disabled(|| {
6073 let root = test_root();
6074 std::fs::create_dir_all(&root).expect("create root");
6075 init_project(&root, false).expect("init");
6076 let memory_id = add_memory(
6077 &root,
6078 kimetsu_core::memory::MemoryScope::Project,
6079 kimetsu_core::memory::MemoryKind::Fact,
6080 "record_regret test fixture",
6081 )
6082 .expect("add memory");
6083
6084 record_regret(&root, &memory_id).expect("record_regret");
6085
6086 let (_paths, _config, conn) = load_project(&root).expect("load");
6087 let event_count: i64 = conn
6088 .query_row(
6089 "SELECT COUNT(*) FROM events
6090 WHERE kind = 'retrieval.regret'
6091 AND json_extract(payload_json, '$.memory_id') = ?1",
6092 rusqlite::params![&memory_id],
6093 |r| r.get(0),
6094 )
6095 .expect("count");
6096 assert_eq!(
6097 event_count, 1,
6098 "a retrieval.regret event must exist for the memory after record_regret"
6099 );
6100 std::fs::remove_dir_all(&root).ok();
6101 });
6102 }
6103
6104 #[cfg(test)]
6106 fn read_outcome_stats(root: &std::path::Path, memory_id: &str) -> (i64, f64, f64) {
6107 let (_paths, _config, conn) = load_project(root).expect("load");
6108 conn.query_row(
6109 "SELECT use_count, usefulness_score, confidence FROM memories WHERE memory_id = ?1",
6110 rusqlite::params![memory_id],
6111 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
6112 )
6113 .expect("stats")
6114 }
6115
6116 #[test]
6119 fn standalone_cite_raises_usefulness() {
6120 with_user_brain_disabled(|| {
6121 let root = test_root();
6122 std::fs::create_dir_all(&root).expect("create root");
6123 init_project(&root, false).expect("init");
6124 let memory_id = add_memory(
6125 &root,
6126 kimetsu_core::memory::MemoryScope::Project,
6127 kimetsu_core::memory::MemoryKind::Fact,
6128 "standalone cite outcome fixture",
6129 )
6130 .expect("add memory");
6131
6132 let (uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
6133 record_mcp_citation(&root, &memory_id, None).expect("cite");
6134 let (uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
6135
6136 assert_eq!(uc1, uc0 + 1, "use_count must increment on standalone cite");
6137 assert!(us1 > us0, "usefulness must rise: {us0} -> {us1}");
6138 assert!(
6142 cf1 > cf0,
6143 "confidence must rise toward 1.0 on a positive outcome: {cf0} -> {cf1}"
6144 );
6145 std::fs::remove_dir_all(&root).ok();
6146 });
6147 }
6148
6149 #[test]
6151 fn manual_regret_lowers_usefulness_and_confidence() {
6152 with_user_brain_disabled(|| {
6153 let root = test_root();
6154 std::fs::create_dir_all(&root).expect("create root");
6155 init_project(&root, false).expect("init");
6156 let memory_id = add_memory(
6157 &root,
6158 kimetsu_core::memory::MemoryScope::Project,
6159 kimetsu_core::memory::MemoryKind::Fact,
6160 "manual regret outcome fixture",
6161 )
6162 .expect("add memory");
6163
6164 let (_uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
6165 record_regret(&root, &memory_id).expect("regret");
6166 let (_uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
6167
6168 assert!(us1 < us0, "usefulness must drop on regret: {us0} -> {us1}");
6169 assert!(cf1 < cf0, "confidence must drop on regret: {cf0} -> {cf1}");
6170 std::fs::remove_dir_all(&root).ok();
6171 });
6172 }
6173
6174 #[test]
6177 fn real_run_cite_does_not_bump_in_apply_memory_cited() {
6178 with_user_brain_disabled(|| {
6179 let root = test_root();
6180 std::fs::create_dir_all(&root).expect("create root");
6181 init_project(&root, false).expect("init");
6182 let memory_id = add_memory(
6183 &root,
6184 kimetsu_core::memory::MemoryScope::Project,
6185 kimetsu_core::memory::MemoryKind::Fact,
6186 "real run cite fixture",
6187 )
6188 .expect("add memory");
6189
6190 let (uc0, us0, _cf0) = read_outcome_stats(&root, &memory_id);
6191
6192 let real_run = kimetsu_core::ids::RunId::new();
6194 let event = kimetsu_core::event::Event::new(
6195 real_run,
6196 "memory.cited",
6197 serde_json::json!({ "memory_id": memory_id, "turn": 0 }),
6198 );
6199 {
6200 let (_paths, _config, conn) = load_project(&root).expect("load");
6201 crate::projector::apply_events(&conn, std::slice::from_ref(&event)).expect("apply");
6202 }
6203
6204 let (uc1, us1, _cf1) = read_outcome_stats(&root, &memory_id);
6205 assert_eq!(uc1, uc0, "real-run cite must NOT increment use_count here");
6206 assert!(
6207 (us1 - us0).abs() < 1e-9,
6208 "real-run cite must NOT change usefulness here"
6209 );
6210 std::fs::remove_dir_all(&root).ok();
6211 });
6212 }
6213
6214 #[test]
6217 fn cite_outcome_survives_rebuild() {
6218 with_user_brain_disabled(|| {
6219 let root = test_root();
6220 std::fs::create_dir_all(&root).expect("create root");
6221 init_project(&root, false).expect("init");
6222 let memory_id = add_memory(
6223 &root,
6224 kimetsu_core::memory::MemoryScope::Project,
6225 kimetsu_core::memory::MemoryKind::Fact,
6226 "rebuild outcome fixture",
6227 )
6228 .expect("add memory");
6229 record_mcp_citation(&root, &memory_id, None).expect("cite");
6230
6231 let before = read_outcome_stats(&root, &memory_id);
6232 {
6233 let (_paths, _config, conn) = load_project(&root).expect("load");
6234 crate::projector::rebuild_in_place(&conn).expect("rebuild");
6235 }
6236 let after = read_outcome_stats(&root, &memory_id);
6237 assert_eq!(before.0, after.0, "use_count must survive rebuild");
6238 assert!(
6239 (before.1 - after.1).abs() < 1e-9,
6240 "usefulness must survive rebuild"
6241 );
6242 assert!(
6243 (before.2 - after.2).abs() < 1e-9,
6244 "confidence must survive rebuild"
6245 );
6246 std::fs::remove_dir_all(&root).ok();
6247 });
6248 }
6249
6250 #[test]
6252 fn set_age_backdates_created_at_and_survives_rebuild() {
6253 with_user_brain_disabled(|| {
6254 let root = test_root();
6255 std::fs::create_dir_all(&root).expect("create root");
6256 init_project(&root, false).expect("init");
6257 let memory_id = add_memory(
6258 &root,
6259 kimetsu_core::memory::MemoryScope::Project,
6260 kimetsu_core::memory::MemoryKind::Fact,
6261 "age injection fixture",
6262 )
6263 .expect("add memory");
6264
6265 let read_created = |root: &std::path::Path| -> String {
6266 let (_paths, _config, conn) = load_project(root).expect("load");
6267 conn.query_row(
6268 "SELECT created_at FROM memories WHERE memory_id = ?1",
6269 rusqlite::params![&memory_id],
6270 |r| r.get::<_, String>(0),
6271 )
6272 .expect("created_at")
6273 };
6274
6275 let created0 = read_created(&root);
6276 record_set_age(&root, &memory_id, 90).expect("set-age");
6277 let created1 = read_created(&root);
6278 assert!(
6280 created1 < created0,
6281 "created_at must move into the past: {created0} -> {created1}"
6282 );
6283
6284 {
6285 let (_paths, _config, conn) = load_project(&root).expect("load");
6286 crate::projector::rebuild_in_place(&conn).expect("rebuild");
6287 }
6288 assert_eq!(
6289 read_created(&root),
6290 created1,
6291 "aged created_at survives rebuild"
6292 );
6293 std::fs::remove_dir_all(&root).ok();
6294 });
6295 }
6296
6297 #[test]
6301 fn fix2_search_excludes_superseded_rows() {
6302 with_user_brain_disabled(|| {
6303 let root = test_root();
6304 init_project(&root, false).expect("init");
6305
6306 let superseded_id = add_memory(
6308 &root,
6309 MemoryScope::Project,
6310 MemoryKind::Fact,
6311 "unique superseded keyword alpha",
6312 )
6313 .expect("add superseded");
6314 add_memory(
6315 &root,
6316 MemoryScope::Project,
6317 MemoryKind::Fact,
6318 "live memory unrelated topic",
6319 )
6320 .expect("add live");
6321
6322 {
6325 let (_paths, _config, conn) = load_project(&root).expect("load for stamp");
6326 conn.execute(
6327 "UPDATE memories SET superseded_by = 'fake-survivor' \
6328 WHERE memory_id = ?1",
6329 rusqlite::params![&superseded_id],
6330 )
6331 .expect("stamp superseded_by");
6332 }
6333
6334 let hits = search_memories(&root, "unique superseded keyword alpha", 20, 0, None, None)
6336 .expect("search");
6337 assert!(
6338 !hits.iter().any(|h| h.memory_id == superseded_id),
6339 "superseded row must not appear in search results"
6340 );
6341
6342 std::fs::remove_dir_all(&root).ok();
6343 });
6344 }
6345
6346 #[test]
6351 fn fix4_top_excludes_superseded_rows() {
6352 with_user_brain_disabled(|| {
6353 let root = test_root();
6354 init_project(&root, false).expect("init");
6355
6356 let superseded_id = add_memory(
6358 &root,
6359 MemoryScope::Project,
6360 MemoryKind::Fact,
6361 "memory to be superseded with high usefulness",
6362 )
6363 .expect("add");
6364
6365 {
6367 let (_paths, _config, conn) = load_project(&root).expect("load");
6368 conn.execute(
6369 "UPDATE memories \
6370 SET superseded_by = 'fake-survivor', \
6371 use_count = 10, usefulness_score = 50.0 \
6372 WHERE memory_id = ?1",
6373 rusqlite::params![&superseded_id],
6374 )
6375 .expect("stamp");
6376 }
6377
6378 let live_id = add_memory(
6380 &root,
6381 MemoryScope::Project,
6382 MemoryKind::Fact,
6383 "live memory with normal stats",
6384 )
6385 .expect("add live");
6386 {
6387 let (_paths, _config, conn) = load_project(&root).expect("load");
6388 conn.execute(
6389 "UPDATE memories SET use_count = 5, usefulness_score = 5.0 \
6390 WHERE memory_id = ?1",
6391 rusqlite::params![&live_id],
6392 )
6393 .expect("seed stats");
6394 }
6395
6396 let opts = TopOptions {
6397 scope: None,
6398 min_uses: 1,
6399 limit: 20,
6400 };
6401 let top = list_memories_top(&root, opts).expect("list_memories_top");
6402
6403 assert!(
6404 !top.iter().any(|r| r.memory_id == superseded_id),
6405 "superseded row must not appear in top"
6406 );
6407 assert!(
6408 top.iter().any(|r| r.memory_id == live_id),
6409 "live row must appear in top"
6410 );
6411
6412 std::fs::remove_dir_all(&root).ok();
6413 });
6414 }
6415
6416 #[test]
6417 fn fix4_prune_excludes_superseded_rows() {
6418 with_user_brain_disabled(|| {
6419 let root = test_root();
6420 init_project(&root, false).expect("init");
6421
6422 let superseded_id = add_memory(
6423 &root,
6424 MemoryScope::Project,
6425 MemoryKind::Fact,
6426 "memory to be superseded with low usefulness",
6427 )
6428 .expect("add");
6429
6430 {
6432 let (_paths, _config, conn) = load_project(&root).expect("load");
6433 conn.execute(
6434 "UPDATE memories \
6435 SET superseded_by = 'fake-survivor', \
6436 use_count = 10, usefulness_score = -99.0 \
6437 WHERE memory_id = ?1",
6438 rusqlite::params![&superseded_id],
6439 )
6440 .expect("stamp");
6441 }
6442
6443 let live_id = add_memory(
6445 &root,
6446 MemoryScope::Project,
6447 MemoryKind::Fact,
6448 "live memory with negative usefulness for prune",
6449 )
6450 .expect("add live");
6451 {
6452 let (_paths, _config, conn) = load_project(&root).expect("load");
6453 conn.execute(
6454 "UPDATE memories SET use_count = 5, usefulness_score = -5.0 \
6455 WHERE memory_id = ?1",
6456 rusqlite::params![&live_id],
6457 )
6458 .expect("seed stats");
6459 }
6460
6461 let opts = PruneOptions {
6462 scope: None,
6463 min_uses: 1,
6464 max_ratio: -0.1,
6465 apply: false,
6466 };
6467 let summary = prune_low_usefulness(&root, opts).expect("prune");
6468
6469 assert!(
6470 !summary
6471 .candidates
6472 .iter()
6473 .any(|c| c.memory_id == superseded_id),
6474 "superseded row must not appear in prune candidates"
6475 );
6476 assert!(
6477 summary.candidates.iter().any(|c| c.memory_id == live_id),
6478 "live negative-score row must appear in prune candidates"
6479 );
6480
6481 std::fs::remove_dir_all(&root).ok();
6482 });
6483 }
6484
6485 #[test]
6498 fn add_memories_batch_present_retrievable_rebuild_safe() {
6499 with_user_brain_disabled(|| {
6500 let root = test_root();
6501 fs::create_dir_all(&root).expect("create temp project");
6502 init_project(&root, false).expect("init project");
6503
6504 let entries: Vec<BatchMemoryEntry> = (1..=5)
6506 .map(|i| BatchMemoryEntry {
6507 text: format!(
6508 "batch memory entry number {i} unique text for semantic distance"
6509 ),
6510 scope: kimetsu_core::memory::MemoryScope::Project,
6511 kind: kimetsu_core::memory::MemoryKind::Fact,
6512 valid_from: None,
6513 valid_to: None,
6514 })
6515 .collect();
6516
6517 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
6518
6519 assert_eq!(ids.len(), 5, "expected 5 ids back; got {:?}", ids);
6521 for id in &ids {
6523 assert!(!id.is_empty(), "id must not be empty");
6524 }
6525
6526 let memories = list_memories(&root).expect("list_memories after batch");
6528 assert_eq!(
6529 memories.len(),
6530 5,
6531 "list_memories should return 5; got {:?}",
6532 memories.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
6533 );
6534 let stored_ids: Vec<_> = memories.iter().map(|m| m.memory_id.clone()).collect();
6535 for id in &ids {
6536 assert!(stored_ids.contains(id), "id {id} must be in list_memories");
6537 }
6538
6539 let ref_id = add_memory(
6545 &root,
6546 kimetsu_core::memory::MemoryScope::Project,
6547 kimetsu_core::memory::MemoryKind::Fact,
6548 "single-add reference for embedding consistency check",
6549 )
6550 .expect("single add ref");
6551 {
6552 let (_paths, _config, conn) = load_project(&root).expect("load project");
6553 let ref_model: Option<String> = conn
6554 .query_row(
6555 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
6556 rusqlite::params![ref_id],
6557 |r| r.get(0),
6558 )
6559 .expect("query ref embedding_model");
6560
6561 for id in &ids {
6563 let bm: Option<String> = conn
6564 .query_row(
6565 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
6566 rusqlite::params![id],
6567 |r| r.get(0),
6568 )
6569 .expect("query batch embedding_model");
6570 assert_eq!(
6571 bm, ref_model,
6572 "batch memory {id} embedding_model ({bm:?}) must match single-add ref ({ref_model:?})"
6573 );
6574 }
6575 }
6576
6577 {
6580 let (_paths, _config, conn) = load_project(&root).expect("load for rebuild");
6581 crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
6582 let after_count: i64 = conn
6583 .query_row(
6584 "SELECT COUNT(*) FROM memories \
6585 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
6586 [],
6587 |r| r.get(0),
6588 )
6589 .expect("count after rebuild");
6590 assert_eq!(
6591 after_count, 6,
6592 "all 6 memories (5 batch + 1 single) must survive rebuild_in_place; got {after_count}"
6593 );
6594 let rebuilt_ids: Vec<String> = {
6595 let mut stmt = conn
6596 .prepare(
6597 "SELECT memory_id FROM memories \
6598 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
6599 )
6600 .expect("prepare");
6601 stmt.query_map([], |r| r.get(0))
6602 .expect("query")
6603 .map(|r| r.expect("row"))
6604 .collect()
6605 };
6606 for id in &ids {
6607 assert!(
6608 rebuilt_ids.contains(id),
6609 "id {id} must survive rebuild_in_place"
6610 );
6611 }
6612 }
6613
6614 let temporal_entries = vec![BatchMemoryEntry {
6616 text: "batch temporal test this fact expires soon".to_string(),
6617 scope: kimetsu_core::memory::MemoryScope::Project,
6618 kind: kimetsu_core::memory::MemoryKind::Fact,
6619 valid_from: Some("2025-01-01T00:00:00Z".to_string()),
6620 valid_to: Some("2099-12-31T00:00:00Z".to_string()),
6621 }];
6622 let temporal_ids =
6623 add_memories_batch(&root, temporal_entries).expect("add_memories_batch temporal");
6624 assert_eq!(temporal_ids.len(), 1);
6625 let temporal_id = &temporal_ids[0];
6626 {
6627 let (_paths, _config, conn) = load_project(&root).expect("load for temporal check");
6628 let (vf, vt): (Option<String>, Option<String>) = conn
6629 .query_row(
6630 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
6631 rusqlite::params![temporal_id],
6632 |r| Ok((r.get(0)?, r.get(1)?)),
6633 )
6634 .expect("query valid_from/valid_to");
6635 assert!(
6636 vf.is_some(),
6637 "valid_from must be set for temporal batch entry"
6638 );
6639 assert!(
6640 vt.is_some(),
6641 "valid_to must be set for temporal batch entry"
6642 );
6643 crate::projector::rebuild_in_place(&conn).expect("rebuild temporal");
6645 let (vf2, vt2): (Option<String>, Option<String>) = conn
6646 .query_row(
6647 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
6648 rusqlite::params![temporal_id],
6649 |r| Ok((r.get(0)?, r.get(1)?)),
6650 )
6651 .expect("query after rebuild");
6652 assert_eq!(vf, vf2, "valid_from must survive rebuild");
6653 assert_eq!(vt, vt2, "valid_to must survive rebuild");
6654 }
6655
6656 fs::remove_dir_all(&root).ok();
6657 });
6658 }
6659
6660 #[test]
6663 fn add_memories_batch_deduplicates() {
6664 with_user_brain_disabled(|| {
6665 let root = test_root();
6666 fs::create_dir_all(&root).expect("create temp project");
6667 init_project(&root, false).expect("init project");
6668
6669 let text = "batch dedup test unique entry";
6670 let entries = vec![
6671 BatchMemoryEntry {
6672 text: text.to_string(),
6673 scope: kimetsu_core::memory::MemoryScope::Project,
6674 kind: kimetsu_core::memory::MemoryKind::Fact,
6675 valid_from: None,
6676 valid_to: None,
6677 },
6678 BatchMemoryEntry {
6679 text: text.to_string(),
6680 scope: kimetsu_core::memory::MemoryScope::Project,
6681 kind: kimetsu_core::memory::MemoryKind::Fact,
6682 valid_from: None,
6683 valid_to: None,
6684 },
6685 ];
6686
6687 let ids = add_memories_batch(&root, entries).expect("add_memories_batch dedup");
6688 assert_eq!(ids.len(), 2);
6689 assert_eq!(
6690 ids[0], ids[1],
6691 "duplicate text must return the same memory_id"
6692 );
6693
6694 let memories = list_memories(&root).expect("list");
6696 assert_eq!(
6697 memories.len(),
6698 1,
6699 "deduped batch must produce exactly 1 DB row; got {}",
6700 memories.len()
6701 );
6702
6703 fs::remove_dir_all(&root).ok();
6704 });
6705 }
6706
6707 #[test]
6717 fn add_memories_batch_all_entries_same_embedding_model() {
6718 with_user_brain_disabled(|| {
6719 let root = test_root();
6720 fs::create_dir_all(&root).expect("create temp project");
6721 init_project(&root, false).expect("init project");
6722
6723 let n = 8_usize;
6724 let entries: Vec<BatchMemoryEntry> = (0..n)
6725 .map(|i| BatchMemoryEntry {
6726 text: format!(
6727 "embedding model consistency test memory {i} distinct content here"
6728 ),
6729 scope: kimetsu_core::memory::MemoryScope::Project,
6730 kind: kimetsu_core::memory::MemoryKind::Convention,
6731 valid_from: None,
6732 valid_to: None,
6733 })
6734 .collect();
6735
6736 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
6737 assert_eq!(ids.len(), n);
6738
6739 let (_paths, _config, conn) = load_project(&root).expect("load project");
6740 let model_id_rows: Vec<Option<String>> = {
6741 let mut stmt = conn
6742 .prepare("SELECT embedding_model FROM memories ORDER BY created_at")
6743 .expect("prepare");
6744 stmt.query_map([], |r| r.get(0))
6745 .expect("query")
6746 .map(|r| r.expect("row"))
6747 .collect()
6748 };
6749 assert_eq!(model_id_rows.len(), n, "expected {n} rows");
6750 let first = &model_id_rows[0];
6752 for (i, model_id) in model_id_rows.iter().enumerate() {
6753 assert_eq!(
6754 model_id, first,
6755 "memory {i} embedding_model ({model_id:?}) must match first ({first:?})"
6756 );
6757 }
6758
6759 fs::remove_dir_all(&root).ok();
6760 });
6761 }
6762}