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::trace::{self, TraceWriter};
24use crate::user_brain;
25
26fn max_corpus_cosine(conn: &Connection, query_vec: &[f32]) -> f32 {
33 let mut stmt = match conn.prepare(
34 "SELECT embedding FROM memories
35 WHERE invalidated_at IS NULL
36 AND superseded_by IS NULL
37 AND embedding IS NOT NULL
38 ORDER BY created_at DESC
39 LIMIT 200",
40 ) {
41 Ok(s) => s,
42 Err(_) => return 0.0,
43 };
44 let rows = match stmt.query_map([], |row| row.get::<_, Vec<u8>>(0)) {
45 Ok(r) => r,
46 Err(_) => return 0.0,
47 };
48 let mut max_cos: f32 = 0.0;
49 for row in rows.flatten() {
50 if let Ok(vec) = embeddings::decode_embedding(&row, None) {
51 if vec.len() == query_vec.len() {
52 let cos = cosine_sim(query_vec, &vec);
53 if cos > max_cos {
54 max_cos = cos;
55 }
56 }
57 }
58 }
59 max_cos
60}
61
62fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
63 if a.len() != b.len() || a.is_empty() {
64 return 0.0;
65 }
66 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
67 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
68 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
69 if na < f32::EPSILON || nb < f32::EPSILON {
70 return 0.0;
71 }
72 (dot / (na * nb)).clamp(-1.0, 1.0)
73}
74
75#[derive(Debug, Clone)]
76pub struct InitSummary {
77 pub project_id: String,
78 pub repo_root: PathBuf,
79 pub kimetsu_dir: PathBuf,
80 pub brain_db: PathBuf,
81 pub model: String,
82 pub api_key_env: String,
83 pub api_key_present: bool,
84 pub wrote_project_toml: bool,
85}
86
87#[derive(Debug, Clone)]
88pub struct RunSummary {
89 pub run_id: String,
90 pub task: String,
91 pub started_at: String,
92 pub terminal_kind: Option<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct MemoryRow {
97 pub memory_id: String,
98 pub scope: String,
99 pub kind: String,
100 pub text: String,
101 pub confidence: f32,
102 pub use_count: u32,
103 pub usefulness_score: f32,
108}
109
110#[derive(Debug, Clone)]
114pub struct MemorySearchHit {
115 pub memory_id: String,
116 pub scope: String,
117 pub kind: String,
118 pub text: String,
119 pub rank: f32,
120}
121
122#[derive(Debug, Clone)]
123pub struct ProposalRow {
124 pub proposal_id: String,
125 pub run_id: String,
126 pub scope: String,
127 pub kind: String,
128 pub text: String,
129 pub rationale: String,
130 pub proposed_confidence: f32,
131 pub status: String,
132 pub decided_reason: Option<String>,
133}
134
135#[derive(Debug, Clone, Default)]
136pub struct ProposalFilter {
137 pub scope: Option<String>,
138 pub kind: Option<String>,
139 pub from_run: Option<String>,
140 pub min_confidence: Option<f32>,
141 pub status: Option<String>,
142 pub limit: u32,
143 pub offset: u32,
146}
147
148#[derive(Debug, Clone, Default)]
149pub struct AcceptOverrides {
150 pub scope: Option<String>,
151 pub confidence: Option<f32>,
152}
153
154#[derive(Debug, Clone)]
155pub struct RecordedBenchmarkOutcome {
156 pub memory_id: String,
157 pub task_slug: Option<String>,
158 pub kind: MemoryKind,
159 pub text: String,
160 pub proposal_id: Option<String>,
161 pub proposal_text: Option<String>,
162}
163
164#[derive(Debug, Clone, serde::Serialize)]
169pub struct BlameReport {
170 pub run_id: String,
171 pub outcome: String,
175 pub failure_category: Option<String>,
178 pub cited: Vec<CitedMemory>,
181 pub silent_passengers: Vec<SilentMemory>,
184}
185
186#[derive(Debug, Clone, serde::Serialize)]
187pub struct CitedMemory {
188 pub memory_id: String,
189 pub turn: i64,
190 pub rationale: Option<String>,
191 pub cited_at: String,
192 pub text_preview: String,
194 pub scope: String,
195 pub kind: String,
196}
197
198#[derive(Debug, Clone, serde::Serialize)]
199pub struct SilentMemory {
200 pub memory_id: String,
201 pub text_preview: String,
202 pub scope: String,
203 pub kind: String,
204}
205
206pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
207 let paths = ProjectPaths::discover(start)?;
208 paths.validate_state_dir()?;
209 fs::create_dir_all(&paths.kimetsu_dir)?;
214
215 let project_id = default_project_id(&paths.repo_root);
216 let config = ProjectConfig::default_for_project(project_id);
217 let wrote_project_toml = if force || !paths.project_toml.exists() {
218 fs::write(&paths.project_toml, config.to_toml()?)?;
219 true
220 } else {
221 false
222 };
223
224 let config = load_config(&paths)?;
225 let conn = Connection::open(&paths.brain_db)?;
226 schema::initialize(&conn)?;
227
228 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
229
230 Ok(InitSummary {
231 project_id: config.kimetsu.project_id,
232 repo_root: paths.repo_root,
233 kimetsu_dir: paths.kimetsu_dir,
234 brain_db: paths.brain_db,
235 model: format!("{}/{}", config.model.provider, config.model.model),
236 api_key_env: config.model.api_key_env,
237 api_key_present,
238 wrote_project_toml,
239 })
240}
241
242pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
243 let paths = ProjectPaths::discover(start)?;
244 paths.validate_state_dir()?;
245 let config = load_config(&paths)?;
246 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
247 return Err(format!(
248 "project.toml schema version {} does not match expected {}",
249 config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
250 )
251 .into());
252 }
253
254 let conn = Connection::open(&paths.brain_db)?;
255 schema::initialize(&conn)?;
256 Ok((paths, config, conn))
257}
258
259pub fn init_project_at_root(root: &Path, force: bool) -> KimetsuResult<InitSummary> {
264 let paths = ProjectPaths::at_root(root);
265 paths.validate_state_dir()?;
266 fs::create_dir_all(&paths.kimetsu_dir)?;
267
268 let project_id = default_project_id(&paths.repo_root);
269 let config = ProjectConfig::default_for_project(project_id);
270 let wrote_project_toml = if force || !paths.project_toml.exists() {
271 fs::write(&paths.project_toml, config.to_toml()?)?;
272 true
273 } else {
274 false
275 };
276
277 let config = load_config(&paths)?;
278 let conn = Connection::open(&paths.brain_db)?;
279 schema::initialize(&conn)?;
280
281 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
282
283 Ok(InitSummary {
284 project_id: config.kimetsu.project_id,
285 repo_root: paths.repo_root,
286 kimetsu_dir: paths.kimetsu_dir,
287 brain_db: paths.brain_db,
288 model: format!("{}/{}", config.model.provider, config.model.model),
289 api_key_env: config.model.api_key_env,
290 api_key_present,
291 wrote_project_toml,
292 })
293}
294
295pub fn load_project_at_root(
298 root: &Path,
299) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
300 let paths = ProjectPaths::at_root(root);
301 paths.validate_state_dir()?;
302 let config = load_config(&paths)?;
303 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
304 return Err(format!(
305 "project.toml schema version {} does not match expected {}",
306 config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
307 )
308 .into());
309 }
310
311 let conn = Connection::open(&paths.brain_db)?;
312 schema::initialize(&conn)?;
313 Ok((paths, config, conn))
314}
315
316pub fn load_project_readonly_at_root(
319 root: &Path,
320) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
321 let paths = ProjectPaths::at_root(root);
322 paths.validate_state_dir()?;
323 let config = load_config(&paths)?;
324 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
325 return Err(format!(
326 "project.toml schema version {} does not match expected {}",
327 config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
328 )
329 .into());
330 }
331
332 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
333 schema::validate(&conn)?;
334 Ok((paths, config, conn))
335}
336
337pub fn schema_version(start: &Path) -> KimetsuResult<i64> {
342 let (_, _, conn) = load_project(start)?;
343 crate::migrate::current_version(&conn)
344}
345
346pub fn load_project_readonly(
347 start: &Path,
348) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
349 let paths = ProjectPaths::discover(start)?;
350 paths.validate_state_dir()?;
351 let config = load_config(&paths)?;
352 if config.kimetsu.schema_version != KIMETSU_CONFIG_VERSION {
353 return Err(format!(
354 "project.toml schema version {} does not match expected {}",
355 config.kimetsu.schema_version, KIMETSU_CONFIG_VERSION
356 )
357 .into());
358 }
359
360 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
361 schema::validate(&conn)?;
362 Ok((paths, config, conn))
363}
364
365pub struct BrainSession {
366 paths: ProjectPaths,
367 config: ProjectConfig,
368 conn: Connection,
369 user_conn: Option<Connection>,
377 repo_root: String,
378}
379
380impl BrainSession {
381 pub fn open(start: &Path) -> KimetsuResult<Self> {
382 let (paths, config, conn) = load_project(start)?;
383 let user_conn = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?;
388 Self::from_parts(paths, config, conn, user_conn)
389 }
390
391 pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
392 let (paths, config, conn) = load_project_readonly(start)?;
393 let user_conn =
398 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
399 Self::from_parts(paths, config, conn, user_conn)
400 }
401
402 fn from_parts(
403 paths: ProjectPaths,
404 config: ProjectConfig,
405 conn: Connection,
406 user_conn: Option<Connection>,
407 ) -> KimetsuResult<Self> {
408 let repo_root = paths
409 .repo_root
410 .canonicalize()?
411 .to_string_lossy()
412 .to_string();
413 Ok(Self {
414 paths,
415 config,
416 conn,
417 user_conn,
418 repo_root,
419 })
420 }
421
422 pub fn retrieve_context(
423 &self,
424 stage: &str,
425 query: &str,
426 budget_tokens: u32,
427 ) -> KimetsuResult<ContextBundle> {
428 self.retrieve_context_with_request(ContextRequest {
429 stage: stage.to_string(),
430 query: query.to_string(),
431 budget_tokens,
432 ..Default::default()
433 })
434 }
435
436 pub fn retrieve_context_with_request(
444 &self,
445 mut request: ContextRequest,
446 ) -> KimetsuResult<ContextBundle> {
447 if request.min_lexical_coverage == 0.0 {
450 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
451 }
452 if request.min_semantic_score == 0.0 {
453 request.min_semantic_score = self.resolved_min_semantic_score();
454 }
455 if request.min_score == 0.0 {
459 request.min_score = self.config.broker.abstain_min_score;
460 }
461 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
462 let backend = crate::backend::backend_for(&self.config.storage.backend);
463 context::retrieve_context_with_embedder_and_backend(
464 &self.conn,
465 &self.repo_root,
466 &self.config.broker.weights,
467 request,
468 &extras,
469 embeddings::open_embedder_for(self.config.embedder.enabled),
470 backend.as_ref(),
471 )
472 }
473
474 fn resolved_min_semantic_score(&self) -> f32 {
483 let configured = self.config.broker.min_semantic_score;
484 if configured >= 0.0 {
485 return configured;
486 }
487 let model = embeddings::resolve_embedder_id(Some(self.config.embedder.model.as_str()));
488 if model.starts_with("bge") { 0.35 } else { 0.0 }
489 }
490
491 pub fn retrieve_proactive(&self, mut request: ContextRequest) -> KimetsuResult<ContextBundle> {
498 if request.min_lexical_coverage == 0.0 {
503 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
504 }
505 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
506 let backend = crate::backend::backend_for(&self.config.storage.backend);
507 context::retrieve_context_with_embedder_and_backend(
508 &self.conn,
509 &self.repo_root,
510 &self.config.broker.weights,
511 request,
512 &extras,
513 &embeddings::NoopEmbedder,
514 backend.as_ref(),
515 )
516 }
517
518 pub fn retrieve_context_lexical(
527 &self,
528 mut request: ContextRequest,
529 ) -> KimetsuResult<ContextBundle> {
530 if request.min_lexical_coverage == 0.0 {
535 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
536 }
537 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
538 let backend = crate::backend::backend_for(&self.config.storage.backend);
539 context::retrieve_context_with_embedder_and_backend(
540 &self.conn,
541 &self.repo_root,
542 &self.config.broker.weights,
543 request,
544 &extras,
545 &embeddings::NoopEmbedder,
546 backend.as_ref(),
547 )
548 }
549
550 pub fn retrieve_context_with_injected_embedder(
556 &self,
557 mut request: ContextRequest,
558 embedder: &dyn embeddings::Embedder,
559 ) -> KimetsuResult<ContextBundle> {
560 if request.min_lexical_coverage == 0.0 {
561 request.min_lexical_coverage = self.config.broker.min_lexical_coverage;
562 }
563 if request.min_semantic_score == 0.0 {
566 request.min_semantic_score = self.resolved_min_semantic_score();
567 }
568 if request.min_score == 0.0 {
572 request.min_score = self.config.broker.abstain_min_score;
573 }
574 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
575 let backend = crate::backend::backend_for(&self.config.storage.backend);
576 context::retrieve_context_with_embedder_and_backend(
577 &self.conn,
578 &self.repo_root,
579 &self.config.broker.weights,
580 request,
581 &extras,
582 embedder,
583 backend.as_ref(),
584 )
585 }
586
587 pub fn repo_root(&self) -> &Path {
588 &self.paths.repo_root
589 }
590
591 pub fn user_conn(&self) -> Option<&Connection> {
596 self.user_conn.as_ref()
597 }
598}
599
600pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
601 let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
602 format!(
603 "failed to read {}; run `kimetsu init` first: {err}",
604 paths.project_toml.display()
605 )
606 })?;
607 let mut config = ProjectConfig::from_toml(&content)?;
608 config.apply_retrieval_level();
614 Ok(config)
615}
616
617pub fn load_config_from_text(toml: &str) -> KimetsuResult<ProjectConfig> {
620 ProjectConfig::from_toml(toml)
621}
622
623pub fn config_text(start: &Path) -> KimetsuResult<String> {
624 let paths = ProjectPaths::discover(start)?;
625 Ok(fs::read_to_string(paths.project_toml)?)
626}
627
628pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
629 let (_paths, _config, conn) = load_project(start)?;
630 let mut stmt = conn.prepare(
631 "
632 SELECT run_id, task, started_at, terminal_kind
633 FROM runs
634 ORDER BY started_at DESC
635 LIMIT 100
636 ",
637 )?;
638
639 let rows = stmt.query_map([], |row| {
640 Ok(RunSummary {
641 run_id: row.get(0)?,
642 task: row.get(1)?,
643 started_at: row.get(2)?,
644 terminal_kind: row.get(3)?,
645 })
646 })?;
647
648 let mut runs = Vec::new();
649 for row in rows {
650 runs.push(row?);
651 }
652 Ok(runs)
653}
654
655pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
656 let (_paths, _config, conn) = load_project(start)?;
657 let mut stmt = conn.prepare(
658 "
659 SELECT run_id, task, started_at, terminal_kind
660 FROM runs
661 WHERE run_id = ?1
662 ",
663 )?;
664
665 let mut rows = stmt.query(params![run_id])?;
666 if let Some(row) = rows.next()? {
667 Ok(Some(RunSummary {
668 run_id: row.get(0)?,
669 task: row.get(1)?,
670 started_at: row.get(2)?,
671 terminal_kind: row.get(3)?,
672 }))
673 } else {
674 Ok(None)
675 }
676}
677
678#[derive(Debug, Clone)]
683pub struct BatchMemoryEntry {
684 pub text: String,
686 pub scope: MemoryScope,
688 pub kind: MemoryKind,
690 pub valid_from: Option<String>,
693 pub valid_to: Option<String>,
696}
697
698const DIRECT_ADD_CONFIDENCE: f32 = 0.85;
709
710pub fn add_memory(
711 start: &Path,
712 scope: MemoryScope,
713 kind: MemoryKind,
714 text: &str,
715) -> KimetsuResult<String> {
716 let redaction = redact::redact_secrets(text);
729 if redaction.was_redacted() {
730 eprintln!("kimetsu-brain: {}", redaction.summary());
731 }
732 let text = redaction.text.as_str();
733
734 if scope == MemoryScope::GlobalUser {
752 let use_user_brain = ProjectPaths::discover(start)
753 .ok()
754 .and_then(|paths| load_config(&paths).ok())
755 .map(|cfg| cfg.kimetsu.use_user_brain)
756 .unwrap_or(true);
757 if let Some(user_conn) = user_brain::open_user_brain_for_config(use_user_brain)? {
758 return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
759 }
760 }
764
765 let (paths, config, conn) = load_project(start)?;
766 let run_id = RunId::new();
767 let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
768
769 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
770 add_memory_inner(
771 &conn, &paths, &config, scope, kind, text, None, None, embedder,
772 )
773}
774
775#[allow(clippy::too_many_arguments)]
784fn add_memory_inner(
785 conn: &Connection,
786 paths: &ProjectPaths,
787 config: &kimetsu_core::config::ProjectConfig,
788 scope: MemoryScope,
789 kind: MemoryKind,
790 text: &str,
791 valid_from: Option<&str>,
792 valid_to: Option<&str>,
793 embedder: &dyn embeddings::Embedder,
794) -> KimetsuResult<String> {
795 let run_id = RunId::new();
796 let memory_id = Ulid::new().to_string();
797 let normalized = normalize_memory_text(text);
798
799 let existing: Option<String> = conn
805 .query_row(
806 "
807 SELECT memory_id FROM memories
808 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
809 AND invalidated_at IS NULL
810 AND superseded_by IS NULL
811 LIMIT 1
812 ",
813 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
814 |row| row.get::<_, String>(0),
815 )
816 .optional()?;
817 if let Some(existing_id) = existing {
818 return Ok(existing_id);
819 }
820
821 let importance_enabled = config.ingestion.initial_importance_scoring;
827 let initial_kind_weight = if importance_enabled {
828 match &kind {
829 MemoryKind::FailurePattern => 0.3_f32,
830 MemoryKind::Command => 0.2,
831 MemoryKind::Convention => 0.15,
832 MemoryKind::Fact => 0.1,
833 MemoryKind::Preference => 0.05,
834 }
835 } else {
836 0.0
837 };
838
839 let started = Event::new(
840 run_id,
841 "run.started",
842 serde_json::json!({
843 "mode": "admin",
844 "task": "memory add",
845 "project_id": config.kimetsu.project_id,
846 "repo_root": paths.repo_root.to_string_lossy(),
847 "model": null,
848 "platform": std::env::consts::OS,
849 "kimetsu_version": env!("CARGO_PKG_VERSION"),
850 "config_hash": config_hash(&paths.project_toml)?,
851 }),
852 );
853 let accepted = Event::new(
854 run_id,
855 "memory.accepted",
856 serde_json::json!({
857 "proposal_id": null,
858 "memory_id": memory_id,
859 "scope": scope.to_string(),
860 "kind": kind.to_string(),
861 "text": text,
862 "normalized_text": normalized,
863 "confidence": DIRECT_ADD_CONFIDENCE,
864 "initial_usefulness": initial_kind_weight,
865 "provenance_snapshot": build_provenance(run_id, text),
866 }),
867 );
868
869 let finished = Event::new(
870 run_id,
871 "run.finished",
872 serde_json::json!({
873 "status": "success",
874 "final_report_path": null,
875 "total_cost_usd": 0.0,
876 "total_tool_calls": 0,
877 }),
878 );
879
880 projector::apply_events(conn, &[started, accepted, finished])?;
881
882 if valid_from.is_some() || valid_to.is_some() {
885 projector::mark_memory_temporal(conn, &memory_id, valid_from, valid_to)?;
886 }
887
888 let embedding_vec = embeddings::embed_and_persist(conn, &memory_id, text, embedder)?;
901
902 if importance_enabled && !embedder.is_noop() {
909 if let Some(vec) = embedding_vec.as_deref() {
910 let rarity_bonus = {
911 let max_cos = max_corpus_cosine(conn, vec);
912 if max_cos < 0.5 { 0.1_f32 } else { 0.0 }
913 };
914 if rarity_bonus > 0.0 {
915 let full_score = (initial_kind_weight + rarity_bonus).min(0.5);
916 conn.execute(
917 "UPDATE memories SET usefulness_score = ?2 WHERE memory_id = ?1",
918 rusqlite::params![memory_id, full_score],
919 )
920 .ok(); }
922 }
923 }
924
925 if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
940 let new_created_at: String = conn
944 .query_row(
945 "SELECT created_at FROM memories WHERE memory_id = ?1",
946 rusqlite::params![memory_id],
947 |row| row.get(0),
948 )
949 .unwrap_or_else(|_| {
950 time::OffsetDateTime::now_utc()
952 .format(&time::format_description::well_known::Rfc3339)
953 .unwrap_or_default()
954 });
955
956 if conflict::resolve_conflicts_enabled(config.ingestion.resolve_conflicts) {
957 let (auto_resolved, queued) = conflict::detect_record_and_resolve_with_vec(
959 conn,
960 &memory_id,
961 &scope,
962 &kind.to_string(),
963 text,
964 embedding_vec.as_deref(),
965 embedder,
966 DIRECT_ADD_CONFIDENCE, &new_created_at,
968 );
969 if auto_resolved > 0 {
970 eprintln!(
971 "kimetsu-brain: memory {memory_id} auto-resolved {auto_resolved} contradiction{} (loser valid_to stamped)",
972 if auto_resolved == 1 { "" } else { "s" }
973 );
974 }
975 if queued > 0 {
976 eprintln!(
977 "kimetsu-brain: memory {memory_id} has {queued} near-tie conflict{} queued for review (run `kimetsu brain memory conflicts`)",
978 if queued == 1 { "" } else { "s" }
979 );
980 }
981 } else {
982 let conflicts = conflict::detect_and_record_with_vec(
984 conn,
985 &memory_id,
986 &scope,
987 &kind.to_string(),
988 text,
989 embedding_vec.as_deref(),
990 embedder,
991 );
992 if conflicts > 0 {
993 eprintln!(
994 "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
995 if conflicts == 1 { "y" } else { "ies" }
996 );
997 }
998 }
999 }
1000
1001 Ok(memory_id)
1002}
1003
1004pub fn add_memories_batch(
1024 start: &Path,
1025 entries: Vec<BatchMemoryEntry>,
1026) -> KimetsuResult<Vec<String>> {
1027 if entries.is_empty() {
1028 return Ok(vec![]);
1029 }
1030
1031 let use_user_brain = ProjectPaths::discover(start)
1034 .ok()
1035 .and_then(|paths| load_config(&paths).ok())
1036 .map(|cfg| cfg.kimetsu.use_user_brain)
1037 .unwrap_or(true);
1038
1039 let user_conn_opt = user_brain::open_user_brain_for_config(use_user_brain)?;
1041
1042 let has_project_entries = entries.iter().any(|e| e.scope != MemoryScope::GlobalUser);
1045
1046 let project_state: Option<(
1048 ProjectPaths,
1049 kimetsu_core::config::ProjectConfig,
1050 Connection,
1051 )> = if has_project_entries {
1052 let state = load_project(start)?;
1053 Some(state)
1054 } else {
1055 None
1056 };
1057
1058 let run_id_for_lock = RunId::new();
1060 let _lock = if let Some((ref paths, _, _)) = project_state {
1061 Some(ProjectLock::acquire(
1062 paths,
1063 "brain memory add-batch",
1064 Some(run_id_for_lock),
1065 )?)
1066 } else {
1067 None
1068 };
1069
1070 let embedder: &dyn embeddings::Embedder = if let Some((_, ref config, _)) = project_state {
1073 embeddings::open_embedder_for(config.embedder.enabled)
1074 } else {
1075 &embeddings::NoopEmbedder
1076 };
1077
1078 let mut ids = Vec::with_capacity(entries.len());
1079
1080 for entry in entries {
1081 let redaction = redact::redact_secrets(&entry.text);
1083 if redaction.was_redacted() {
1084 eprintln!("kimetsu-brain: {}", redaction.summary());
1085 }
1086 let text = redaction.text.as_str();
1087
1088 if entry.scope == MemoryScope::GlobalUser {
1089 if let Some(ref uc) = user_conn_opt {
1092 let id = user_brain::add_user_memory(uc, entry.kind, text, 1.0)?;
1093 ids.push(id);
1094 continue;
1095 }
1096 }
1098
1099 let (paths, config, conn) = project_state
1100 .as_ref()
1101 .expect("project must be open when non-GlobalUser entries are present");
1102
1103 let id = add_memory_inner(
1104 conn,
1105 paths,
1106 config,
1107 entry.scope,
1108 entry.kind,
1109 text,
1110 entry.valid_from.as_deref(),
1111 entry.valid_to.as_deref(),
1112 embedder,
1113 )?;
1114 ids.push(id);
1115 }
1116
1117 Ok(ids)
1118}
1119
1120pub fn propose_memory(
1125 start: &Path,
1126 scope: MemoryScope,
1127 kind: MemoryKind,
1128 text: &str,
1129 confidence: f32,
1130 rationale: &str,
1131) -> KimetsuResult<String> {
1132 let redaction = redact::redact_secrets(text);
1133 if redaction.was_redacted() {
1134 eprintln!("kimetsu-brain: {}", redaction.summary());
1135 }
1136 let text = redaction.text.as_str();
1137 let rationale_redaction = redact::redact_secrets(rationale);
1138 if rationale_redaction.was_redacted() {
1139 eprintln!("kimetsu-brain: {}", rationale_redaction.summary());
1140 }
1141 let rationale = rationale_redaction.text.as_str();
1142 let (paths, config, conn) = load_project(start)?;
1143 let run_id = RunId::new();
1144 let _lock = ProjectLock::acquire(&paths, "memory propose", Some(run_id))?;
1145 let proposal_id = Ulid::new().to_string();
1146
1147 let started = admin_started_event(&paths, &config, run_id, "memory propose")?;
1148
1149 let proposed = Event::new(
1150 run_id,
1151 "memory.proposed",
1152 serde_json::json!({
1153 "proposal_id": proposal_id,
1154 "scope": scope.to_string(),
1155 "kind": kind.to_string(),
1156 "text": text,
1157 "rationale": rationale,
1158 "proposed_confidence": confidence.clamp(0.0, 1.0),
1159 "source_event_ids": [],
1160 }),
1161 );
1162
1163 let finished = admin_finished_event(run_id);
1164
1165 projector::apply_events(&conn, &[started, proposed, finished])?;
1166 Ok(proposal_id)
1167}
1168
1169#[derive(Debug)]
1171pub enum ProposeResult {
1172 Added(String), Proposed(String), Merged(String), Duplicate(String), }
1177
1178pub fn propose_or_merge_memory(
1189 start: &Path,
1190 scope: MemoryScope,
1191 kind: MemoryKind,
1192 text: &str,
1193 confidence: f32,
1194 rationale: &str,
1195) -> KimetsuResult<ProposeResult> {
1196 let redaction = redact::redact_secrets(text);
1197 if redaction.was_redacted() {
1198 eprintln!("kimetsu-brain: {}", redaction.summary());
1199 }
1200 let text = redaction.text.as_str();
1201
1202 let (_, config, _) = {
1205 let (paths, config, ro_conn) = load_project_readonly(start)?;
1206 let normalized = normalize_memory_text(text);
1207 let existing: Option<String> = ro_conn
1208 .query_row(
1209 "SELECT memory_id FROM memories
1210 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
1211 AND invalidated_at IS NULL
1212 AND superseded_by IS NULL
1213 LIMIT 1",
1214 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
1215 |row| row.get::<_, String>(0),
1216 )
1217 .optional()?;
1218 if let Some(id) = existing {
1219 return Ok(ProposeResult::Duplicate(id));
1220 }
1221 (paths, config, ro_conn)
1222 };
1223
1224 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
1230 {
1231 let (_, _, ro_conn) = load_project_readonly(start)?;
1232 let conflicts = if conflict::conflict_detection_enabled(config.ingestion.detect_conflicts) {
1233 conflict::find_potential_conflicts(&ro_conn, &scope, text, embedder, 1, 0.85)?
1234 } else {
1235 Vec::new()
1236 };
1237 if let Some(hit) = conflicts.into_iter().next() {
1238 let (paths, _config, conn) = load_project(start)?;
1240 let run_id = RunId::new();
1241 let _lock = ProjectLock::acquire(&paths, "memory merge", Some(run_id))?;
1242 let merged_text = format!("{}\n\nAlso: {text}", hit.existing_text);
1243 let new_normalized = normalize_memory_text(&merged_text);
1244 conn.execute(
1245 "UPDATE memories
1246 SET text = ?1, normalized_text = ?2, use_count = use_count + 1
1247 WHERE memory_id = ?3",
1248 rusqlite::params![merged_text, new_normalized, hit.existing_memory_id],
1249 )?;
1250 embeddings::embed_and_persist(&conn, &hit.existing_memory_id, &merged_text, embedder)?;
1252 return Ok(ProposeResult::Merged(hit.existing_memory_id));
1253 }
1254 }
1255
1256 if confidence >= 0.7 {
1258 let memory_id = add_memory(start, scope, kind, text)?;
1259 Ok(ProposeResult::Added(memory_id))
1260 } else {
1261 let proposal_id = propose_memory(start, scope, kind, text, confidence, rationale)?;
1262 Ok(ProposeResult::Proposed(proposal_id))
1263 }
1264}
1265
1266#[derive(Debug, Clone)]
1270pub struct ListOptions {
1271 pub limit: u32,
1273 pub offset: u32,
1275 pub scope: Option<String>,
1277}
1278
1279impl Default for ListOptions {
1280 fn default() -> Self {
1281 Self {
1282 limit: 100,
1283 offset: 0,
1284 scope: None,
1285 }
1286 }
1287}
1288
1289pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
1290 list_memories_with(start, ListOptions::default())
1291}
1292
1293pub fn list_memories_with(start: &Path, opts: ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1298 let (_paths, config, conn) = load_project(start)?;
1299 let mut memories = list_memories_from_conn(&conn, &opts)?;
1300 if opts.offset == 0
1302 && let Some(user_conn) =
1303 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1304 {
1305 memories.extend(user_brain::list_user_memories(&user_conn)?);
1306 }
1307 Ok(memories)
1308}
1309
1310pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
1320 let (_paths, config, conn) = load_project(start)?;
1321 let user_conn = user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?;
1323
1324 let (outcome, failure_category) = run_outcome(&conn, run_id)?;
1326
1327 let cited_rows: Vec<(String, i64, Option<String>, String)> = {
1329 let mut stmt = conn.prepare(
1330 "
1331 SELECT memory_id, turn, rationale, cited_at
1332 FROM memory_citations
1333 WHERE run_id = ?1
1334 ORDER BY turn ASC, cited_at ASC
1335 ",
1336 )?;
1337 let rows = stmt.query_map(rusqlite::params![run_id], |row| {
1338 Ok((
1339 row.get::<_, String>(0)?,
1340 row.get::<_, i64>(1)?,
1341 row.get::<_, Option<String>>(2)?,
1342 row.get::<_, String>(3)?,
1343 ))
1344 })?;
1345 let mut out = Vec::new();
1346 for row in rows {
1347 out.push(row?);
1348 }
1349 out
1350 };
1351
1352 let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
1353 let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1354 for (memory_id, turn, rationale, cited_at) in cited_rows {
1355 cited_set.insert(memory_id.clone());
1356 let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
1357 cited.push(CitedMemory {
1358 memory_id,
1359 turn,
1360 rationale,
1361 cited_at,
1362 text_preview: text_preview(&text, 120),
1363 scope,
1364 kind,
1365 });
1366 }
1367
1368 let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
1370 let mut silent: Vec<SilentMemory> = Vec::new();
1371 for memory_id in retrieved_ids {
1372 if cited_set.contains(&memory_id) {
1373 continue;
1374 }
1375 let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
1376 silent.push(SilentMemory {
1377 memory_id,
1378 text_preview: text_preview(&text, 120),
1379 scope,
1380 kind,
1381 });
1382 }
1383
1384 Ok(BlameReport {
1385 run_id: run_id.to_string(),
1386 outcome,
1387 failure_category,
1388 cited,
1389 silent_passengers: silent,
1390 })
1391}
1392
1393fn run_outcome(conn: &Connection, run_id: &str) -> KimetsuResult<(String, Option<String>)> {
1394 let row: Option<(String, String)> = conn
1396 .query_row(
1397 "
1398 SELECT kind, payload_json
1399 FROM events
1400 WHERE run_id = ?1
1401 AND kind IN ('run.finished', 'run.failed', 'run.aborted')
1402 ORDER BY ts DESC
1403 LIMIT 1
1404 ",
1405 rusqlite::params![run_id],
1406 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
1407 )
1408 .optional()?;
1409 Ok(match row {
1410 Some((kind, payload_json)) => {
1411 let outcome = match kind.as_str() {
1412 "run.finished" => "success".to_string(),
1413 "run.failed" => "failed".to_string(),
1414 "run.aborted" => "aborted".to_string(),
1415 other => other.to_string(),
1416 };
1417 let category = if kind == "run.failed" {
1418 serde_json::from_str::<serde_json::Value>(&payload_json)
1419 .ok()
1420 .and_then(|v| {
1421 v.get("category")
1422 .and_then(|c| c.as_str())
1423 .map(str::to_string)
1424 })
1425 } else {
1426 None
1427 };
1428 (outcome, category)
1429 }
1430 None => ("unknown".to_string(), None),
1431 })
1432}
1433
1434fn collect_injected_memory_ids_for_blame(
1435 conn: &Connection,
1436 run_id: &str,
1437) -> KimetsuResult<Vec<String>> {
1438 let mut stmt = conn.prepare(
1439 "
1440 SELECT payload_json
1441 FROM events
1442 WHERE run_id = ?1 AND kind = 'context.injected'
1443 ",
1444 )?;
1445 let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
1446 let mut seen = std::collections::BTreeSet::new();
1447 for row in rows {
1448 let payload_json = row?;
1449 let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
1450 if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
1451 for id in ids {
1452 if let Some(s) = id.as_str()
1453 && !s.is_empty()
1454 {
1455 seen.insert(s.to_string());
1456 }
1457 }
1458 }
1459 }
1460 Ok(seen.into_iter().collect())
1461}
1462
1463fn resolve_memory(
1469 project_conn: &Connection,
1470 user_conn: Option<&Connection>,
1471 memory_id: &str,
1472) -> (String, String, String) {
1473 let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
1474 let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
1475 conn.query_row(q, rusqlite::params![memory_id], |row| {
1476 Ok((
1477 row.get::<_, String>(0)?,
1478 row.get::<_, String>(1)?,
1479 row.get::<_, String>(2)?,
1480 ))
1481 })
1482 .optional()
1483 .ok()
1484 .flatten()
1485 };
1486 try_conn(project_conn)
1487 .or_else(|| user_conn.and_then(try_conn))
1488 .unwrap_or_else(|| {
1489 (
1490 "<unknown — deleted or invalid memory_id>".to_string(),
1491 String::new(),
1492 String::new(),
1493 )
1494 })
1495}
1496
1497fn text_preview(text: &str, max_chars: usize) -> String {
1498 let trimmed = text.trim();
1499 if trimmed.chars().count() <= max_chars {
1500 trimmed.to_string()
1501 } else {
1502 let head: String = trimmed.chars().take(max_chars).collect();
1503 format!("{head}…")
1504 }
1505}
1506
1507fn list_memories_from_conn(conn: &Connection, opts: &ListOptions) -> KimetsuResult<Vec<MemoryRow>> {
1508 let limit = if opts.limit == 0 { 100 } else { opts.limit } as i64;
1515 let offset = opts.offset as i64;
1516
1517 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
1518 (
1519 "
1520 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1521 FROM memories
1522 WHERE invalidated_at IS NULL
1523 AND superseded_by IS NULL
1524 AND lower(scope) = lower(?1)
1525 ORDER BY created_at DESC
1526 LIMIT ?2 OFFSET ?3
1527 ",
1528 Some(scope.to_string()),
1529 )
1530 } else {
1531 (
1532 "
1533 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1534 FROM memories
1535 WHERE invalidated_at IS NULL
1536 AND superseded_by IS NULL
1537 ORDER BY created_at DESC
1538 LIMIT ?1 OFFSET ?2
1539 ",
1540 None,
1541 )
1542 };
1543
1544 let mut stmt = conn.prepare(sql)?;
1545 let rows = if let Some(scope) = scope_param {
1546 stmt.query_map(params![scope, limit, offset], map_memory_row)?
1547 .collect::<Result<Vec<_>, _>>()?
1548 } else {
1549 stmt.query_map(params![limit, offset], map_memory_row)?
1550 .collect::<Result<Vec<_>, _>>()?
1551 };
1552 Ok(rows)
1553}
1554
1555#[derive(Debug, Clone, Default)]
1563pub struct TopOptions {
1564 pub scope: Option<String>,
1565 pub min_uses: u32,
1566 pub limit: u32,
1567}
1568
1569pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
1570 let (_paths, _config, conn) = load_project(start)?;
1571 let min_uses = opts.min_uses.max(1) as i64;
1572 let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
1573
1574 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
1575 (
1576 "
1577 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1578 FROM memories
1579 WHERE invalidated_at IS NULL
1580 AND superseded_by IS NULL
1581 AND use_count >= ?1
1582 AND lower(scope) = lower(?2)
1583 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
1584 LIMIT ?3
1585 ",
1586 Some(scope.to_string()),
1587 )
1588 } else {
1589 (
1590 "
1591 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
1592 FROM memories
1593 WHERE invalidated_at IS NULL
1594 AND superseded_by IS NULL
1595 AND use_count >= ?1
1596 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
1597 LIMIT ?2
1598 ",
1599 None,
1600 )
1601 };
1602
1603 let mut stmt = conn.prepare(sql)?;
1604 let mut rows = if let Some(scope) = scope_param {
1605 stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
1606 .collect::<Result<Vec<_>, _>>()?
1607 } else {
1608 stmt.query_map(params![min_uses, limit], map_memory_row)?
1609 .collect::<Result<Vec<_>, _>>()?
1610 };
1611
1612 rows.sort_by(|a, b| {
1617 let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
1618 let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
1619 rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
1620 });
1621 Ok(rows)
1622}
1623
1624fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
1625 Ok(MemoryRow {
1626 memory_id: row.get(0)?,
1627 scope: row.get(1)?,
1628 kind: row.get(2)?,
1629 text: row.get(3)?,
1630 confidence: row.get(4)?,
1631 use_count: row.get(5)?,
1632 usefulness_score: row.get::<_, f64>(6)? as f32,
1633 })
1634}
1635
1636#[derive(Debug, Clone)]
1648pub struct PruneOptions {
1649 pub scope: Option<String>,
1650 pub min_uses: u32,
1651 pub max_ratio: f32,
1652 pub apply: bool,
1653}
1654
1655impl Default for PruneOptions {
1656 fn default() -> Self {
1657 Self {
1658 scope: None,
1659 min_uses: 3,
1660 max_ratio: -0.2,
1661 apply: false,
1662 }
1663 }
1664}
1665
1666#[derive(Debug, Clone)]
1667pub struct PruneCandidate {
1668 pub memory_id: String,
1669 pub scope: String,
1670 pub kind: String,
1671 pub use_count: u32,
1672 pub usefulness_score: f32,
1673 pub text: String,
1674}
1675
1676#[derive(Debug, Clone, Default)]
1677pub struct PruneSummary {
1678 pub candidates: Vec<PruneCandidate>,
1679 pub invalidated: u32,
1680 pub failed: u32,
1681}
1682
1683pub fn prune_low_usefulness(start: &Path, opts: PruneOptions) -> KimetsuResult<PruneSummary> {
1684 let min_uses = opts.min_uses.max(1) as i64;
1685
1686 let candidates = {
1687 let (_paths, _config, conn) = load_project(start)?;
1688 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref()
1689 {
1690 (
1691 "
1692 SELECT memory_id, scope, kind, text, use_count, usefulness_score
1693 FROM memories
1694 WHERE invalidated_at IS NULL
1695 AND superseded_by IS NULL
1696 AND use_count >= ?1
1697 AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
1698 AND lower(scope) = lower(?3)
1699 ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
1700 ",
1701 Some(scope.to_string()),
1702 )
1703 } else {
1704 (
1705 "
1706 SELECT memory_id, scope, kind, text, use_count, usefulness_score
1707 FROM memories
1708 WHERE invalidated_at IS NULL
1709 AND superseded_by IS NULL
1710 AND use_count >= ?1
1711 AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
1712 ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
1713 ",
1714 None,
1715 )
1716 };
1717 let mut stmt = conn.prepare(sql)?;
1718 let max_ratio = opts.max_ratio as f64;
1719 let mut found: Vec<PruneCandidate> = if let Some(scope) = scope_param {
1720 stmt.query_map(params![min_uses, max_ratio, scope], |row| {
1721 Ok(PruneCandidate {
1722 memory_id: row.get(0)?,
1723 scope: row.get(1)?,
1724 kind: row.get(2)?,
1725 text: row.get(3)?,
1726 use_count: row.get(4)?,
1727 usefulness_score: row.get::<_, f64>(5)? as f32,
1728 })
1729 })?
1730 .collect::<Result<Vec<_>, _>>()?
1731 } else {
1732 stmt.query_map(params![min_uses, max_ratio], |row| {
1733 Ok(PruneCandidate {
1734 memory_id: row.get(0)?,
1735 scope: row.get(1)?,
1736 kind: row.get(2)?,
1737 text: row.get(3)?,
1738 use_count: row.get(4)?,
1739 usefulness_score: row.get::<_, f64>(5)? as f32,
1740 })
1741 })?
1742 .collect::<Result<Vec<_>, _>>()?
1743 };
1744 found.sort_by(|a, b| {
1747 let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
1748 let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
1749 ra.partial_cmp(&rb)
1750 .unwrap_or(std::cmp::Ordering::Equal)
1751 .then_with(|| b.use_count.cmp(&a.use_count))
1752 });
1753 found
1754 };
1755
1756 let mut summary = PruneSummary {
1757 candidates: candidates.clone(),
1758 invalidated: 0,
1759 failed: 0,
1760 };
1761 if !opts.apply {
1762 return Ok(summary);
1763 }
1764
1765 for candidate in &candidates {
1766 let ratio = candidate.usefulness_score / candidate.use_count.max(1) as f32;
1767 let reason = format!(
1768 "pruned_by_usefulness ratio={:+.2} use_count={}",
1769 ratio, candidate.use_count
1770 );
1771 match invalidate_memory(start, &candidate.memory_id, Some(&reason)) {
1772 Ok(()) => summary.invalidated += 1,
1773 Err(_) => summary.failed += 1,
1774 }
1775 }
1776 Ok(summary)
1777}
1778
1779pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
1780 let (_paths, _config, conn) = load_project(start)?;
1781 let mut sql = String::from(
1782 "
1783 SELECT proposal_id, run_id, scope, kind, text, rationale,
1784 proposed_confidence, status, decided_reason
1785 FROM memory_proposals
1786 ",
1787 );
1788 let mut clauses = Vec::<String>::new();
1789 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1790 if let Some(scope) = filter.scope.as_deref() {
1791 clauses.push("scope = ?".to_string());
1792 params.push(Box::new(scope.to_string()));
1793 }
1794 if let Some(kind) = filter.kind.as_deref() {
1795 clauses.push("kind = ?".to_string());
1796 params.push(Box::new(kind.to_string()));
1797 }
1798 if let Some(run_id) = filter.from_run.as_deref() {
1799 clauses.push("run_id = ?".to_string());
1800 params.push(Box::new(run_id.to_string()));
1801 }
1802 if let Some(min_conf) = filter.min_confidence {
1803 clauses.push("proposed_confidence >= ?".to_string());
1804 params.push(Box::new(min_conf as f64));
1805 }
1806 if let Some(status) = filter.status.as_deref()
1807 && !status.eq_ignore_ascii_case("any")
1808 {
1809 clauses.push("status = ?".to_string());
1810 params.push(Box::new(status.to_string()));
1811 }
1812 if !clauses.is_empty() {
1813 sql.push_str(" WHERE ");
1814 sql.push_str(&clauses.join(" AND "));
1815 }
1816 let limit = if filter.limit == 0 { 100 } else { filter.limit };
1817 sql.push_str(&format!(
1818 " ORDER BY rowid DESC LIMIT {limit} OFFSET {}",
1819 filter.offset
1820 ));
1821
1822 let mut stmt = conn.prepare(&sql)?;
1823 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1824 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1825 Ok(ProposalRow {
1826 proposal_id: row.get(0)?,
1827 run_id: row.get(1)?,
1828 scope: row.get(2)?,
1829 kind: row.get(3)?,
1830 text: row.get(4)?,
1831 rationale: row.get(5)?,
1832 proposed_confidence: row.get(6)?,
1833 status: row.get(7)?,
1834 decided_reason: row.get(8)?,
1835 })
1836 })?;
1837
1838 let mut proposals = Vec::new();
1839 for row in rows {
1840 proposals.push(row?);
1841 }
1842 Ok(proposals)
1843}
1844
1845pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1846 let (paths, config, conn) = load_project(start)?;
1847 let run_id = RunId::new();
1848 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1849
1850 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1851
1852 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1853
1854 let ingested = Event::new(
1855 run_id,
1856 "repo.ingested",
1857 serde_json::json!({
1858 "repo_root": summary.repo_root.to_string_lossy(),
1859 "indexed_files": summary.indexed_files,
1860 "skipped_files": summary.skipped_files,
1861 "manifests": summary.manifests,
1862 }),
1863 );
1864
1865 let finished = admin_finished_event(run_id);
1866 projector::apply_events(&conn, &[started, ingested, finished])?;
1867
1868 Ok(summary)
1869}
1870
1871pub fn ingest_repo_at_root(
1876 brain_root: &Path,
1877 files_root: &Path,
1878) -> KimetsuResult<RepoIngestSummary> {
1879 let (mut paths, config, conn) = load_project_at_root(brain_root)?;
1880 paths.repo_root = files_root
1882 .canonicalize()
1883 .unwrap_or_else(|_| files_root.to_path_buf());
1884 let run_id = RunId::new();
1885 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo (remote)", Some(run_id))?;
1886
1887 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1888 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1889 let ingested = Event::new(
1890 run_id,
1891 "repo.ingested",
1892 serde_json::json!({
1893 "repo_root": summary.repo_root.to_string_lossy(),
1894 "indexed_files": summary.indexed_files,
1895 "skipped_files": summary.skipped_files,
1896 "manifests": summary.manifests,
1897 }),
1898 );
1899 let finished = admin_finished_event(run_id);
1900 projector::apply_events(&conn, &[started, ingested, finished])?;
1901
1902 Ok(summary)
1903}
1904
1905pub fn search_files(
1906 start: &Path,
1907 query: &str,
1908 limit: u32,
1909) -> KimetsuResult<Vec<context::ContextCapsule>> {
1910 let (paths, _config, conn) = load_project(start)?;
1911 let repo_root = paths
1912 .repo_root
1913 .canonicalize()?
1914 .to_string_lossy()
1915 .to_string();
1916 context::search_repo_files(&conn, &repo_root, query, limit)
1917}
1918
1919pub fn retrieve_context(
1920 start: &Path,
1921 stage: &str,
1922 query: &str,
1923 budget_tokens: u32,
1924) -> KimetsuResult<ContextBundle> {
1925 BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1926}
1927
1928pub fn retrieve_context_readonly(
1929 start: &Path,
1930 stage: &str,
1931 query: &str,
1932 budget_tokens: u32,
1933) -> KimetsuResult<ContextBundle> {
1934 BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1935}
1936
1937pub fn retrieve_context_readonly_with_request(
1940 start: &Path,
1941 request: ContextRequest,
1942) -> KimetsuResult<ContextBundle> {
1943 BrainSession::open_readonly(start)?.retrieve_context_with_request(request)
1944}
1945
1946pub fn retrieve_context_lexical_readonly(
1952 start: &Path,
1953 request: ContextRequest,
1954) -> KimetsuResult<ContextBundle> {
1955 BrainSession::open_readonly(start)?.retrieve_context_lexical(request)
1956}
1957
1958pub fn retrieve_proactive_readonly(
1962 start: &Path,
1963 request: ContextRequest,
1964) -> KimetsuResult<ContextBundle> {
1965 BrainSession::open_readonly(start)?.retrieve_proactive(request)
1966}
1967
1968pub fn search_memories(
1973 start: &Path,
1974 query: &str,
1975 limit: u32,
1976 offset: u32,
1977 kind: Option<&str>,
1978 scope: Option<&str>,
1979) -> KimetsuResult<Vec<MemorySearchHit>> {
1980 let Some(fts) = context::fts_query(query) else {
1981 return Ok(Vec::new());
1982 };
1983 let (_paths, config, conn) = load_project(start)?;
1984 let mut hits = search_memories_in_conn(&conn, &fts, limit, offset, kind, scope)?;
1985 if offset == 0
1987 && let Some(user_conn) =
1988 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
1989 {
1990 hits.extend(search_memories_in_conn(
1991 &user_conn, &fts, limit, 0, kind, scope,
1992 )?);
1993 }
1994 Ok(hits)
1995}
1996
1997fn search_memories_in_conn(
1998 conn: &Connection,
1999 fts_query: &str,
2000 limit: u32,
2001 offset: u32,
2002 kind: Option<&str>,
2003 scope: Option<&str>,
2004) -> KimetsuResult<Vec<MemorySearchHit>> {
2005 let limit = if limit == 0 { 20 } else { limit } as i64;
2006 let offset = offset as i64;
2007 let mut sql = String::from(
2008 "
2009 SELECT m.memory_id, m.scope, m.kind, m.text, bm25(memories_fts) AS rank
2010 FROM memories_fts
2011 JOIN memories m ON m.memory_id = memories_fts.memory_id
2012 WHERE m.invalidated_at IS NULL
2013 AND m.superseded_by IS NULL
2014 AND memories_fts MATCH ?
2015 ",
2016 );
2017 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(fts_query.to_string())];
2018 if let Some(k) = kind {
2019 sql.push_str(" AND m.kind = ?");
2020 bind.push(Box::new(k.to_string()));
2021 }
2022 if let Some(s) = scope {
2023 sql.push_str(" AND lower(m.scope) = lower(?)");
2024 bind.push(Box::new(s.to_string()));
2025 }
2026 sql.push_str(" ORDER BY rank LIMIT ? OFFSET ?");
2028 bind.push(Box::new(limit));
2029 bind.push(Box::new(offset));
2030
2031 let mut stmt = conn.prepare(&sql)?;
2032 let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
2033 let rows = stmt.query_map(refs.as_slice(), |row| {
2034 let raw_rank = row.get::<_, f64>(4)? as f32;
2035 Ok(MemorySearchHit {
2036 memory_id: row.get(0)?,
2037 scope: row.get(1)?,
2038 kind: row.get(2)?,
2039 text: row.get(3)?,
2040 rank: (-raw_rank).max(0.0),
2042 })
2043 })?;
2044 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
2045}
2046
2047#[allow(clippy::too_many_arguments)]
2048pub fn retrieve_benchmark_context_readonly(
2049 start: &Path,
2050 task: &str,
2051 dataset: &str,
2052 task_slug: Option<&str>,
2053 warm_policy: benchmark::BenchmarkWarmPolicy,
2054 stage: &str,
2055 budget_tokens: u32,
2056 require_benchmark_memory: bool,
2057 max_capsules: usize,
2058) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
2059 retrieve_benchmark_context_readonly_with_ambient(
2060 start,
2061 task,
2062 dataset,
2063 task_slug,
2064 warm_policy,
2065 stage,
2066 budget_tokens,
2067 require_benchmark_memory,
2068 max_capsules,
2069 None,
2070 )
2071}
2072
2073#[allow(clippy::too_many_arguments)]
2079pub fn retrieve_benchmark_context_readonly_with_ambient(
2080 start: &Path,
2081 task: &str,
2082 dataset: &str,
2083 task_slug: Option<&str>,
2084 warm_policy: benchmark::BenchmarkWarmPolicy,
2085 stage: &str,
2086 budget_tokens: u32,
2087 require_benchmark_memory: bool,
2088 max_capsules: usize,
2089 ambient_suffix: Option<&str>,
2090) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
2091 let normalized_slug = task_slug
2092 .and_then(benchmark::normalize_task_slug)
2093 .or_else(|| benchmark::normalize_task_slug(task));
2094 let mut query =
2095 benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
2096 if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
2097 query.push_str(suffix);
2098 }
2099 let bundle =
2100 BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
2101 Ok(benchmark::build_benchmark_context(
2102 bundle,
2103 task,
2104 dataset,
2105 &query,
2106 normalized_slug,
2107 warm_policy,
2108 require_benchmark_memory,
2109 max_capsules,
2110 ))
2111}
2112
2113pub fn record_benchmark_outcome(
2114 start: &Path,
2115 outcome: benchmark::BenchmarkOutcome,
2116) -> KimetsuResult<RecordedBenchmarkOutcome> {
2117 let task_slug = outcome
2118 .task_slug
2119 .clone()
2120 .or_else(|| benchmark::normalize_task_slug(&outcome.task));
2121 let kind = benchmark::outcome_memory_kind(&outcome);
2122 let text = benchmark::outcome_memory_text(&outcome);
2123 let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
2124 let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
2125 Some(proposal) if proposal.role.is_generalizable() => {
2126 let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
2127 (Some(proposal_id), Some(proposal_text))
2128 }
2129 _ => (None, None),
2130 };
2131 Ok(RecordedBenchmarkOutcome {
2132 memory_id,
2133 task_slug,
2134 kind,
2135 text,
2136 proposal_id,
2137 proposal_text,
2138 })
2139}
2140
2141fn propose_benchmark_memory(
2142 start: &Path,
2143 outcome: &benchmark::BenchmarkOutcome,
2144 proposal: &benchmark::BenchmarkMemoryProposal,
2145) -> KimetsuResult<(String, String)> {
2146 let (paths, config, conn) = load_project(start)?;
2147 let run_id = RunId::new();
2148 let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
2149 let proposal_id = Ulid::new().to_string();
2150 let raw_text = benchmark::proposal_memory_text(outcome, proposal);
2154 let text_redaction = redact::redact_secrets(&raw_text);
2155 if text_redaction.was_redacted() {
2156 eprintln!(
2157 "kimetsu-brain (benchmark proposal): {}",
2158 text_redaction.summary()
2159 );
2160 }
2161 let text = text_redaction.text;
2162 let kind = benchmark::proposal_memory_kind(proposal);
2163 let rationale_raw = if proposal.rationale.trim().is_empty() {
2164 "generalized from benchmark outcome".to_string()
2165 } else {
2166 proposal.rationale.trim().to_string()
2167 };
2168 let rationale = redact::redact_secrets(&rationale_raw).text;
2169
2170 let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
2171
2172 let proposed = Event::new(
2173 run_id,
2174 "memory.proposed",
2175 serde_json::json!({
2176 "proposal_id": proposal_id,
2177 "scope": "global_user",
2178 "kind": kind.to_string(),
2179 "text": text,
2180 "rationale": rationale,
2181 "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
2182 "source_event_ids": [],
2183 }),
2184 );
2185
2186 let finished = admin_finished_event(run_id);
2187
2188 projector::apply_events(&conn, &[started, proposed, finished])?;
2189 Ok((proposal_id, text))
2190}
2191
2192pub fn accept_proposal(
2193 start: &Path,
2194 proposal_id: &str,
2195 overrides: AcceptOverrides,
2196) -> KimetsuResult<String> {
2197 let (paths, config, conn) = load_project(start)?;
2198 let proposal = load_pending_proposal(&conn, proposal_id)?;
2199 let run_id = RunId::new();
2200 let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
2201 let memory_id = Ulid::new().to_string();
2202 let normalized = normalize_memory_text(&proposal.text);
2203
2204 let resolved_scope = match overrides.scope.as_deref() {
2205 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
2206 _ => proposal.scope.clone(),
2207 };
2208 let resolved_confidence = overrides
2209 .confidence
2210 .map(|c| c.clamp(0.0, 1.0))
2211 .unwrap_or(proposal.proposed_confidence);
2212
2213 let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
2214
2215 let accepted = Event::new(
2216 run_id,
2217 "memory.accepted",
2218 serde_json::json!({
2219 "proposal_id": proposal.proposal_id,
2220 "memory_id": memory_id,
2221 "scope": resolved_scope,
2222 "kind": proposal.kind,
2223 "text": proposal.text,
2224 "normalized_text": normalized,
2225 "confidence": resolved_confidence,
2226 "provenance_snapshot": {
2227 "source": "memory_proposal",
2228 "proposal_id": proposal.proposal_id,
2229 "source_run_id": proposal.run_id,
2230 "scope_override": overrides.scope.clone(),
2231 "confidence_override": overrides.confidence,
2232 }
2233 }),
2234 );
2235
2236 let finished = admin_finished_event(run_id);
2237
2238 projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
2239 conn.execute(
2240 "
2241 UPDATE memory_proposals
2242 SET status = 'accepted',
2243 decided_at = ?2,
2244 decided_by = 'cli'
2245 WHERE proposal_id = ?1
2246 ",
2247 params![
2248 proposal_id,
2249 accepted
2250 .ts
2251 .format(&time::format_description::well_known::Rfc3339)?
2252 ],
2253 )?;
2254
2255 Ok(memory_id)
2256}
2257
2258pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2264 let (paths, config, conn) = load_project(start)?;
2265 let exists: i64 = conn.query_row(
2266 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
2267 params![memory_id],
2268 |row| row.get(0),
2269 )?;
2270 if exists == 0 {
2271 return Err(format!("memory not found: {memory_id}").into());
2272 }
2273
2274 let run_id = RunId::new();
2275 let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
2276
2277 let resolved_reason = reason
2278 .and_then(|s| {
2279 let trimmed = s.trim();
2280 if trimmed.is_empty() {
2281 None
2282 } else {
2283 Some(trimmed.to_string())
2284 }
2285 })
2286 .unwrap_or_else(|| "invalidated_by_cli".to_string());
2287
2288 let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
2289
2290 let invalidated = Event::new(
2291 run_id,
2292 "memory.invalidated",
2293 serde_json::json!({
2294 "memory_id": memory_id,
2295 "reason": resolved_reason,
2296 }),
2297 );
2298
2299 let finished = admin_finished_event(run_id);
2300
2301 projector::apply_events(&conn, &[started, invalidated, finished])?;
2302 Ok(())
2303}
2304
2305#[derive(Debug, Clone)]
2307pub struct UndoneMemory {
2308 pub memory_id: String,
2309 pub text: String,
2310 pub scope: String,
2311 pub kind: String,
2312}
2313
2314pub fn edit_memory(
2329 start: &Path,
2330 memory_id: &str,
2331 new_text: Option<&str>,
2332 new_kind: Option<MemoryKind>,
2333) -> KimetsuResult<()> {
2334 if new_text.is_none() && new_kind.is_none() {
2335 return Err("edit_memory: at least one of --text or --kind must be provided".into());
2336 }
2337
2338 let (paths, config, conn) = load_project(start)?;
2339
2340 let row: Option<(String, String, String)> = conn
2342 .query_row(
2343 "SELECT scope, kind, invalidated_at FROM memories WHERE memory_id = ?1",
2344 params![memory_id],
2345 |row| {
2346 Ok((
2347 row.get::<_, String>(0)?,
2348 row.get::<_, String>(1)?,
2349 row.get::<_, String>(2).unwrap_or_default(),
2350 ))
2351 },
2352 )
2353 .optional()?;
2354
2355 let (scope, current_kind, invalidated_at) = match row {
2356 None => return Err(format!("memory not found: {memory_id}").into()),
2357 Some(r) => r,
2358 };
2359 if !invalidated_at.is_empty() {
2360 return Err(format!("memory {memory_id} is already invalidated").into());
2361 }
2362
2363 let run_id = RunId::new();
2364 let _lock = ProjectLock::acquire(&paths, "brain memory edit", Some(run_id))?;
2365
2366 if let Some(raw_text) = new_text {
2368 let redaction = redact::redact_secrets(raw_text);
2369 if redaction.was_redacted() {
2370 eprintln!("kimetsu-brain: {}", redaction.summary());
2371 }
2372 let text = &redaction.text;
2373 let normalized = normalize_memory_text(text);
2374
2375 conn.execute(
2376 "UPDATE memories SET text = ?1, normalized_text = ?2 WHERE memory_id = ?3",
2377 params![text, normalized, memory_id],
2378 )?;
2379
2380 conn.execute(
2382 "DELETE FROM memories_fts WHERE memory_id = ?1",
2383 params![memory_id],
2384 )?;
2385 let kind_for_fts = new_kind
2386 .as_ref()
2387 .map(|k| k.to_string())
2388 .unwrap_or(current_kind.clone());
2389 conn.execute(
2390 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
2391 params![memory_id, text, kind_for_fts, scope],
2392 )?;
2393
2394 let embedder = embeddings::open_embedder_for(config.embedder.enabled);
2396 embeddings::embed_and_persist(&conn, memory_id, text, embedder)?;
2397 }
2399
2400 if let Some(kind) = new_kind {
2402 conn.execute(
2403 "UPDATE memories SET kind = ?1 WHERE memory_id = ?2",
2404 params![kind.to_string(), memory_id],
2405 )?;
2406
2407 if new_text.is_none() {
2409 let current_text: String = conn.query_row(
2412 "SELECT text FROM memories WHERE memory_id = ?1",
2413 params![memory_id],
2414 |row| row.get(0),
2415 )?;
2416 conn.execute(
2417 "DELETE FROM memories_fts WHERE memory_id = ?1",
2418 params![memory_id],
2419 )?;
2420 conn.execute(
2421 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
2422 params![memory_id, current_text, kind.to_string(), scope],
2423 )?;
2424 }
2425 }
2426
2427 Ok(())
2428}
2429
2430pub fn peek_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2434 let (_paths, _config, conn) = load_project(start)?;
2435 let row: Option<(String, String, String, String)> = conn
2438 .query_row(
2439 "SELECT memory_id, text, scope, kind FROM memories
2440 WHERE invalidated_at IS NULL
2441 AND superseded_by IS NULL
2442 ORDER BY created_at DESC, memory_id DESC
2443 LIMIT 1",
2444 [],
2445 |row| {
2446 Ok((
2447 row.get::<_, String>(0)?,
2448 row.get::<_, String>(1)?,
2449 row.get::<_, String>(2)?,
2450 row.get::<_, String>(3)?,
2451 ))
2452 },
2453 )
2454 .optional()?;
2455
2456 Ok(row.map(|(memory_id, text, scope, kind)| UndoneMemory {
2457 memory_id,
2458 text,
2459 scope,
2460 kind,
2461 }))
2462}
2463
2464pub fn undo_last_memory(start: &Path) -> KimetsuResult<Option<UndoneMemory>> {
2473 let (paths, _config, conn) = load_project(start)?;
2474
2475 let row: Option<(String, String, String, String)> = conn
2478 .query_row(
2479 "SELECT memory_id, text, scope, kind FROM memories
2480 WHERE invalidated_at IS NULL
2481 AND superseded_by IS NULL
2482 ORDER BY created_at DESC, memory_id DESC
2483 LIMIT 1",
2484 [],
2485 |row| {
2486 Ok((
2487 row.get::<_, String>(0)?,
2488 row.get::<_, String>(1)?,
2489 row.get::<_, String>(2)?,
2490 row.get::<_, String>(3)?,
2491 ))
2492 },
2493 )
2494 .optional()?;
2495
2496 let (memory_id, text, scope, kind) = match row {
2497 None => return Ok(None),
2498 Some(r) => r,
2499 };
2500
2501 drop(conn);
2503 drop(paths);
2504
2505 invalidate_memory(start, &memory_id, Some("undo: last recorded memory"))?;
2506
2507 Ok(Some(UndoneMemory {
2508 memory_id,
2509 text,
2510 scope,
2511 kind,
2512 }))
2513}
2514
2515pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
2516 let (paths, config, conn) = load_project(start)?;
2517 let _proposal = load_pending_proposal(&conn, proposal_id)?;
2518 let run_id = RunId::new();
2519 let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
2520
2521 let resolved_reason = reason
2522 .and_then(|s| {
2523 let trimmed = s.trim();
2524 if trimmed.is_empty() {
2525 None
2526 } else {
2527 Some(trimmed.to_string())
2528 }
2529 })
2530 .unwrap_or_else(|| "rejected_by_cli".to_string());
2531
2532 let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
2533
2534 let rejected = Event::new(
2535 run_id,
2536 "memory.rejected",
2537 serde_json::json!({
2538 "proposal_id": proposal_id,
2539 "reason": resolved_reason,
2540 }),
2541 );
2542
2543 let finished = admin_finished_event(run_id);
2544
2545 projector::apply_events(&conn, &[started, rejected, finished])?;
2546 Ok(())
2547}
2548
2549pub fn rebuild_projection(start: &Path, from_traces: bool) -> KimetsuResult<usize> {
2550 let (paths, _config, conn) = load_project(start)?;
2551 let _lock = ProjectLock::acquire(&paths, "brain rebuild", None)?;
2552
2553 if from_traces {
2556 let events = trace::read_all_traces(&paths)?;
2557 projector::rebuild(&conn, &events)?;
2558 return Ok(events.len());
2559 }
2560
2561 let event_count: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))?;
2565 if event_count == 0 {
2566 let events = trace::read_all_traces(&paths)?;
2567 if !events.is_empty() {
2568 eprintln!(
2569 "[kimetsu] events table empty; importing {} event(s) from legacy traces",
2570 events.len()
2571 );
2572 projector::rebuild(&conn, &events)?;
2573 return Ok(events.len());
2574 }
2575 }
2576
2577 projector::rebuild_in_place(&conn)
2579}
2580
2581pub fn clear_lock(start: &Path) -> KimetsuResult<bool> {
2582 let paths = ProjectPaths::discover(start)?;
2583 crate::lock::clear_force(&paths)
2584}
2585
2586#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2592pub struct ScopedConflict {
2593 pub source: String,
2596 #[serde(flatten)]
2597 pub report: conflict::ConflictReport,
2598}
2599
2600pub fn list_conflicts(start: &Path, limit: u32) -> KimetsuResult<Vec<ScopedConflict>> {
2604 let mut out = Vec::new();
2605 let (_paths, config, project_conn) = load_project_readonly(start)?;
2606 for report in conflict::list_unresolved_conflicts(&project_conn, limit)? {
2607 out.push(ScopedConflict {
2608 source: "project".to_string(),
2609 report,
2610 });
2611 }
2612 if let Some(user_conn) =
2614 user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)?
2615 {
2616 for report in conflict::list_unresolved_conflicts(&user_conn, limit)? {
2617 out.push(ScopedConflict {
2618 source: "user".to_string(),
2619 report,
2620 });
2621 }
2622 }
2623 out.sort_by(|a, b| b.report.detected_at.cmp(&a.report.detected_at));
2624 Ok(out)
2625}
2626
2627pub fn resolve_conflict(start: &Path, conflict_id: &str, resolution: &str) -> KimetsuResult<bool> {
2639 let (paths, config, project_conn) = load_project(start)?;
2640 let _lock = ProjectLock::acquire(&paths, "brain memory conflict resolve", None)?;
2641 if conflict::resolve_conflict(&project_conn, conflict_id, resolution)? {
2642 return Ok(true);
2643 }
2644 drop(project_conn); if let Some(user_conn) = user_brain::open_user_brain_for_config(config.kimetsu.use_user_brain)?
2647 {
2648 return conflict::resolve_conflict(&user_conn, conflict_id, resolution);
2649 }
2650 Ok(false)
2651}
2652
2653fn load_pending_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<ProposalRow> {
2654 let mut stmt = conn.prepare(
2655 "
2656 SELECT proposal_id, run_id, scope, kind, text, rationale,
2657 proposed_confidence, status
2658 FROM memory_proposals
2659 WHERE proposal_id = ?1
2660 ",
2661 )?;
2662 let mut rows = stmt.query(params![proposal_id])?;
2663 let Some(row) = rows.next()? else {
2664 return Err(format!("memory proposal not found: {proposal_id}").into());
2665 };
2666
2667 let proposal = ProposalRow {
2668 proposal_id: row.get(0)?,
2669 run_id: row.get(1)?,
2670 scope: row.get(2)?,
2671 kind: row.get(3)?,
2672 text: row.get(4)?,
2673 rationale: row.get(5)?,
2674 proposed_confidence: row.get(6)?,
2675 status: row.get(7)?,
2676 decided_reason: None,
2677 };
2678
2679 if proposal.status != "pending" {
2680 return Err(format!(
2681 "memory proposal {proposal_id} is {}, not pending",
2682 proposal.status
2683 )
2684 .into());
2685 }
2686
2687 Ok(proposal)
2688}
2689
2690fn admin_started_event(
2691 paths: &ProjectPaths,
2692 config: &ProjectConfig,
2693 run_id: RunId,
2694 task: &str,
2695) -> KimetsuResult<Event> {
2696 Ok(Event::new(
2697 run_id,
2698 "run.started",
2699 serde_json::json!({
2700 "mode": "admin",
2701 "task": task,
2702 "project_id": config.kimetsu.project_id,
2703 "repo_root": paths.repo_root.to_string_lossy(),
2704 "model": null,
2705 "platform": std::env::consts::OS,
2706 "kimetsu_version": env!("CARGO_PKG_VERSION"),
2707 "config_hash": config_hash(&paths.project_toml)?,
2708 }),
2709 ))
2710}
2711
2712fn admin_finished_event(run_id: RunId) -> Event {
2713 Event::new(
2714 run_id,
2715 "run.finished",
2716 serde_json::json!({
2717 "status": "success",
2718 "final_report_path": null,
2719 "total_cost_usd": 0.0,
2720 "total_tool_calls": 0,
2721 }),
2722 )
2723}
2724
2725fn config_hash(path: &Path) -> KimetsuResult<String> {
2726 let bytes = fs::read(path)?;
2727 Ok(blake3::hash(&bytes).to_hex().to_string())
2728}
2729
2730pub fn abort_run(start: &Path, run_id_str: &str) -> KimetsuResult<()> {
2741 {
2743 let (_paths, _config, ro_conn) = load_project_readonly(start)?;
2744 let row: Option<Option<String>> = ro_conn
2745 .query_row(
2746 "SELECT terminal_kind FROM runs WHERE run_id = ?1",
2747 rusqlite::params![run_id_str],
2748 |row| row.get::<_, Option<String>>(0),
2749 )
2750 .optional()?;
2751 match row {
2752 None => {
2753 return Err(format!("run abort: unknown run_id `{run_id_str}`").into());
2754 }
2755 Some(Some(terminal_kind)) => {
2756 return Err(format!(
2757 "run abort: run `{run_id_str}` is already terminal ({})",
2758 terminal_kind
2759 )
2760 .into());
2761 }
2762 Some(None) => {} }
2764 }
2765
2766 let run_id: RunId = run_id_str
2768 .parse::<ulid::Ulid>()
2769 .map(RunId)
2770 .map_err(|_| format!("run abort: `{run_id_str}` is not a valid ULID run id"))?;
2771
2772 let (paths, _config, conn) = load_project(start)?;
2774 let lock = ProjectLock::acquire(&paths, "run abort", Some(run_id))?;
2775
2776 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
2778
2779 let aborted_event = Event::new(
2780 run_id,
2781 "run.aborted",
2782 serde_json::json!({
2783 "reason": "manual_abort_via_cli",
2784 }),
2785 );
2786 writer.append(&aborted_event, true)?;
2787 projector::apply_events(&conn, &[aborted_event])?;
2788
2789 lock.release()?;
2793 crate::lock::clear_force(&paths)?;
2794
2795 Ok(())
2796}
2797
2798pub fn log_telemetry_event(
2811 start: &Path,
2812 kind: &str,
2813 payload: serde_json::Value,
2814) -> KimetsuResult<()> {
2815 let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2819 let conn = Connection::open(&paths.brain_db)?;
2820 schema::initialize(&conn)?;
2821
2822 let sentinel_run_id = RunId(ulid::Ulid::nil());
2824 let event = Event::new(sentinel_run_id, kind, payload);
2825 projector::insert_event(&conn, &event)?;
2826 Ok(())
2827}
2828
2829pub fn emit_regret_for_cited_memories(start: &Path, events: &[kimetsu_core::event::Event]) {
2845 use crate::dropped_capsule;
2846 use kimetsu_core::paths::{ProjectPaths, user_cache_dir_for};
2847
2848 let cache_dir = match ProjectPaths::discover(start) {
2851 Ok(paths) => user_cache_dir_for(&paths.repo_root),
2852 Err(_) => return,
2853 };
2854
2855 let cited_at = dropped_capsule::now_secs();
2856
2857 for event in events {
2858 if event.kind != "memory.cited" {
2859 continue;
2860 }
2861 let Some(memory_id) = event.payload.get("memory_id").and_then(|v| v.as_str()) else {
2862 continue;
2863 };
2864 let Some(dropped_entry) = dropped_capsule::take_if_dropped(&cache_dir, memory_id, cited_at)
2866 else {
2867 continue;
2868 };
2869 let _ = log_telemetry_event(
2871 start,
2872 "retrieval.regret",
2873 serde_json::json!({
2874 "memory_id": memory_id,
2875 "dropped_at": dropped_entry.dropped_at,
2876 "cited_at": cited_at,
2877 }),
2878 );
2879 }
2880}
2881
2882pub fn record_mcp_citation(start: &Path, memory_id: &str, note: Option<&str>) -> KimetsuResult<()> {
2889 let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2890 let conn = Connection::open(&paths.brain_db)?;
2891 schema::initialize(&conn)?;
2892
2893 let sentinel_run_id = RunId(ulid::Ulid::nil());
2894 let mut payload = serde_json::json!({
2895 "memory_id": memory_id,
2896 "turn": 0,
2897 });
2898 if let Some(n) = note {
2899 payload["rationale"] = serde_json::json!(n);
2900 }
2901 let event = kimetsu_core::event::Event::new(sentinel_run_id, "memory.cited", payload);
2902 projector::apply_events(&conn, std::slice::from_ref(&event))?;
2904
2905 emit_regret_for_cited_memories(start, std::slice::from_ref(&event));
2907
2908 Ok(())
2909}
2910
2911pub fn record_regret(start: &Path, memory_id: &str) -> KimetsuResult<()> {
2919 let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2920 let conn = Connection::open(&paths.brain_db)?;
2921 schema::initialize(&conn)?;
2922
2923 let sentinel_run_id = RunId(ulid::Ulid::nil());
2926 let event = kimetsu_core::event::Event::new(
2927 sentinel_run_id,
2928 "retrieval.regret",
2929 serde_json::json!({ "memory_id": memory_id, "source": "manual" }),
2930 );
2931 projector::apply_events(&conn, std::slice::from_ref(&event))?;
2932 Ok(())
2933}
2934
2935pub fn record_set_age(start: &Path, memory_id: &str, days_ago: u32) -> KimetsuResult<()> {
2940 use time::format_description::well_known::Rfc3339;
2941
2942 let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2943 let conn = Connection::open(&paths.brain_db)?;
2944 schema::initialize(&conn)?;
2945
2946 let target = time::OffsetDateTime::now_utc() - time::Duration::days(days_ago as i64);
2947 let ts = target.format(&Rfc3339).unwrap_or_default();
2948
2949 let sentinel_run_id = RunId(ulid::Ulid::nil());
2950 let event = kimetsu_core::event::Event::new(
2951 sentinel_run_id,
2952 "memory.aged",
2953 serde_json::json!({ "memory_id": memory_id, "created_at": ts, "last_useful_at": ts }),
2954 );
2955 projector::apply_events(&conn, std::slice::from_ref(&event))?;
2956 Ok(())
2957}
2958
2959#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
2963pub struct GraphBuildSummary {
2964 pub active_memories: usize,
2966 pub rule_edges: usize,
2968 pub enrich_edges: usize,
2970 pub written: usize,
2972 pub by_type: std::collections::BTreeMap<String, usize>,
2974 pub dry_run: bool,
2976}
2977
2978pub fn active_memory_texts(start: &Path) -> KimetsuResult<Vec<(String, String)>> {
2982 let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
2983 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2984 let mut stmt = conn.prepare(
2985 "SELECT memory_id, text
2986 FROM memories
2987 WHERE invalidated_at IS NULL AND superseded_by IS NULL
2988 ORDER BY memory_id",
2989 )?;
2990 let rows = stmt
2991 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
2992 .collect::<Result<Vec<_>, _>>()?;
2993 Ok(rows)
2994}
2995
2996pub fn build_graph(
3005 start: &Path,
3006 extra_edges: &[(String, String, String)],
3007 max_fan_out: usize,
3008 dry_run: bool,
3009) -> KimetsuResult<GraphBuildSummary> {
3010 use std::collections::{BTreeMap, BTreeSet};
3011
3012 let paths = kimetsu_core::paths::ProjectPaths::discover(start)?;
3013 let conn = Connection::open(&paths.brain_db)?;
3014 schema::initialize(&conn)?;
3015
3016 let active_memories = conn.query_row(
3017 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL AND superseded_by IS NULL",
3018 [],
3019 |r| r.get::<_, i64>(0),
3020 )? as usize;
3021
3022 let rule = crate::graph::build_relates_to_edges(&conn, max_fan_out)?;
3023 let rule_edges = rule.len();
3024 let enrich_edges = extra_edges.len();
3025
3026 let mut seen: BTreeSet<(String, String, String)> = BTreeSet::new();
3029 let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
3030 let mut merged: Vec<(String, String, String)> = Vec::new();
3031 let push = |src: String,
3032 dst: String,
3033 ty: String,
3034 seen: &mut BTreeSet<(String, String, String)>,
3035 by_type: &mut BTreeMap<String, usize>,
3036 merged: &mut Vec<(String, String, String)>| {
3037 if src == dst {
3038 return;
3039 }
3040 let key = (src.clone(), dst.clone(), ty.clone());
3041 if seen.insert(key) {
3042 *by_type.entry(ty.clone()).or_insert(0) += 1;
3043 merged.push((src, dst, ty));
3044 }
3045 };
3046 for e in &rule {
3047 push(
3048 e.src_id.clone(),
3049 e.dst_id.clone(),
3050 e.edge_type.clone(),
3051 &mut seen,
3052 &mut by_type,
3053 &mut merged,
3054 );
3055 }
3056 for (src, dst, ty) in extra_edges {
3057 push(
3058 src.clone(),
3059 dst.clone(),
3060 ty.clone(),
3061 &mut seen,
3062 &mut by_type,
3063 &mut merged,
3064 );
3065 }
3066
3067 let written = if dry_run {
3068 0
3069 } else {
3070 projector::add_memory_edges(&conn, &merged)?
3071 };
3072
3073 Ok(GraphBuildSummary {
3074 active_memories,
3075 rule_edges,
3076 enrich_edges,
3077 written,
3078 by_type,
3079 dry_run,
3080 })
3081}
3082
3083#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3092pub struct MemoryExport {
3093 pub text: String,
3094 pub scope: String,
3095 pub kind: String,
3096 pub confidence: f32,
3097 pub created_at: Option<String>,
3098}
3099
3100#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3105pub struct Pack {
3106 pub kimetsu_pack: u32,
3108 #[serde(default, skip_serializing_if = "Option::is_none")]
3109 pub name: Option<String>,
3110 #[serde(default, skip_serializing_if = "Option::is_none")]
3111 pub version: Option<String>,
3112 #[serde(default, skip_serializing_if = "Option::is_none")]
3113 pub description: Option<String>,
3114 #[serde(default, skip_serializing_if = "Option::is_none")]
3115 pub exported_at: Option<String>,
3116 #[serde(default)]
3117 pub memory_count: usize,
3118 pub memories: Vec<MemoryExport>,
3119}
3120
3121#[derive(Debug, Clone, Default)]
3124pub struct PackRef {
3125 pub name: Option<String>,
3126 pub version: Option<String>,
3127}
3128
3129pub fn parse_pack_or_array(json: &str) -> KimetsuResult<(PackRef, Vec<MemoryExport>)> {
3133 if let Ok(pack) = serde_json::from_str::<Pack>(json) {
3136 return Ok((
3137 PackRef {
3138 name: pack.name,
3139 version: pack.version,
3140 },
3141 pack.memories,
3142 ));
3143 }
3144 let entries: Vec<MemoryExport> = serde_json::from_str(json)
3145 .map_err(|e| format!("pack: not a Pack envelope or a memory array: {e}"))?;
3146 Ok((PackRef::default(), entries))
3147}
3148
3149pub fn redact_context_suffix(text: &str) -> &str {
3173 let trimmed = text.trim_end();
3174 if let Some(pos) = find_trailing_context_paren(trimmed) {
3177 let candidate = trimmed[..pos].trim_end();
3178 if !candidate.is_empty() {
3179 return candidate;
3180 }
3181 }
3182 text
3183}
3184
3185pub fn redact_tags_prefix(text: &str) -> &str {
3207 let trimmed = text.trim_start();
3208 if let Some(rest) = trimmed.strip_prefix("[tags: ") {
3209 if let Some(close) = rest.find(']') {
3210 let after = rest[close + 1..].trim_start();
3211 if !after.is_empty() {
3212 return after;
3213 }
3214 }
3215 }
3216 text
3217}
3218
3219pub fn apply_export_redaction(
3228 entry: MemoryExport,
3229 redact: bool,
3230 redact_tags: bool,
3231) -> MemoryExport {
3232 if !redact && !redact_tags {
3233 return entry;
3234 }
3235 let mut text: &str = &entry.text;
3236 let after_tags: String;
3238 let after_ctx: String;
3239 if redact_tags {
3240 let stripped = redact_tags_prefix(text);
3241 after_tags = stripped.to_string();
3242 text = &after_tags;
3243 }
3244 if redact {
3245 let stripped = redact_context_suffix(text);
3246 after_ctx = stripped.to_string();
3247 text = &after_ctx;
3248 }
3249 MemoryExport {
3250 text: text.to_string(),
3251 ..entry
3252 }
3253}
3254
3255fn find_trailing_context_paren(s: &str) -> Option<usize> {
3259 if !s.ends_with(')') {
3261 return None;
3262 }
3263 let bytes = s.as_bytes();
3265 let close = s.len() - 1;
3267 let prefix = b" (context: ";
3271 for start in (0..close).rev() {
3272 if start + prefix.len() > close {
3273 continue;
3274 }
3275 if &bytes[start..start + prefix.len()] == prefix {
3276 return Some(start);
3278 }
3279 }
3280 None
3281}
3282
3283#[derive(Debug, Clone, Default)]
3285pub struct ImportSummary {
3286 pub imported: usize,
3288 pub deduped: usize,
3292 pub superseded: usize,
3295}
3296
3297thread_local! {
3298 static IMPORT_PROVENANCE: std::cell::RefCell<Option<serde_json::Value>> =
3303 const { std::cell::RefCell::new(None) };
3304}
3305
3306struct ImportProvenanceScope;
3307impl ImportProvenanceScope {
3308 fn new(v: serde_json::Value) -> Self {
3309 IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = Some(v));
3310 ImportProvenanceScope
3311 }
3312}
3313impl Drop for ImportProvenanceScope {
3314 fn drop(&mut self) {
3315 IMPORT_PROVENANCE.with(|c| *c.borrow_mut() = None);
3316 }
3317}
3318
3319fn build_provenance(run_id: RunId, text: &str) -> serde_json::Value {
3322 IMPORT_PROVENANCE.with(|c| {
3323 if let Some(src) = c.borrow().as_ref() {
3324 let mut v = src.clone();
3325 if let Some(obj) = v.as_object_mut() {
3326 obj.insert("run_id".into(), serde_json::json!(run_id.to_string()));
3327 obj.insert("text".into(), serde_json::json!(text));
3328 }
3329 v
3330 } else {
3331 serde_json::json!({
3332 "source": "manual_cli",
3333 "run_id": run_id.to_string(),
3334 "text": text,
3335 })
3336 }
3337 })
3338}
3339
3340#[derive(Debug, Clone, Default, serde::Serialize)]
3348pub struct ScrubReport {
3349 pub total: usize,
3350 pub kinds: std::collections::BTreeMap<String, usize>,
3351}
3352
3353impl ScrubReport {
3354 pub fn is_clean(&self) -> bool {
3355 self.total == 0
3356 }
3357 pub fn summary(&self) -> String {
3359 if self.total == 0 {
3360 return "no credentials or PII found".to_string();
3361 }
3362 let parts: Vec<String> = self.kinds.iter().map(|(k, n)| format!("{k}×{n}")).collect();
3363 format!("scrubbed {}: {}", self.total, parts.join(", "))
3364 }
3365}
3366
3367pub fn export_memories(
3368 start: &Path,
3369 scope: Option<MemoryScope>,
3370 kind: Option<MemoryKind>,
3371 redact: bool,
3372 redact_tags: bool,
3373) -> KimetsuResult<(Vec<MemoryExport>, ScrubReport)> {
3374 let (sql, params_vec): (&str, Vec<String>) = match (scope.as_ref(), kind.as_ref()) {
3377 (Some(s), Some(k)) => (
3378 "SELECT scope, kind, text, confidence, created_at
3379 FROM memories
3380 WHERE invalidated_at IS NULL
3381 AND superseded_by IS NULL
3382 AND lower(scope) = lower(?1)
3383 AND lower(kind) = lower(?2)
3384 ORDER BY created_at DESC",
3385 vec![s.to_string(), k.to_string()],
3386 ),
3387 (Some(s), None) => (
3388 "SELECT scope, kind, text, confidence, created_at
3389 FROM memories
3390 WHERE invalidated_at IS NULL
3391 AND superseded_by IS NULL
3392 AND lower(scope) = lower(?1)
3393 ORDER BY created_at DESC",
3394 vec![s.to_string()],
3395 ),
3396 (None, Some(k)) => (
3397 "SELECT scope, kind, text, confidence, created_at
3398 FROM memories
3399 WHERE invalidated_at IS NULL
3400 AND superseded_by IS NULL
3401 AND lower(kind) = lower(?1)
3402 ORDER BY created_at DESC",
3403 vec![k.to_string()],
3404 ),
3405 (None, None) => (
3406 "SELECT scope, kind, text, confidence, created_at
3407 FROM memories
3408 WHERE invalidated_at IS NULL
3409 AND superseded_by IS NULL
3410 ORDER BY created_at DESC",
3411 vec![],
3412 ),
3413 };
3414
3415 let (_paths, _config, conn) = load_project(start)?;
3419
3420 let mut stmt = conn.prepare(sql)?;
3421 let refs: Vec<&dyn rusqlite::ToSql> = params_vec
3422 .iter()
3423 .map(|s| s as &dyn rusqlite::ToSql)
3424 .collect();
3425 let rows = stmt.query_map(refs.as_slice(), |row| {
3426 Ok(MemoryExport {
3427 scope: row.get(0)?,
3428 kind: row.get(1)?,
3429 text: row.get(2)?,
3430 confidence: row.get::<_, f64>(3)? as f32,
3431 created_at: row.get(4)?,
3432 })
3433 })?;
3434
3435 let mut out = Vec::new();
3440 let mut report = ScrubReport::default();
3441 for row in rows {
3442 let mut entry = apply_export_redaction(row?, redact, redact_tags);
3443 let scrubbed = crate::redact::scrub_for_export(&entry.text);
3444 for m in &scrubbed.matches {
3445 *report.kinds.entry(m.kind.to_string()).or_insert(0) += 1;
3446 report.total += 1;
3447 }
3448 entry.text = scrubbed.text;
3449 out.push(entry);
3450 }
3451 Ok((out, report))
3452}
3453
3454pub fn import_memories(
3468 start: &Path,
3469 entries: &[MemoryExport],
3470 scope_override: Option<MemoryScope>,
3471) -> KimetsuResult<ImportSummary> {
3472 let mut summary = ImportSummary::default();
3473
3474 let pre_existing_ids: std::collections::HashSet<String> = {
3477 match load_project_readonly(start) {
3480 Ok((_paths, _config, conn)) => {
3481 let mut stmt = conn
3482 .prepare("SELECT memory_id FROM memories WHERE invalidated_at IS NULL")
3483 .unwrap_or_else(|_| conn.prepare("SELECT memory_id FROM memories").unwrap());
3484 stmt.query_map([], |row| row.get::<_, String>(0))
3485 .map(|rows| rows.filter_map(|r| r.ok()).collect())
3486 .unwrap_or_default()
3487 }
3488 Err(_) => std::collections::HashSet::new(),
3489 }
3490 };
3491
3492 let mut this_batch_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
3495
3496 for entry in entries {
3497 let scope = if let Some(ref ov) = scope_override {
3499 *ov
3500 } else {
3501 match entry.scope.parse::<MemoryScope>() {
3502 Ok(s) => s,
3503 Err(_) => {
3504 eprintln!(
3505 "kimetsu-brain import: skipping entry with unknown scope `{}`",
3506 entry.scope
3507 );
3508 summary.deduped += 1;
3509 continue;
3510 }
3511 }
3512 };
3513
3514 let kind = match entry.kind.parse::<MemoryKind>() {
3516 Ok(k) => k,
3517 Err(_) => {
3518 eprintln!(
3519 "kimetsu-brain import: skipping entry with unknown kind `{}`",
3520 entry.kind
3521 );
3522 summary.deduped += 1;
3523 continue;
3524 }
3525 };
3526
3527 match add_memory(start, scope, kind, &entry.text) {
3528 Ok(id) => {
3529 if pre_existing_ids.contains(&id) || !this_batch_ids.insert(id) {
3532 summary.deduped += 1;
3533 } else {
3534 summary.imported += 1;
3535 }
3536 }
3537 Err(e) => {
3538 eprintln!("kimetsu-brain import: failed to add memory: {e}");
3539 summary.deduped += 1;
3540 }
3541 }
3542 }
3543
3544 Ok(summary)
3545}
3546
3547pub fn import_pack(
3552 start: &Path,
3553 entries: &[MemoryExport],
3554 scope_override: Option<MemoryScope>,
3555 replace: bool,
3556 pack: Option<&PackRef>,
3557) -> KimetsuResult<ImportSummary> {
3558 let mut superseded = 0usize;
3559 if replace {
3560 let scopes = pack_target_scopes(entries, scope_override);
3561 let reason = match pack {
3562 Some(p) => format!(
3563 "replaced_by_pack:{}@{}",
3564 p.name.as_deref().unwrap_or("unknown"),
3565 p.version.as_deref().unwrap_or("?")
3566 ),
3567 None => "replaced_by_import".to_string(),
3568 };
3569 for id in active_memory_ids_in_scopes(start, &scopes)? {
3570 invalidate_memory(start, &id, Some(&reason))?;
3571 superseded += 1;
3572 }
3573 }
3574
3575 let scrubbed: Vec<MemoryExport> = entries
3579 .iter()
3580 .map(|e| {
3581 let mut e = e.clone();
3582 e.text = crate::redact::scrub_for_export(&e.text).text;
3583 e
3584 })
3585 .collect();
3586
3587 let _prov = pack.map(|p| {
3589 ImportProvenanceScope::new(serde_json::json!({
3590 "source": "pack",
3591 "pack_name": p.name,
3592 "pack_version": p.version,
3593 }))
3594 });
3595 let mut summary = import_memories(start, &scrubbed, scope_override)?;
3596 summary.superseded = superseded;
3597 Ok(summary)
3598}
3599
3600fn pack_target_scopes(
3602 entries: &[MemoryExport],
3603 scope_override: Option<MemoryScope>,
3604) -> Vec<MemoryScope> {
3605 if let Some(ov) = scope_override {
3606 return vec![ov];
3607 }
3608 let mut seen = std::collections::HashSet::new();
3609 let mut out = Vec::new();
3610 for e in entries {
3611 if let Ok(s) = e.scope.parse::<MemoryScope>() {
3612 if seen.insert(s.to_string()) {
3613 out.push(s);
3614 }
3615 }
3616 }
3617 out
3618}
3619
3620fn active_memory_ids_in_scopes(start: &Path, scopes: &[MemoryScope]) -> KimetsuResult<Vec<String>> {
3622 if scopes.is_empty() {
3623 return Ok(Vec::new());
3624 }
3625 let (_p, _c, conn) = load_project_readonly(start)?;
3626 let mut ids = Vec::new();
3627 for sc in scopes {
3628 let mut stmt = conn.prepare(
3629 "SELECT memory_id FROM memories
3630 WHERE scope = ?1 AND invalidated_at IS NULL AND superseded_by IS NULL",
3631 )?;
3632 let rows = stmt.query_map(params![sc.to_string()], |r| r.get::<_, String>(0))?;
3633 for r in rows {
3634 ids.push(r?);
3635 }
3636 }
3637 Ok(ids)
3638}
3639
3640#[derive(Debug, Clone, serde::Serialize)]
3644pub struct CompactReport {
3645 pub bytes_before: u64,
3647 pub bytes_after: u64,
3649 pub events_trimmed: u64,
3651 pub invalidated_memories_purged: u64,
3653}
3654
3655pub fn compact_brain(
3664 start: &Path,
3665 trim_events_older_than: Option<std::time::Duration>,
3666 purge_invalidated: bool,
3667) -> KimetsuResult<CompactReport> {
3668 let (paths, _config, conn) = load_project(start)?;
3669 let _lock = ProjectLock::acquire(&paths, "brain compact", None)?;
3670
3671 let bytes_before = fs::metadata(&paths.brain_db).map(|m| m.len()).unwrap_or(0);
3673
3674 let invalidated_memories_purged = if purge_invalidated {
3676 let count: i64 = conn.query_row(
3677 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NOT NULL",
3678 [],
3679 |r| r.get(0),
3680 )?;
3681 conn.execute_batch(
3682 "DELETE FROM memories_fts WHERE memory_id IN (
3683 SELECT memory_id FROM memories WHERE invalidated_at IS NOT NULL
3684 );
3685 DELETE FROM memories WHERE invalidated_at IS NOT NULL;",
3686 )?;
3687 count as u64
3688 } else {
3689 0
3690 };
3691
3692 let events_trimmed = if let Some(dur) = trim_events_older_than {
3694 let cutoff_secs = dur.as_secs();
3697 let now_unix = std::time::SystemTime::now()
3698 .duration_since(std::time::UNIX_EPOCH)
3699 .map(|d| d.as_secs())
3700 .unwrap_or(0);
3701 let cutoff_unix = now_unix.saturating_sub(cutoff_secs);
3702 let cutoff_rfc3339 = {
3704 let secs = cutoff_unix as i64;
3705 use time::OffsetDateTime;
3707 use time::format_description::well_known::Rfc3339;
3708 OffsetDateTime::from_unix_timestamp(secs)
3709 .map_err(|e| format!("compact_brain: invalid cutoff timestamp: {e}"))?
3710 .format(&Rfc3339)
3711 .map_err(|e| format!("compact_brain: failed to format cutoff: {e}"))?
3712 };
3713 let count: i64 = conn.query_row(
3714 "SELECT COUNT(*) FROM events WHERE ts < ?1",
3715 rusqlite::params![cutoff_rfc3339],
3716 |r| r.get(0),
3717 )?;
3718 conn.execute(
3719 "DELETE FROM events WHERE ts < ?1",
3720 rusqlite::params![cutoff_rfc3339],
3721 )?;
3722 count as u64
3723 } else {
3724 0
3725 };
3726
3727 conn.execute_batch("VACUUM;")?;
3731
3732 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
3735
3736 let bytes_after = fs::metadata(&paths.brain_db).map(|m| m.len()).unwrap_or(0);
3737
3738 Ok(CompactReport {
3739 bytes_before,
3740 bytes_after,
3741 events_trimmed,
3742 invalidated_memories_purged,
3743 })
3744}
3745
3746#[cfg(test)]
3747mod tests {
3748 use std::fs;
3749
3750 use super::*;
3751 use crate::user_brain::with_user_brain_disabled;
3758
3759 fn test_root() -> std::path::PathBuf {
3766 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
3767 kimetsu_core::paths::git_init_boundary(&root);
3768 root
3769 }
3770
3771 #[test]
3772 fn w1_5_init_creates_kimetsu_dir_but_no_runs_dir() {
3773 with_user_brain_disabled(|| {
3774 let root = test_root();
3775 let summary = init_project(&root, false).expect("init");
3776 assert!(summary.kimetsu_dir.exists(), ".kimetsu/ must exist");
3778 assert!(summary.brain_db.exists(), "brain.db must be created");
3779 assert!(
3780 summary.kimetsu_dir.join("project.toml").exists(),
3781 "project.toml must be written"
3782 );
3783 assert!(
3786 !summary.kimetsu_dir.join("runs").exists(),
3787 "fresh init must NOT create a runs/ dir"
3788 );
3789 });
3790 }
3791
3792 #[test]
3793 fn search_memories_paginates_and_filters_by_kind() {
3794 with_user_brain_disabled(|| {
3795 let root = test_root();
3796 init_project(&root, false).expect("init");
3797 add_memory(
3798 &root,
3799 MemoryScope::Project,
3800 MemoryKind::FailurePattern,
3801 "linker link.exe not found on windows",
3802 )
3803 .expect("add fp");
3804 add_memory(
3805 &root,
3806 MemoryScope::Project,
3807 MemoryKind::Command,
3808 "run cargo build with the link.exe linker on PATH",
3809 )
3810 .expect("add cmd");
3811 add_memory(
3812 &root,
3813 MemoryScope::Project,
3814 MemoryKind::Fact,
3815 "the office plant needs watering on tuesdays",
3816 )
3817 .expect("add fact");
3818
3819 let hits = search_memories(&root, "linker", 10, 0, None, None).expect("search");
3821 assert!(hits.len() >= 2, "expected >=2 hits, got {}", hits.len());
3822 assert!(
3823 hits.iter()
3824 .all(|h| h.text.to_ascii_lowercase().contains("link"))
3825 );
3826
3827 let p1 = search_memories(&root, "linker", 1, 0, None, None).expect("p1");
3829 let p2 = search_memories(&root, "linker", 1, 1, None, None).expect("p2");
3830 assert_eq!(p1.len(), 1);
3831 assert_eq!(p2.len(), 1);
3832 assert_ne!(p1[0].memory_id, p2[0].memory_id, "offset must advance");
3833
3834 let fp =
3836 search_memories(&root, "linker", 10, 0, Some("failure_pattern"), None).expect("fp");
3837 assert!(!fp.is_empty());
3838 assert!(fp.iter().all(|h| h.kind == "failure_pattern"));
3839
3840 assert!(
3842 search_memories(&root, " ", 10, 0, None, None)
3843 .unwrap()
3844 .is_empty()
3845 );
3846 });
3847 }
3848
3849 #[test]
3850 fn reindex_with_explicit_embedder_uses_that_model() {
3851 with_user_brain_disabled(|| {
3852 let root = test_root();
3853 init_project(&root, false).expect("init");
3854 add_memory(
3855 &root,
3856 MemoryScope::Project,
3857 MemoryKind::Fact,
3858 "alpha beta gamma",
3859 )
3860 .expect("add");
3861 use crate::embeddings::Embedder as _;
3865 let stub = crate::embeddings::StubEmbedder::new();
3866 let report = crate::reindex::reindex_all_with_embedder(
3867 &root,
3868 crate::reindex::ReindexOptions {
3869 scope: crate::reindex::ReindexScope::Project,
3870 dry_run: false,
3871 force: false,
3872 limit: None,
3873 },
3874 &stub,
3875 )
3876 .expect("reindex");
3877 assert_eq!(report.embedder_model_id, stub.model_id());
3878 assert!(
3879 report.project.updated >= 1,
3880 "the row should be re-embedded with the stub model"
3881 );
3882 });
3883 }
3884
3885 #[test]
3886 fn retrieve_proactive_returns_actionable_kind_and_excludes_others() {
3887 with_user_brain_disabled(|| {
3888 let root = test_root();
3889 init_project(&root, false).expect("init");
3890 add_memory(
3891 &root,
3892 MemoryScope::Project,
3893 MemoryKind::FailurePattern,
3894 "linker link.exe not found -> run from x64 Native Tools prompt",
3895 )
3896 .expect("add fp");
3897 add_memory(
3900 &root,
3901 MemoryScope::Project,
3902 MemoryKind::Fact,
3903 "linker link.exe trivia: link.exe ships with MSVC",
3904 )
3905 .expect("add fact");
3906
3907 let request = ContextRequest {
3908 stage: "localization".to_string(),
3909 query: "error: linker `link.exe` not found".to_string(),
3910 budget_tokens: 600,
3911 min_score: 0.2,
3912 max_capsules: 1,
3913 kinds: vec!["failure_pattern".to_string(), "command".to_string()],
3914 ..Default::default()
3915 };
3916 let bundle = retrieve_proactive_readonly(&root, request).expect("proactive");
3917 assert!(!bundle.skipped, "should surface the failure_pattern");
3918 assert_eq!(bundle.capsules.len(), 1);
3919 assert!(
3921 bundle.capsules[0].summary.contains("failure_pattern"),
3922 "got summary: {}",
3923 bundle.capsules[0].summary
3924 );
3925 assert!(!bundle.capsules[0].summary.contains("trivia"));
3926 });
3927 }
3928
3929 #[test]
3930 fn memory_add_survives_projection_rebuild_from_trace() {
3931 with_user_brain_disabled(|| {
3932 let root = test_root();
3933 fs::create_dir_all(&root).expect("create temp project");
3934
3935 init_project(&root, false).expect("init project");
3936 let memory_id = add_memory(
3937 &root,
3938 MemoryScope::GlobalUser,
3939 MemoryKind::Preference,
3940 "User prefers Rust for core infrastructure.",
3941 )
3942 .expect("add memory");
3943
3944 let memories = list_memories(&root).expect("list memories");
3945 assert_eq!(memories.len(), 1);
3946 assert_eq!(memories[0].memory_id, memory_id);
3947
3948 let event_count = rebuild_projection(&root, false).expect("rebuild projection");
3949 assert_eq!(event_count, 3);
3950
3951 let memories = list_memories(&root).expect("list rebuilt memories");
3952 assert_eq!(memories.len(), 1);
3953 assert_eq!(memories[0].memory_id, memory_id);
3954 assert_eq!(
3955 memories[0].text,
3956 "User prefers Rust for core infrastructure."
3957 );
3958
3959 fs::remove_dir_all(root).expect("remove temp project");
3960 });
3961 }
3962
3963 #[test]
3967 fn add_memory_redacts_secrets_before_persist() {
3968 with_user_brain_disabled(|| {
3969 let root = test_root();
3970 fs::create_dir_all(&root).expect("create temp project");
3971 init_project(&root, false).expect("init project");
3972
3973 let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
3974 let memory_id =
3975 add_memory(&root, MemoryScope::Repo, MemoryKind::Command, raw).expect("add memory");
3976
3977 let memories = list_memories(&root).expect("list");
3978 let stored = memories
3979 .iter()
3980 .find(|m| m.memory_id == memory_id)
3981 .expect("memory present");
3982 assert!(
3983 !stored.text.contains("sk-ant-api03"),
3984 "raw secret must NOT survive to brain.db: {}",
3985 stored.text
3986 );
3987 assert!(
3988 stored.text.contains("[REDACTED:anthropic_oauth]"),
3989 "placeholder must be present: {}",
3990 stored.text
3991 );
3992 assert!(
3993 stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN") && stored.text.contains(".env"),
3994 "non-secret context must be preserved: {}",
3995 stored.text
3996 );
3997
3998 fs::remove_dir_all(root).expect("cleanup");
3999 });
4000 }
4001
4002 #[test]
4003 fn repo_ingest_indexes_searchable_files_and_context_capsules() {
4004 let root = test_root();
4005 fs::create_dir_all(root.join("src")).expect("create src");
4006 fs::create_dir_all(root.join("target")).expect("create target");
4007 fs::write(
4008 root.join("Cargo.toml"),
4009 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
4010 )
4011 .expect("write manifest");
4012 fs::write(
4013 root.join("src").join("lib.rs"),
4014 "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
4015 )
4016 .expect("write source");
4017 fs::write(
4018 root.join("target").join("generated.rs"),
4019 "projection rebuild",
4020 )
4021 .expect("write skipped");
4022 fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
4023 fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
4024
4025 init_project(&root, false).expect("init project");
4026 add_memory(
4027 &root,
4028 MemoryScope::GlobalUser,
4029 MemoryKind::Preference,
4030 "User prefers Rust for core infrastructure.",
4031 )
4032 .expect("add memory");
4033
4034 let summary = ingest_repo(&root).expect("ingest repo");
4035 assert_eq!(summary.indexed_files, 2);
4036 assert_eq!(summary.manifests, 1);
4037
4038 let matches = search_files(&root, "projection rebuild", 5).expect("search files");
4039 assert!(
4040 matches
4041 .iter()
4042 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
4043 "expected src/lib.rs in search results: {matches:?}"
4044 );
4045 assert!(
4046 matches
4047 .iter()
4048 .all(|capsule| !capsule.expansion_handle.contains("target/")),
4049 "target files must not be indexed: {matches:?}"
4050 );
4051
4052 let context =
4053 retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
4054 assert!(
4055 context
4056 .capsules
4057 .iter()
4058 .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
4059 "expected memory capsule in context: {:?}",
4060 context.capsules
4061 );
4062
4063 rebuild_projection(&root, false).expect("rebuild projection");
4064 let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
4065 assert!(
4066 matches
4067 .iter()
4068 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
4069 "repo index should survive event-only rebuild: {matches:?}"
4070 );
4071
4072 fs::remove_dir_all(root).expect("remove temp project");
4073 }
4074
4075 #[test]
4076 fn run_finished_increments_usefulness_for_injected_memories() {
4077 with_user_brain_disabled(|| {
4078 let root = test_root();
4086 fs::create_dir_all(&root).expect("create temp project");
4087 init_project(&root, false).expect("init project");
4088 let memory_id = add_memory(
4089 &root,
4090 MemoryScope::GlobalUser,
4091 MemoryKind::Preference,
4092 "Prefer ripgrep over grep.",
4093 )
4094 .expect("add memory");
4095
4096 {
4097 let (paths, _config, conn) = load_project(&root).expect("load");
4098 let run_id = RunId::new();
4099 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4100 let evs: Vec<Event> = vec![
4101 Event::new(
4102 run_id,
4103 "run.started",
4104 serde_json::json!({"project_id": "test", "task": "x"}),
4105 ),
4106 Event::new(
4107 run_id,
4108 "context.injected",
4109 serde_json::json!({
4110 "stage": "localization",
4111 "capsule_handles": [format!("memory:{memory_id}")],
4112 "memory_ids": [memory_id.clone()],
4113 "prior_run_ids": [],
4114 "file_paths": [],
4115 }),
4116 ),
4117 Event::new(
4118 run_id,
4119 "context.injected",
4120 serde_json::json!({
4121 "stage": "patch_plan",
4122 "capsule_handles": [format!("memory:{memory_id}")],
4123 "memory_ids": [memory_id.clone()],
4124 "prior_run_ids": [],
4125 "file_paths": [],
4126 }),
4127 ),
4128 Event::new(
4131 run_id,
4132 "memory.cited",
4133 serde_json::json!({
4134 "memory_id": memory_id,
4135 "turn": 3,
4136 "rationale": "using rg from memory",
4137 }),
4138 ),
4139 Event::new(
4140 run_id,
4141 "run.finished",
4142 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
4143 ),
4144 ];
4145 for ev in &evs {
4146 writer.append(ev, true).expect("append");
4147 }
4148 projector::apply_events(&conn, &evs).expect("project");
4149 }
4150
4151 let memories = list_memories(&root).expect("list memories");
4152 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4153 assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
4154 let expected = 1.0 + 0.05; assert!(
4158 (m.usefulness_score - expected).abs() < 1e-4,
4159 "expected strong-signal usefulness_score = {expected}, got {}",
4160 m.usefulness_score
4161 );
4162
4163 fs::remove_dir_all(root).expect("remove temp project");
4164 });
4165 }
4166
4167 #[test]
4172 fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
4173 with_user_brain_disabled(|| {
4174 let root = test_root();
4175 fs::create_dir_all(&root).expect("create temp project");
4176 init_project(&root, false).expect("init project");
4177 let memory_id = add_memory(
4178 &root,
4179 MemoryScope::GlobalUser,
4180 MemoryKind::Preference,
4181 "Silent passenger memory.",
4182 )
4183 .expect("add memory");
4184
4185 {
4186 let (paths, _config, conn) = load_project(&root).expect("load");
4187 let run_id = RunId::new();
4188 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4189 let evs: Vec<Event> = vec![
4190 Event::new(
4191 run_id,
4192 "run.started",
4193 serde_json::json!({"project_id": "test", "task": "x"}),
4194 ),
4195 Event::new(
4196 run_id,
4197 "context.injected",
4198 serde_json::json!({
4199 "stage": "localization",
4200 "memory_ids": [memory_id.clone()],
4201 "prior_run_ids": [],
4202 "file_paths": [],
4203 }),
4204 ),
4205 Event::new(
4207 run_id,
4208 "run.finished",
4209 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
4210 ),
4211 ];
4212 for ev in &evs {
4213 writer.append(ev, true).expect("append");
4214 }
4215 projector::apply_events(&conn, &evs).expect("project");
4216 }
4217
4218 let memories = list_memories(&root).expect("list memories");
4219 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4220 assert_eq!(m.use_count, 1);
4221 let expected = 0.1 + 0.05; assert!(
4225 (m.usefulness_score - expected).abs() < 1e-4,
4226 "silent passenger should get +0.1 on top of seed, got {}",
4227 m.usefulness_score
4228 );
4229 });
4230 }
4231
4232 #[test]
4238 fn blame_run_separates_cited_from_silent_passengers() {
4239 with_user_brain_disabled(|| {
4240 let root = test_root();
4241 fs::create_dir_all(&root).expect("create temp project");
4242 init_project(&root, false).expect("init project");
4243 let cited_id = add_memory(
4244 &root,
4245 MemoryScope::Repo,
4246 MemoryKind::Preference,
4247 "prefer ripgrep over grep",
4248 )
4249 .expect("add cited");
4250 let silent_id = add_memory(
4251 &root,
4252 MemoryScope::Repo,
4253 MemoryKind::Convention,
4254 "use cargo nextest for tests",
4255 )
4256 .expect("add silent");
4257
4258 let run_id = RunId::new();
4259 {
4260 let (paths, _config, conn) = load_project(&root).expect("load");
4261 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4262 let evs: Vec<Event> = vec![
4263 Event::new(
4264 run_id,
4265 "run.started",
4266 serde_json::json!({"project_id": "test", "task": "x"}),
4267 ),
4268 Event::new(
4269 run_id,
4270 "context.injected",
4271 serde_json::json!({
4272 "stage": "localization",
4273 "memory_ids": [cited_id.clone(), silent_id.clone()],
4274 "prior_run_ids": [],
4275 "file_paths": [],
4276 }),
4277 ),
4278 Event::new(
4279 run_id,
4280 "memory.cited",
4281 serde_json::json!({
4282 "memory_id": cited_id,
4283 "turn": 4,
4284 "rationale": "used the rg pattern",
4285 }),
4286 ),
4287 Event::new(
4288 run_id,
4289 "run.finished",
4290 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
4291 ),
4292 ];
4293 for ev in &evs {
4294 writer.append(ev, true).expect("append");
4295 }
4296 projector::apply_events(&conn, &evs).expect("project");
4297 }
4298
4299 let report = blame_run(&root, &run_id.to_string()).expect("blame");
4300 assert_eq!(report.outcome, "success");
4301 assert!(report.failure_category.is_none());
4302 assert_eq!(report.cited.len(), 1, "exactly one cited memory");
4303 let cited = &report.cited[0];
4304 assert_eq!(cited.memory_id, cited_id);
4305 assert_eq!(cited.turn, 4);
4306 assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
4307 assert!(cited.text_preview.contains("ripgrep"));
4308
4309 assert_eq!(report.silent_passengers.len(), 1);
4310 let silent = &report.silent_passengers[0];
4311 assert_eq!(silent.memory_id, silent_id);
4312 assert!(silent.text_preview.contains("nextest"));
4313
4314 fs::remove_dir_all(root).expect("cleanup");
4315 });
4316 }
4317
4318 #[test]
4319 fn run_failed_decrements_usefulness_unless_gate() {
4320 let root = test_root();
4324 fs::create_dir_all(&root).expect("create temp project");
4325 init_project(&root, false).expect("init project");
4326 let memory_id = add_memory(
4327 &root,
4328 MemoryScope::Repo,
4329 MemoryKind::Convention,
4330 "Use find_* for fallible lookups.",
4331 )
4332 .expect("add memory");
4333
4334 {
4335 let (paths, _config, conn) = load_project(&root).expect("load");
4336
4337 let gate_run = RunId::new();
4339 let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
4340 let gate_events: Vec<Event> = vec![
4341 Event::new(
4342 gate_run,
4343 "run.started",
4344 serde_json::json!({"project_id": "test", "task": "g"}),
4345 ),
4346 Event::new(
4347 gate_run,
4348 "context.injected",
4349 serde_json::json!({
4350 "stage": "patch_plan",
4351 "capsule_handles": [format!("memory:{memory_id}")],
4352 "memory_ids": [memory_id.clone()],
4353 "prior_run_ids": [],
4354 "file_paths": [],
4355 }),
4356 ),
4357 Event::new(
4358 gate_run,
4359 "run.failed",
4360 serde_json::json!({
4361 "category": "Gate",
4362 "failed_stage": "patch_plan",
4363 "message": "files_to_create_already_exist",
4364 }),
4365 ),
4366 ];
4367 for ev in &gate_events {
4368 writer.append(ev, true).expect("append");
4369 }
4370 projector::apply_events(&conn, &gate_events).expect("project gate-fail");
4371
4372 let impl_run = RunId::new();
4375 let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
4376 let impl_events: Vec<Event> = vec![
4377 Event::new(
4378 impl_run,
4379 "run.started",
4380 serde_json::json!({"project_id": "test", "task": "i"}),
4381 ),
4382 Event::new(
4383 impl_run,
4384 "context.injected",
4385 serde_json::json!({
4386 "stage": "patch_plan",
4387 "capsule_handles": [format!("memory:{memory_id}")],
4388 "memory_ids": [memory_id.clone()],
4389 "prior_run_ids": [],
4390 "file_paths": [],
4391 }),
4392 ),
4393 Event::new(
4396 impl_run,
4397 "memory.cited",
4398 serde_json::json!({
4399 "memory_id": memory_id,
4400 "turn": 2,
4401 "rationale": "trusted the memory's pattern",
4402 }),
4403 ),
4404 Event::new(
4405 impl_run,
4406 "run.failed",
4407 serde_json::json!({
4408 "category": "Implementation",
4409 "failed_stage": "implementation",
4410 "message": "test broke",
4411 }),
4412 ),
4413 ];
4414 for ev in &impl_events {
4415 writer2.append(ev, true).expect("append");
4416 }
4417 projector::apply_events(&conn, &impl_events).expect("project impl-fail");
4418 }
4419
4420 let memories = list_memories(&root).expect("list memories");
4421 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4422 assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
4423 let expected = 0.15 - 1.0; assert!(
4427 (m.usefulness_score - expected).abs() < 1e-4,
4428 "expected usefulness_score = {expected}, got {}",
4429 m.usefulness_score
4430 );
4431
4432 fs::remove_dir_all(root).expect("remove temp project");
4433 }
4434
4435 #[test]
4436 fn run_aborted_does_not_update_usefulness() {
4437 let root = test_root();
4438 fs::create_dir_all(&root).expect("create temp project");
4439 init_project(&root, false).expect("init project");
4440 let memory_id = add_memory(
4441 &root,
4442 MemoryScope::Repo,
4443 MemoryKind::Convention,
4444 "Module re-exports live in lib.rs.",
4445 )
4446 .expect("add memory");
4447
4448 {
4449 let (paths, _config, conn) = load_project(&root).expect("load");
4450 let run_id = RunId::new();
4451 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
4452 let evs: Vec<Event> = vec![
4453 Event::new(
4454 run_id,
4455 "run.started",
4456 serde_json::json!({"project_id": "test", "task": "a"}),
4457 ),
4458 Event::new(
4459 run_id,
4460 "context.injected",
4461 serde_json::json!({
4462 "stage": "patch_plan",
4463 "capsule_handles": [format!("memory:{memory_id}")],
4464 "memory_ids": [memory_id.clone()],
4465 "prior_run_ids": [],
4466 "file_paths": [],
4467 }),
4468 ),
4469 Event::new(
4470 run_id,
4471 "run.aborted",
4472 serde_json::json!({"reason": "user_abort"}),
4473 ),
4474 ];
4475 for ev in &evs {
4476 writer.append(ev, true).expect("append");
4477 }
4478 projector::apply_events(&conn, &evs).expect("project");
4479 }
4480
4481 let memories = list_memories(&root).expect("list memories");
4482 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
4483 assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
4484 let expected_seed = 0.15_f32; assert!(
4488 (m.usefulness_score - expected_seed).abs() < 1e-4,
4489 "expected usefulness_score = {expected_seed} (initial seed only), got {}",
4490 m.usefulness_score
4491 );
4492
4493 fs::remove_dir_all(root).expect("remove temp project");
4494 }
4495
4496 #[test]
4497 fn list_proposals_filters_and_reject_records_reason() {
4498 let root = test_root();
4499 fs::create_dir_all(&root).expect("create temp project");
4500 init_project(&root, false).expect("init project");
4501
4502 let proposals = [
4504 (
4505 "p1",
4506 "global_user",
4507 "preference",
4508 0.9_f32,
4509 "Prefer rg over grep",
4510 ),
4511 (
4512 "p2",
4513 "repo",
4514 "convention",
4515 0.8,
4516 "Use find_* for fallible lookups",
4517 ),
4518 (
4519 "p3",
4520 "repo",
4521 "convention",
4522 0.4,
4523 "Use let-else where possible",
4524 ),
4525 ];
4526 {
4527 let (paths, _config, conn) = load_project(&root).expect("load");
4528 let run_id = RunId::new();
4529 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
4530 for (proposal_id, scope, kind, conf, text) in &proposals {
4531 let event = Event::new(
4532 run_id,
4533 "memory.proposed",
4534 serde_json::json!({
4535 "proposal_id": proposal_id,
4536 "scope": scope,
4537 "kind": kind,
4538 "text": text,
4539 "rationale": "test rationale",
4540 "proposed_confidence": conf,
4541 "source_event_ids": [],
4542 }),
4543 );
4544 writer.append(&event, true).expect("append proposal");
4545 projector::apply_events(&conn, &[event]).expect("project");
4546 }
4547 }
4548
4549 let global = list_proposals(
4551 &root,
4552 ProposalFilter {
4553 scope: Some("global_user".into()),
4554 status: Some("pending".into()),
4555 ..ProposalFilter::default()
4556 },
4557 )
4558 .expect("list proposals");
4559 assert_eq!(global.len(), 1);
4560 assert_eq!(global[0].proposal_id, "p1");
4561
4562 let strong = list_proposals(
4564 &root,
4565 ProposalFilter {
4566 min_confidence: Some(0.7),
4567 status: Some("pending".into()),
4568 ..ProposalFilter::default()
4569 },
4570 )
4571 .expect("list strong");
4572 assert_eq!(strong.len(), 2);
4573 for row in &strong {
4574 assert!(row.proposed_confidence >= 0.7);
4575 }
4576
4577 reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
4579 let rejected = list_proposals(
4580 &root,
4581 ProposalFilter {
4582 status: Some("rejected".into()),
4583 ..ProposalFilter::default()
4584 },
4585 )
4586 .expect("list rejected");
4587 assert_eq!(rejected.len(), 1);
4588 assert_eq!(rejected[0].proposal_id, "p3");
4589 assert_eq!(
4590 rejected[0].decided_reason.as_deref(),
4591 Some("not specific to the user")
4592 );
4593
4594 let memory_id = accept_proposal(
4597 &root,
4598 "p1",
4599 AcceptOverrides {
4600 scope: None,
4601 confidence: Some(0.55),
4602 },
4603 )
4604 .expect("accept");
4605 let memories = list_memories(&root).expect("list memories");
4606 let promoted = memories
4607 .into_iter()
4608 .find(|m| m.memory_id == memory_id)
4609 .expect("promoted memory present");
4610 assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
4611
4612 fs::remove_dir_all(root).expect("remove temp project");
4613 }
4614
4615 #[test]
4620 fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
4621 let root = test_root();
4622 fs::create_dir_all(&root).expect("create temp project");
4623 init_project(&root, false).expect("init project");
4624
4625 let memory_id = add_memory(
4626 &root,
4627 MemoryScope::Repo,
4628 MemoryKind::Convention,
4629 "Use find_* for fallible lookups.",
4630 )
4631 .expect("add memory");
4632
4633 invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
4634 .expect("invalidate memory");
4635
4636 {
4639 let (_paths, _config, conn) = load_project(&root).expect("load");
4640 let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
4641 .query_row(
4642 "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
4643 params![memory_id],
4644 |row| Ok((row.get(0)?, row.get(1)?)),
4645 )
4646 .expect("query invalidated metadata");
4647 assert!(invalidated_at.is_some(), "invalidated_at must be set");
4648 assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
4649 }
4650
4651 rebuild_projection(&root, false).expect("rebuild projection");
4653 {
4654 let (_paths, _config, conn) = load_project(&root).expect("load");
4655 let invalidated_at: Option<String> = conn
4656 .query_row(
4657 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
4658 params![memory_id],
4659 |row| row.get(0),
4660 )
4661 .expect("query after rebuild");
4662 assert!(
4663 invalidated_at.is_some(),
4664 "invalidated_at must survive event replay"
4665 );
4666 }
4667
4668 fs::remove_dir_all(root).expect("remove temp project");
4669 }
4670
4671 #[test]
4675 fn invalidated_memory_is_excluded_from_broker_retrieval() {
4676 with_user_brain_disabled(|| {
4677 let root = test_root();
4678 fs::create_dir_all(&root).expect("create temp project");
4679 init_project(&root, false).expect("init project");
4680
4681 let memory_id = add_memory(
4682 &root,
4683 MemoryScope::GlobalUser,
4684 MemoryKind::Preference,
4685 "Prefer ripgrep over grep for repo search.",
4686 )
4687 .expect("add memory");
4688
4689 let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
4691 .expect("pre context");
4692 assert!(
4693 pre.capsules
4694 .iter()
4695 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
4696 "memory must appear before invalidation: {:?}",
4697 pre.capsules
4698 );
4699
4700 invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
4701
4702 let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
4703 .expect("post context");
4704 assert!(
4705 post.capsules
4706 .iter()
4707 .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
4708 "invalidated memory must not be retrieved: {:?}",
4709 post.capsules
4710 );
4711
4712 {
4716 let (_paths2, _config2, conn2) = load_project(&root).expect("load for check");
4717 let still_there: i64 = conn2
4718 .query_row(
4719 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
4720 rusqlite::params![&memory_id],
4721 |row| row.get(0),
4722 )
4723 .expect("db query");
4724 assert_eq!(still_there, 1, "invalidated row must persist in brain.db");
4725 } let active = list_memories(&root).expect("list after invalidation");
4728 assert!(
4729 active.iter().all(|m| m.memory_id != memory_id),
4730 "invalidated memory must not appear in list_memories"
4731 );
4732
4733 fs::remove_dir_all(root).expect("remove temp project");
4734 });
4735 }
4736
4737 #[test]
4742 fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
4743 let root = test_root();
4744 fs::create_dir_all(&root).expect("create temp project");
4745 init_project(&root, false).expect("init project");
4746
4747 let m_great =
4748 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
4749 let m_meh =
4750 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
4751 let m_bad =
4752 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
4753 let _m_fresh =
4754 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
4755
4756 {
4759 let (_paths, _config, conn) = load_project(&root).expect("load");
4760 conn.execute(
4761 "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
4762 params![m_great],
4763 )
4764 .expect("set great");
4765 conn.execute(
4766 "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
4767 params![m_meh],
4768 )
4769 .expect("set meh");
4770 conn.execute(
4771 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
4772 params![m_bad],
4773 )
4774 .expect("set bad");
4775 }
4777
4778 let top = list_memories_top(
4779 &root,
4780 TopOptions {
4781 scope: None,
4782 min_uses: 3,
4783 limit: 10,
4784 },
4785 )
4786 .expect("top");
4787 assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
4788 assert_eq!(top[0].memory_id, m_great);
4789 assert_eq!(top[1].memory_id, m_meh);
4790 assert_eq!(top[2].memory_id, m_bad);
4791
4792 invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
4794 let top_after = list_memories_top(
4795 &root,
4796 TopOptions {
4797 scope: None,
4798 min_uses: 3,
4799 limit: 10,
4800 },
4801 )
4802 .expect("top after");
4803 assert_eq!(top_after.len(), 2);
4804 assert!(top_after.iter().all(|m| m.memory_id != m_great));
4805
4806 fs::remove_dir_all(root).expect("remove temp project");
4807 }
4808
4809 #[test]
4815 fn prune_low_usefulness_dry_run_then_apply() {
4816 with_user_brain_disabled(|| {
4817 prune_low_usefulness_dry_run_then_apply_body();
4818 });
4819 }
4820
4821 fn prune_low_usefulness_dry_run_then_apply_body() {
4822 let root = test_root();
4823 fs::create_dir_all(&root).expect("create temp project");
4824 init_project(&root, false).expect("init project");
4825
4826 let m_keep = add_memory(
4827 &root,
4828 MemoryScope::Repo,
4829 MemoryKind::Convention,
4830 "keep me, I help",
4831 )
4832 .expect("keep");
4833 let m_drop_1 = add_memory(
4834 &root,
4835 MemoryScope::Repo,
4836 MemoryKind::Convention,
4837 "drop me, I hurt",
4838 )
4839 .expect("drop1");
4840 let m_drop_2 = add_memory(
4841 &root,
4842 MemoryScope::Repo,
4843 MemoryKind::Convention,
4844 "drop me too",
4845 )
4846 .expect("drop2");
4847 let m_small_sample = add_memory(
4848 &root,
4849 MemoryScope::Repo,
4850 MemoryKind::Convention,
4851 "small sample shouldn't be pruned even if score is bad",
4852 )
4853 .expect("small");
4854
4855 {
4856 let (_paths, _config, conn) = load_project(&root).expect("load");
4857 conn.execute(
4859 "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
4860 params![m_keep],
4861 )
4862 .expect("set keep");
4863 conn.execute(
4865 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
4866 params![m_drop_1],
4867 )
4868 .expect("set drop1");
4869 conn.execute(
4871 "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
4872 params![m_drop_2],
4873 )
4874 .expect("set drop2");
4875 conn.execute(
4877 "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
4878 params![m_small_sample],
4879 )
4880 .expect("set small");
4881 }
4882
4883 let dry = prune_low_usefulness(
4885 &root,
4886 PruneOptions {
4887 scope: None,
4888 min_uses: 3,
4889 max_ratio: -0.2,
4890 apply: false,
4891 },
4892 )
4893 .expect("dry-run");
4894 assert_eq!(dry.candidates.len(), 2);
4895 assert_eq!(dry.invalidated, 0);
4896 let ids: Vec<&str> = dry
4897 .candidates
4898 .iter()
4899 .map(|c| c.memory_id.as_str())
4900 .collect();
4901 assert!(ids.contains(&m_drop_1.as_str()));
4902 assert!(ids.contains(&m_drop_2.as_str()));
4903 assert!(!ids.contains(&m_small_sample.as_str()));
4905
4906 let pre = list_memories(&root).expect("pre");
4908 assert_eq!(pre.len(), 4);
4909
4910 let applied = prune_low_usefulness(
4912 &root,
4913 PruneOptions {
4914 scope: None,
4915 min_uses: 3,
4916 max_ratio: -0.2,
4917 apply: true,
4918 },
4919 )
4920 .expect("apply");
4921 assert_eq!(applied.candidates.len(), 2);
4922 assert_eq!(applied.invalidated, 2);
4923 assert_eq!(applied.failed, 0);
4924
4925 let top = list_memories_top(
4929 &root,
4930 TopOptions {
4931 scope: None,
4932 min_uses: 3,
4933 limit: 10,
4934 },
4935 )
4936 .expect("top after prune");
4937 assert_eq!(top.len(), 1);
4938 assert_eq!(top[0].memory_id, m_keep);
4939
4940 {
4945 let (_paths, _config, conn) = load_project(&root).expect("load");
4946 let reason: String = conn
4947 .query_row(
4948 "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
4949 params![m_drop_1],
4950 |row| row.get(0),
4951 )
4952 .expect("invalidated reason");
4953 assert!(
4954 reason.starts_with("pruned_by_usefulness"),
4955 "unexpected reason: {reason}"
4956 );
4957 }
4958
4959 fs::remove_dir_all(root).expect("remove temp project");
4960 }
4961
4962 #[test]
4969 fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
4970 with_user_brain_disabled(|| {
4971 batch_review_accepts_filtered_subset_and_rejects_remainder_body();
4972 });
4973 }
4974
4975 fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
4976 let root = test_root();
4977 fs::create_dir_all(&root).expect("create temp project");
4978 init_project(&root, false).expect("init project");
4979
4980 let run_a = RunId::new();
4981 let run_b = RunId::new();
4982
4983 let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
4987 (
4988 "p_a_strong",
4989 run_a,
4990 "global_user",
4991 "preference",
4992 0.92,
4993 "Prefer rg over grep",
4994 ),
4995 (
4996 "p_a_weak",
4997 run_a,
4998 "repo",
4999 "convention",
5000 0.55,
5001 "Always use let-else",
5002 ),
5003 (
5004 "p_b1",
5005 run_b,
5006 "repo",
5007 "convention",
5008 0.70,
5009 "Use Result not panic",
5010 ),
5011 (
5012 "p_b2",
5013 run_b,
5014 "global_user",
5015 "preference",
5016 0.88,
5017 "Open links in new tab",
5018 ),
5019 ];
5020
5021 {
5022 let (paths, _config, conn) = load_project(&root).expect("load");
5023 for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
5024 let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
5025 let event = Event::new(
5026 *run_id,
5027 "memory.proposed",
5028 serde_json::json!({
5029 "proposal_id": proposal_id,
5030 "scope": scope,
5031 "kind": kind,
5032 "text": text,
5033 "rationale": "fixture",
5034 "proposed_confidence": conf,
5035 "source_event_ids": [],
5036 }),
5037 );
5038 writer.append(&event, true).expect("append");
5039 projector::apply_events(&conn, &[event]).expect("project");
5040 }
5041 }
5042
5043 let to_accept = list_proposals(
5046 &root,
5047 ProposalFilter {
5048 from_run: Some(run_a.to_string()),
5049 min_confidence: Some(0.8),
5050 status: Some("pending".into()),
5051 limit: 100,
5052 ..ProposalFilter::default()
5053 },
5054 )
5055 .expect("list strong from run_a");
5056 assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
5057 assert_eq!(to_accept[0].proposal_id, "p_a_strong");
5058 let memory_id =
5059 accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
5060 .expect("accept p_a_strong");
5061
5062 let to_reject = list_proposals(
5065 &root,
5066 ProposalFilter {
5067 status: Some("pending".into()),
5068 limit: 100,
5069 ..ProposalFilter::default()
5070 },
5071 )
5072 .expect("list remaining pending");
5073 assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
5074 for p in &to_reject {
5075 reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
5076 }
5077
5078 let memories = list_memories(&root).expect("list memories");
5081 assert_eq!(
5082 memories.len(),
5083 1,
5084 "only the accepted proposal becomes a memory"
5085 );
5086 assert_eq!(memories[0].memory_id, memory_id);
5087
5088 let pending = list_proposals(
5089 &root,
5090 ProposalFilter {
5091 status: Some("pending".into()),
5092 limit: 100,
5093 ..ProposalFilter::default()
5094 },
5095 )
5096 .expect("list pending");
5097 assert!(
5098 pending.is_empty(),
5099 "no proposals left pending after batch review"
5100 );
5101
5102 let rejected = list_proposals(
5103 &root,
5104 ProposalFilter {
5105 status: Some("rejected".into()),
5106 limit: 100,
5107 ..ProposalFilter::default()
5108 },
5109 )
5110 .expect("list rejected");
5111 assert_eq!(rejected.len(), 3);
5112 for row in &rejected {
5113 assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
5114 }
5115
5116 fs::remove_dir_all(root).expect("remove temp project");
5117 }
5118
5119 #[test]
5135 fn add_memory_distinct_texts_no_conflicts() {
5136 with_user_brain_disabled(|| {
5137 let root = test_root();
5138 fs::create_dir_all(&root).expect("create temp project");
5139 init_project(&root, false).expect("init project");
5140
5141 let _m1 = add_memory(
5146 &root,
5147 MemoryScope::GlobalUser,
5148 MemoryKind::Preference,
5149 "Prefer thiserror for library error types.",
5150 )
5151 .expect("add m1");
5152 let _m2 = add_memory(
5153 &root,
5154 MemoryScope::GlobalUser,
5155 MemoryKind::Preference,
5156 "Cache HTTP responses with a one-hour TTL.",
5157 )
5158 .expect("add m2");
5159
5160 let open = list_conflicts(&root, 50).expect("list_conflicts");
5161 assert!(
5162 open.is_empty(),
5163 "distinct-topic memories must not conflict; got {} rows",
5164 open.len()
5165 );
5166
5167 let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
5169 .expect("resolve_conflict on unknown id");
5170 assert!(!resolved, "unknown conflict id should resolve to false");
5171
5172 let err = resolve_conflict(&root, "does-not-exist", "garbage")
5174 .expect_err("invalid resolution should error");
5175 assert!(format!("{err}").contains("invalid conflict resolution"));
5176
5177 fs::remove_dir_all(root).expect("remove temp project");
5178 });
5179 }
5180
5181 #[test]
5188 fn load_project_rejects_future_config_version() {
5189 with_user_brain_disabled(|| {
5190 use kimetsu_core::KIMETSU_CONFIG_VERSION;
5191 let root = test_root();
5192 fs::create_dir_all(&root).expect("create temp project");
5193 let paths = kimetsu_core::paths::ProjectPaths::discover(&root)
5195 .expect("discover paths after git_init_boundary");
5196 fs::create_dir_all(&paths.kimetsu_dir).expect("create .kimetsu dir");
5197 let bad_version = KIMETSU_CONFIG_VERSION + 1;
5198 let toml_str = format!(
5199 r#"
5200[kimetsu]
5201project_id = "test-config-gate"
5202schema_version = {bad_version}
5203
5204[model]
5205provider = "anthropic"
5206model = "claude-opus-4-7"
5207api_key_env = "ANTHROPIC_API_KEY"
5208max_output_tokens = 8192
5209temperature = 0.2
5210request_timeout_secs = 120
5211
5212[broker]
5213default_budget_tokens = 6000
5214
5215[broker.weights]
5216relevance = 0.5
5217confidence = 0.2
5218freshness = 0.2
5219scope = 0.1
5220
5221[shell]
5222default_timeout_secs = 60
5223max_timeout_secs = 600
5224env_allowlist_extra = []
5225redact_secrets = true
5226
5227[ingestion]
5228max_file_bytes = 524288
5229extra_skip_dirs = []
5230max_total_files = 50000
5231
5232[run]
5233max_total_tool_calls = 60
5234max_total_model_turns = 30
5235max_total_cost_usd = 250.0
5236"#
5237 );
5238 fs::write(&paths.project_toml, &toml_str).expect("write bad project.toml");
5239 let err = load_project(&root).expect_err("future config version must be rejected");
5240 let msg = format!("{err}");
5241 assert!(
5242 msg.contains(&bad_version.to_string()),
5243 "error message should mention the bad version; got: {msg}"
5244 );
5245 assert!(
5246 msg.contains(&KIMETSU_CONFIG_VERSION.to_string()),
5247 "error message should mention the expected version; got: {msg}"
5248 );
5249 fs::remove_dir_all(root).expect("remove temp project");
5250 });
5251 }
5252
5253 fn make_dangling_run(root: &std::path::Path) -> RunId {
5257 let (paths, _config, conn) = load_project(root).expect("load project");
5258 let run_id = RunId::new();
5259 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("create trace");
5260 let started = Event::new(
5261 run_id,
5262 "run.started",
5263 serde_json::json!({"project_id": "test", "task": "dangling task"}),
5264 );
5265 writer.append(&started, true).expect("append started");
5266 projector::apply_events(&conn, &[started]).expect("project started");
5267 run_id
5268 }
5269
5270 #[test]
5271 fn abort_run_stamps_aborted_and_frees_lock() {
5272 with_user_brain_disabled(|| {
5273 let root = test_root();
5274 fs::create_dir_all(&root).expect("mkdir");
5275 init_project(&root, false).expect("init");
5276
5277 let run_id = make_dangling_run(&root);
5278
5279 abort_run(&root, &run_id.to_string()).expect("abort_run");
5281
5282 let run = show_run(&root, &run_id.to_string())
5284 .expect("show_run")
5285 .expect("run exists");
5286 assert_eq!(
5287 run.terminal_kind.as_deref(),
5288 Some("run.aborted"),
5289 "terminal_kind should be run.aborted"
5290 );
5291
5292 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
5294 assert!(
5295 !paths.lock_file.exists(),
5296 "lock file should not exist after abort"
5297 );
5298
5299 fs::remove_dir_all(root).expect("cleanup");
5300 });
5301 }
5302
5303 #[test]
5304 fn abort_run_already_finished_returns_err() {
5305 with_user_brain_disabled(|| {
5306 let root = test_root();
5307 fs::create_dir_all(&root).expect("mkdir");
5308 init_project(&root, false).expect("init");
5309
5310 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "some fact")
5312 .expect("add memory");
5313
5314 let runs = list_runs(&root).expect("list runs");
5315 assert!(!runs.is_empty(), "should have at least one run");
5316 let finished_run = runs
5317 .iter()
5318 .find(|r| r.terminal_kind.is_some())
5319 .expect("should have a finished run");
5320
5321 let err = abort_run(&root, &finished_run.run_id)
5322 .expect_err("aborting a finished run should error");
5323 let msg = format!("{err}");
5324 assert!(
5325 msg.contains("already terminal"),
5326 "error should mention 'already terminal', got: {msg}"
5327 );
5328
5329 fs::remove_dir_all(root).expect("cleanup");
5330 });
5331 }
5332
5333 #[test]
5334 fn abort_run_unknown_id_returns_err() {
5335 with_user_brain_disabled(|| {
5336 let root = test_root();
5337 fs::create_dir_all(&root).expect("mkdir");
5338 init_project(&root, false).expect("init");
5339
5340 let fake_id = RunId::new().to_string();
5341 let err = abort_run(&root, &fake_id).expect_err("aborting an unknown run should error");
5342 let msg = format!("{err}");
5343 assert!(
5344 msg.contains("unknown run_id"),
5345 "error should mention 'unknown run_id', got: {msg}"
5346 );
5347
5348 fs::remove_dir_all(root).expect("cleanup");
5349 });
5350 }
5351
5352 #[test]
5358 fn rebuild_from_events_table_restores_memories() {
5359 with_user_brain_disabled(|| {
5360 let root = test_root();
5361 fs::create_dir_all(&root).expect("create temp project");
5362 init_project(&root, false).expect("init project");
5363
5364 let id1 = add_memory(
5365 &root,
5366 MemoryScope::Repo,
5367 MemoryKind::Convention,
5368 "W1.3: prefer explicit error types over anyhow in library crates",
5369 )
5370 .expect("add memory 1");
5371 let id2 = add_memory(
5372 &root,
5373 MemoryScope::Repo,
5374 MemoryKind::Command,
5375 "W1.3: run cargo fmt --all before committing",
5376 )
5377 .expect("add memory 2");
5378
5379 {
5381 let (_paths, _config, conn) = load_project(&root).expect("load");
5382 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
5383 .expect("wipe derived tables");
5384 }
5385
5386 let gone = list_memories(&root).expect("list after wipe");
5388 assert_eq!(gone.len(), 0, "derived tables should be empty after wipe");
5389
5390 let count = rebuild_projection(&root, false).expect("rebuild_projection");
5392 assert!(
5393 count > 0,
5394 "should have replayed at least one event; got {count}"
5395 );
5396
5397 let restored = list_memories(&root).expect("list after rebuild");
5399 assert_eq!(
5400 restored.len(),
5401 2,
5402 "both memories should be restored after rebuild; got {:?}",
5403 restored.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
5404 );
5405 let ids: Vec<_> = restored.iter().map(|m| m.memory_id.clone()).collect();
5406 assert!(ids.contains(&id1), "id1 must be restored");
5407 assert!(ids.contains(&id2), "id2 must be restored");
5408
5409 fs::remove_dir_all(root).expect("cleanup");
5410 });
5411 }
5412
5413 #[test]
5422 fn rebuild_from_traces_flag_reimports_on_disk_traces() {
5423 with_user_brain_disabled(|| {
5424 let root = test_root();
5425 fs::create_dir_all(&root).expect("create temp project");
5426 init_project(&root, false).expect("init project");
5427
5428 let memory_id = Ulid::new().to_string();
5432 let run_id = RunId::new();
5433 {
5434 let (paths, config, conn) = load_project(&root).expect("load");
5435 let (mut writer, _run_paths) =
5436 TraceWriter::create(&paths, run_id).expect("trace writer");
5437 let text = "W1.3: from_traces re-imports events from trace.jsonl files";
5438 let normalized = kimetsu_core::memory::normalize_memory_text(text);
5439 let evs: Vec<Event> = vec![
5440 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
5441 Event::new(
5442 run_id,
5443 "memory.accepted",
5444 serde_json::json!({
5445 "proposal_id": null,
5446 "memory_id": memory_id,
5447 "scope": "repo",
5448 "kind": "fact",
5449 "text": text,
5450 "normalized_text": normalized,
5451 "confidence": 1.0,
5452 "provenance_snapshot": {
5453 "source": "manual_cli",
5454 "run_id": run_id.to_string(),
5455 "text": text,
5456 }
5457 }),
5458 ),
5459 admin_finished_event(run_id),
5460 ];
5461 for ev in &evs {
5462 writer.append(ev, true).expect("append");
5463 }
5464 projector::apply_events(&conn, &evs).expect("apply");
5466 }
5467
5468 let initial = list_memories(&root).expect("list initial");
5470 assert_eq!(initial.len(), 1);
5471
5472 {
5475 let (_paths, _config, conn) = load_project(&root).expect("load");
5476 conn.execute_batch(
5477 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
5478 )
5479 .expect("wipe events + derived tables");
5480 }
5481
5482 let count = rebuild_projection(&root, true).expect("rebuild_projection --from-traces");
5484 assert!(
5485 count > 0,
5486 "should have imported ≥1 event from on-disk traces; got {count}"
5487 );
5488
5489 let restored = list_memories(&root).expect("list after trace import");
5490 assert_eq!(
5491 restored.len(),
5492 1,
5493 "memory must be restored from on-disk traces"
5494 );
5495 assert_eq!(restored[0].memory_id, memory_id);
5496
5497 fs::remove_dir_all(root).expect("cleanup");
5498 });
5499 }
5500
5501 #[test]
5509 fn rebuild_auto_fallback_imports_traces_when_events_table_empty() {
5510 with_user_brain_disabled(|| {
5511 let root = test_root();
5512 fs::create_dir_all(&root).expect("create temp project");
5513 init_project(&root, false).expect("init project");
5514
5515 let memory_id = Ulid::new().to_string();
5517 let run_id = RunId::new();
5518 {
5519 let (paths, config, conn) = load_project(&root).expect("load");
5520 let (mut writer, _run_paths) =
5521 TraceWriter::create(&paths, run_id).expect("trace writer");
5522 let text = "W1.3: auto-fallback recovers from pre-W1.1 events wipe";
5523 let normalized = kimetsu_core::memory::normalize_memory_text(text);
5524 let evs: Vec<Event> = vec![
5525 admin_started_event(&paths, &config, run_id, "memory add").expect("started"),
5526 Event::new(
5527 run_id,
5528 "memory.accepted",
5529 serde_json::json!({
5530 "proposal_id": null,
5531 "memory_id": memory_id,
5532 "scope": "repo",
5533 "kind": "convention",
5534 "text": text,
5535 "normalized_text": normalized,
5536 "confidence": 1.0,
5537 "provenance_snapshot": {
5538 "source": "manual_cli",
5539 "run_id": run_id.to_string(),
5540 "text": text,
5541 }
5542 }),
5543 ),
5544 admin_finished_event(run_id),
5545 ];
5546 for ev in &evs {
5547 writer.append(ev, true).expect("append");
5548 }
5549 projector::apply_events(&conn, &evs).expect("apply");
5551 }
5552
5553 {
5556 let (_paths, _config, conn) = load_project(&root).expect("load");
5557 conn.execute_batch(
5558 "DELETE FROM events; DELETE FROM memories; DELETE FROM memories_fts;",
5559 )
5560 .expect("simulate pre-W1.1 wipe");
5561 }
5562
5563 let count = rebuild_projection(&root, false).expect("rebuild_projection auto-fallback");
5566 assert!(
5567 count > 0,
5568 "auto-fallback should have imported ≥1 event from traces; got {count}"
5569 );
5570
5571 let restored = list_memories(&root).expect("list after auto-fallback");
5572 assert_eq!(
5573 restored.len(),
5574 1,
5575 "auto-fallback must restore memory from traces when events table was empty"
5576 );
5577 assert_eq!(restored[0].memory_id, memory_id);
5578
5579 fs::remove_dir_all(root).expect("cleanup");
5580 });
5581 }
5582
5583 fn run_subdir_count(runs_dir: &std::path::Path) -> usize {
5587 if !runs_dir.exists() {
5588 return 0;
5589 }
5590 fs::read_dir(runs_dir)
5591 .map(|rd| {
5592 rd.filter_map(|e| e.ok())
5593 .filter(|e| e.path().is_dir())
5594 .count()
5595 })
5596 .unwrap_or(0)
5597 }
5598
5599 #[test]
5602 fn w1_4_add_memory_creates_no_run_dir_but_memory_and_runs_row_exist() {
5603 with_user_brain_disabled(|| {
5604 let root = test_root();
5605 fs::create_dir_all(&root).expect("create temp project");
5606 init_project(&root, false).expect("init project");
5607
5608 let runs_dir = {
5610 let paths =
5611 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
5612 paths.runs_dir.clone()
5613 };
5614 let before = run_subdir_count(&runs_dir);
5615
5616 let memory_id = add_memory(
5617 &root,
5618 MemoryScope::Project,
5619 MemoryKind::Fact,
5620 "W1.4: no run dir should be created for memory writes",
5621 )
5622 .expect("add memory");
5623
5624 let after = run_subdir_count(&runs_dir);
5626 assert_eq!(
5627 after, before,
5628 "add_memory must not create a runs/<id>/ directory (before={before}, after={after})"
5629 );
5630
5631 let memories = list_memories(&root).expect("list");
5633 assert!(
5634 memories.iter().any(|m| m.memory_id == memory_id),
5635 "memory must be present after add_memory"
5636 );
5637
5638 let runs_count: i64 = {
5640 let (_paths, _config, conn) = load_project(&root).expect("load for runs check");
5641 conn.query_row("SELECT COUNT(*) FROM runs", [], |r| r.get(0))
5642 .expect("count runs")
5643 };
5644 assert!(
5645 runs_count >= 1,
5646 "projector must have inserted a runs row from the run.started event (got {runs_count})"
5647 );
5648
5649 fs::remove_dir_all(root).expect("cleanup");
5650 });
5651 }
5652
5653 #[test]
5656 fn w1_4_memory_survives_rebuild_from_events_table_no_trace() {
5657 with_user_brain_disabled(|| {
5658 let root = test_root();
5659 fs::create_dir_all(&root).expect("create temp project");
5660 init_project(&root, false).expect("init project");
5661
5662 let memory_id = add_memory(
5663 &root,
5664 MemoryScope::Repo,
5665 MemoryKind::Convention,
5666 "W1.4: events are durable without a trace file",
5667 )
5668 .expect("add memory");
5669
5670 {
5672 let (_paths, _config, conn) = load_project(&root).expect("load");
5673 conn.execute_batch("DELETE FROM memories; DELETE FROM memories_fts;")
5674 .expect("wipe derived tables");
5675 }
5676
5677 let count = rebuild_projection(&root, false).expect("rebuild");
5679 assert!(count > 0, "should have replayed events; got {count}");
5680
5681 let restored = list_memories(&root).expect("list after rebuild");
5682 assert!(
5683 restored.iter().any(|m| m.memory_id == memory_id),
5684 "memory must survive rebuild from events table without a trace file"
5685 );
5686
5687 fs::remove_dir_all(root).expect("cleanup");
5688 });
5689 }
5690
5691 #[test]
5694 fn w1_4_memory_ops_create_no_run_dirs() {
5695 with_user_brain_disabled(|| {
5696 let root = test_root();
5697 fs::write(
5699 root.join("Cargo.toml"),
5700 "[package]\nname = \"w14-fixture\"\nversion = \"0.1.0\"\n",
5701 )
5702 .expect("write Cargo.toml");
5703 init_project(&root, false).expect("init project");
5704
5705 let runs_dir = {
5707 let paths =
5708 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
5709 paths.runs_dir.clone()
5710 };
5711
5712 let before = run_subdir_count(&runs_dir);
5714 let proposal_id = propose_memory(
5715 &root,
5716 MemoryScope::Project,
5717 MemoryKind::Fact,
5718 "W1.4 propose: no run dir",
5719 0.5,
5720 "test rationale",
5721 )
5722 .expect("propose");
5723 assert_eq!(
5724 run_subdir_count(&runs_dir),
5725 before,
5726 "propose_memory must not create a run dir"
5727 );
5728
5729 let before = run_subdir_count(&runs_dir);
5731 ingest_repo(&root).expect("ingest");
5732 assert_eq!(
5733 run_subdir_count(&runs_dir),
5734 before,
5735 "ingest_repo must not create a run dir"
5736 );
5737
5738 let before = run_subdir_count(&runs_dir);
5740 reject_proposal(&root, &proposal_id, Some("W1.4 test")).expect("reject");
5741 assert_eq!(
5742 run_subdir_count(&runs_dir),
5743 before,
5744 "reject_proposal must not create a run dir"
5745 );
5746
5747 let mem_id = add_memory(
5749 &root,
5750 MemoryScope::Project,
5751 MemoryKind::Command,
5752 "W1.4 invalidate: no run dir",
5753 )
5754 .expect("add");
5755 let before = run_subdir_count(&runs_dir);
5756 invalidate_memory(&root, &mem_id, Some("W1.4 test")).expect("invalidate");
5757 assert_eq!(
5758 run_subdir_count(&runs_dir),
5759 before,
5760 "invalidate_memory must not create a run dir"
5761 );
5762
5763 fs::remove_dir_all(root).expect("cleanup");
5764 });
5765 }
5766
5767 #[test]
5769 fn w1_4_dedup_hit_creates_no_orphan_run_dir() {
5770 with_user_brain_disabled(|| {
5771 let root = test_root();
5772 fs::create_dir_all(&root).expect("create temp project");
5773 init_project(&root, false).expect("init project");
5774
5775 let runs_dir = {
5777 let paths =
5778 kimetsu_core::paths::ProjectPaths::discover(&root).expect("discover paths");
5779 paths.runs_dir.clone()
5780 };
5781
5782 let id1 = add_memory(
5784 &root,
5785 MemoryScope::Project,
5786 MemoryKind::Fact,
5787 "W1.4 dedup: identical text",
5788 )
5789 .expect("first add");
5790
5791 let after_first = run_subdir_count(&runs_dir);
5793 assert_eq!(after_first, 0, "first add must create no run dir");
5794
5795 let id2 = add_memory(
5797 &root,
5798 MemoryScope::Project,
5799 MemoryKind::Fact,
5800 "W1.4 dedup: identical text",
5801 )
5802 .expect("second add");
5803
5804 assert_eq!(id1, id2, "dedup must return the same memory_id");
5805 let after_second = run_subdir_count(&runs_dir);
5806 assert_eq!(
5807 after_second, 0,
5808 "dedup hit must not create an orphan run dir"
5809 );
5810
5811 fs::remove_dir_all(root).expect("cleanup");
5812 });
5813 }
5814
5815 #[test]
5821 fn w3_1_open_embedder_for_resolver() {
5822 use crate::embeddings;
5823 use crate::user_brain::test_env_lock;
5824
5825 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
5826 let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
5827 unsafe {
5829 std::env::remove_var("KIMETSU_BRAIN_EMBEDDER");
5830 }
5831
5832 assert!(
5834 embeddings::open_embedder_for(false).is_noop(),
5835 "open_embedder_for(false) must return a noop embedder"
5836 );
5837 assert_eq!(
5839 embeddings::open_embedder_for(true).is_noop(),
5840 embeddings::open_default_embedder().is_noop(),
5841 "open_embedder_for(true) must match open_default_embedder().is_noop()"
5842 );
5843
5844 unsafe {
5846 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
5847 }
5848 assert!(
5849 embeddings::open_embedder_for(true).is_noop(),
5850 "KIMETSU_BRAIN_EMBEDDER=noop must override config=true → noop"
5851 );
5852
5853 unsafe {
5855 match prev {
5856 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
5857 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
5858 }
5859 }
5860 }
5861
5862 #[test]
5865 fn w3_1_config_disabled_writes_null_embedding() {
5866 with_user_brain_disabled(|| {
5867 let root = test_root();
5868 init_project(&root, false).expect("init");
5869
5870 let (paths, mut config, _conn) = load_project(&root).expect("load");
5872 config.embedder.enabled = false;
5873 let toml = config.to_toml().expect("serialize");
5874 drop(_conn);
5876 fs::write(&paths.project_toml, toml).expect("write project.toml");
5877
5878 let memory_id = add_memory(
5879 &root,
5880 MemoryScope::Project,
5881 MemoryKind::Fact,
5882 "w3.1 write-disabled: embedder disabled via config",
5883 )
5884 .expect("add memory");
5885
5886 let embedding: Option<Vec<u8>> = {
5888 let (_, _, conn) = load_project_readonly(&root).expect("reload");
5889 let val = conn
5890 .query_row(
5891 "SELECT embedding FROM memories WHERE memory_id = ?1",
5892 rusqlite::params![memory_id],
5893 |row| row.get(0),
5894 )
5895 .expect("query embedding");
5896 drop(conn);
5897 val
5898 };
5899 assert!(
5900 embedding.is_none(),
5901 "embedding must be NULL when [embedder] enabled = false"
5902 );
5903
5904 fs::remove_dir_all(root).ok(); });
5906 }
5907
5908 #[test]
5914 fn w3_1_config_enabled_default_does_not_regress() {
5915 use crate::embeddings;
5916 let e_default = embeddings::open_default_embedder();
5919 let e_config = embeddings::open_embedder_for(true);
5920 assert_eq!(
5921 e_default.is_noop(),
5922 e_config.is_noop(),
5923 "open_embedder_for(true) and open_default_embedder() must have identical noop status"
5924 );
5925 }
5926
5927 #[test]
5930 fn w3_1_retrieval_fts_only_when_embedder_disabled() {
5931 with_user_brain_disabled(|| {
5932 let root = test_root();
5933 init_project(&root, false).expect("init");
5934
5935 add_memory(
5937 &root,
5938 MemoryScope::Project,
5939 MemoryKind::Fact,
5940 "the quick brown fox jumps over the lazy dog",
5941 )
5942 .expect("add memory");
5943
5944 let (paths, mut config, _) = load_project(&root).expect("load");
5946 config.embedder.enabled = false;
5947 let toml = config.to_toml().expect("serialize");
5948 fs::write(&paths.project_toml, toml).expect("write project.toml");
5949
5950 {
5953 let session = BrainSession::open_readonly(&root).expect("open readonly");
5954 let bundle = session
5955 .retrieve_context_with_request(crate::context::ContextRequest {
5956 stage: "localization".to_string(),
5957 query: "fox jumps".to_string(),
5958 budget_tokens: 4096,
5959 ..Default::default()
5960 })
5961 .expect("retrieve");
5962 let _ = bundle; }
5967
5968 fs::remove_dir_all(root).ok(); });
5970 }
5971
5972 #[test]
5979 fn retrieve_context_lexical_returns_fts_hits_without_embedder() {
5980 with_user_brain_disabled(|| {
5981 let root = test_root();
5982 init_project(&root, false).expect("init");
5983
5984 let memory_id = add_memory(
5985 &root,
5986 MemoryScope::Project,
5987 MemoryKind::Convention,
5988 "Run zylophonecheck before finalizing the deployment pipeline.",
5989 )
5990 .expect("add memory");
5991
5992 {
5993 let session = BrainSession::open_readonly(&root).expect("open readonly");
5994 let bundle = session
5995 .retrieve_context_lexical(crate::context::ContextRequest {
5996 stage: "localization".to_string(),
5997 query: "zylophonecheck deployment pipeline".to_string(),
5998 budget_tokens: 4096,
5999 ..Default::default()
6000 })
6001 .expect("retrieve lexical");
6002
6003 assert!(
6004 bundle
6005 .capsules
6006 .iter()
6007 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
6008 "FTS-only lexical retrieval must surface the seeded memory; \
6009 got handles: {:?}",
6010 bundle
6011 .capsules
6012 .iter()
6013 .map(|c| &c.expansion_handle)
6014 .collect::<Vec<_>>()
6015 );
6016 }
6017
6018 fs::remove_dir_all(root).ok(); });
6020 }
6021
6022 #[test]
6027 fn retrieve_with_injected_embedder_returns_fts_hits() {
6028 with_user_brain_disabled(|| {
6029 let root = test_root();
6030 init_project(&root, false).expect("init");
6031
6032 let memory_id = add_memory(
6033 &root,
6034 MemoryScope::Project,
6035 MemoryKind::Fact,
6036 "the distiller harvests lessons at session end",
6037 )
6038 .expect("add");
6039
6040 {
6041 let session = BrainSession::open_readonly(&root).expect("open ro");
6042 let bundle = session
6043 .retrieve_context_with_injected_embedder(
6044 crate::context::ContextRequest {
6045 stage: "localization".to_string(),
6046 query: "how does the distiller work".to_string(),
6047 budget_tokens: 2000,
6048 ..Default::default()
6049 },
6050 &crate::embeddings::NoopEmbedder,
6051 )
6052 .expect("retrieve");
6053 assert!(
6054 bundle
6055 .capsules
6056 .iter()
6057 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
6058 "FTS path via injected embedder must surface the memory; \
6059 got handles: {:?}",
6060 bundle
6061 .capsules
6062 .iter()
6063 .map(|c| &c.expansion_handle)
6064 .collect::<Vec<_>>()
6065 );
6066 }
6067
6068 fs::remove_dir_all(root).ok(); });
6070 }
6071
6072 fn with_user_brain_at_p0<R>(dir: &std::path::Path, f: impl FnOnce() -> R) -> R {
6077 use crate::user_brain::test_env_lock;
6078 let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
6079 let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
6080 let prev_en = std::env::var("KIMETSU_USER_BRAIN").ok();
6081 unsafe {
6083 std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
6084 std::env::remove_var("KIMETSU_USER_BRAIN");
6085 }
6086 let out = f();
6087 unsafe {
6088 match prev_dir {
6089 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
6090 None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
6091 }
6092 match prev_en {
6093 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
6094 None => std::env::remove_var("KIMETSU_USER_BRAIN"),
6095 }
6096 }
6097 out
6098 }
6099
6100 #[test]
6104 fn p0_global_user_add_memory_works_from_non_project_dir() {
6105 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
6106
6107 let user_brain_dir =
6108 std::env::temp_dir().join(format!("kimetsu-p0-ubrain-{}", Ulid::new()));
6109 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
6110
6111 let non_project_dir =
6113 std::env::temp_dir().join(format!("kimetsu-p0-nonproj-{}", Ulid::new()));
6114 fs::create_dir_all(&non_project_dir).expect("create non-project dir");
6115
6116 with_user_brain_at_p0(&user_brain_dir, || {
6117 add_memory(
6118 &non_project_dir,
6119 MemoryScope::GlobalUser,
6120 MemoryKind::Fact,
6121 "P0 regression: GlobalUser write from non-project dir",
6122 )
6123 .expect("P0: add_memory(GlobalUser) from a non-project dir must succeed");
6124
6125 let conn = open_user_brain_readonly()
6127 .expect("open ok")
6128 .expect("user brain must exist after write");
6129 let mems = list_user_memories(&conn).expect("list");
6130 assert!(
6131 mems.iter().any(|m| m
6132 .text
6133 .contains("P0 regression: GlobalUser write from non-project dir")),
6134 "P0: the GlobalUser memory must land in the user brain"
6135 );
6136 });
6137
6138 fs::remove_dir_all(&non_project_dir).ok();
6139 fs::remove_dir_all(&user_brain_dir).ok();
6140 }
6141
6142 #[test]
6146 fn p0_global_user_honors_use_user_brain_false_when_start_is_project() {
6147 use crate::user_brain::{list_user_memories, open_user_brain_readonly};
6148
6149 let user_brain_dir =
6151 std::env::temp_dir().join(format!("kimetsu-p0-w3-ubrain-{}", Ulid::new()));
6152 fs::create_dir_all(&user_brain_dir).expect("create user brain dir");
6153
6154 let root = test_root();
6156 init_project(&root, false).expect("init project");
6157
6158 {
6160 let (paths, mut config, _) = load_project(&root).expect("load project");
6161 config.kimetsu.use_user_brain = false;
6162 let toml = config.to_toml().expect("serialize");
6163 fs::write(&paths.project_toml, toml).expect("write project.toml");
6164 }
6165
6166 let mem_id = with_user_brain_at_p0(&user_brain_dir, || {
6167 let id = add_memory(
6170 &root,
6171 MemoryScope::GlobalUser,
6172 MemoryKind::Fact,
6173 "W3.3 toggle: this must stay in the project DB",
6174 )
6175 .expect("add_memory must succeed (falls through to project DB)");
6176
6177 let user_conn_opt = open_user_brain_readonly().expect("open ok");
6179 let user_mems_count = user_conn_opt
6180 .map(|c| list_user_memories(&c).unwrap_or_default().len())
6181 .unwrap_or(0);
6182 assert_eq!(
6183 user_mems_count, 0,
6184 "W3.3 toggle: user brain must be empty when use_user_brain=false"
6185 );
6186 id
6187 });
6188
6189 let project_mems = list_memories(&root).expect("list project memories");
6191 assert!(
6192 project_mems.iter().any(|m| m.memory_id == mem_id),
6193 "W3.3 toggle: memory must be in the project DB when use_user_brain=false"
6194 );
6195
6196 fs::remove_dir_all(&root).ok();
6197 fs::remove_dir_all(&user_brain_dir).ok();
6198 }
6199
6200 #[test]
6205 fn export_import_round_trip() {
6206 with_user_brain_disabled(|| {
6207 let root_a = test_root();
6209 init_project(&root_a, false).expect("init A");
6210 add_memory(
6211 &root_a,
6212 MemoryScope::Project,
6213 MemoryKind::Fact,
6214 "alpha fact",
6215 )
6216 .expect("add fact");
6217 add_memory(
6218 &root_a,
6219 MemoryScope::Project,
6220 MemoryKind::Convention,
6221 "beta convention",
6222 )
6223 .expect("add conv");
6224 add_memory(
6225 &root_a,
6226 MemoryScope::Project,
6227 MemoryKind::FailurePattern,
6228 "gamma failure",
6229 )
6230 .expect("add fp");
6231
6232 let (exported, _scrub) =
6234 export_memories(&root_a, None, None, false, false).expect("export");
6235 assert_eq!(exported.len(), 3, "must export all 3 active memories");
6236
6237 for e in &exported {
6239 assert!(!e.text.is_empty());
6240 assert!(!e.scope.is_empty());
6241 assert!(!e.kind.is_empty());
6242 }
6243
6244 let json = serde_json::to_string_pretty(&exported).expect("serialize");
6246 let parsed: Vec<MemoryExport> = serde_json::from_str(&json).expect("deserialize");
6247 assert_eq!(parsed.len(), 3);
6248
6249 let root_b = test_root();
6251 init_project(&root_b, false).expect("init B");
6252
6253 let summary = import_memories(&root_b, &parsed, None).expect("import");
6254 assert_eq!(
6255 summary.imported, 3,
6256 "all 3 must be imported into the empty project B"
6257 );
6258 assert_eq!(summary.deduped, 0, "no duplicates expected on first import");
6259
6260 let mems_b = list_memories(&root_b).expect("list B");
6261 let texts_b: Vec<&str> = mems_b.iter().map(|m| m.text.as_str()).collect();
6262 assert!(
6263 texts_b.contains(&"alpha fact"),
6264 "alpha fact missing from B: {texts_b:?}"
6265 );
6266 assert!(
6267 texts_b.contains(&"beta convention"),
6268 "beta convention missing from B: {texts_b:?}"
6269 );
6270 assert!(
6271 texts_b.contains(&"gamma failure"),
6272 "gamma failure missing from B: {texts_b:?}"
6273 );
6274
6275 fs::remove_dir_all(&root_a).ok();
6276 fs::remove_dir_all(&root_b).ok();
6277 });
6278 }
6279
6280 #[test]
6283 fn export_scope_kind_filter() {
6284 with_user_brain_disabled(|| {
6285 let root = test_root();
6286 init_project(&root, false).expect("init");
6287 add_memory(
6288 &root,
6289 MemoryScope::Project,
6290 MemoryKind::FailurePattern,
6291 "fp1",
6292 )
6293 .expect("add fp1");
6294 add_memory(
6295 &root,
6296 MemoryScope::Project,
6297 MemoryKind::FailurePattern,
6298 "fp2",
6299 )
6300 .expect("add fp2");
6301 add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact1").expect("add fact");
6302 add_memory(
6303 &root,
6304 MemoryScope::Repo,
6305 MemoryKind::FailurePattern,
6306 "repo-fp",
6307 )
6308 .expect("add repo-fp");
6309
6310 let (filtered, _) = export_memories(
6312 &root,
6313 Some(MemoryScope::Project),
6314 Some(MemoryKind::FailurePattern),
6315 false,
6316 false,
6317 )
6318 .expect("export filtered");
6319 assert_eq!(
6320 filtered.len(),
6321 2,
6322 "must return only the 2 project-scope failure_patterns, got: {filtered:?}"
6323 );
6324 assert!(filtered.iter().all(|e| e.scope == "project"));
6325 assert!(filtered.iter().all(|e| e.kind == "failure_pattern"));
6326
6327 let (scope_only, _) =
6329 export_memories(&root, Some(MemoryScope::Project), None, false, false)
6330 .expect("scope filter");
6331 assert_eq!(scope_only.len(), 3, "3 project-scope memories total");
6332
6333 let (kind_only, _) =
6335 export_memories(&root, None, Some(MemoryKind::FailurePattern), false, false)
6336 .expect("kind filter");
6337 assert_eq!(
6338 kind_only.len(),
6339 3,
6340 "3 failure_patterns total (2 project + 1 repo)"
6341 );
6342
6343 fs::remove_dir_all(&root).ok();
6344 });
6345 }
6346
6347 #[test]
6350 fn import_dedup_on_second_import() {
6351 with_user_brain_disabled(|| {
6352 let root = test_root();
6353 init_project(&root, false).expect("init");
6354
6355 let entries = vec![
6356 MemoryExport {
6357 text: "dedup alpha".to_string(),
6358 scope: "project".to_string(),
6359 kind: "fact".to_string(),
6360 confidence: 1.0,
6361 created_at: None,
6362 },
6363 MemoryExport {
6364 text: "dedup beta".to_string(),
6365 scope: "project".to_string(),
6366 kind: "convention".to_string(),
6367 confidence: 1.0,
6368 created_at: None,
6369 },
6370 ];
6371
6372 let s1 = import_memories(&root, &entries, None).expect("import 1");
6374 assert_eq!(s1.imported, 2, "first import: 2 new rows");
6375 assert_eq!(s1.deduped, 0, "first import: no dups");
6376
6377 let count_after_first = list_memories(&root).expect("list after 1st").len();
6378 assert_eq!(count_after_first, 2);
6379
6380 let s2 = import_memories(&root, &entries, None).expect("import 2");
6382 assert_eq!(s2.imported, 0, "second import: no new rows");
6383 assert_eq!(s2.deduped, 2, "second import: both entries deduped");
6384
6385 let count_after_second = list_memories(&root).expect("list after 2nd").len();
6386 assert_eq!(
6387 count_after_second, 2,
6388 "list_memories count must be unchanged after second import"
6389 );
6390
6391 fs::remove_dir_all(&root).ok();
6392 });
6393 }
6394
6395 #[test]
6398 fn import_scope_override_global_user() {
6399 with_user_brain_disabled(|| {
6400 let root = test_root();
6402 init_project(&root, false).expect("init");
6403
6404 let entries = vec![MemoryExport {
6405 text: "scope override test memory".to_string(),
6406 scope: "project".to_string(), kind: "fact".to_string(),
6408 confidence: 1.0,
6409 created_at: None,
6410 }];
6411
6412 let summary =
6413 import_memories(&root, &entries, Some(MemoryScope::GlobalUser)).expect("import");
6414 assert_eq!(summary.imported, 1);
6415 assert_eq!(summary.deduped, 0);
6416
6417 let mems = list_memories(&root).expect("list");
6420 assert_eq!(mems.len(), 1);
6421 assert_eq!(
6422 mems[0].scope, "global_user",
6423 "scope_override must win over entry.scope"
6424 );
6425 assert_eq!(mems[0].text, "scope override test memory");
6426
6427 fs::remove_dir_all(&root).ok();
6428 });
6429 }
6430
6431 #[test]
6434 fn import_skips_malformed_entries() {
6435 with_user_brain_disabled(|| {
6436 let root = test_root();
6437 init_project(&root, false).expect("init");
6438
6439 let entries = vec![
6440 MemoryExport {
6442 text: "good entry".to_string(),
6443 scope: "project".to_string(),
6444 kind: "fact".to_string(),
6445 confidence: 1.0,
6446 created_at: None,
6447 },
6448 MemoryExport {
6450 text: "bad scope entry".to_string(),
6451 scope: "not_a_real_scope".to_string(),
6452 kind: "fact".to_string(),
6453 confidence: 1.0,
6454 created_at: None,
6455 },
6456 MemoryExport {
6458 text: "bad kind entry".to_string(),
6459 scope: "project".to_string(),
6460 kind: "not_a_real_kind".to_string(),
6461 confidence: 1.0,
6462 created_at: None,
6463 },
6464 MemoryExport {
6466 text: "second good entry".to_string(),
6467 scope: "repo".to_string(),
6468 kind: "convention".to_string(),
6469 confidence: 1.0,
6470 created_at: None,
6471 },
6472 ];
6473
6474 let summary = import_memories(&root, &entries, None).expect("import with bad entries");
6475 assert_eq!(
6476 summary.imported, 2,
6477 "2 valid entries must be imported; got {summary:?}"
6478 );
6479 assert_eq!(
6480 summary.deduped, 2,
6481 "2 malformed entries counted as skipped/deduped; got {summary:?}"
6482 );
6483
6484 let mems = list_memories(&root).expect("list");
6485 assert_eq!(mems.len(), 2, "exactly 2 memories in DB; got {mems:?}");
6486 let texts: Vec<&str> = mems.iter().map(|m| m.text.as_str()).collect();
6487 assert!(
6488 texts.contains(&"good entry"),
6489 "good entry missing: {texts:?}"
6490 );
6491 assert!(
6492 texts.contains(&"second good entry"),
6493 "second good entry missing: {texts:?}"
6494 );
6495
6496 fs::remove_dir_all(&root).ok();
6497 });
6498 }
6499
6500 #[test]
6504 fn redact_context_suffix_strips_trailing_context() {
6505 assert_eq!(
6506 redact_context_suffix("always use --locked (context: cargo build)"),
6507 "always use --locked"
6508 );
6509 assert_eq!(
6511 redact_context_suffix("lesson body (context: some task)"),
6512 "lesson body"
6513 );
6514 assert_eq!(redact_context_suffix("bare lesson"), "bare lesson");
6516 assert_eq!(
6518 redact_context_suffix("(context: only context)"),
6519 "(context: only context)"
6520 );
6521 assert_eq!(
6523 redact_context_suffix("lesson (context: (nested) task)"),
6524 "lesson"
6525 );
6526 assert_eq!(redact_context_suffix("lesson (context: task) "), "lesson");
6528 }
6529
6530 #[test]
6532 fn redact_tags_prefix_strips_leading_tags() {
6533 assert_eq!(
6534 redact_tags_prefix("[tags: rust, cargo] always use --locked"),
6535 "always use --locked"
6536 );
6537 assert_eq!(redact_tags_prefix("no tags here"), "no tags here");
6539 assert_eq!(redact_tags_prefix("[tags: only-tag]"), "[tags: only-tag]");
6541 assert_eq!(redact_tags_prefix(" [tags: rust] lesson"), "lesson");
6543 }
6544
6545 #[test]
6547 fn apply_export_redaction_no_flags_is_passthrough() {
6548 let entry = MemoryExport {
6549 text: "[tags: rust] lesson (context: task)".to_string(),
6550 scope: "project".to_string(),
6551 kind: "fact".to_string(),
6552 confidence: 1.0,
6553 created_at: None,
6554 };
6555 let out = apply_export_redaction(entry.clone(), false, false);
6556 assert_eq!(out.text, entry.text);
6557 }
6558
6559 #[test]
6561 fn apply_export_redaction_redact_only_strips_context() {
6562 let entry = MemoryExport {
6563 text: "[tags: rust] lesson (context: task)".to_string(),
6564 scope: "project".to_string(),
6565 kind: "fact".to_string(),
6566 confidence: 1.0,
6567 created_at: None,
6568 };
6569 let out = apply_export_redaction(entry, true, false);
6570 assert_eq!(out.text, "[tags: rust] lesson");
6571 }
6572
6573 #[test]
6575 fn apply_export_redaction_both_flags_strips_tags_and_context() {
6576 let entry = MemoryExport {
6577 text: "[tags: rust, cargo] lesson (context: task)".to_string(),
6578 scope: "project".to_string(),
6579 kind: "fact".to_string(),
6580 confidence: 1.0,
6581 created_at: None,
6582 };
6583 let out = apply_export_redaction(entry, true, true);
6584 assert_eq!(out.text, "lesson");
6585 }
6586
6587 #[test]
6593 fn export_redact_import_roundtrip_and_dedup() {
6594 with_user_brain_disabled(|| {
6595 let root_a = test_root();
6596 init_project(&root_a, false).expect("init A");
6597
6598 add_memory(
6600 &root_a,
6601 MemoryScope::Project,
6602 MemoryKind::Fact,
6603 "use --locked for reproducibility (context: cargo test failing)",
6604 )
6605 .expect("add memory");
6606
6607 let (exported, _) =
6609 export_memories(&root_a, None, None, true, false).expect("export redacted");
6610 assert_eq!(exported.len(), 1);
6611 assert_eq!(
6612 exported[0].text, "use --locked for reproducibility",
6613 "context suffix must be stripped"
6614 );
6615
6616 let root_b = test_root();
6618 init_project(&root_b, false).expect("init B");
6619 let s1 = import_memories(&root_b, &exported, None).expect("import 1");
6620 assert_eq!(s1.imported, 1, "first import must create 1 row");
6621 assert_eq!(s1.deduped, 0);
6622
6623 let s2 = import_memories(&root_b, &exported, None).expect("import 2");
6625 assert_eq!(s2.imported, 0, "second import must dedup");
6626 assert_eq!(s2.deduped, 1);
6627
6628 let mems = list_memories(&root_b).expect("list");
6630 assert_eq!(mems.len(), 1);
6631 assert_eq!(mems[0].text, "use --locked for reproducibility");
6632
6633 fs::remove_dir_all(&root_a).ok();
6634 fs::remove_dir_all(&root_b).ok();
6635 });
6636 }
6637
6638 #[test]
6640 fn import_pack_merge_replace_and_provenance() {
6641 with_user_brain_disabled(|| {
6642 let root_a = test_root();
6643 init_project(&root_a, false).expect("init A");
6644 add_memory(
6645 &root_a,
6646 MemoryScope::Project,
6647 MemoryKind::Convention,
6648 "use cargo --locked",
6649 )
6650 .expect("a1");
6651 add_memory(
6652 &root_a,
6653 MemoryScope::Project,
6654 MemoryKind::Fact,
6655 "brain db lives in dot kimetsu",
6656 )
6657 .expect("a2");
6658
6659 let (entries, scrub) =
6661 export_memories(&root_a, None, None, false, false).expect("export");
6662 assert!(scrub.is_clean(), "clean memories must scrub to nothing");
6663 let pack = Pack {
6664 kimetsu_pack: 1,
6665 name: Some("demo".into()),
6666 version: Some("1.0".into()),
6667 description: None,
6668 exported_at: None,
6669 memory_count: entries.len(),
6670 memories: entries.clone(),
6671 };
6672 let json = serde_json::to_string(&pack).expect("ser");
6673 let (pref, parsed) = parse_pack_or_array(&json).expect("parse");
6674 assert_eq!(pref.name.as_deref(), Some("demo"));
6675 assert_eq!(parsed.len(), 2);
6676 let (bare_ref, bare) =
6678 parse_pack_or_array(&serde_json::to_string(&entries).unwrap()).expect("parse bare");
6679 assert!(bare_ref.name.is_none());
6680 assert_eq!(bare.len(), 2);
6681
6682 let root_b = test_root();
6684 init_project(&root_b, false).expect("init B");
6685 add_memory(
6686 &root_b,
6687 MemoryScope::Project,
6688 MemoryKind::Fact,
6689 "B's own memory",
6690 )
6691 .expect("b1");
6692 let s = import_pack(&root_b, &parsed, None, false, Some(&pref)).expect("merge");
6693 assert_eq!(s.imported, 2, "two new pack memories");
6694 assert_eq!(s.superseded, 0);
6695
6696 let pack_tagged = |root: &Path| -> i64 {
6698 let (_p, _c, conn) = load_project_readonly(root).expect("ro");
6699 conn.query_row(
6700 "SELECT COUNT(*) FROM memories
6701 WHERE provenance_snapshot_json LIKE '%\"source\":\"pack\"%'",
6702 [],
6703 |r| r.get(0),
6704 )
6705 .unwrap()
6706 };
6707 assert_eq!(
6708 pack_tagged(&root_b),
6709 2,
6710 "installed memories tagged with pack provenance"
6711 );
6712
6713 let s2 = import_pack(&root_b, &parsed, None, false, Some(&pref)).expect("merge2");
6715 assert_eq!(s2.imported, 0);
6716 assert_eq!(s2.deduped, 2);
6717
6718 let s3 = import_pack(&root_b, &parsed, None, true, Some(&pref)).expect("replace");
6721 assert_eq!(
6722 s3.superseded, 3,
6723 "all 3 active project memories invalidated"
6724 );
6725 assert_eq!(s3.imported, 2, "pack reloaded as fresh rows");
6726 let active_project = {
6727 let (_p, _c, conn) = load_project_readonly(&root_b).expect("ro2");
6728 conn.query_row(
6729 "SELECT COUNT(*) FROM memories
6730 WHERE scope='project' AND invalidated_at IS NULL AND superseded_by IS NULL",
6731 [],
6732 |r| r.get::<_, i64>(0),
6733 )
6734 .unwrap()
6735 };
6736 assert_eq!(
6737 active_project, 2,
6738 "only the pack's 2 memories remain active"
6739 );
6740
6741 fs::remove_dir_all(&root_a).ok();
6742 fs::remove_dir_all(&root_b).ok();
6743 });
6744 }
6745
6746 #[test]
6750 fn edit_memory_updates_text_and_preserves_history() {
6751 with_user_brain_disabled(|| {
6752 let root = test_root();
6753 init_project(&root, false).expect("init");
6754 let mid = add_memory(
6755 &root,
6756 MemoryScope::Project,
6757 MemoryKind::Fact,
6758 "original text for edit test",
6759 )
6760 .expect("add");
6761
6762 {
6764 let (_p, _c, conn) = load_project(&root).expect("open conn");
6765 conn.execute(
6766 "UPDATE memories SET use_count = 7, usefulness_score = 3.5 WHERE memory_id = ?1",
6767 params![mid],
6768 )
6769 .expect("bump counters");
6770 }
6771
6772 edit_memory(&root, &mid, Some("corrected text for edit test"), None)
6774 .expect("edit_memory");
6775
6776 {
6778 let (_p, _c, conn) = load_project(&root).expect("open conn");
6779 let (text, normalized, use_count, usefulness_score): (String, String, i64, f64) =
6780 conn.query_row(
6781 "SELECT text, normalized_text, use_count, usefulness_score FROM memories WHERE memory_id = ?1",
6782 params![mid],
6783 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
6784 )
6785 .expect("query");
6786
6787 assert_eq!(text, "corrected text for edit test");
6788 assert!(!normalized.is_empty(), "normalized_text must not be empty");
6789 assert_eq!(use_count, 7, "use_count must not be reset");
6791 assert!(
6792 (usefulness_score - 3.5).abs() < 0.01,
6793 "usefulness_score must not be reset"
6794 );
6795 }
6796
6797 let hits = search_memories(&root, "corrected", 10, 0, None, None).expect("search new");
6799 assert!(
6800 hits.iter().any(|h| h.memory_id == mid),
6801 "edited text must appear in FTS search: {hits:?}"
6802 );
6803
6804 let old_hits =
6806 search_memories(&root, "original", 10, 0, None, None).expect("search old");
6807 assert!(
6808 !old_hits.iter().any(|h| h.memory_id == mid),
6809 "old text must NOT appear after edit: {old_hits:?}"
6810 );
6811
6812 let mems = list_memories(&root).expect("list");
6814 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
6815 assert_eq!(m.text, "corrected text for edit test");
6816 });
6817 }
6818
6819 #[test]
6821 fn edit_memory_changes_kind_only() {
6822 with_user_brain_disabled(|| {
6823 let root = test_root();
6824 init_project(&root, false).expect("init");
6825 let mid = add_memory(
6826 &root,
6827 MemoryScope::Project,
6828 MemoryKind::Fact,
6829 "kind-change test memory",
6830 )
6831 .expect("add");
6832
6833 edit_memory(&root, &mid, None, Some(MemoryKind::Convention)).expect("edit kind");
6834
6835 let mems = list_memories(&root).expect("list");
6836 let m = mems.iter().find(|m| m.memory_id == mid).expect("found");
6837 assert_eq!(m.kind, "convention", "kind must be updated");
6838 assert_eq!(m.text, "kind-change test memory", "text must be unchanged");
6839 });
6840 }
6841
6842 #[test]
6844 fn edit_memory_errors() {
6845 with_user_brain_disabled(|| {
6846 let root = test_root();
6847 init_project(&root, false).expect("init");
6848
6849 let err = edit_memory(&root, "does-not-matter", None, None)
6851 .expect_err("must err when no fields");
6852 assert!(
6853 format!("{err}").contains("at least one"),
6854 "unexpected err: {err}"
6855 );
6856
6857 let err = edit_memory(&root, "UNKNOWN_ID", Some("x"), None)
6859 .expect_err("must err on unknown id");
6860 assert!(
6861 format!("{err}").contains("not found"),
6862 "unexpected err: {err}"
6863 );
6864
6865 let mid = add_memory(
6867 &root,
6868 MemoryScope::Project,
6869 MemoryKind::Fact,
6870 "will be invalidated",
6871 )
6872 .expect("add");
6873 invalidate_memory(&root, &mid, None).expect("invalidate");
6874 let err = edit_memory(&root, &mid, Some("new text"), None)
6875 .expect_err("must err on invalidated id");
6876 assert!(
6877 format!("{err}").contains("invalidated"),
6878 "unexpected err: {err}"
6879 );
6880 });
6881 }
6882
6883 #[test]
6886 fn undo_last_memory_invalidates_newest_first() {
6887 with_user_brain_disabled(|| {
6888 let root = test_root();
6889 init_project(&root, false).expect("init");
6890
6891 let mid_a = add_memory(
6892 &root,
6893 MemoryScope::Project,
6894 MemoryKind::Fact,
6895 "memory A older undo test",
6896 )
6897 .expect("add A");
6898
6899 let mid_b = add_memory(
6900 &root,
6901 MemoryScope::Project,
6902 MemoryKind::Fact,
6903 "memory B newer undo test",
6904 )
6905 .expect("add B");
6906
6907 let undone = undo_last_memory(&root)
6909 .expect("undo 1")
6910 .expect("must return Some");
6911 assert_eq!(undone.memory_id, mid_b, "undo must target B (newest)");
6912
6913 {
6915 let (_p, _c, conn) = load_project(&root).expect("open conn");
6916 let b_inv: Option<String> = conn
6917 .query_row(
6918 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
6919 params![mid_b],
6920 |row| row.get(0),
6921 )
6922 .optional()
6923 .expect("query")
6924 .flatten();
6925 assert!(b_inv.is_some(), "B must be invalidated after undo");
6926
6927 let a_inv: Option<String> = conn
6928 .query_row(
6929 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
6930 params![mid_a],
6931 |row| row.get(0),
6932 )
6933 .optional()
6934 .expect("query")
6935 .flatten();
6936 assert!(a_inv.is_none(), "A must still be active");
6937 }
6938
6939 let undone2 = undo_last_memory(&root)
6941 .expect("undo 2")
6942 .expect("must return Some");
6943 assert_eq!(undone2.memory_id, mid_a, "second undo must target A");
6944
6945 {
6947 let (_p, _c, conn) = load_project(&root).expect("open conn");
6948 let a_inv: Option<String> = conn
6949 .query_row(
6950 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
6951 params![mid_a],
6952 |row| row.get(0),
6953 )
6954 .optional()
6955 .expect("query")
6956 .flatten();
6957 assert!(a_inv.is_some(), "A must be invalidated after second undo");
6958 }
6959
6960 let peek = peek_last_memory(&root).expect("peek after both undone");
6962 assert!(peek.is_none(), "peek must return None when all invalidated");
6963 });
6964 }
6965
6966 #[test]
6968 fn undo_last_memory_on_empty_brain_returns_none() {
6969 with_user_brain_disabled(|| {
6970 let root = test_root();
6971 init_project(&root, false).expect("init");
6972 let result = undo_last_memory(&root).expect("undo on empty");
6973 assert!(result.is_none(), "must return None on empty brain");
6974 });
6975 }
6976
6977 #[test]
6987 fn compact_brain_purge_invalidated_reclaims_space() {
6988 with_user_brain_disabled(|| {
6989 let root = test_root();
6990 init_project(&root, false).expect("init");
6991
6992 let mut active_id = String::new();
6994 for i in 0..20usize {
6995 let text = format!(
6996 "compact test memory number {i}: rust sqlite vacuum reclaim disk space \
6997 kimetsu brain compact test payload to increase file size substantially \
6998 so that vacuum has meaningful dead pages to reclaim after deletion"
6999 );
7000 let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, &text)
7001 .expect("add memory");
7002 if i == 0 {
7003 active_id = mid.clone();
7004 }
7005 if i > 0 {
7007 invalidate_memory(&root, &mid, Some("compact test"))
7008 .expect("invalidate memory");
7009 }
7010 }
7011
7012 let report = compact_brain(&root, None, true).expect("compact_brain");
7014
7015 assert_eq!(
7017 report.invalidated_memories_purged, 19,
7018 "should have purged 19 invalidated memories, got {}",
7019 report.invalidated_memories_purged
7020 );
7021 assert!(
7023 report.bytes_after <= report.bytes_before,
7024 "bytes_after ({}) should be <= bytes_before ({}) after purge+vacuum",
7025 report.bytes_after,
7026 report.bytes_before
7027 );
7028 assert_eq!(
7030 report.events_trimmed, 0,
7031 "events_trimmed must be 0 when trim_events_older_than is None"
7032 );
7033
7034 let memories = list_memories(&root).expect("list memories after compact");
7036 let active_memories: Vec<_> = memories
7037 .iter()
7038 .filter(|m| m.memory_id == active_id)
7039 .collect();
7040 assert_eq!(
7041 active_memories.len(),
7042 1,
7043 "the active memory must survive compaction"
7044 );
7045 });
7046 }
7047
7048 #[test]
7053 fn compact_brain_default_preserves_everything() {
7054 with_user_brain_disabled(|| {
7055 let root = test_root();
7056 init_project(&root, false).expect("init");
7057
7058 let mid = add_memory(
7059 &root,
7060 MemoryScope::Project,
7061 MemoryKind::Fact,
7062 "preserve me through compact",
7063 )
7064 .expect("add memory");
7065 let mid2 = add_memory(
7066 &root,
7067 MemoryScope::Project,
7068 MemoryKind::Fact,
7069 "preserve invalidated too",
7070 )
7071 .expect("add memory 2");
7072 invalidate_memory(&root, &mid2, Some("test")).expect("invalidate");
7073
7074 let event_count_before: i64 = {
7076 let (_p, _c, conn) = load_project(&root).expect("load");
7077 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
7078 .expect("count events")
7079 };
7080
7081 let report = compact_brain(&root, None, false).expect("compact_brain");
7083 assert_eq!(
7084 report.events_trimmed, 0,
7085 "events_trimmed must be 0 in default compact"
7086 );
7087 assert_eq!(
7088 report.invalidated_memories_purged, 0,
7089 "invalidated_memories_purged must be 0 in default compact"
7090 );
7091
7092 let all_mems: Vec<_> = {
7094 let (_p, _c, conn) = load_project(&root).expect("load");
7095 let mut stmt = conn
7096 .prepare("SELECT memory_id FROM memories")
7097 .expect("prepare");
7098 stmt.query_map([], |r| r.get::<_, String>(0))
7099 .expect("query")
7100 .collect::<Result<Vec<_>, _>>()
7101 .expect("collect")
7102 };
7103 assert!(
7104 all_mems.contains(&mid),
7105 "active memory must survive default compact"
7106 );
7107 assert!(
7108 all_mems.contains(&mid2),
7109 "invalidated memory must survive default compact"
7110 );
7111
7112 let event_count_after: i64 = {
7114 let (_p, _c, conn) = load_project(&root).expect("load");
7115 conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
7116 .expect("count events")
7117 };
7118 assert_eq!(
7119 event_count_after, event_count_before,
7120 "event count must not change in default compact"
7121 );
7122 });
7123 }
7124
7125 #[test]
7133 fn compact_brain_event_trim_keeps_materialized_memories() {
7134 with_user_brain_disabled(|| {
7135 let root = test_root();
7136 init_project(&root, false).expect("init");
7137
7138 let mid = add_memory(
7139 &root,
7140 MemoryScope::Project,
7141 MemoryKind::Fact,
7142 "this memory must survive event trim",
7143 )
7144 .expect("add memory");
7145
7146 let trim_dur = std::time::Duration::from_secs(0);
7151
7152 std::thread::sleep(std::time::Duration::from_millis(100));
7155
7156 let report = compact_brain(&root, Some(trim_dur), false).expect("compact_brain");
7157
7158 assert!(
7159 report.events_trimmed > 0,
7160 "events_trimmed should be > 0 after trim with duration=0; got {}",
7161 report.events_trimmed
7162 );
7163
7164 let memories = list_memories(&root).expect("list memories after event trim");
7166 let found = memories.iter().any(|m| m.memory_id == mid);
7167 assert!(
7168 found,
7169 "memory must still be in the projection after event trim"
7170 );
7171
7172 assert_eq!(
7174 report.invalidated_memories_purged, 0,
7175 "invalidated_memories_purged must be 0 when purge_invalidated=false"
7176 );
7177 });
7178 }
7179
7180 #[test]
7185 fn compact_brain_event_trim_then_rebuild_is_consistent() {
7186 with_user_brain_disabled(|| {
7187 let root = test_root();
7188 init_project(&root, false).expect("init");
7189
7190 add_memory(
7191 &root,
7192 MemoryScope::Project,
7193 MemoryKind::Fact,
7194 "pre-trim memory for rebuild test",
7195 )
7196 .expect("add memory");
7197
7198 std::thread::sleep(std::time::Duration::from_millis(100));
7200 let report = compact_brain(&root, Some(std::time::Duration::from_secs(0)), false)
7201 .expect("compact_brain");
7202 assert!(report.events_trimmed > 0, "events must have been trimmed");
7203
7204 let replayed =
7206 rebuild_projection(&root, false).expect("rebuild_projection after event trim");
7207 assert_eq!(
7209 replayed, 0,
7210 "replayed should be 0 after all events are trimmed"
7211 );
7212 });
7213 }
7214
7215 #[test]
7222 fn at_root_init_and_round_trip_memory() {
7223 with_user_brain_disabled(|| {
7224 let root = std::env::temp_dir().join(format!("kimetsu-at-root-{}", Ulid::new()));
7230 kimetsu_core::paths::git_init_boundary(&root);
7231
7232 let summary = init_project_at_root(&root, false).expect("init_project_at_root");
7234
7235 assert!(
7236 summary.kimetsu_dir.exists(),
7237 ".kimetsu/ must be created at root"
7238 );
7239 assert!(
7241 summary.kimetsu_dir.starts_with(&root),
7242 ".kimetsu dir {:?} must be inside root {:?}",
7243 summary.kimetsu_dir,
7244 root
7245 );
7246 assert!(summary.brain_db.exists(), "brain.db must exist");
7247 assert!(
7248 root.join(".kimetsu").join("project.toml").exists(),
7249 "project.toml must be at root/.kimetsu/"
7250 );
7251
7252 let (paths, _config, _conn) =
7254 load_project_at_root(&root).expect("load_project_at_root");
7255 assert_eq!(
7256 paths
7257 .repo_root
7258 .canonicalize()
7259 .unwrap_or(paths.repo_root.clone()),
7260 root.canonicalize().unwrap_or(root.clone()),
7261 "repo_root must be our explicit root"
7262 );
7263
7264 let memory_id = add_memory(
7266 &root,
7267 MemoryScope::Project,
7268 MemoryKind::Fact,
7269 "at_root seam test memory",
7270 )
7271 .expect("add_memory");
7272
7273 let memories = list_memories(&root).expect("list_memories");
7276 assert!(
7277 memories.iter().any(|m| m.memory_id == memory_id),
7278 "memory {memory_id} must be present in the at_root brain"
7279 );
7280
7281 let (_, _, ro_conn) =
7283 load_project_readonly_at_root(&root).expect("load_project_readonly_at_root");
7284 let ro_count: i64 = ro_conn
7285 .query_row(
7286 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
7287 rusqlite::params![memory_id],
7288 |row| row.get(0),
7289 )
7290 .expect("count memory ro");
7291 assert_eq!(ro_count, 1, "readonly view must see the same memory");
7292
7293 std::fs::remove_dir_all(&root).ok();
7294 });
7295 }
7296
7297 #[test]
7300 fn at_root_init_is_idempotent() {
7301 with_user_brain_disabled(|| {
7302 let root = std::env::temp_dir().join(format!("kimetsu-at-root-idem-{}", Ulid::new()));
7303 std::fs::create_dir_all(&root).expect("create root");
7304
7305 let s1 = init_project_at_root(&root, false).expect("first init");
7306 assert!(s1.wrote_project_toml, "first init must write project.toml");
7307
7308 let s2 = init_project_at_root(&root, false).expect("second init");
7309 assert!(
7310 !s2.wrote_project_toml,
7311 "second init (force=false) must not overwrite project.toml"
7312 );
7313 assert_eq!(s1.project_id, s2.project_id, "project_id must be stable");
7314
7315 std::fs::remove_dir_all(&root).ok();
7316 });
7317 }
7318
7319 #[test]
7327 fn detect_conflicts_env_off_writes_no_conflict_rows() {
7328 with_user_brain_disabled(|| {
7331 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
7332 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
7333
7334 unsafe {
7338 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop");
7339 std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
7340 }
7341
7342 let root = test_root();
7343 init_project(&root, false).expect("init");
7344
7345 add_memory(
7350 &root,
7351 MemoryScope::Project,
7352 MemoryKind::Fact,
7353 "use clippy for linting Rust code",
7354 )
7355 .expect("add 1");
7356
7357 unsafe {
7359 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
7360 }
7361 add_memory(
7362 &root,
7363 MemoryScope::Project,
7364 MemoryKind::Fact,
7365 "use clippy for linting all Rust projects",
7366 )
7367 .expect("add 2");
7368
7369 unsafe {
7371 match prev_dc {
7372 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
7373 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
7374 }
7375 match prev_emb {
7376 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
7377 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
7378 }
7379 }
7380 std::fs::remove_dir_all(&root).ok();
7381 });
7382 }
7383
7384 #[test]
7396 fn perf_tier1_structural_invariant_and_timing() {
7397 with_user_brain_disabled(|| {
7400 #[allow(unused_imports)]
7401 use crate::embeddings::StubEmbedder;
7402 use std::time::Instant;
7403
7404 let prev_dc = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
7405 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
7406
7407 unsafe {
7410 std::env::set_var("KIMETSU_DETECT_CONFLICTS", "0");
7411 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "noop"); }
7413
7414 let root = test_root();
7415 init_project(&root, false).expect("init");
7416
7417 const EARLY_SAMPLE: usize = 100;
7418 const TOTAL: usize = 200; for i in 0..EARLY_SAMPLE {
7422 add_memory(
7423 &root,
7424 MemoryScope::Project,
7425 MemoryKind::Fact,
7426 &format!("perf test memory row {i} unique content abcdef"),
7427 )
7428 .expect("add early");
7429 }
7430
7431 let t_early = Instant::now();
7432 add_memory(
7433 &root,
7434 MemoryScope::Project,
7435 MemoryKind::Fact,
7436 &format!("perf sampled early add memory row {EARLY_SAMPLE} unique zxcvbn"),
7437 )
7438 .expect("timed early add");
7439 let early_us = t_early.elapsed().as_micros();
7440
7441 for i in (EARLY_SAMPLE + 1)..TOTAL {
7443 add_memory(
7444 &root,
7445 MemoryScope::Project,
7446 MemoryKind::Fact,
7447 &format!("perf test memory row {i} unique content qwerty"),
7448 )
7449 .expect("add fill");
7450 }
7451
7452 let t_late = Instant::now();
7453 add_memory(
7454 &root,
7455 MemoryScope::Project,
7456 MemoryKind::Fact,
7457 &format!("perf sampled late add memory row {TOTAL} unique rtyfgh"),
7458 )
7459 .expect("timed late add");
7460 let late_us = t_late.elapsed().as_micros();
7461
7462 let (_, _, conn) = load_project(&root).expect("load");
7464 let mem_count: i64 = conn
7465 .query_row(
7466 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
7467 [],
7468 |r| r.get(0),
7469 )
7470 .expect("count memories");
7471 assert!(
7473 mem_count >= TOTAL as i64,
7474 "must have at least {TOTAL} memories, got {mem_count}"
7475 );
7476
7477 if early_us > 0 && late_us > 0 {
7481 assert!(
7482 late_us < early_us * 20,
7483 "late add ({late_us}µs) is > 20× slower than early add ({early_us}µs) — O(N) regression"
7484 );
7485 }
7486
7487 unsafe {
7489 match prev_dc {
7490 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
7491 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
7492 }
7493 match prev_emb {
7494 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
7495 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
7496 }
7497 }
7498 std::fs::remove_dir_all(&root).ok();
7499 });
7500 }
7501
7502 #[cfg(feature = "embeddings")]
7503 #[test]
7504 fn ann_retrieval_round_trips_and_invalidate_drops() {
7505 use crate::user_brain::with_user_brain_disabled;
7506 with_user_brain_disabled(|| {
7507 let prev_emb = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
7509 unsafe {
7510 std::env::set_var("KIMETSU_BRAIN_EMBEDDER", "stub-d8");
7511 }
7512 let root = test_root();
7513 init_project(&root, false).expect("init");
7514 add_memory(
7515 &root,
7516 MemoryScope::Project,
7517 MemoryKind::Fact,
7518 "ripgrep is the fast recursive search tool",
7519 )
7520 .expect("add a");
7521 let id = add_memory(
7522 &root,
7523 MemoryScope::Project,
7524 MemoryKind::Fact,
7525 "use fd to find files quickly",
7526 )
7527 .expect("add b");
7528
7529 let ctx = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx");
7531 assert!(
7532 format!("{ctx:?}").contains("fd to find files"),
7533 "expected the fd memory in context"
7534 );
7535
7536 invalidate_memory(&root, &id, Some("test")).expect("invalidate");
7538 let ctx2 = retrieve_context(&root, "recall", "find files fast", 1024).expect("ctx2");
7539 assert!(
7540 !format!("{ctx2:?}").contains("fd to find files"),
7541 "invalidated memory must not return"
7542 );
7543
7544 unsafe {
7545 match prev_emb {
7546 Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
7547 None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
7548 }
7549 }
7550 std::fs::remove_dir_all(&root).ok();
7551 });
7552 }
7553
7554 #[test]
7556 fn record_mcp_citation_writes_memory_citations_row() {
7557 with_user_brain_disabled(|| {
7558 let root = test_root();
7559 std::fs::create_dir_all(&root).expect("create root");
7560 init_project(&root, false).expect("init");
7561 let memory_id = add_memory(
7562 &root,
7563 kimetsu_core::memory::MemoryScope::Project,
7564 kimetsu_core::memory::MemoryKind::Fact,
7565 "record_mcp_citation test fixture",
7566 )
7567 .expect("add memory");
7568
7569 record_mcp_citation(&root, &memory_id, Some("helped with test"))
7570 .expect("record_mcp_citation");
7571
7572 let (_paths, _config, conn) = load_project(&root).expect("load");
7573 let row_count: i64 = conn
7574 .query_row(
7575 "SELECT COUNT(*) FROM memory_citations WHERE memory_id = ?1",
7576 rusqlite::params![&memory_id],
7577 |r| r.get(0),
7578 )
7579 .expect("count");
7580 assert_eq!(
7581 row_count, 1,
7582 "memory_citations row must exist after MCP cite"
7583 );
7584 std::fs::remove_dir_all(&root).ok();
7585 });
7586 }
7587
7588 #[test]
7590 fn record_regret_writes_retrieval_regret_event() {
7591 with_user_brain_disabled(|| {
7592 let root = test_root();
7593 std::fs::create_dir_all(&root).expect("create root");
7594 init_project(&root, false).expect("init");
7595 let memory_id = add_memory(
7596 &root,
7597 kimetsu_core::memory::MemoryScope::Project,
7598 kimetsu_core::memory::MemoryKind::Fact,
7599 "record_regret test fixture",
7600 )
7601 .expect("add memory");
7602
7603 record_regret(&root, &memory_id).expect("record_regret");
7604
7605 let (_paths, _config, conn) = load_project(&root).expect("load");
7606 let event_count: i64 = conn
7607 .query_row(
7608 "SELECT COUNT(*) FROM events
7609 WHERE kind = 'retrieval.regret'
7610 AND json_extract(payload_json, '$.memory_id') = ?1",
7611 rusqlite::params![&memory_id],
7612 |r| r.get(0),
7613 )
7614 .expect("count");
7615 assert_eq!(
7616 event_count, 1,
7617 "a retrieval.regret event must exist for the memory after record_regret"
7618 );
7619 std::fs::remove_dir_all(&root).ok();
7620 });
7621 }
7622
7623 #[cfg(test)]
7625 fn read_outcome_stats(root: &std::path::Path, memory_id: &str) -> (i64, f64, f64) {
7626 let (_paths, _config, conn) = load_project(root).expect("load");
7627 conn.query_row(
7628 "SELECT use_count, usefulness_score, confidence FROM memories WHERE memory_id = ?1",
7629 rusqlite::params![memory_id],
7630 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
7631 )
7632 .expect("stats")
7633 }
7634
7635 #[test]
7638 fn standalone_cite_raises_usefulness() {
7639 with_user_brain_disabled(|| {
7640 let root = test_root();
7641 std::fs::create_dir_all(&root).expect("create root");
7642 init_project(&root, false).expect("init");
7643 let memory_id = add_memory(
7644 &root,
7645 kimetsu_core::memory::MemoryScope::Project,
7646 kimetsu_core::memory::MemoryKind::Fact,
7647 "standalone cite outcome fixture",
7648 )
7649 .expect("add memory");
7650
7651 let (uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
7652 record_mcp_citation(&root, &memory_id, None).expect("cite");
7653 let (uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
7654
7655 assert_eq!(uc1, uc0 + 1, "use_count must increment on standalone cite");
7656 assert!(us1 > us0, "usefulness must rise: {us0} -> {us1}");
7657 assert!(
7661 cf1 > cf0,
7662 "confidence must rise toward 1.0 on a positive outcome: {cf0} -> {cf1}"
7663 );
7664 std::fs::remove_dir_all(&root).ok();
7665 });
7666 }
7667
7668 #[test]
7670 fn manual_regret_lowers_usefulness_and_confidence() {
7671 with_user_brain_disabled(|| {
7672 let root = test_root();
7673 std::fs::create_dir_all(&root).expect("create root");
7674 init_project(&root, false).expect("init");
7675 let memory_id = add_memory(
7676 &root,
7677 kimetsu_core::memory::MemoryScope::Project,
7678 kimetsu_core::memory::MemoryKind::Fact,
7679 "manual regret outcome fixture",
7680 )
7681 .expect("add memory");
7682
7683 let (_uc0, us0, cf0) = read_outcome_stats(&root, &memory_id);
7684 record_regret(&root, &memory_id).expect("regret");
7685 let (_uc1, us1, cf1) = read_outcome_stats(&root, &memory_id);
7686
7687 assert!(us1 < us0, "usefulness must drop on regret: {us0} -> {us1}");
7688 assert!(cf1 < cf0, "confidence must drop on regret: {cf0} -> {cf1}");
7689 std::fs::remove_dir_all(&root).ok();
7690 });
7691 }
7692
7693 #[test]
7696 fn real_run_cite_does_not_bump_in_apply_memory_cited() {
7697 with_user_brain_disabled(|| {
7698 let root = test_root();
7699 std::fs::create_dir_all(&root).expect("create root");
7700 init_project(&root, false).expect("init");
7701 let memory_id = add_memory(
7702 &root,
7703 kimetsu_core::memory::MemoryScope::Project,
7704 kimetsu_core::memory::MemoryKind::Fact,
7705 "real run cite fixture",
7706 )
7707 .expect("add memory");
7708
7709 let (uc0, us0, _cf0) = read_outcome_stats(&root, &memory_id);
7710
7711 let real_run = kimetsu_core::ids::RunId::new();
7713 let event = kimetsu_core::event::Event::new(
7714 real_run,
7715 "memory.cited",
7716 serde_json::json!({ "memory_id": memory_id, "turn": 0 }),
7717 );
7718 {
7719 let (_paths, _config, conn) = load_project(&root).expect("load");
7720 crate::projector::apply_events(&conn, std::slice::from_ref(&event)).expect("apply");
7721 }
7722
7723 let (uc1, us1, _cf1) = read_outcome_stats(&root, &memory_id);
7724 assert_eq!(uc1, uc0, "real-run cite must NOT increment use_count here");
7725 assert!(
7726 (us1 - us0).abs() < 1e-9,
7727 "real-run cite must NOT change usefulness here"
7728 );
7729 std::fs::remove_dir_all(&root).ok();
7730 });
7731 }
7732
7733 #[test]
7736 fn cite_outcome_survives_rebuild() {
7737 with_user_brain_disabled(|| {
7738 let root = test_root();
7739 std::fs::create_dir_all(&root).expect("create root");
7740 init_project(&root, false).expect("init");
7741 let memory_id = add_memory(
7742 &root,
7743 kimetsu_core::memory::MemoryScope::Project,
7744 kimetsu_core::memory::MemoryKind::Fact,
7745 "rebuild outcome fixture",
7746 )
7747 .expect("add memory");
7748 record_mcp_citation(&root, &memory_id, None).expect("cite");
7749
7750 let before = read_outcome_stats(&root, &memory_id);
7751 {
7752 let (_paths, _config, conn) = load_project(&root).expect("load");
7753 crate::projector::rebuild_in_place(&conn).expect("rebuild");
7754 }
7755 let after = read_outcome_stats(&root, &memory_id);
7756 assert_eq!(before.0, after.0, "use_count must survive rebuild");
7757 assert!(
7758 (before.1 - after.1).abs() < 1e-9,
7759 "usefulness must survive rebuild"
7760 );
7761 assert!(
7762 (before.2 - after.2).abs() < 1e-9,
7763 "confidence must survive rebuild"
7764 );
7765 std::fs::remove_dir_all(&root).ok();
7766 });
7767 }
7768
7769 #[test]
7771 fn set_age_backdates_created_at_and_survives_rebuild() {
7772 with_user_brain_disabled(|| {
7773 let root = test_root();
7774 std::fs::create_dir_all(&root).expect("create root");
7775 init_project(&root, false).expect("init");
7776 let memory_id = add_memory(
7777 &root,
7778 kimetsu_core::memory::MemoryScope::Project,
7779 kimetsu_core::memory::MemoryKind::Fact,
7780 "age injection fixture",
7781 )
7782 .expect("add memory");
7783
7784 let read_created = |root: &std::path::Path| -> String {
7785 let (_paths, _config, conn) = load_project(root).expect("load");
7786 conn.query_row(
7787 "SELECT created_at FROM memories WHERE memory_id = ?1",
7788 rusqlite::params![&memory_id],
7789 |r| r.get::<_, String>(0),
7790 )
7791 .expect("created_at")
7792 };
7793
7794 let created0 = read_created(&root);
7795 record_set_age(&root, &memory_id, 90).expect("set-age");
7796 let created1 = read_created(&root);
7797 assert!(
7799 created1 < created0,
7800 "created_at must move into the past: {created0} -> {created1}"
7801 );
7802
7803 {
7804 let (_paths, _config, conn) = load_project(&root).expect("load");
7805 crate::projector::rebuild_in_place(&conn).expect("rebuild");
7806 }
7807 assert_eq!(
7808 read_created(&root),
7809 created1,
7810 "aged created_at survives rebuild"
7811 );
7812 std::fs::remove_dir_all(&root).ok();
7813 });
7814 }
7815
7816 #[test]
7820 fn fix2_search_excludes_superseded_rows() {
7821 with_user_brain_disabled(|| {
7822 let root = test_root();
7823 init_project(&root, false).expect("init");
7824
7825 let superseded_id = add_memory(
7827 &root,
7828 MemoryScope::Project,
7829 MemoryKind::Fact,
7830 "unique superseded keyword alpha",
7831 )
7832 .expect("add superseded");
7833 add_memory(
7834 &root,
7835 MemoryScope::Project,
7836 MemoryKind::Fact,
7837 "live memory unrelated topic",
7838 )
7839 .expect("add live");
7840
7841 {
7844 let (_paths, _config, conn) = load_project(&root).expect("load for stamp");
7845 conn.execute(
7846 "UPDATE memories SET superseded_by = 'fake-survivor' \
7847 WHERE memory_id = ?1",
7848 rusqlite::params![&superseded_id],
7849 )
7850 .expect("stamp superseded_by");
7851 }
7852
7853 let hits = search_memories(&root, "unique superseded keyword alpha", 20, 0, None, None)
7855 .expect("search");
7856 assert!(
7857 !hits.iter().any(|h| h.memory_id == superseded_id),
7858 "superseded row must not appear in search results"
7859 );
7860
7861 std::fs::remove_dir_all(&root).ok();
7862 });
7863 }
7864
7865 #[test]
7870 fn fix4_top_excludes_superseded_rows() {
7871 with_user_brain_disabled(|| {
7872 let root = test_root();
7873 init_project(&root, false).expect("init");
7874
7875 let superseded_id = add_memory(
7877 &root,
7878 MemoryScope::Project,
7879 MemoryKind::Fact,
7880 "memory to be superseded with high usefulness",
7881 )
7882 .expect("add");
7883
7884 {
7886 let (_paths, _config, conn) = load_project(&root).expect("load");
7887 conn.execute(
7888 "UPDATE memories \
7889 SET superseded_by = 'fake-survivor', \
7890 use_count = 10, usefulness_score = 50.0 \
7891 WHERE memory_id = ?1",
7892 rusqlite::params![&superseded_id],
7893 )
7894 .expect("stamp");
7895 }
7896
7897 let live_id = add_memory(
7899 &root,
7900 MemoryScope::Project,
7901 MemoryKind::Fact,
7902 "live memory with normal stats",
7903 )
7904 .expect("add live");
7905 {
7906 let (_paths, _config, conn) = load_project(&root).expect("load");
7907 conn.execute(
7908 "UPDATE memories SET use_count = 5, usefulness_score = 5.0 \
7909 WHERE memory_id = ?1",
7910 rusqlite::params![&live_id],
7911 )
7912 .expect("seed stats");
7913 }
7914
7915 let opts = TopOptions {
7916 scope: None,
7917 min_uses: 1,
7918 limit: 20,
7919 };
7920 let top = list_memories_top(&root, opts).expect("list_memories_top");
7921
7922 assert!(
7923 !top.iter().any(|r| r.memory_id == superseded_id),
7924 "superseded row must not appear in top"
7925 );
7926 assert!(
7927 top.iter().any(|r| r.memory_id == live_id),
7928 "live row must appear in top"
7929 );
7930
7931 std::fs::remove_dir_all(&root).ok();
7932 });
7933 }
7934
7935 #[test]
7936 fn fix4_prune_excludes_superseded_rows() {
7937 with_user_brain_disabled(|| {
7938 let root = test_root();
7939 init_project(&root, false).expect("init");
7940
7941 let superseded_id = add_memory(
7942 &root,
7943 MemoryScope::Project,
7944 MemoryKind::Fact,
7945 "memory to be superseded with low usefulness",
7946 )
7947 .expect("add");
7948
7949 {
7951 let (_paths, _config, conn) = load_project(&root).expect("load");
7952 conn.execute(
7953 "UPDATE memories \
7954 SET superseded_by = 'fake-survivor', \
7955 use_count = 10, usefulness_score = -99.0 \
7956 WHERE memory_id = ?1",
7957 rusqlite::params![&superseded_id],
7958 )
7959 .expect("stamp");
7960 }
7961
7962 let live_id = add_memory(
7964 &root,
7965 MemoryScope::Project,
7966 MemoryKind::Fact,
7967 "live memory with negative usefulness for prune",
7968 )
7969 .expect("add live");
7970 {
7971 let (_paths, _config, conn) = load_project(&root).expect("load");
7972 conn.execute(
7973 "UPDATE memories SET use_count = 5, usefulness_score = -5.0 \
7974 WHERE memory_id = ?1",
7975 rusqlite::params![&live_id],
7976 )
7977 .expect("seed stats");
7978 }
7979
7980 let opts = PruneOptions {
7981 scope: None,
7982 min_uses: 1,
7983 max_ratio: -0.1,
7984 apply: false,
7985 };
7986 let summary = prune_low_usefulness(&root, opts).expect("prune");
7987
7988 assert!(
7989 !summary
7990 .candidates
7991 .iter()
7992 .any(|c| c.memory_id == superseded_id),
7993 "superseded row must not appear in prune candidates"
7994 );
7995 assert!(
7996 summary.candidates.iter().any(|c| c.memory_id == live_id),
7997 "live negative-score row must appear in prune candidates"
7998 );
7999
8000 std::fs::remove_dir_all(&root).ok();
8001 });
8002 }
8003
8004 #[test]
8017 fn add_memories_batch_present_retrievable_rebuild_safe() {
8018 with_user_brain_disabled(|| {
8019 let root = test_root();
8020 fs::create_dir_all(&root).expect("create temp project");
8021 init_project(&root, false).expect("init project");
8022
8023 let entries: Vec<BatchMemoryEntry> = (1..=5)
8025 .map(|i| BatchMemoryEntry {
8026 text: format!(
8027 "batch memory entry number {i} unique text for semantic distance"
8028 ),
8029 scope: kimetsu_core::memory::MemoryScope::Project,
8030 kind: kimetsu_core::memory::MemoryKind::Fact,
8031 valid_from: None,
8032 valid_to: None,
8033 })
8034 .collect();
8035
8036 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
8037
8038 assert_eq!(ids.len(), 5, "expected 5 ids back; got {:?}", ids);
8040 for id in &ids {
8042 assert!(!id.is_empty(), "id must not be empty");
8043 }
8044
8045 let memories = list_memories(&root).expect("list_memories after batch");
8047 assert_eq!(
8048 memories.len(),
8049 5,
8050 "list_memories should return 5; got {:?}",
8051 memories.iter().map(|m| &m.memory_id).collect::<Vec<_>>()
8052 );
8053 let stored_ids: Vec<_> = memories.iter().map(|m| m.memory_id.clone()).collect();
8054 for id in &ids {
8055 assert!(stored_ids.contains(id), "id {id} must be in list_memories");
8056 }
8057
8058 let ref_id = add_memory(
8064 &root,
8065 kimetsu_core::memory::MemoryScope::Project,
8066 kimetsu_core::memory::MemoryKind::Fact,
8067 "single-add reference for embedding consistency check",
8068 )
8069 .expect("single add ref");
8070 {
8071 let (_paths, _config, conn) = load_project(&root).expect("load project");
8072 let ref_model: Option<String> = conn
8073 .query_row(
8074 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
8075 rusqlite::params![ref_id],
8076 |r| r.get(0),
8077 )
8078 .expect("query ref embedding_model");
8079
8080 for id in &ids {
8082 let bm: Option<String> = conn
8083 .query_row(
8084 "SELECT embedding_model FROM memories WHERE memory_id = ?1",
8085 rusqlite::params![id],
8086 |r| r.get(0),
8087 )
8088 .expect("query batch embedding_model");
8089 assert_eq!(
8090 bm, ref_model,
8091 "batch memory {id} embedding_model ({bm:?}) must match single-add ref ({ref_model:?})"
8092 );
8093 }
8094 }
8095
8096 {
8099 let (_paths, _config, conn) = load_project(&root).expect("load for rebuild");
8100 crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
8101 let after_count: i64 = conn
8102 .query_row(
8103 "SELECT COUNT(*) FROM memories \
8104 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
8105 [],
8106 |r| r.get(0),
8107 )
8108 .expect("count after rebuild");
8109 assert_eq!(
8110 after_count, 6,
8111 "all 6 memories (5 batch + 1 single) must survive rebuild_in_place; got {after_count}"
8112 );
8113 let rebuilt_ids: Vec<String> = {
8114 let mut stmt = conn
8115 .prepare(
8116 "SELECT memory_id FROM memories \
8117 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
8118 )
8119 .expect("prepare");
8120 stmt.query_map([], |r| r.get(0))
8121 .expect("query")
8122 .map(|r| r.expect("row"))
8123 .collect()
8124 };
8125 for id in &ids {
8126 assert!(
8127 rebuilt_ids.contains(id),
8128 "id {id} must survive rebuild_in_place"
8129 );
8130 }
8131 }
8132
8133 let temporal_entries = vec![BatchMemoryEntry {
8135 text: "batch temporal test this fact expires soon".to_string(),
8136 scope: kimetsu_core::memory::MemoryScope::Project,
8137 kind: kimetsu_core::memory::MemoryKind::Fact,
8138 valid_from: Some("2025-01-01T00:00:00Z".to_string()),
8139 valid_to: Some("2099-12-31T00:00:00Z".to_string()),
8140 }];
8141 let temporal_ids =
8142 add_memories_batch(&root, temporal_entries).expect("add_memories_batch temporal");
8143 assert_eq!(temporal_ids.len(), 1);
8144 let temporal_id = &temporal_ids[0];
8145 {
8146 let (_paths, _config, conn) = load_project(&root).expect("load for temporal check");
8147 let (vf, vt): (Option<String>, Option<String>) = conn
8148 .query_row(
8149 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
8150 rusqlite::params![temporal_id],
8151 |r| Ok((r.get(0)?, r.get(1)?)),
8152 )
8153 .expect("query valid_from/valid_to");
8154 assert!(
8155 vf.is_some(),
8156 "valid_from must be set for temporal batch entry"
8157 );
8158 assert!(
8159 vt.is_some(),
8160 "valid_to must be set for temporal batch entry"
8161 );
8162 crate::projector::rebuild_in_place(&conn).expect("rebuild temporal");
8164 let (vf2, vt2): (Option<String>, Option<String>) = conn
8165 .query_row(
8166 "SELECT valid_from, valid_to FROM memories WHERE memory_id = ?1",
8167 rusqlite::params![temporal_id],
8168 |r| Ok((r.get(0)?, r.get(1)?)),
8169 )
8170 .expect("query after rebuild");
8171 assert_eq!(vf, vf2, "valid_from must survive rebuild");
8172 assert_eq!(vt, vt2, "valid_to must survive rebuild");
8173 }
8174
8175 fs::remove_dir_all(&root).ok();
8176 });
8177 }
8178
8179 #[test]
8182 fn add_memories_batch_deduplicates() {
8183 with_user_brain_disabled(|| {
8184 let root = test_root();
8185 fs::create_dir_all(&root).expect("create temp project");
8186 init_project(&root, false).expect("init project");
8187
8188 let text = "batch dedup test unique entry";
8189 let entries = vec![
8190 BatchMemoryEntry {
8191 text: text.to_string(),
8192 scope: kimetsu_core::memory::MemoryScope::Project,
8193 kind: kimetsu_core::memory::MemoryKind::Fact,
8194 valid_from: None,
8195 valid_to: None,
8196 },
8197 BatchMemoryEntry {
8198 text: text.to_string(),
8199 scope: kimetsu_core::memory::MemoryScope::Project,
8200 kind: kimetsu_core::memory::MemoryKind::Fact,
8201 valid_from: None,
8202 valid_to: None,
8203 },
8204 ];
8205
8206 let ids = add_memories_batch(&root, entries).expect("add_memories_batch dedup");
8207 assert_eq!(ids.len(), 2);
8208 assert_eq!(
8209 ids[0], ids[1],
8210 "duplicate text must return the same memory_id"
8211 );
8212
8213 let memories = list_memories(&root).expect("list");
8215 assert_eq!(
8216 memories.len(),
8217 1,
8218 "deduped batch must produce exactly 1 DB row; got {}",
8219 memories.len()
8220 );
8221
8222 fs::remove_dir_all(&root).ok();
8223 });
8224 }
8225
8226 #[test]
8236 fn add_memories_batch_all_entries_same_embedding_model() {
8237 with_user_brain_disabled(|| {
8238 let root = test_root();
8239 fs::create_dir_all(&root).expect("create temp project");
8240 init_project(&root, false).expect("init project");
8241
8242 let n = 8_usize;
8243 let entries: Vec<BatchMemoryEntry> = (0..n)
8244 .map(|i| BatchMemoryEntry {
8245 text: format!(
8246 "embedding model consistency test memory {i} distinct content here"
8247 ),
8248 scope: kimetsu_core::memory::MemoryScope::Project,
8249 kind: kimetsu_core::memory::MemoryKind::Convention,
8250 valid_from: None,
8251 valid_to: None,
8252 })
8253 .collect();
8254
8255 let ids = add_memories_batch(&root, entries).expect("add_memories_batch");
8256 assert_eq!(ids.len(), n);
8257
8258 let (_paths, _config, conn) = load_project(&root).expect("load project");
8259 let model_id_rows: Vec<Option<String>> = {
8260 let mut stmt = conn
8261 .prepare("SELECT embedding_model FROM memories ORDER BY created_at")
8262 .expect("prepare");
8263 stmt.query_map([], |r| r.get(0))
8264 .expect("query")
8265 .map(|r| r.expect("row"))
8266 .collect()
8267 };
8268 assert_eq!(model_id_rows.len(), n, "expected {n} rows");
8269 let first = &model_id_rows[0];
8271 for (i, model_id) in model_id_rows.iter().enumerate() {
8272 assert_eq!(
8273 model_id, first,
8274 "memory {i} embedding_model ({model_id:?}) must match first ({first:?})"
8275 );
8276 }
8277
8278 fs::remove_dir_all(&root).ok();
8279 });
8280 }
8281}