1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kimetsu_core::config::ProjectConfig;
5use kimetsu_core::env_file::resolve_env_value;
6use kimetsu_core::event::Event;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::memory::{MemoryKind, MemoryScope, normalize_memory_text};
9use kimetsu_core::paths::{ProjectPaths, default_project_id};
10use kimetsu_core::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
11use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
12use ulid::Ulid;
13
14use crate::benchmark;
15use crate::conflict;
16use crate::context::{self, ContextBundle, ContextRequest};
17use crate::embeddings;
18use crate::ingest::{self, RepoIngestSummary};
19use crate::lock::ProjectLock;
20use crate::projector;
21use crate::redact;
22use crate::schema;
23use crate::trace::{self, TraceWriter};
24use crate::user_brain;
25
26#[derive(Debug, Clone)]
27pub struct InitSummary {
28 pub project_id: String,
29 pub repo_root: PathBuf,
30 pub kimetsu_dir: PathBuf,
31 pub brain_db: PathBuf,
32 pub model: String,
33 pub api_key_env: String,
34 pub api_key_present: bool,
35 pub wrote_project_toml: bool,
36}
37
38#[derive(Debug, Clone)]
39pub struct RunSummary {
40 pub run_id: String,
41 pub task: String,
42 pub started_at: String,
43 pub terminal_kind: Option<String>,
44}
45
46#[derive(Debug, Clone)]
47pub struct MemoryRow {
48 pub memory_id: String,
49 pub scope: String,
50 pub kind: String,
51 pub text: String,
52 pub confidence: f32,
53 pub use_count: u32,
54 pub usefulness_score: f32,
59}
60
61#[derive(Debug, Clone)]
62pub struct ProposalRow {
63 pub proposal_id: String,
64 pub run_id: String,
65 pub scope: String,
66 pub kind: String,
67 pub text: String,
68 pub rationale: String,
69 pub proposed_confidence: f32,
70 pub status: String,
71 pub decided_reason: Option<String>,
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct ProposalFilter {
76 pub scope: Option<String>,
77 pub kind: Option<String>,
78 pub from_run: Option<String>,
79 pub min_confidence: Option<f32>,
80 pub status: Option<String>,
81 pub limit: u32,
82}
83
84#[derive(Debug, Clone, Default)]
85pub struct AcceptOverrides {
86 pub scope: Option<String>,
87 pub confidence: Option<f32>,
88}
89
90#[derive(Debug, Clone)]
91pub struct RecordedBenchmarkOutcome {
92 pub memory_id: String,
93 pub task_slug: Option<String>,
94 pub kind: MemoryKind,
95 pub text: String,
96 pub proposal_id: Option<String>,
97 pub proposal_text: Option<String>,
98}
99
100#[derive(Debug, Clone, serde::Serialize)]
105pub struct BlameReport {
106 pub run_id: String,
107 pub outcome: String,
111 pub failure_category: Option<String>,
114 pub cited: Vec<CitedMemory>,
117 pub silent_passengers: Vec<SilentMemory>,
120}
121
122#[derive(Debug, Clone, serde::Serialize)]
123pub struct CitedMemory {
124 pub memory_id: String,
125 pub turn: i64,
126 pub rationale: Option<String>,
127 pub cited_at: String,
128 pub text_preview: String,
130 pub scope: String,
131 pub kind: String,
132}
133
134#[derive(Debug, Clone, serde::Serialize)]
135pub struct SilentMemory {
136 pub memory_id: String,
137 pub text_preview: String,
138 pub scope: String,
139 pub kind: String,
140}
141
142pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
143 let paths = ProjectPaths::discover(start)?;
144 fs::create_dir_all(&paths.runs_dir)?;
145
146 let project_id = default_project_id(&paths.repo_root);
147 let config = ProjectConfig::default_for_project(project_id);
148 let wrote_project_toml = if force || !paths.project_toml.exists() {
149 fs::write(&paths.project_toml, config.to_toml()?)?;
150 true
151 } else {
152 false
153 };
154
155 let config = load_config(&paths)?;
156 let conn = Connection::open(&paths.brain_db)?;
157 schema::initialize(&conn)?;
158
159 let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
160
161 Ok(InitSummary {
162 project_id: config.kimetsu.project_id,
163 repo_root: paths.repo_root,
164 kimetsu_dir: paths.kimetsu_dir,
165 brain_db: paths.brain_db,
166 model: format!("{}/{}", config.model.provider, config.model.model),
167 api_key_env: config.model.api_key_env,
168 api_key_present,
169 wrote_project_toml,
170 })
171}
172
173pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
174 let paths = ProjectPaths::discover(start)?;
175 let config = load_config(&paths)?;
176 if config.kimetsu.schema_version != KIMETSU_SCHEMA_VERSION {
177 return Err(format!(
178 "project.toml schema version {} does not match expected {}",
179 config.kimetsu.schema_version, KIMETSU_SCHEMA_VERSION
180 )
181 .into());
182 }
183
184 let conn = Connection::open(&paths.brain_db)?;
185 schema::initialize(&conn)?;
186 Ok((paths, config, conn))
187}
188
189pub fn load_project_readonly(
190 start: &Path,
191) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
192 let paths = ProjectPaths::discover(start)?;
193 let config = load_config(&paths)?;
194 if config.kimetsu.schema_version != KIMETSU_SCHEMA_VERSION {
195 return Err(format!(
196 "project.toml schema version {} does not match expected {}",
197 config.kimetsu.schema_version, KIMETSU_SCHEMA_VERSION
198 )
199 .into());
200 }
201
202 let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
203 schema::validate(&conn)?;
204 Ok((paths, config, conn))
205}
206
207pub struct BrainSession {
208 paths: ProjectPaths,
209 config: ProjectConfig,
210 conn: Connection,
211 user_conn: Option<Connection>,
219 repo_root: String,
220}
221
222impl BrainSession {
223 pub fn open(start: &Path) -> KimetsuResult<Self> {
224 let (paths, config, conn) = load_project(start)?;
225 let user_conn = user_brain::open_user_brain()?;
229 Self::from_parts(paths, config, conn, user_conn)
230 }
231
232 pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
233 let (paths, config, conn) = load_project_readonly(start)?;
234 let user_conn = user_brain::open_user_brain_readonly()?;
238 Self::from_parts(paths, config, conn, user_conn)
239 }
240
241 fn from_parts(
242 paths: ProjectPaths,
243 config: ProjectConfig,
244 conn: Connection,
245 user_conn: Option<Connection>,
246 ) -> KimetsuResult<Self> {
247 let repo_root = paths
248 .repo_root
249 .canonicalize()?
250 .to_string_lossy()
251 .to_string();
252 Ok(Self {
253 paths,
254 config,
255 conn,
256 user_conn,
257 repo_root,
258 })
259 }
260
261 pub fn retrieve_context(
262 &self,
263 stage: &str,
264 query: &str,
265 budget_tokens: u32,
266 ) -> KimetsuResult<ContextBundle> {
267 let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
273 context::retrieve_context_multi(
274 &self.conn,
275 &self.repo_root,
276 &self.config.broker.weights,
277 ContextRequest {
278 stage: stage.to_string(),
279 query: query.to_string(),
280 budget_tokens,
281 },
282 &extras,
283 )
284 }
285
286 pub fn repo_root(&self) -> &Path {
287 &self.paths.repo_root
288 }
289
290 pub fn user_conn(&self) -> Option<&Connection> {
295 self.user_conn.as_ref()
296 }
297}
298
299pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
300 let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
301 format!(
302 "failed to read {}; run `kimetsu init` first: {err}",
303 paths.project_toml.display()
304 )
305 })?;
306 ProjectConfig::from_toml(&content)
307}
308
309pub fn config_text(start: &Path) -> KimetsuResult<String> {
310 let paths = ProjectPaths::discover(start)?;
311 Ok(fs::read_to_string(paths.project_toml)?)
312}
313
314pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
315 let (_paths, _config, conn) = load_project(start)?;
316 let mut stmt = conn.prepare(
317 "
318 SELECT run_id, task, started_at, terminal_kind
319 FROM runs
320 ORDER BY started_at DESC
321 LIMIT 100
322 ",
323 )?;
324
325 let rows = stmt.query_map([], |row| {
326 Ok(RunSummary {
327 run_id: row.get(0)?,
328 task: row.get(1)?,
329 started_at: row.get(2)?,
330 terminal_kind: row.get(3)?,
331 })
332 })?;
333
334 let mut runs = Vec::new();
335 for row in rows {
336 runs.push(row?);
337 }
338 Ok(runs)
339}
340
341pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
342 let (_paths, _config, conn) = load_project(start)?;
343 let mut stmt = conn.prepare(
344 "
345 SELECT run_id, task, started_at, terminal_kind
346 FROM runs
347 WHERE run_id = ?1
348 ",
349 )?;
350
351 let mut rows = stmt.query(params![run_id])?;
352 if let Some(row) = rows.next()? {
353 Ok(Some(RunSummary {
354 run_id: row.get(0)?,
355 task: row.get(1)?,
356 started_at: row.get(2)?,
357 terminal_kind: row.get(3)?,
358 }))
359 } else {
360 Ok(None)
361 }
362}
363
364pub fn add_memory(
365 start: &Path,
366 scope: MemoryScope,
367 kind: MemoryKind,
368 text: &str,
369) -> KimetsuResult<String> {
370 let redaction = redact::redact_secrets(text);
383 if redaction.was_redacted() {
384 eprintln!("kimetsu-brain: {}", redaction.summary());
385 }
386 let text = redaction.text.as_str();
387
388 if scope == MemoryScope::GlobalUser
398 && let Some(user_conn) = user_brain::open_user_brain()?
399 {
400 return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
401 }
402 let (paths, config, conn) = load_project(start)?;
403 let run_id = RunId::new();
404 let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
405 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
406 let memory_id = Ulid::new().to_string();
407 let normalized = normalize_memory_text(text);
408
409 let existing: Option<String> = conn
415 .query_row(
416 "
417 SELECT memory_id FROM memories
418 WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
419 AND invalidated_at IS NULL
420 LIMIT 1
421 ",
422 rusqlite::params![scope.to_string(), kind.to_string(), normalized],
423 |row| row.get::<_, String>(0),
424 )
425 .optional()?;
426 if let Some(existing_id) = existing {
427 return Ok(existing_id);
428 }
429
430 let started = Event::new(
431 run_id,
432 "run.started",
433 serde_json::json!({
434 "mode": "admin",
435 "task": "memory add",
436 "project_id": config.kimetsu.project_id,
437 "repo_root": paths.repo_root.to_string_lossy(),
438 "model": null,
439 "platform": std::env::consts::OS,
440 "kimetsu_version": env!("CARGO_PKG_VERSION"),
441 "config_hash": config_hash(&paths.project_toml)?,
442 }),
443 );
444 writer.append(&started, true)?;
445
446 let accepted = Event::new(
447 run_id,
448 "memory.accepted",
449 serde_json::json!({
450 "proposal_id": null,
451 "memory_id": memory_id,
452 "scope": scope.to_string(),
453 "kind": kind.to_string(),
454 "text": text,
455 "normalized_text": normalized,
456 "confidence": 1.0,
457 "provenance_snapshot": {
458 "source": "manual_cli",
459 "run_id": run_id.to_string(),
460 "text": text,
461 }
462 }),
463 );
464 writer.append(&accepted, true)?;
465
466 let finished = Event::new(
467 run_id,
468 "run.finished",
469 serde_json::json!({
470 "status": "success",
471 "final_report_path": null,
472 "total_cost_usd": 0.0,
473 "total_tool_calls": 0,
474 }),
475 );
476 writer.append(&finished, true)?;
477
478 projector::apply_events(&conn, &[started, accepted, finished])?;
479
480 let embedder = embeddings::open_default_embedder();
488 embeddings::embed_and_persist(&conn, &memory_id, text, embedder)?;
489
490 let conflicts = conflict::detect_and_record(
497 &conn,
498 &memory_id,
499 &scope,
500 &kind.to_string(),
501 text,
502 embedder,
503 );
504 if conflicts > 0 {
505 eprintln!(
506 "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
507 if conflicts == 1 { "y" } else { "ies" }
508 );
509 }
510
511 Ok(memory_id)
512}
513
514pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
515 let (_paths, _config, conn) = load_project(start)?;
516 let mut memories = list_memories_from_conn(&conn)?;
517 if let Some(user_conn) = user_brain::open_user_brain_readonly()? {
521 memories.extend(user_brain::list_user_memories(&user_conn)?);
522 }
523 Ok(memories)
524}
525
526pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
536 let (_paths, _config, conn) = load_project(start)?;
537 let user_conn = user_brain::open_user_brain_readonly()?;
538
539 let (outcome, failure_category) = run_outcome(&conn, run_id)?;
541
542 let cited_rows: Vec<(String, i64, Option<String>, String)> = {
544 let mut stmt = conn.prepare(
545 "
546 SELECT memory_id, turn, rationale, cited_at
547 FROM memory_citations
548 WHERE run_id = ?1
549 ORDER BY turn ASC, cited_at ASC
550 ",
551 )?;
552 let rows = stmt.query_map(rusqlite::params![run_id], |row| {
553 Ok((
554 row.get::<_, String>(0)?,
555 row.get::<_, i64>(1)?,
556 row.get::<_, Option<String>>(2)?,
557 row.get::<_, String>(3)?,
558 ))
559 })?;
560 let mut out = Vec::new();
561 for row in rows {
562 out.push(row?);
563 }
564 out
565 };
566
567 let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
568 let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
569 for (memory_id, turn, rationale, cited_at) in cited_rows {
570 cited_set.insert(memory_id.clone());
571 let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
572 cited.push(CitedMemory {
573 memory_id,
574 turn,
575 rationale,
576 cited_at,
577 text_preview: text_preview(&text, 120),
578 scope,
579 kind,
580 });
581 }
582
583 let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
585 let mut silent: Vec<SilentMemory> = Vec::new();
586 for memory_id in retrieved_ids {
587 if cited_set.contains(&memory_id) {
588 continue;
589 }
590 let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
591 silent.push(SilentMemory {
592 memory_id,
593 text_preview: text_preview(&text, 120),
594 scope,
595 kind,
596 });
597 }
598
599 Ok(BlameReport {
600 run_id: run_id.to_string(),
601 outcome,
602 failure_category,
603 cited,
604 silent_passengers: silent,
605 })
606}
607
608fn run_outcome(
609 conn: &Connection,
610 run_id: &str,
611) -> KimetsuResult<(String, Option<String>)> {
612 let row: Option<(String, String)> = conn
614 .query_row(
615 "
616 SELECT kind, payload_json
617 FROM events
618 WHERE run_id = ?1
619 AND kind IN ('run.finished', 'run.failed', 'run.aborted')
620 ORDER BY ts DESC
621 LIMIT 1
622 ",
623 rusqlite::params![run_id],
624 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
625 )
626 .optional()?;
627 Ok(match row {
628 Some((kind, payload_json)) => {
629 let outcome = match kind.as_str() {
630 "run.finished" => "success".to_string(),
631 "run.failed" => "failed".to_string(),
632 "run.aborted" => "aborted".to_string(),
633 other => other.to_string(),
634 };
635 let category = if kind == "run.failed" {
636 serde_json::from_str::<serde_json::Value>(&payload_json)
637 .ok()
638 .and_then(|v| {
639 v.get("category")
640 .and_then(|c| c.as_str())
641 .map(str::to_string)
642 })
643 } else {
644 None
645 };
646 (outcome, category)
647 }
648 None => ("unknown".to_string(), None),
649 })
650}
651
652fn collect_injected_memory_ids_for_blame(
653 conn: &Connection,
654 run_id: &str,
655) -> KimetsuResult<Vec<String>> {
656 let mut stmt = conn.prepare(
657 "
658 SELECT payload_json
659 FROM events
660 WHERE run_id = ?1 AND kind = 'context.injected'
661 ",
662 )?;
663 let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
664 let mut seen = std::collections::BTreeSet::new();
665 for row in rows {
666 let payload_json = row?;
667 let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
668 if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
669 for id in ids {
670 if let Some(s) = id.as_str()
671 && !s.is_empty()
672 {
673 seen.insert(s.to_string());
674 }
675 }
676 }
677 }
678 Ok(seen.into_iter().collect())
679}
680
681fn resolve_memory(
687 project_conn: &Connection,
688 user_conn: Option<&Connection>,
689 memory_id: &str,
690) -> (String, String, String) {
691 let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
692 let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
693 conn.query_row(q, rusqlite::params![memory_id], |row| {
694 Ok((
695 row.get::<_, String>(0)?,
696 row.get::<_, String>(1)?,
697 row.get::<_, String>(2)?,
698 ))
699 })
700 .optional()
701 .ok()
702 .flatten()
703 };
704 try_conn(project_conn)
705 .or_else(|| user_conn.and_then(try_conn))
706 .unwrap_or_else(|| {
707 (
708 "<unknown — deleted or invalid memory_id>".to_string(),
709 String::new(),
710 String::new(),
711 )
712 })
713}
714
715fn text_preview(text: &str, max_chars: usize) -> String {
716 let trimmed = text.trim();
717 if trimmed.chars().count() <= max_chars {
718 trimmed.to_string()
719 } else {
720 let head: String = trimmed.chars().take(max_chars).collect();
721 format!("{head}…")
722 }
723}
724
725fn list_memories_from_conn(conn: &Connection) -> KimetsuResult<Vec<MemoryRow>> {
726 let mut stmt = conn.prepare(
727 "
728 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
729 FROM memories
730 ORDER BY created_at DESC
731 LIMIT 100
732 ",
733 )?;
734
735 let rows = stmt.query_map([], |row| {
736 Ok(MemoryRow {
737 memory_id: row.get(0)?,
738 scope: row.get(1)?,
739 kind: row.get(2)?,
740 text: row.get(3)?,
741 confidence: row.get(4)?,
742 use_count: row.get(5)?,
743 usefulness_score: row.get::<_, f64>(6)? as f32,
744 })
745 })?;
746
747 let mut memories = Vec::new();
748 for row in rows {
749 memories.push(row?);
750 }
751 Ok(memories)
752}
753
754#[derive(Debug, Clone, Default)]
762pub struct TopOptions {
763 pub scope: Option<String>,
764 pub min_uses: u32,
765 pub limit: u32,
766}
767
768pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
769 let (_paths, _config, conn) = load_project(start)?;
770 let min_uses = opts.min_uses.max(1) as i64;
771 let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
772
773 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
774 (
775 "
776 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
777 FROM memories
778 WHERE invalidated_at IS NULL
779 AND use_count >= ?1
780 AND lower(scope) = lower(?2)
781 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
782 LIMIT ?3
783 ",
784 Some(scope.to_string()),
785 )
786 } else {
787 (
788 "
789 SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
790 FROM memories
791 WHERE invalidated_at IS NULL
792 AND use_count >= ?1
793 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
794 LIMIT ?2
795 ",
796 None,
797 )
798 };
799
800 let mut stmt = conn.prepare(sql)?;
801 let mut rows = if let Some(scope) = scope_param {
802 stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
803 .collect::<Result<Vec<_>, _>>()?
804 } else {
805 stmt.query_map(params![min_uses, limit], map_memory_row)?
806 .collect::<Result<Vec<_>, _>>()?
807 };
808
809 rows.sort_by(|a, b| {
814 let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
815 let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
816 rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
817 });
818 Ok(rows)
819}
820
821fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
822 Ok(MemoryRow {
823 memory_id: row.get(0)?,
824 scope: row.get(1)?,
825 kind: row.get(2)?,
826 text: row.get(3)?,
827 confidence: row.get(4)?,
828 use_count: row.get(5)?,
829 usefulness_score: row.get::<_, f64>(6)? as f32,
830 })
831}
832
833#[derive(Debug, Clone)]
845pub struct PruneOptions {
846 pub scope: Option<String>,
847 pub min_uses: u32,
848 pub max_ratio: f32,
849 pub apply: bool,
850}
851
852impl Default for PruneOptions {
853 fn default() -> Self {
854 Self {
855 scope: None,
856 min_uses: 3,
857 max_ratio: -0.2,
858 apply: false,
859 }
860 }
861}
862
863#[derive(Debug, Clone)]
864pub struct PruneCandidate {
865 pub memory_id: String,
866 pub scope: String,
867 pub kind: String,
868 pub use_count: u32,
869 pub usefulness_score: f32,
870 pub text: String,
871}
872
873#[derive(Debug, Clone, Default)]
874pub struct PruneSummary {
875 pub candidates: Vec<PruneCandidate>,
876 pub invalidated: u32,
877 pub failed: u32,
878}
879
880pub fn prune_low_usefulness(start: &Path, opts: PruneOptions) -> KimetsuResult<PruneSummary> {
881 let min_uses = opts.min_uses.max(1) as i64;
882
883 let candidates = {
884 let (_paths, _config, conn) = load_project(start)?;
885 let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref()
886 {
887 (
888 "
889 SELECT memory_id, scope, kind, text, use_count, usefulness_score
890 FROM memories
891 WHERE invalidated_at IS NULL
892 AND use_count >= ?1
893 AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
894 AND lower(scope) = lower(?3)
895 ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
896 ",
897 Some(scope.to_string()),
898 )
899 } else {
900 (
901 "
902 SELECT memory_id, scope, kind, text, use_count, usefulness_score
903 FROM memories
904 WHERE invalidated_at IS NULL
905 AND use_count >= ?1
906 AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
907 ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
908 ",
909 None,
910 )
911 };
912 let mut stmt = conn.prepare(sql)?;
913 let max_ratio = opts.max_ratio as f64;
914 let mut found: Vec<PruneCandidate> = if let Some(scope) = scope_param {
915 stmt.query_map(params![min_uses, max_ratio, scope], |row| {
916 Ok(PruneCandidate {
917 memory_id: row.get(0)?,
918 scope: row.get(1)?,
919 kind: row.get(2)?,
920 text: row.get(3)?,
921 use_count: row.get(4)?,
922 usefulness_score: row.get::<_, f64>(5)? as f32,
923 })
924 })?
925 .collect::<Result<Vec<_>, _>>()?
926 } else {
927 stmt.query_map(params![min_uses, max_ratio], |row| {
928 Ok(PruneCandidate {
929 memory_id: row.get(0)?,
930 scope: row.get(1)?,
931 kind: row.get(2)?,
932 text: row.get(3)?,
933 use_count: row.get(4)?,
934 usefulness_score: row.get::<_, f64>(5)? as f32,
935 })
936 })?
937 .collect::<Result<Vec<_>, _>>()?
938 };
939 found.sort_by(|a, b| {
942 let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
943 let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
944 ra.partial_cmp(&rb)
945 .unwrap_or(std::cmp::Ordering::Equal)
946 .then_with(|| b.use_count.cmp(&a.use_count))
947 });
948 found
949 };
950
951 let mut summary = PruneSummary {
952 candidates: candidates.clone(),
953 invalidated: 0,
954 failed: 0,
955 };
956 if !opts.apply {
957 return Ok(summary);
958 }
959
960 for candidate in &candidates {
961 let ratio = candidate.usefulness_score / candidate.use_count.max(1) as f32;
962 let reason = format!(
963 "pruned_by_usefulness ratio={:+.2} use_count={}",
964 ratio, candidate.use_count
965 );
966 match invalidate_memory(start, &candidate.memory_id, Some(&reason)) {
967 Ok(()) => summary.invalidated += 1,
968 Err(_) => summary.failed += 1,
969 }
970 }
971 Ok(summary)
972}
973
974pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
975 let (_paths, _config, conn) = load_project(start)?;
976 let mut sql = String::from(
977 "
978 SELECT proposal_id, run_id, scope, kind, text, rationale,
979 proposed_confidence, status, decided_reason
980 FROM memory_proposals
981 ",
982 );
983 let mut clauses = Vec::<String>::new();
984 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
985 if let Some(scope) = filter.scope.as_deref() {
986 clauses.push("scope = ?".to_string());
987 params.push(Box::new(scope.to_string()));
988 }
989 if let Some(kind) = filter.kind.as_deref() {
990 clauses.push("kind = ?".to_string());
991 params.push(Box::new(kind.to_string()));
992 }
993 if let Some(run_id) = filter.from_run.as_deref() {
994 clauses.push("run_id = ?".to_string());
995 params.push(Box::new(run_id.to_string()));
996 }
997 if let Some(min_conf) = filter.min_confidence {
998 clauses.push("proposed_confidence >= ?".to_string());
999 params.push(Box::new(min_conf as f64));
1000 }
1001 if let Some(status) = filter.status.as_deref()
1002 && !status.eq_ignore_ascii_case("any")
1003 {
1004 clauses.push("status = ?".to_string());
1005 params.push(Box::new(status.to_string()));
1006 }
1007 if !clauses.is_empty() {
1008 sql.push_str(" WHERE ");
1009 sql.push_str(&clauses.join(" AND "));
1010 }
1011 let limit = if filter.limit == 0 { 100 } else { filter.limit };
1012 sql.push_str(&format!(" ORDER BY rowid DESC LIMIT {limit}"));
1013
1014 let mut stmt = conn.prepare(&sql)?;
1015 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1016 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1017 Ok(ProposalRow {
1018 proposal_id: row.get(0)?,
1019 run_id: row.get(1)?,
1020 scope: row.get(2)?,
1021 kind: row.get(3)?,
1022 text: row.get(4)?,
1023 rationale: row.get(5)?,
1024 proposed_confidence: row.get(6)?,
1025 status: row.get(7)?,
1026 decided_reason: row.get(8)?,
1027 })
1028 })?;
1029
1030 let mut proposals = Vec::new();
1031 for row in rows {
1032 proposals.push(row?);
1033 }
1034 Ok(proposals)
1035}
1036
1037pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1038 let (paths, config, conn) = load_project(start)?;
1039 let run_id = RunId::new();
1040 let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1041 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1042
1043 let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1044 writer.append(&started, true)?;
1045
1046 let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1047
1048 let ingested = Event::new(
1049 run_id,
1050 "repo.ingested",
1051 serde_json::json!({
1052 "repo_root": summary.repo_root.to_string_lossy(),
1053 "indexed_files": summary.indexed_files,
1054 "skipped_files": summary.skipped_files,
1055 "manifests": summary.manifests,
1056 }),
1057 );
1058 writer.append(&ingested, true)?;
1059
1060 let finished = admin_finished_event(run_id);
1061 writer.append(&finished, true)?;
1062 projector::apply_events(&conn, &[started, ingested, finished])?;
1063
1064 Ok(summary)
1065}
1066
1067pub fn search_files(
1068 start: &Path,
1069 query: &str,
1070 limit: u32,
1071) -> KimetsuResult<Vec<context::ContextCapsule>> {
1072 let (paths, _config, conn) = load_project(start)?;
1073 let repo_root = paths
1074 .repo_root
1075 .canonicalize()?
1076 .to_string_lossy()
1077 .to_string();
1078 context::search_repo_files(&conn, &repo_root, query, limit)
1079}
1080
1081pub fn retrieve_context(
1082 start: &Path,
1083 stage: &str,
1084 query: &str,
1085 budget_tokens: u32,
1086) -> KimetsuResult<ContextBundle> {
1087 BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1088}
1089
1090pub fn retrieve_context_readonly(
1091 start: &Path,
1092 stage: &str,
1093 query: &str,
1094 budget_tokens: u32,
1095) -> KimetsuResult<ContextBundle> {
1096 BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1097}
1098
1099#[allow(clippy::too_many_arguments)]
1100pub fn retrieve_benchmark_context_readonly(
1101 start: &Path,
1102 task: &str,
1103 dataset: &str,
1104 task_slug: Option<&str>,
1105 warm_policy: benchmark::BenchmarkWarmPolicy,
1106 stage: &str,
1107 budget_tokens: u32,
1108 require_benchmark_memory: bool,
1109 max_capsules: usize,
1110) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1111 retrieve_benchmark_context_readonly_with_ambient(
1112 start,
1113 task,
1114 dataset,
1115 task_slug,
1116 warm_policy,
1117 stage,
1118 budget_tokens,
1119 require_benchmark_memory,
1120 max_capsules,
1121 None,
1122 )
1123}
1124
1125#[allow(clippy::too_many_arguments)]
1131pub fn retrieve_benchmark_context_readonly_with_ambient(
1132 start: &Path,
1133 task: &str,
1134 dataset: &str,
1135 task_slug: Option<&str>,
1136 warm_policy: benchmark::BenchmarkWarmPolicy,
1137 stage: &str,
1138 budget_tokens: u32,
1139 require_benchmark_memory: bool,
1140 max_capsules: usize,
1141 ambient_suffix: Option<&str>,
1142) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1143 let normalized_slug = task_slug
1144 .and_then(benchmark::normalize_task_slug)
1145 .or_else(|| benchmark::normalize_task_slug(task));
1146 let mut query =
1147 benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
1148 if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
1149 query.push_str(suffix);
1150 }
1151 let bundle =
1152 BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
1153 Ok(benchmark::build_benchmark_context(
1154 bundle,
1155 task,
1156 dataset,
1157 &query,
1158 normalized_slug,
1159 warm_policy,
1160 require_benchmark_memory,
1161 max_capsules,
1162 ))
1163}
1164
1165pub fn record_benchmark_outcome(
1166 start: &Path,
1167 outcome: benchmark::BenchmarkOutcome,
1168) -> KimetsuResult<RecordedBenchmarkOutcome> {
1169 let task_slug = outcome
1170 .task_slug
1171 .clone()
1172 .or_else(|| benchmark::normalize_task_slug(&outcome.task));
1173 let kind = benchmark::outcome_memory_kind(&outcome);
1174 let text = benchmark::outcome_memory_text(&outcome);
1175 let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
1176 let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
1177 Some(proposal) if proposal.role.is_generalizable() => {
1178 let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
1179 (Some(proposal_id), Some(proposal_text))
1180 }
1181 _ => (None, None),
1182 };
1183 Ok(RecordedBenchmarkOutcome {
1184 memory_id,
1185 task_slug,
1186 kind,
1187 text,
1188 proposal_id,
1189 proposal_text,
1190 })
1191}
1192
1193fn propose_benchmark_memory(
1194 start: &Path,
1195 outcome: &benchmark::BenchmarkOutcome,
1196 proposal: &benchmark::BenchmarkMemoryProposal,
1197) -> KimetsuResult<(String, String)> {
1198 let (paths, config, conn) = load_project(start)?;
1199 let run_id = RunId::new();
1200 let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
1201 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1202 let proposal_id = Ulid::new().to_string();
1203 let raw_text = benchmark::proposal_memory_text(outcome, proposal);
1208 let text_redaction = redact::redact_secrets(&raw_text);
1209 if text_redaction.was_redacted() {
1210 eprintln!(
1211 "kimetsu-brain (benchmark proposal): {}",
1212 text_redaction.summary()
1213 );
1214 }
1215 let text = text_redaction.text;
1216 let kind = benchmark::proposal_memory_kind(proposal);
1217 let rationale_raw = if proposal.rationale.trim().is_empty() {
1218 "generalized from benchmark outcome".to_string()
1219 } else {
1220 proposal.rationale.trim().to_string()
1221 };
1222 let rationale = redact::redact_secrets(&rationale_raw).text;
1223
1224 let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
1225 writer.append(&started, true)?;
1226
1227 let proposed = Event::new(
1228 run_id,
1229 "memory.proposed",
1230 serde_json::json!({
1231 "proposal_id": proposal_id,
1232 "scope": "global_user",
1233 "kind": kind.to_string(),
1234 "text": text,
1235 "rationale": rationale,
1236 "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
1237 "source_event_ids": [],
1238 }),
1239 );
1240 writer.append(&proposed, true)?;
1241
1242 let finished = admin_finished_event(run_id);
1243 writer.append(&finished, true)?;
1244
1245 projector::apply_events(&conn, &[started, proposed, finished])?;
1246 Ok((proposal_id, text))
1247}
1248
1249pub fn accept_proposal(
1250 start: &Path,
1251 proposal_id: &str,
1252 overrides: AcceptOverrides,
1253) -> KimetsuResult<String> {
1254 let (paths, config, conn) = load_project(start)?;
1255 let proposal = load_pending_proposal(&conn, proposal_id)?;
1256 let run_id = RunId::new();
1257 let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
1258 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1259 let memory_id = Ulid::new().to_string();
1260 let normalized = normalize_memory_text(&proposal.text);
1261
1262 let resolved_scope = match overrides.scope.as_deref() {
1263 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
1264 _ => proposal.scope.clone(),
1265 };
1266 let resolved_confidence = overrides
1267 .confidence
1268 .map(|c| c.clamp(0.0, 1.0))
1269 .unwrap_or(proposal.proposed_confidence);
1270
1271 let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
1272 writer.append(&started, true)?;
1273
1274 let accepted = Event::new(
1275 run_id,
1276 "memory.accepted",
1277 serde_json::json!({
1278 "proposal_id": proposal.proposal_id,
1279 "memory_id": memory_id,
1280 "scope": resolved_scope,
1281 "kind": proposal.kind,
1282 "text": proposal.text,
1283 "normalized_text": normalized,
1284 "confidence": resolved_confidence,
1285 "provenance_snapshot": {
1286 "source": "memory_proposal",
1287 "proposal_id": proposal.proposal_id,
1288 "source_run_id": proposal.run_id,
1289 "scope_override": overrides.scope.clone(),
1290 "confidence_override": overrides.confidence,
1291 }
1292 }),
1293 );
1294 writer.append(&accepted, true)?;
1295
1296 let finished = admin_finished_event(run_id);
1297 writer.append(&finished, true)?;
1298
1299 projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
1300 conn.execute(
1301 "
1302 UPDATE memory_proposals
1303 SET status = 'accepted',
1304 decided_at = ?2,
1305 decided_by = 'cli'
1306 WHERE proposal_id = ?1
1307 ",
1308 params![
1309 proposal_id,
1310 accepted
1311 .ts
1312 .format(&time::format_description::well_known::Rfc3339)?
1313 ],
1314 )?;
1315
1316 Ok(memory_id)
1317}
1318
1319pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1325 let (paths, config, conn) = load_project(start)?;
1326 let exists: i64 = conn.query_row(
1327 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1328 params![memory_id],
1329 |row| row.get(0),
1330 )?;
1331 if exists == 0 {
1332 return Err(format!("memory not found: {memory_id}").into());
1333 }
1334
1335 let run_id = RunId::new();
1336 let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
1337 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1338
1339 let resolved_reason = reason
1340 .and_then(|s| {
1341 let trimmed = s.trim();
1342 if trimmed.is_empty() {
1343 None
1344 } else {
1345 Some(trimmed.to_string())
1346 }
1347 })
1348 .unwrap_or_else(|| "invalidated_by_cli".to_string());
1349
1350 let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
1351 writer.append(&started, true)?;
1352
1353 let invalidated = Event::new(
1354 run_id,
1355 "memory.invalidated",
1356 serde_json::json!({
1357 "memory_id": memory_id,
1358 "reason": resolved_reason,
1359 }),
1360 );
1361 writer.append(&invalidated, true)?;
1362
1363 let finished = admin_finished_event(run_id);
1364 writer.append(&finished, true)?;
1365
1366 projector::apply_events(&conn, &[started, invalidated, finished])?;
1367 Ok(())
1368}
1369
1370pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1371 let (paths, config, conn) = load_project(start)?;
1372 let _proposal = load_pending_proposal(&conn, proposal_id)?;
1373 let run_id = RunId::new();
1374 let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
1375 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1376
1377 let resolved_reason = reason
1378 .and_then(|s| {
1379 let trimmed = s.trim();
1380 if trimmed.is_empty() {
1381 None
1382 } else {
1383 Some(trimmed.to_string())
1384 }
1385 })
1386 .unwrap_or_else(|| "rejected_by_cli".to_string());
1387
1388 let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
1389 writer.append(&started, true)?;
1390
1391 let rejected = Event::new(
1392 run_id,
1393 "memory.rejected",
1394 serde_json::json!({
1395 "proposal_id": proposal_id,
1396 "reason": resolved_reason,
1397 }),
1398 );
1399 writer.append(&rejected, true)?;
1400
1401 let finished = admin_finished_event(run_id);
1402 writer.append(&finished, true)?;
1403
1404 projector::apply_events(&conn, &[started, rejected, finished])?;
1405 Ok(())
1406}
1407
1408pub fn rebuild_projection(start: &Path) -> KimetsuResult<usize> {
1409 let (paths, _config, conn) = load_project(start)?;
1410 let _lock = ProjectLock::acquire(&paths, "brain rebuild", None)?;
1411 let events = trace::read_all_traces(&paths)?;
1412 projector::rebuild(&conn, &events)?;
1413 Ok(events.len())
1414}
1415
1416pub fn clear_lock(start: &Path) -> KimetsuResult<bool> {
1417 let paths = ProjectPaths::discover(start)?;
1418 crate::lock::clear_force(&paths)
1419}
1420
1421#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1427pub struct ScopedConflict {
1428 pub source: String,
1431 #[serde(flatten)]
1432 pub report: conflict::ConflictReport,
1433}
1434
1435pub fn list_conflicts(start: &Path, limit: u32) -> KimetsuResult<Vec<ScopedConflict>> {
1439 let mut out = Vec::new();
1440 let (_paths, _config, project_conn) = load_project_readonly(start)?;
1441 for report in conflict::list_unresolved_conflicts(&project_conn, limit)? {
1442 out.push(ScopedConflict {
1443 source: "project".to_string(),
1444 report,
1445 });
1446 }
1447 if let Some(user_conn) = user_brain::open_user_brain_readonly()? {
1448 for report in conflict::list_unresolved_conflicts(&user_conn, limit)? {
1449 out.push(ScopedConflict {
1450 source: "user".to_string(),
1451 report,
1452 });
1453 }
1454 }
1455 out.sort_by(|a, b| b.report.detected_at.cmp(&a.report.detected_at));
1456 Ok(out)
1457}
1458
1459pub fn resolve_conflict(
1471 start: &Path,
1472 conflict_id: &str,
1473 resolution: &str,
1474) -> KimetsuResult<bool> {
1475 let (paths, _config, project_conn) = load_project(start)?;
1476 let _lock = ProjectLock::acquire(&paths, "brain memory conflict resolve", None)?;
1477 if conflict::resolve_conflict(&project_conn, conflict_id, resolution)? {
1478 return Ok(true);
1479 }
1480 drop(project_conn); if let Some(user_conn) = user_brain::open_user_brain()? {
1482 return conflict::resolve_conflict(&user_conn, conflict_id, resolution);
1483 }
1484 Ok(false)
1485}
1486
1487fn load_pending_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<ProposalRow> {
1488 let mut stmt = conn.prepare(
1489 "
1490 SELECT proposal_id, run_id, scope, kind, text, rationale,
1491 proposed_confidence, status
1492 FROM memory_proposals
1493 WHERE proposal_id = ?1
1494 ",
1495 )?;
1496 let mut rows = stmt.query(params![proposal_id])?;
1497 let Some(row) = rows.next()? else {
1498 return Err(format!("memory proposal not found: {proposal_id}").into());
1499 };
1500
1501 let proposal = ProposalRow {
1502 proposal_id: row.get(0)?,
1503 run_id: row.get(1)?,
1504 scope: row.get(2)?,
1505 kind: row.get(3)?,
1506 text: row.get(4)?,
1507 rationale: row.get(5)?,
1508 proposed_confidence: row.get(6)?,
1509 status: row.get(7)?,
1510 decided_reason: None,
1511 };
1512
1513 if proposal.status != "pending" {
1514 return Err(format!(
1515 "memory proposal {proposal_id} is {}, not pending",
1516 proposal.status
1517 )
1518 .into());
1519 }
1520
1521 Ok(proposal)
1522}
1523
1524fn admin_started_event(
1525 paths: &ProjectPaths,
1526 config: &ProjectConfig,
1527 run_id: RunId,
1528 task: &str,
1529) -> KimetsuResult<Event> {
1530 Ok(Event::new(
1531 run_id,
1532 "run.started",
1533 serde_json::json!({
1534 "mode": "admin",
1535 "task": task,
1536 "project_id": config.kimetsu.project_id,
1537 "repo_root": paths.repo_root.to_string_lossy(),
1538 "model": null,
1539 "platform": std::env::consts::OS,
1540 "kimetsu_version": env!("CARGO_PKG_VERSION"),
1541 "config_hash": config_hash(&paths.project_toml)?,
1542 }),
1543 ))
1544}
1545
1546fn admin_finished_event(run_id: RunId) -> Event {
1547 Event::new(
1548 run_id,
1549 "run.finished",
1550 serde_json::json!({
1551 "status": "success",
1552 "final_report_path": null,
1553 "total_cost_usd": 0.0,
1554 "total_tool_calls": 0,
1555 }),
1556 )
1557}
1558
1559fn config_hash(path: &Path) -> KimetsuResult<String> {
1560 let bytes = fs::read(path)?;
1561 Ok(blake3::hash(&bytes).to_hex().to_string())
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566 use std::fs;
1567
1568 use super::*;
1569 use crate::user_brain::with_user_brain_disabled;
1576
1577 #[test]
1578 fn memory_add_survives_projection_rebuild_from_trace() {
1579 with_user_brain_disabled(|| {
1580 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1581 fs::create_dir_all(&root).expect("create temp project");
1582
1583 init_project(&root, false).expect("init project");
1584 let memory_id = add_memory(
1585 &root,
1586 MemoryScope::GlobalUser,
1587 MemoryKind::Preference,
1588 "User prefers Rust for core infrastructure.",
1589 )
1590 .expect("add memory");
1591
1592 let memories = list_memories(&root).expect("list memories");
1593 assert_eq!(memories.len(), 1);
1594 assert_eq!(memories[0].memory_id, memory_id);
1595
1596 let event_count = rebuild_projection(&root).expect("rebuild projection");
1597 assert_eq!(event_count, 3);
1598
1599 let memories = list_memories(&root).expect("list rebuilt memories");
1600 assert_eq!(memories.len(), 1);
1601 assert_eq!(memories[0].memory_id, memory_id);
1602 assert_eq!(
1603 memories[0].text,
1604 "User prefers Rust for core infrastructure."
1605 );
1606
1607 fs::remove_dir_all(root).expect("remove temp project");
1608 });
1609 }
1610
1611 #[test]
1615 fn add_memory_redacts_secrets_before_persist() {
1616 with_user_brain_disabled(|| {
1617 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1618 fs::create_dir_all(&root).expect("create temp project");
1619 init_project(&root, false).expect("init project");
1620
1621 let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
1622 let memory_id = add_memory(
1623 &root,
1624 MemoryScope::Repo,
1625 MemoryKind::Command,
1626 raw,
1627 )
1628 .expect("add memory");
1629
1630 let memories = list_memories(&root).expect("list");
1631 let stored = memories
1632 .iter()
1633 .find(|m| m.memory_id == memory_id)
1634 .expect("memory present");
1635 assert!(
1636 !stored.text.contains("sk-ant-api03"),
1637 "raw secret must NOT survive to brain.db: {}",
1638 stored.text
1639 );
1640 assert!(
1641 stored.text.contains("[REDACTED:anthropic_oauth]"),
1642 "placeholder must be present: {}",
1643 stored.text
1644 );
1645 assert!(
1646 stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN")
1647 && stored.text.contains(".env"),
1648 "non-secret context must be preserved: {}",
1649 stored.text
1650 );
1651
1652 fs::remove_dir_all(root).expect("cleanup");
1653 });
1654 }
1655
1656 #[test]
1657 fn repo_ingest_indexes_searchable_files_and_context_capsules() {
1658 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1659 fs::create_dir_all(root.join("src")).expect("create src");
1660 fs::create_dir_all(root.join("target")).expect("create target");
1661 fs::write(
1662 root.join("Cargo.toml"),
1663 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
1664 )
1665 .expect("write manifest");
1666 fs::write(
1667 root.join("src").join("lib.rs"),
1668 "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
1669 )
1670 .expect("write source");
1671 fs::write(
1672 root.join("target").join("generated.rs"),
1673 "projection rebuild",
1674 )
1675 .expect("write skipped");
1676 fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
1677 fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
1678
1679 init_project(&root, false).expect("init project");
1680 add_memory(
1681 &root,
1682 MemoryScope::GlobalUser,
1683 MemoryKind::Preference,
1684 "User prefers Rust for core infrastructure.",
1685 )
1686 .expect("add memory");
1687
1688 let summary = ingest_repo(&root).expect("ingest repo");
1689 assert_eq!(summary.indexed_files, 2);
1690 assert_eq!(summary.manifests, 1);
1691
1692 let matches = search_files(&root, "projection rebuild", 5).expect("search files");
1693 assert!(
1694 matches
1695 .iter()
1696 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
1697 "expected src/lib.rs in search results: {matches:?}"
1698 );
1699 assert!(
1700 matches
1701 .iter()
1702 .all(|capsule| !capsule.expansion_handle.contains("target/")),
1703 "target files must not be indexed: {matches:?}"
1704 );
1705
1706 let context =
1707 retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
1708 assert!(
1709 context
1710 .capsules
1711 .iter()
1712 .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
1713 "expected memory capsule in context: {:?}",
1714 context.capsules
1715 );
1716
1717 rebuild_projection(&root).expect("rebuild projection");
1718 let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
1719 assert!(
1720 matches
1721 .iter()
1722 .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
1723 "repo index should survive event-only rebuild: {matches:?}"
1724 );
1725
1726 fs::remove_dir_all(root).expect("remove temp project");
1727 }
1728
1729 #[test]
1730 fn run_finished_increments_usefulness_for_injected_memories() {
1731 with_user_brain_disabled(|| {
1732 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1740 fs::create_dir_all(&root).expect("create temp project");
1741 init_project(&root, false).expect("init project");
1742 let memory_id = add_memory(
1743 &root,
1744 MemoryScope::GlobalUser,
1745 MemoryKind::Preference,
1746 "Prefer ripgrep over grep.",
1747 )
1748 .expect("add memory");
1749
1750 {
1751 let (paths, _config, conn) = load_project(&root).expect("load");
1752 let run_id = RunId::new();
1753 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
1754 let evs: Vec<Event> = vec![
1755 Event::new(
1756 run_id,
1757 "run.started",
1758 serde_json::json!({"project_id": "test", "task": "x"}),
1759 ),
1760 Event::new(
1761 run_id,
1762 "context.injected",
1763 serde_json::json!({
1764 "stage": "localization",
1765 "capsule_handles": [format!("memory:{memory_id}")],
1766 "memory_ids": [memory_id.clone()],
1767 "prior_run_ids": [],
1768 "file_paths": [],
1769 }),
1770 ),
1771 Event::new(
1772 run_id,
1773 "context.injected",
1774 serde_json::json!({
1775 "stage": "patch_plan",
1776 "capsule_handles": [format!("memory:{memory_id}")],
1777 "memory_ids": [memory_id.clone()],
1778 "prior_run_ids": [],
1779 "file_paths": [],
1780 }),
1781 ),
1782 Event::new(
1785 run_id,
1786 "memory.cited",
1787 serde_json::json!({
1788 "memory_id": memory_id,
1789 "turn": 3,
1790 "rationale": "using rg from memory",
1791 }),
1792 ),
1793 Event::new(
1794 run_id,
1795 "run.finished",
1796 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
1797 ),
1798 ];
1799 for ev in &evs {
1800 writer.append(ev, true).expect("append");
1801 }
1802 projector::apply_events(&conn, &evs).expect("project");
1803 }
1804
1805 let memories = list_memories(&root).expect("list memories");
1806 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
1807 assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
1808 assert!(
1809 (m.usefulness_score - 1.0).abs() < f32::EPSILON,
1810 "expected strong-signal usefulness_score = 1.0, got {}",
1811 m.usefulness_score
1812 );
1813
1814 fs::remove_dir_all(root).expect("remove temp project");
1815 });
1816 }
1817
1818 #[test]
1823 fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
1824 with_user_brain_disabled(|| {
1825 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1826 fs::create_dir_all(&root).expect("create temp project");
1827 init_project(&root, false).expect("init project");
1828 let memory_id = add_memory(
1829 &root,
1830 MemoryScope::GlobalUser,
1831 MemoryKind::Preference,
1832 "Silent passenger memory.",
1833 )
1834 .expect("add memory");
1835
1836 {
1837 let (paths, _config, conn) = load_project(&root).expect("load");
1838 let run_id = RunId::new();
1839 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
1840 let evs: Vec<Event> = vec![
1841 Event::new(
1842 run_id,
1843 "run.started",
1844 serde_json::json!({"project_id": "test", "task": "x"}),
1845 ),
1846 Event::new(
1847 run_id,
1848 "context.injected",
1849 serde_json::json!({
1850 "stage": "localization",
1851 "memory_ids": [memory_id.clone()],
1852 "prior_run_ids": [],
1853 "file_paths": [],
1854 }),
1855 ),
1856 Event::new(
1858 run_id,
1859 "run.finished",
1860 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
1861 ),
1862 ];
1863 for ev in &evs {
1864 writer.append(ev, true).expect("append");
1865 }
1866 projector::apply_events(&conn, &evs).expect("project");
1867 }
1868
1869 let memories = list_memories(&root).expect("list memories");
1870 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
1871 assert_eq!(m.use_count, 1);
1872 assert!(
1873 (m.usefulness_score - 0.1).abs() < 1e-5,
1874 "silent passenger should get +0.1, got {}",
1875 m.usefulness_score
1876 );
1877 });
1878 }
1879
1880 #[test]
1886 fn blame_run_separates_cited_from_silent_passengers() {
1887 with_user_brain_disabled(|| {
1888 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1889 fs::create_dir_all(&root).expect("create temp project");
1890 init_project(&root, false).expect("init project");
1891 let cited_id = add_memory(
1892 &root,
1893 MemoryScope::Repo,
1894 MemoryKind::Preference,
1895 "prefer ripgrep over grep",
1896 )
1897 .expect("add cited");
1898 let silent_id = add_memory(
1899 &root,
1900 MemoryScope::Repo,
1901 MemoryKind::Convention,
1902 "use cargo nextest for tests",
1903 )
1904 .expect("add silent");
1905
1906 let run_id = RunId::new();
1907 {
1908 let (paths, _config, conn) = load_project(&root).expect("load");
1909 let (mut writer, _run_paths) =
1910 TraceWriter::create(&paths, run_id).expect("trace");
1911 let evs: Vec<Event> = vec![
1912 Event::new(
1913 run_id,
1914 "run.started",
1915 serde_json::json!({"project_id": "test", "task": "x"}),
1916 ),
1917 Event::new(
1918 run_id,
1919 "context.injected",
1920 serde_json::json!({
1921 "stage": "localization",
1922 "memory_ids": [cited_id.clone(), silent_id.clone()],
1923 "prior_run_ids": [],
1924 "file_paths": [],
1925 }),
1926 ),
1927 Event::new(
1928 run_id,
1929 "memory.cited",
1930 serde_json::json!({
1931 "memory_id": cited_id,
1932 "turn": 4,
1933 "rationale": "used the rg pattern",
1934 }),
1935 ),
1936 Event::new(
1937 run_id,
1938 "run.finished",
1939 serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
1940 ),
1941 ];
1942 for ev in &evs {
1943 writer.append(ev, true).expect("append");
1944 }
1945 projector::apply_events(&conn, &evs).expect("project");
1946 }
1947
1948 let report = blame_run(&root, &run_id.to_string()).expect("blame");
1949 assert_eq!(report.outcome, "success");
1950 assert!(report.failure_category.is_none());
1951 assert_eq!(report.cited.len(), 1, "exactly one cited memory");
1952 let cited = &report.cited[0];
1953 assert_eq!(cited.memory_id, cited_id);
1954 assert_eq!(cited.turn, 4);
1955 assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
1956 assert!(cited.text_preview.contains("ripgrep"));
1957
1958 assert_eq!(report.silent_passengers.len(), 1);
1959 let silent = &report.silent_passengers[0];
1960 assert_eq!(silent.memory_id, silent_id);
1961 assert!(silent.text_preview.contains("nextest"));
1962
1963 fs::remove_dir_all(root).expect("cleanup");
1964 });
1965 }
1966
1967 #[test]
1968 fn run_failed_decrements_usefulness_unless_gate() {
1969 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1973 fs::create_dir_all(&root).expect("create temp project");
1974 init_project(&root, false).expect("init project");
1975 let memory_id = add_memory(
1976 &root,
1977 MemoryScope::Repo,
1978 MemoryKind::Convention,
1979 "Use find_* for fallible lookups.",
1980 )
1981 .expect("add memory");
1982
1983 {
1984 let (paths, _config, conn) = load_project(&root).expect("load");
1985
1986 let gate_run = RunId::new();
1988 let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
1989 let gate_events: Vec<Event> = vec![
1990 Event::new(
1991 gate_run,
1992 "run.started",
1993 serde_json::json!({"project_id": "test", "task": "g"}),
1994 ),
1995 Event::new(
1996 gate_run,
1997 "context.injected",
1998 serde_json::json!({
1999 "stage": "patch_plan",
2000 "capsule_handles": [format!("memory:{memory_id}")],
2001 "memory_ids": [memory_id.clone()],
2002 "prior_run_ids": [],
2003 "file_paths": [],
2004 }),
2005 ),
2006 Event::new(
2007 gate_run,
2008 "run.failed",
2009 serde_json::json!({
2010 "category": "Gate",
2011 "failed_stage": "patch_plan",
2012 "message": "files_to_create_already_exist",
2013 }),
2014 ),
2015 ];
2016 for ev in &gate_events {
2017 writer.append(ev, true).expect("append");
2018 }
2019 projector::apply_events(&conn, &gate_events).expect("project gate-fail");
2020
2021 let impl_run = RunId::new();
2024 let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
2025 let impl_events: Vec<Event> = vec![
2026 Event::new(
2027 impl_run,
2028 "run.started",
2029 serde_json::json!({"project_id": "test", "task": "i"}),
2030 ),
2031 Event::new(
2032 impl_run,
2033 "context.injected",
2034 serde_json::json!({
2035 "stage": "patch_plan",
2036 "capsule_handles": [format!("memory:{memory_id}")],
2037 "memory_ids": [memory_id.clone()],
2038 "prior_run_ids": [],
2039 "file_paths": [],
2040 }),
2041 ),
2042 Event::new(
2045 impl_run,
2046 "memory.cited",
2047 serde_json::json!({
2048 "memory_id": memory_id,
2049 "turn": 2,
2050 "rationale": "trusted the memory's pattern",
2051 }),
2052 ),
2053 Event::new(
2054 impl_run,
2055 "run.failed",
2056 serde_json::json!({
2057 "category": "Implementation",
2058 "failed_stage": "implementation",
2059 "message": "test broke",
2060 }),
2061 ),
2062 ];
2063 for ev in &impl_events {
2064 writer2.append(ev, true).expect("append");
2065 }
2066 projector::apply_events(&conn, &impl_events).expect("project impl-fail");
2067 }
2068
2069 let memories = list_memories(&root).expect("list memories");
2070 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2071 assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
2072 assert!(
2073 (m.usefulness_score - (-1.0)).abs() < f32::EPSILON,
2074 "expected usefulness_score = -1.0, got {}",
2075 m.usefulness_score
2076 );
2077
2078 fs::remove_dir_all(root).expect("remove temp project");
2079 }
2080
2081 #[test]
2082 fn run_aborted_does_not_update_usefulness() {
2083 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2084 fs::create_dir_all(&root).expect("create temp project");
2085 init_project(&root, false).expect("init project");
2086 let memory_id = add_memory(
2087 &root,
2088 MemoryScope::Repo,
2089 MemoryKind::Convention,
2090 "Module re-exports live in lib.rs.",
2091 )
2092 .expect("add memory");
2093
2094 {
2095 let (paths, _config, conn) = load_project(&root).expect("load");
2096 let run_id = RunId::new();
2097 let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
2098 let evs: Vec<Event> = vec![
2099 Event::new(
2100 run_id,
2101 "run.started",
2102 serde_json::json!({"project_id": "test", "task": "a"}),
2103 ),
2104 Event::new(
2105 run_id,
2106 "context.injected",
2107 serde_json::json!({
2108 "stage": "patch_plan",
2109 "capsule_handles": [format!("memory:{memory_id}")],
2110 "memory_ids": [memory_id.clone()],
2111 "prior_run_ids": [],
2112 "file_paths": [],
2113 }),
2114 ),
2115 Event::new(
2116 run_id,
2117 "run.aborted",
2118 serde_json::json!({"reason": "user_abort"}),
2119 ),
2120 ];
2121 for ev in &evs {
2122 writer.append(ev, true).expect("append");
2123 }
2124 projector::apply_events(&conn, &evs).expect("project");
2125 }
2126
2127 let memories = list_memories(&root).expect("list memories");
2128 let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2129 assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
2130 assert!(
2131 m.usefulness_score.abs() < f32::EPSILON,
2132 "expected usefulness_score = 0.0, got {}",
2133 m.usefulness_score
2134 );
2135
2136 fs::remove_dir_all(root).expect("remove temp project");
2137 }
2138
2139 #[test]
2140 fn list_proposals_filters_and_reject_records_reason() {
2141 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2142 fs::create_dir_all(&root).expect("create temp project");
2143 init_project(&root, false).expect("init project");
2144
2145 let proposals = [
2147 (
2148 "p1",
2149 "global_user",
2150 "preference",
2151 0.9_f32,
2152 "Prefer rg over grep",
2153 ),
2154 (
2155 "p2",
2156 "repo",
2157 "convention",
2158 0.8,
2159 "Use find_* for fallible lookups",
2160 ),
2161 (
2162 "p3",
2163 "repo",
2164 "convention",
2165 0.4,
2166 "Use let-else where possible",
2167 ),
2168 ];
2169 {
2170 let (paths, _config, conn) = load_project(&root).expect("load");
2171 let run_id = RunId::new();
2172 let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2173 for (proposal_id, scope, kind, conf, text) in &proposals {
2174 let event = Event::new(
2175 run_id,
2176 "memory.proposed",
2177 serde_json::json!({
2178 "proposal_id": proposal_id,
2179 "scope": scope,
2180 "kind": kind,
2181 "text": text,
2182 "rationale": "test rationale",
2183 "proposed_confidence": conf,
2184 "source_event_ids": [],
2185 }),
2186 );
2187 writer.append(&event, true).expect("append proposal");
2188 projector::apply_events(&conn, &[event]).expect("project");
2189 }
2190 }
2191
2192 let global = list_proposals(
2194 &root,
2195 ProposalFilter {
2196 scope: Some("global_user".into()),
2197 status: Some("pending".into()),
2198 ..ProposalFilter::default()
2199 },
2200 )
2201 .expect("list proposals");
2202 assert_eq!(global.len(), 1);
2203 assert_eq!(global[0].proposal_id, "p1");
2204
2205 let strong = list_proposals(
2207 &root,
2208 ProposalFilter {
2209 min_confidence: Some(0.7),
2210 status: Some("pending".into()),
2211 ..ProposalFilter::default()
2212 },
2213 )
2214 .expect("list strong");
2215 assert_eq!(strong.len(), 2);
2216 for row in &strong {
2217 assert!(row.proposed_confidence >= 0.7);
2218 }
2219
2220 reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
2222 let rejected = list_proposals(
2223 &root,
2224 ProposalFilter {
2225 status: Some("rejected".into()),
2226 ..ProposalFilter::default()
2227 },
2228 )
2229 .expect("list rejected");
2230 assert_eq!(rejected.len(), 1);
2231 assert_eq!(rejected[0].proposal_id, "p3");
2232 assert_eq!(
2233 rejected[0].decided_reason.as_deref(),
2234 Some("not specific to the user")
2235 );
2236
2237 let memory_id = accept_proposal(
2240 &root,
2241 "p1",
2242 AcceptOverrides {
2243 scope: None,
2244 confidence: Some(0.55),
2245 },
2246 )
2247 .expect("accept");
2248 let memories = list_memories(&root).expect("list memories");
2249 let promoted = memories
2250 .into_iter()
2251 .find(|m| m.memory_id == memory_id)
2252 .expect("promoted memory present");
2253 assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
2254
2255 fs::remove_dir_all(root).expect("remove temp project");
2256 }
2257
2258 #[test]
2263 fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
2264 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2265 fs::create_dir_all(&root).expect("create temp project");
2266 init_project(&root, false).expect("init project");
2267
2268 let memory_id = add_memory(
2269 &root,
2270 MemoryScope::Repo,
2271 MemoryKind::Convention,
2272 "Use find_* for fallible lookups.",
2273 )
2274 .expect("add memory");
2275
2276 invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
2277 .expect("invalidate memory");
2278
2279 {
2282 let (_paths, _config, conn) = load_project(&root).expect("load");
2283 let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
2284 .query_row(
2285 "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
2286 params![memory_id],
2287 |row| Ok((row.get(0)?, row.get(1)?)),
2288 )
2289 .expect("query invalidated metadata");
2290 assert!(invalidated_at.is_some(), "invalidated_at must be set");
2291 assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
2292 }
2293
2294 rebuild_projection(&root).expect("rebuild projection");
2296 {
2297 let (_paths, _config, conn) = load_project(&root).expect("load");
2298 let invalidated_at: Option<String> = conn
2299 .query_row(
2300 "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
2301 params![memory_id],
2302 |row| row.get(0),
2303 )
2304 .expect("query after rebuild");
2305 assert!(
2306 invalidated_at.is_some(),
2307 "invalidated_at must survive event replay"
2308 );
2309 }
2310
2311 fs::remove_dir_all(root).expect("remove temp project");
2312 }
2313
2314 #[test]
2318 fn invalidated_memory_is_excluded_from_broker_retrieval() {
2319 with_user_brain_disabled(|| {
2320 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2321 fs::create_dir_all(&root).expect("create temp project");
2322 init_project(&root, false).expect("init project");
2323
2324 let memory_id = add_memory(
2325 &root,
2326 MemoryScope::GlobalUser,
2327 MemoryKind::Preference,
2328 "Prefer ripgrep over grep for repo search.",
2329 )
2330 .expect("add memory");
2331
2332 let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
2334 .expect("pre context");
2335 assert!(
2336 pre.capsules
2337 .iter()
2338 .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
2339 "memory must appear before invalidation: {:?}",
2340 pre.capsules
2341 );
2342
2343 invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
2344
2345 let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
2346 .expect("post context");
2347 assert!(
2348 post.capsules
2349 .iter()
2350 .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
2351 "invalidated memory must not be retrieved: {:?}",
2352 post.capsules
2353 );
2354
2355 let memories = list_memories(&root).expect("list");
2357 assert!(memories.iter().any(|m| m.memory_id == memory_id));
2358
2359 fs::remove_dir_all(root).expect("remove temp project");
2360 });
2361 }
2362
2363 #[test]
2368 fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
2369 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2370 fs::create_dir_all(&root).expect("create temp project");
2371 init_project(&root, false).expect("init project");
2372
2373 let m_great =
2374 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
2375 let m_meh =
2376 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
2377 let m_bad =
2378 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
2379 let _m_fresh =
2380 add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
2381
2382 {
2385 let (_paths, _config, conn) = load_project(&root).expect("load");
2386 conn.execute(
2387 "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
2388 params![m_great],
2389 )
2390 .expect("set great");
2391 conn.execute(
2392 "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
2393 params![m_meh],
2394 )
2395 .expect("set meh");
2396 conn.execute(
2397 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
2398 params![m_bad],
2399 )
2400 .expect("set bad");
2401 }
2403
2404 let top = list_memories_top(
2405 &root,
2406 TopOptions {
2407 scope: None,
2408 min_uses: 3,
2409 limit: 10,
2410 },
2411 )
2412 .expect("top");
2413 assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
2414 assert_eq!(top[0].memory_id, m_great);
2415 assert_eq!(top[1].memory_id, m_meh);
2416 assert_eq!(top[2].memory_id, m_bad);
2417
2418 invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
2420 let top_after = list_memories_top(
2421 &root,
2422 TopOptions {
2423 scope: None,
2424 min_uses: 3,
2425 limit: 10,
2426 },
2427 )
2428 .expect("top after");
2429 assert_eq!(top_after.len(), 2);
2430 assert!(top_after.iter().all(|m| m.memory_id != m_great));
2431
2432 fs::remove_dir_all(root).expect("remove temp project");
2433 }
2434
2435 #[test]
2441 fn prune_low_usefulness_dry_run_then_apply() {
2442 with_user_brain_disabled(|| {
2443 prune_low_usefulness_dry_run_then_apply_body();
2444 });
2445 }
2446
2447 fn prune_low_usefulness_dry_run_then_apply_body() {
2448 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2449 fs::create_dir_all(&root).expect("create temp project");
2450 init_project(&root, false).expect("init project");
2451
2452 let m_keep = add_memory(
2453 &root,
2454 MemoryScope::Repo,
2455 MemoryKind::Convention,
2456 "keep me, I help",
2457 )
2458 .expect("keep");
2459 let m_drop_1 = add_memory(
2460 &root,
2461 MemoryScope::Repo,
2462 MemoryKind::Convention,
2463 "drop me, I hurt",
2464 )
2465 .expect("drop1");
2466 let m_drop_2 = add_memory(
2467 &root,
2468 MemoryScope::Repo,
2469 MemoryKind::Convention,
2470 "drop me too",
2471 )
2472 .expect("drop2");
2473 let m_small_sample = add_memory(
2474 &root,
2475 MemoryScope::Repo,
2476 MemoryKind::Convention,
2477 "small sample shouldn't be pruned even if score is bad",
2478 )
2479 .expect("small");
2480
2481 {
2482 let (_paths, _config, conn) = load_project(&root).expect("load");
2483 conn.execute(
2485 "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
2486 params![m_keep],
2487 )
2488 .expect("set keep");
2489 conn.execute(
2491 "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
2492 params![m_drop_1],
2493 )
2494 .expect("set drop1");
2495 conn.execute(
2497 "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
2498 params![m_drop_2],
2499 )
2500 .expect("set drop2");
2501 conn.execute(
2503 "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
2504 params![m_small_sample],
2505 )
2506 .expect("set small");
2507 }
2508
2509 let dry = prune_low_usefulness(
2511 &root,
2512 PruneOptions {
2513 scope: None,
2514 min_uses: 3,
2515 max_ratio: -0.2,
2516 apply: false,
2517 },
2518 )
2519 .expect("dry-run");
2520 assert_eq!(dry.candidates.len(), 2);
2521 assert_eq!(dry.invalidated, 0);
2522 let ids: Vec<&str> = dry
2523 .candidates
2524 .iter()
2525 .map(|c| c.memory_id.as_str())
2526 .collect();
2527 assert!(ids.contains(&m_drop_1.as_str()));
2528 assert!(ids.contains(&m_drop_2.as_str()));
2529 assert!(!ids.contains(&m_small_sample.as_str()));
2531
2532 let pre = list_memories(&root).expect("pre");
2534 assert_eq!(pre.len(), 4);
2535
2536 let applied = prune_low_usefulness(
2538 &root,
2539 PruneOptions {
2540 scope: None,
2541 min_uses: 3,
2542 max_ratio: -0.2,
2543 apply: true,
2544 },
2545 )
2546 .expect("apply");
2547 assert_eq!(applied.candidates.len(), 2);
2548 assert_eq!(applied.invalidated, 2);
2549 assert_eq!(applied.failed, 0);
2550
2551 let top = list_memories_top(
2555 &root,
2556 TopOptions {
2557 scope: None,
2558 min_uses: 3,
2559 limit: 10,
2560 },
2561 )
2562 .expect("top after prune");
2563 assert_eq!(top.len(), 1);
2564 assert_eq!(top[0].memory_id, m_keep);
2565
2566 {
2571 let (_paths, _config, conn) = load_project(&root).expect("load");
2572 let reason: String = conn
2573 .query_row(
2574 "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
2575 params![m_drop_1],
2576 |row| row.get(0),
2577 )
2578 .expect("invalidated reason");
2579 assert!(
2580 reason.starts_with("pruned_by_usefulness"),
2581 "unexpected reason: {reason}"
2582 );
2583 }
2584
2585 fs::remove_dir_all(root).expect("remove temp project");
2586 }
2587
2588 #[test]
2595 fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
2596 with_user_brain_disabled(|| {
2597 batch_review_accepts_filtered_subset_and_rejects_remainder_body();
2598 });
2599 }
2600
2601 fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
2602 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2603 fs::create_dir_all(&root).expect("create temp project");
2604 init_project(&root, false).expect("init project");
2605
2606 let run_a = RunId::new();
2607 let run_b = RunId::new();
2608
2609 let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
2613 (
2614 "p_a_strong",
2615 run_a,
2616 "global_user",
2617 "preference",
2618 0.92,
2619 "Prefer rg over grep",
2620 ),
2621 (
2622 "p_a_weak",
2623 run_a,
2624 "repo",
2625 "convention",
2626 0.55,
2627 "Always use let-else",
2628 ),
2629 (
2630 "p_b1",
2631 run_b,
2632 "repo",
2633 "convention",
2634 0.70,
2635 "Use Result not panic",
2636 ),
2637 (
2638 "p_b2",
2639 run_b,
2640 "global_user",
2641 "preference",
2642 0.88,
2643 "Open links in new tab",
2644 ),
2645 ];
2646
2647 {
2648 let (paths, _config, conn) = load_project(&root).expect("load");
2649 for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
2650 let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
2651 let event = Event::new(
2652 *run_id,
2653 "memory.proposed",
2654 serde_json::json!({
2655 "proposal_id": proposal_id,
2656 "scope": scope,
2657 "kind": kind,
2658 "text": text,
2659 "rationale": "fixture",
2660 "proposed_confidence": conf,
2661 "source_event_ids": [],
2662 }),
2663 );
2664 writer.append(&event, true).expect("append");
2665 projector::apply_events(&conn, &[event]).expect("project");
2666 }
2667 }
2668
2669 let to_accept = list_proposals(
2672 &root,
2673 ProposalFilter {
2674 from_run: Some(run_a.to_string()),
2675 min_confidence: Some(0.8),
2676 status: Some("pending".into()),
2677 limit: 100,
2678 ..ProposalFilter::default()
2679 },
2680 )
2681 .expect("list strong from run_a");
2682 assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
2683 assert_eq!(to_accept[0].proposal_id, "p_a_strong");
2684 let memory_id =
2685 accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
2686 .expect("accept p_a_strong");
2687
2688 let to_reject = list_proposals(
2691 &root,
2692 ProposalFilter {
2693 status: Some("pending".into()),
2694 limit: 100,
2695 ..ProposalFilter::default()
2696 },
2697 )
2698 .expect("list remaining pending");
2699 assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
2700 for p in &to_reject {
2701 reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
2702 }
2703
2704 let memories = list_memories(&root).expect("list memories");
2707 assert_eq!(
2708 memories.len(),
2709 1,
2710 "only the accepted proposal becomes a memory"
2711 );
2712 assert_eq!(memories[0].memory_id, memory_id);
2713
2714 let pending = list_proposals(
2715 &root,
2716 ProposalFilter {
2717 status: Some("pending".into()),
2718 limit: 100,
2719 ..ProposalFilter::default()
2720 },
2721 )
2722 .expect("list pending");
2723 assert!(
2724 pending.is_empty(),
2725 "no proposals left pending after batch review"
2726 );
2727
2728 let rejected = list_proposals(
2729 &root,
2730 ProposalFilter {
2731 status: Some("rejected".into()),
2732 limit: 100,
2733 ..ProposalFilter::default()
2734 },
2735 )
2736 .expect("list rejected");
2737 assert_eq!(rejected.len(), 3);
2738 for row in &rejected {
2739 assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
2740 }
2741
2742 fs::remove_dir_all(root).expect("remove temp project");
2743 }
2744
2745 #[test]
2756 fn add_memory_under_noop_embedder_writes_no_conflicts() {
2757 with_user_brain_disabled(|| {
2758 let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2759 fs::create_dir_all(&root).expect("create temp project");
2760 init_project(&root, false).expect("init project");
2761
2762 let _m1 = add_memory(
2767 &root,
2768 MemoryScope::GlobalUser,
2769 MemoryKind::Preference,
2770 "Prefer thiserror for library error types.",
2771 )
2772 .expect("add m1");
2773 let _m2 = add_memory(
2774 &root,
2775 MemoryScope::GlobalUser,
2776 MemoryKind::Preference,
2777 "Prefer anyhow for library error types.",
2778 )
2779 .expect("add m2");
2780
2781 let open = list_conflicts(&root, 50).expect("list_conflicts");
2782 assert!(
2783 open.is_empty(),
2784 "noop embedder must not generate conflicts; got {} rows",
2785 open.len()
2786 );
2787
2788 let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
2790 .expect("resolve_conflict on unknown id");
2791 assert!(!resolved, "unknown conflict id should resolve to false");
2792
2793 let err = resolve_conflict(&root, "does-not-exist", "garbage")
2795 .expect_err("invalid resolution should error");
2796 assert!(format!("{err}").contains("invalid conflict resolution"));
2797
2798 fs::remove_dir_all(root).expect("remove temp project");
2799 });
2800 }
2801}