1use std::collections::hash_map::DefaultHasher;
29use std::hash::{Hash, Hasher};
30use std::path::Path;
31
32use kimetsu_core::KimetsuResult;
33use rusqlite::Connection;
34use serde::{Deserialize, Serialize};
35
36use crate::project::{load_project, load_project_readonly};
37
38const DIGEST_CHAR_BUDGET: usize = 1_600;
42const TOP_MEMORY_COUNT: usize = 5;
44const RECENT_RUNS_COUNT: usize = 3;
46const MEMORY_SNIPPET_CHARS: usize = 180;
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DigestMeta {
53 pub input_hash: u64,
55 pub built_at: String,
57}
58
59pub fn build_or_load_digest(workspace: &Path, force_rebuild: bool) -> Option<String> {
71 build_or_load_digest_inner(workspace, force_rebuild).unwrap_or(None)
72}
73
74fn build_or_load_digest_inner(
75 workspace: &Path,
76 force_rebuild: bool,
77) -> KimetsuResult<Option<String>> {
78 let (paths, config, conn) = load_project_readonly(workspace)?;
79 let repo_root_str = paths.repo_root.to_string_lossy().to_string();
80
81 let inputs = gather_inputs(&conn, &repo_root_str)?;
83 if inputs.is_empty() {
84 return Ok(None);
85 }
86
87 let hash = content_hash(&inputs);
89
90 let cache_path = paths.kimetsu_dir.join("digest.md");
92 let meta_path = paths.kimetsu_dir.join("digest-meta.json");
93
94 if !force_rebuild {
96 if let Some(cached) = try_load_cache(&cache_path, &meta_path, hash) {
97 return Ok(Some(cached));
98 }
99 }
100
101 let digest_text = assemble_rule_based(&inputs, &config)?;
103 if digest_text.trim().is_empty() {
104 return Ok(None);
105 }
106
107 let meta = DigestMeta {
109 input_hash: hash,
110 built_at: now_utc_rfc3339(),
111 };
112 atomic_write_text(&cache_path, &digest_text);
113 atomic_write_json_meta(&meta_path, &meta);
114
115 Ok(Some(digest_text))
116}
117
118pub fn load_cached_digest(workspace: &Path) -> Option<String> {
128 let (paths, _config, _conn) = load_project_readonly(workspace).ok()?;
129 let text = std::fs::read_to_string(paths.kimetsu_dir.join("digest.md")).ok()?;
130 if text.trim().is_empty() {
131 None
132 } else {
133 Some(text)
134 }
135}
136
137pub fn is_stale(workspace: &Path) -> bool {
147 is_stale_inner(workspace).unwrap_or(false)
148}
149
150fn is_stale_inner(workspace: &Path) -> KimetsuResult<bool> {
151 let (paths, _config, conn) = load_project_readonly(workspace)?;
152 let repo_root_str = paths.repo_root.to_string_lossy().to_string();
153
154 let meta_path = paths.kimetsu_dir.join("digest-meta.json");
155 let cache_path = paths.kimetsu_dir.join("digest.md");
156
157 if !cache_path.exists() || !meta_path.exists() {
158 return Ok(true);
159 }
160
161 let meta = load_meta(&meta_path)?;
162 let inputs = gather_inputs(&conn, &repo_root_str)?;
163 let current_hash = content_hash(&inputs);
164
165 Ok(meta.input_hash != current_hash)
166}
167
168pub fn warm_start_block(workspace: &Path) -> Option<String> {
188 let warm_start_enabled = kimetsu_core::paths::ProjectPaths::discover(workspace)
190 .ok()
191 .and_then(|paths| crate::project::load_config(&paths).ok())
192 .map(|cfg| cfg.broker.warm_start)
193 .unwrap_or(true);
194 if !warm_start_enabled {
195 return None;
196 }
197
198 let digest = match load_cached_digest(workspace) {
199 Some(cached) => {
200 if is_stale(workspace) {
201 spawn_detached_refresh(workspace);
202 }
203 Some(cached)
204 }
205 None => build_or_load_digest(workspace, false),
206 };
207 let resume = crate::episode::render_resume_context(workspace);
208
209 let profile = user_profile_block(workspace);
217
218 let skills = skills_block(workspace);
223
224 if digest.is_none() && resume.is_none() && profile.is_none() && skills.is_none() {
225 return None;
226 }
227
228 let mut parts: Vec<String> = Vec::new();
229 if let Some(d) = &digest {
230 parts.push(format!("## Repo context\n{d}"));
231 }
232 if let Some(p) = &profile {
233 parts.push(format!("## How you like to work\n{p}"));
234 }
235 if let Some(r) = &resume {
236 parts.push(format!("## Your prior session\n{r}"));
237 }
238 if let Some(s) = &skills {
241 parts.push(format!("## Skills ready to graduate\n{s}"));
242 }
243
244 record_warmstart_served(
245 workspace,
246 digest.as_ref().map(|d| d.len()).unwrap_or(0),
247 resume.as_ref().map(|r| r.len()).unwrap_or(0),
248 );
249
250 Some(parts.join("\n\n"))
251}
252
253fn skills_block(workspace: &Path) -> Option<String> {
258 let (_paths, _config, conn) = load_project_readonly(workspace).ok()?;
259 crate::skill_synthesis::graduation_notice(&conn)
260}
261
262fn user_profile_block(workspace: &Path) -> Option<String> {
267 let (_paths, _config, conn) = load_project_readonly(workspace).ok()?;
268 let user_conn = kimetsu_core::paths::user_brain_db_path()
271 .filter(|path| path.exists())
272 .and_then(|path| Connection::open(&path).ok());
273 let profile = crate::user_profile::build_profile(&conn, user_conn.as_ref()).ok()?;
274 crate::user_profile::render_profile(&profile)
275}
276
277fn spawn_detached_refresh(workspace: &Path) {
287 let Ok(exe) = std::env::current_exe() else {
288 return;
289 };
290 let mut cmd = std::process::Command::new(exe);
291 cmd.args(["brain", "digest", "--refresh", "--workspace"])
292 .arg(workspace)
293 .stdin(std::process::Stdio::null())
294 .stdout(std::process::Stdio::null())
295 .stderr(std::process::Stdio::null());
296 #[cfg(windows)]
297 {
298 use std::os::windows::process::CommandExt;
299 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
301 }
302 let _ = cmd.spawn();
303}
304
305pub fn record_warmstart_served(workspace: &Path, digest_chars: usize, resume_chars: usize) {
314 let _ = record_warmstart_served_inner(workspace, digest_chars, resume_chars);
315}
316
317fn record_warmstart_served_inner(
318 workspace: &Path,
319 digest_chars: usize,
320 resume_chars: usize,
321) -> KimetsuResult<()> {
322 if digest_chars == 0 && resume_chars == 0 {
323 return Ok(());
324 }
325 let (_paths, _config, conn) = load_project(workspace)?;
326 let ts = now_utc_rfc3339();
327
328 if digest_chars > 0 {
329 let approx_tokens = digest_chars / 4;
330 let event = kimetsu_core::event::Event::new(
331 kimetsu_core::ids::RunId::new(),
332 "digest_served",
333 serde_json::json!({
334 "digest_chars": digest_chars,
335 "approx_tokens": approx_tokens,
336 "ts": ts,
337 }),
338 );
339 let _ = crate::projector::insert_event(&conn, &event);
340 }
341
342 if resume_chars > 0 {
343 let approx_tokens = resume_chars / 4;
344 let event = kimetsu_core::event::Event::new(
345 kimetsu_core::ids::RunId::new(),
346 "resume_served",
347 serde_json::json!({
348 "resume_chars": resume_chars,
349 "approx_tokens": approx_tokens,
350 "ts": ts,
351 }),
352 );
353 let _ = crate::projector::insert_event(&conn, &event);
354 }
355
356 Ok(())
357}
358
359#[derive(Debug, Default)]
363struct DigestInputs {
364 top_memories: Vec<(String, String)>,
366 manifests: Vec<(String, String)>,
368 recent_runs: Vec<String>,
370}
371
372impl DigestInputs {
373 fn is_empty(&self) -> bool {
374 self.top_memories.is_empty() && self.manifests.is_empty() && self.recent_runs.is_empty()
375 }
376}
377
378fn gather_inputs(conn: &Connection, repo_root: &str) -> KimetsuResult<DigestInputs> {
379 let mut inputs = DigestInputs::default();
380
381 {
393 let mut stmt = conn.prepare(
394 "SELECT kind, text
395 FROM memories
396 WHERE invalidated_at IS NULL
397 AND superseded_by IS NULL
398 AND kind != 'preference'
399 ORDER BY
400 CASE WHEN use_count > 0
401 THEN (usefulness_score / CAST(use_count AS REAL))
402 ELSE 0.0
403 END DESC,
404 use_count DESC,
405 created_at DESC
406 LIMIT ?1",
407 )?;
408 let rows = stmt.query_map([TOP_MEMORY_COUNT as i64], |row| {
409 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
410 })?;
411 for (kind, text) in rows.flatten() {
412 let snippet: String = text.chars().take(MEMORY_SNIPPET_CHARS).collect();
413 inputs.top_memories.push((kind, snippet));
414 }
415 }
416
417 {
419 let mut stmt = conn.prepare(
420 "SELECT manifest_kind, manifest_path
421 FROM repo_manifests
422 WHERE repo_root = ?1
423 LIMIT 10",
424 )?;
425 let rows = stmt.query_map([repo_root], |row| {
426 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
427 })?;
428 for pair in rows.flatten() {
429 inputs.manifests.push(pair);
430 }
431 }
432
433 {
435 let mut stmt = conn.prepare(
436 "SELECT task
437 FROM work_episodes
438 WHERE repo_root = ?1
439 AND superseded_by IS NULL
440 ORDER BY created_at DESC
441 LIMIT ?2",
442 )?;
443 let rows = stmt.query_map([repo_root, &RECENT_RUNS_COUNT.to_string()], |row| {
444 row.get::<_, String>(0)
445 })?;
446 for task in rows.flatten() {
447 if !task.trim().is_empty() {
448 inputs.recent_runs.push(task);
449 }
450 }
451 }
452
453 Ok(inputs)
454}
455
456fn content_hash(inputs: &DigestInputs) -> u64 {
459 let mut h = DefaultHasher::new();
460 for (kind, text) in &inputs.top_memories {
461 kind.hash(&mut h);
462 text.hash(&mut h);
463 }
464 for (mk, mp) in &inputs.manifests {
465 mk.hash(&mut h);
466 mp.hash(&mut h);
467 }
468 for task in &inputs.recent_runs {
469 task.hash(&mut h);
470 }
471 h.finish()
472}
473
474fn assemble_rule_based(
477 inputs: &DigestInputs,
478 _config: &kimetsu_core::config::ProjectConfig,
479) -> KimetsuResult<String> {
480 let mut parts: Vec<String> = Vec::new();
481
482 if !inputs.manifests.is_empty() {
484 let manifest_list: Vec<String> = inputs
485 .manifests
486 .iter()
487 .map(|(kind, path)| format!("{kind}: {path}"))
488 .collect();
489 parts.push(format!("Project manifests: {}", manifest_list.join(", ")));
490 }
491
492 if !inputs.recent_runs.is_empty() {
494 let focus = inputs
495 .recent_runs
496 .iter()
497 .map(|t| t.trim().to_string())
498 .filter(|t| !t.is_empty())
499 .collect::<Vec<_>>();
500 if !focus.is_empty() {
501 parts.push(format!("Current focus: {}", focus.join(" / ")));
502 }
503 }
504
505 if !inputs.top_memories.is_empty() {
507 parts.push("Key conventions and facts:".to_string());
508 for (kind, text) in &inputs.top_memories {
509 parts.push(format!("[{kind}] {text}"));
510 }
511 }
512
513 let digest = parts.join("\n");
514
515 if digest.len() > DIGEST_CHAR_BUDGET {
517 let mut s: String = digest.chars().take(DIGEST_CHAR_BUDGET - 3).collect();
518 s.push_str("...");
519 Ok(s)
520 } else {
521 Ok(digest)
522 }
523}
524
525fn try_load_cache(cache_path: &Path, meta_path: &Path, current_hash: u64) -> Option<String> {
528 if !cache_path.exists() || !meta_path.exists() {
529 return None;
530 }
531 let meta = load_meta(meta_path).ok()?;
532 if meta.input_hash != current_hash {
533 return None;
534 }
535 std::fs::read_to_string(cache_path).ok()
536}
537
538fn load_meta(meta_path: &Path) -> KimetsuResult<DigestMeta> {
539 let text = std::fs::read_to_string(meta_path)?;
540 Ok(serde_json::from_str(&text)?)
541}
542
543fn atomic_write_text(path: &Path, content: &str) {
545 let Some(parent) = path.parent() else {
546 return;
547 };
548 let _ = std::fs::create_dir_all(parent);
549 let tmp = path.with_extension("md.tmp");
550 if std::fs::write(&tmp, content).is_ok() {
551 let _ = std::fs::rename(&tmp, path);
552 }
553}
554
555fn atomic_write_json_meta(path: &Path, meta: &DigestMeta) {
557 let Some(parent) = path.parent() else {
558 return;
559 };
560 let _ = std::fs::create_dir_all(parent);
561 let Ok(text) = serde_json::to_string(meta) else {
562 return;
563 };
564 let tmp = path.with_extension("json.tmp");
565 if std::fs::write(&tmp, &text).is_ok() {
566 let _ = std::fs::rename(&tmp, path);
567 }
568}
569
570fn now_utc_rfc3339() -> String {
571 time::OffsetDateTime::now_utc()
572 .format(&time::format_description::well_known::Rfc3339)
573 .unwrap_or_default()
574}
575
576#[cfg(test)]
579mod tests {
580 use kimetsu_core::paths::git_init_boundary;
581
582 use super::*;
583 use crate::{project, user_brain};
584
585 fn tmp_workspace(name: &str) -> std::path::PathBuf {
586 let ts = std::time::SystemTime::now()
587 .duration_since(std::time::UNIX_EPOCH)
588 .map(|d| d.as_nanos())
589 .unwrap_or(0);
590 let dir = std::env::temp_dir().join(format!("kimetsu-digest-{name}-{ts}"));
591 std::fs::create_dir_all(&dir).expect("create tmp");
592 dir
593 }
594
595 #[test]
597 fn empty_brain_returns_none() {
598 let dir = tmp_workspace("empty");
599 git_init_boundary(&dir);
600 user_brain::with_user_brain_disabled(|| {
601 project::init_project(&dir, true).expect("init");
602 let result = build_or_load_digest(&dir, false);
603 assert!(result.is_none(), "empty brain must return None digest");
604 });
605 std::fs::remove_dir_all(dir).ok();
606 }
607
608 #[test]
610 fn digest_with_memories_is_bounded() {
611 let dir = tmp_workspace("bounded");
612 git_init_boundary(&dir);
613 user_brain::with_user_brain_disabled(|| {
614 project::init_project(&dir, true).expect("init");
615 project::add_memory(
618 &dir,
619 kimetsu_core::memory::MemoryScope::Project,
620 kimetsu_core::memory::MemoryKind::Convention,
621 "Always use git_init_boundary before init_project in tests",
622 )
623 .expect("add_memory");
624
625 let digest = build_or_load_digest(&dir, true).expect("digest must be Some");
626 assert!(!digest.is_empty(), "digest must be non-empty");
627 assert!(
628 digest.len() <= DIGEST_CHAR_BUDGET + 3,
629 "digest must respect char budget: {} chars",
630 digest.len()
631 );
632 });
633 std::fs::remove_dir_all(dir).ok();
634 }
635
636 #[test]
638 fn cache_is_reused_on_second_call() {
639 let dir = tmp_workspace("cache");
640 git_init_boundary(&dir);
641 user_brain::with_user_brain_disabled(|| {
642 project::init_project(&dir, true).expect("init");
643 project::add_memory(
644 &dir,
645 kimetsu_core::memory::MemoryScope::Project,
646 kimetsu_core::memory::MemoryKind::Fact,
647 "Rust edition 2024 is the target edition for this workspace",
648 )
649 .expect("add_memory");
650 let d1 = build_or_load_digest(&dir, true).expect("first build");
651 let d2 = build_or_load_digest(&dir, false).expect("cached load");
652 assert_eq!(d1, d2, "cached digest must match first build");
653 });
654 std::fs::remove_dir_all(dir).ok();
655 }
656
657 #[test]
659 fn force_rebuild_bypasses_cache() {
660 let dir = tmp_workspace("force");
661 git_init_boundary(&dir);
662 user_brain::with_user_brain_disabled(|| {
663 project::init_project(&dir, true).expect("init");
664 project::add_memory(
665 &dir,
666 kimetsu_core::memory::MemoryScope::Project,
667 kimetsu_core::memory::MemoryKind::Convention,
668 "Force rebuild test convention",
669 )
670 .expect("add_memory");
671 let d1 = build_or_load_digest(&dir, true).expect("first build");
672 let d2 = build_or_load_digest(&dir, true).expect("forced rebuild");
673 assert_eq!(
675 d1, d2,
676 "forced rebuild must produce same content when inputs unchanged"
677 );
678 });
679 std::fs::remove_dir_all(dir).ok();
680 }
681
682 #[test]
684 fn is_stale_true_when_no_cache() {
685 let dir = tmp_workspace("stale");
686 git_init_boundary(&dir);
687 user_brain::with_user_brain_disabled(|| {
688 project::init_project(&dir, true).expect("init");
689 assert!(is_stale(&dir), "must be stale when cache does not exist");
690 });
691 std::fs::remove_dir_all(dir).ok();
692 }
693
694 #[test]
696 fn is_stale_false_after_build() {
697 let dir = tmp_workspace("fresh");
698 git_init_boundary(&dir);
699 user_brain::with_user_brain_disabled(|| {
700 project::init_project(&dir, true).expect("init");
701 project::add_memory(
702 &dir,
703 kimetsu_core::memory::MemoryScope::Project,
704 kimetsu_core::memory::MemoryKind::Fact,
705 "After-build staleness check fact",
706 )
707 .expect("add_memory");
708 let _ = build_or_load_digest(&dir, true);
709 assert!(
710 !is_stale(&dir),
711 "must NOT be stale immediately after a fresh build"
712 );
713 });
714 std::fs::remove_dir_all(dir).ok();
715 }
716
717 #[test]
721 fn load_cached_digest_serves_stale_text() {
722 let dir = tmp_workspace("cached-stale");
723 git_init_boundary(&dir);
724 user_brain::with_user_brain_disabled(|| {
725 project::init_project(&dir, true).expect("init");
726 assert!(
727 load_cached_digest(&dir).is_none(),
728 "nothing cached yet on a cold brain"
729 );
730
731 project::add_memory(
732 &dir,
733 kimetsu_core::memory::MemoryScope::Project,
734 kimetsu_core::memory::MemoryKind::Fact,
735 "Cached digest fact",
736 )
737 .expect("add_memory");
738 let built = build_or_load_digest(&dir, true).expect("first build");
739 assert_eq!(load_cached_digest(&dir).as_deref(), Some(built.as_str()));
740
741 project::add_memory(
743 &dir,
744 kimetsu_core::memory::MemoryScope::Project,
745 kimetsu_core::memory::MemoryKind::Convention,
746 "A second memory that invalidates the digest hash",
747 )
748 .expect("add_memory");
749 assert!(is_stale(&dir), "corpus moved — cache must read as stale");
750 assert_eq!(
751 load_cached_digest(&dir).as_deref(),
752 Some(built.as_str()),
753 "stale cache is still served verbatim"
754 );
755 });
756 std::fs::remove_dir_all(dir).ok();
757 }
758
759 #[test]
762 fn warm_start_block_respects_gate_and_renders_digest() {
763 let dir = tmp_workspace("warm-block");
764 git_init_boundary(&dir);
765 user_brain::with_user_brain_disabled(|| {
766 project::init_project(&dir, true).expect("init");
767 project::add_memory(
768 &dir,
769 kimetsu_core::memory::MemoryScope::Project,
770 kimetsu_core::memory::MemoryKind::Convention,
771 "Warm start block convention",
772 )
773 .expect("add_memory");
774
775 let block = warm_start_block(&dir).expect("warm start must have content");
776 assert!(
777 block.contains("## Repo context"),
778 "warm start must carry the repo digest: {block}"
779 );
780
781 let paths = kimetsu_core::paths::ProjectPaths::discover(&dir).expect("paths");
783 let mut config = crate::project::load_config(&paths).expect("config");
784 config.broker.warm_start = false;
785 std::fs::write(&paths.project_toml, config.to_toml().expect("to_toml"))
786 .expect("write project.toml");
787
788 assert!(
789 warm_start_block(&dir).is_none(),
790 "[broker] warm_start = false must silence the warm start"
791 );
792 });
793 std::fs::remove_dir_all(dir).ok();
794 }
795
796 #[test]
799 fn digest_size_within_400_token_budget() {
800 let inputs = DigestInputs {
803 top_memories: (0..10)
804 .map(|i| {
805 (
806 "convention".to_string(),
807 "A".repeat(MEMORY_SNIPPET_CHARS) + &format!(" #{i}"),
808 )
809 })
810 .collect(),
811 manifests: (0..5)
812 .map(|i| ("cargo".to_string(), format!("Cargo{i}.toml")))
813 .collect(),
814 recent_runs: (0..5).map(|i| format!("task {i}")).collect(),
815 };
816 let config = kimetsu_core::config::ProjectConfig::default_for_project("test");
817 let digest = assemble_rule_based(&inputs, &config).expect("assemble");
818 let char_count = digest.chars().count();
819 assert!(
820 char_count <= DIGEST_CHAR_BUDGET + 3,
821 "digest must fit in budget: got {char_count} chars (budget={DIGEST_CHAR_BUDGET})"
822 );
823 let approx_tokens = char_count / 4;
825 assert!(
826 approx_tokens <= 420,
827 "approx token count {approx_tokens} must be ≤ 420"
828 );
829 }
830
831 #[test]
833 fn record_warmstart_served_is_best_effort() {
834 let tmp = std::env::temp_dir().join("kimetsu-digest-roi-besteffort");
835 record_warmstart_served(&tmp, 500, 100);
837 }
838}