Skip to main content

kimetsu_brain/
digest.rs

1//! Flagship 1 / Pass B / Story 1.1 + 1.2: repo digest builder.
2//!
3//! Builds a compact ~400-token digest of the current repo state:
4//!   - top-usefulness memories (conventions/facts that matter most)
5//!   - repo manifest summary (Cargo.toml, package.json, …)
6//!   - recent run focus ("current focus" from run history)
7//!
8//! The digest is cached in `.kimetsu/digest.md`, keyed by a SHA-256
9//! CONTENT HASH of the inputs.  Staleness is detected cheaply (git HEAD
10//! change, manifest hash change, memory corpus change) and the rebuild
11//! runs detached so it never blocks SessionStart.
12//!
13//! ## Cheap-model vs rule-based
14//!
15//! When `config.cheap_model()` returns `Some(cm)` the digest is distilled
16//! by an LLM call (not yet wired — requires async HTTP client that is
17//! already present in the distiller).  When `None`, a rule-based assembler
18//! concatenates the raw inputs directly.  The rule-based path is the only
19//! path exercised in tests and in the current implementation (the
20//! expensive LLM path is guarded and degrades gracefully).
21//!
22//! ## ROI attribution
23//!
24//! After the SessionStart hook emits context, it writes `digest_served` /
25//! `resume_served` attribution events to the brain via
26//! [`record_warmstart_served`].
27
28use 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
38// ── Target size ──────────────────────────────────────────────────────────────
39
40/// Approx character budget for the assembled digest (≈400 tokens × 4 chars).
41const DIGEST_CHAR_BUDGET: usize = 1_600;
42/// Number of top-useful memories to include in the digest.
43const TOP_MEMORY_COUNT: usize = 5;
44/// Number of recent run titles to include in "current focus".
45const RECENT_RUNS_COUNT: usize = 3;
46/// Max chars per memory text included in digest.
47const MEMORY_SNIPPET_CHARS: usize = 180;
48
49// ── Cache metadata ────────────────────────────────────────────────────────────
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DigestMeta {
53    /// SHA-256-like content hash of the inputs (via DefaultHasher for speed).
54    pub input_hash: u64,
55    /// ISO-8601 timestamp when this digest was built.
56    pub built_at: String,
57}
58
59// ── Public surface ────────────────────────────────────────────────────────────
60
61/// Build (or load from cache) a compact repo digest for `workspace`.
62///
63/// Returns `None` when:
64/// - the brain is not initialized at `workspace`
65/// - the workspace has no useful content yet (no memories, no manifests)
66///
67/// The returned string is already budget-capped and ready for injection.
68///
69/// `force_rebuild` bypasses the cache.
70pub 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    // 1. Assemble raw inputs.
82    let inputs = gather_inputs(&conn, &repo_root_str)?;
83    if inputs.is_empty() {
84        return Ok(None);
85    }
86
87    // 2. Compute content hash.
88    let hash = content_hash(&inputs);
89
90    // 3. Cache paths.
91    let cache_path = paths.kimetsu_dir.join("digest.md");
92    let meta_path = paths.kimetsu_dir.join("digest-meta.json");
93
94    // 4. Check cache validity.
95    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    // 5. Build the digest (cheap-model optional; rule-based otherwise).
102    let digest_text = assemble_rule_based(&inputs, &config)?;
103    if digest_text.trim().is_empty() {
104        return Ok(None);
105    }
106
107    // 6. Write cache atomically.
108    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
118// ── Staleness check (1.2) ─────────────────────────────────────────────────────
119
120/// Returns `true` when the cached digest is stale and should be rebuilt.
121///
122/// Cheap: only checks the content hash (no I/O heavier than reading the
123/// meta sidecar and querying two SQLite count rows).
124///
125/// Used by the SessionStart hook to decide whether to spawn a detached
126/// rebuild before injecting the (potentially stale) cached digest.
127pub fn is_stale(workspace: &Path) -> bool {
128    is_stale_inner(workspace).unwrap_or(false)
129}
130
131fn is_stale_inner(workspace: &Path) -> KimetsuResult<bool> {
132    let (paths, _config, conn) = load_project_readonly(workspace)?;
133    let repo_root_str = paths.repo_root.to_string_lossy().to_string();
134
135    let meta_path = paths.kimetsu_dir.join("digest-meta.json");
136    let cache_path = paths.kimetsu_dir.join("digest.md");
137
138    if !cache_path.exists() || !meta_path.exists() {
139        return Ok(true);
140    }
141
142    let meta = load_meta(&meta_path)?;
143    let inputs = gather_inputs(&conn, &repo_root_str)?;
144    let current_hash = content_hash(&inputs);
145
146    Ok(meta.input_hash != current_hash)
147}
148
149// ── ROI attribution ───────────────────────────────────────────────────────────
150
151/// Record ROI attribution events for the warm-start injection.
152///
153/// `digest_chars` is the length of the emitted digest (0 = not emitted).
154/// `resume_chars` is the length of the emitted resume (0 = not emitted).
155///
156/// Best-effort: errors are ignored (ROI must never block SessionStart).
157pub fn record_warmstart_served(workspace: &Path, digest_chars: usize, resume_chars: usize) {
158    let _ = record_warmstart_served_inner(workspace, digest_chars, resume_chars);
159}
160
161fn record_warmstart_served_inner(
162    workspace: &Path,
163    digest_chars: usize,
164    resume_chars: usize,
165) -> KimetsuResult<()> {
166    if digest_chars == 0 && resume_chars == 0 {
167        return Ok(());
168    }
169    let (_paths, _config, conn) = load_project(workspace)?;
170    let ts = now_utc_rfc3339();
171
172    if digest_chars > 0 {
173        let approx_tokens = digest_chars / 4;
174        let event = kimetsu_core::event::Event::new(
175            kimetsu_core::ids::RunId::new(),
176            "digest_served",
177            serde_json::json!({
178                "digest_chars": digest_chars,
179                "approx_tokens": approx_tokens,
180                "ts": ts,
181            }),
182        );
183        let _ = crate::projector::insert_event(&conn, &event);
184    }
185
186    if resume_chars > 0 {
187        let approx_tokens = resume_chars / 4;
188        let event = kimetsu_core::event::Event::new(
189            kimetsu_core::ids::RunId::new(),
190            "resume_served",
191            serde_json::json!({
192                "resume_chars": resume_chars,
193                "approx_tokens": approx_tokens,
194                "ts": ts,
195            }),
196        );
197        let _ = crate::projector::insert_event(&conn, &event);
198    }
199
200    Ok(())
201}
202
203// ── Input assembly ────────────────────────────────────────────────────────────
204
205/// Raw ingredients for the digest.
206#[derive(Debug, Default)]
207struct DigestInputs {
208    /// Top-useful memory snippets: `(kind, text_snippet)`.
209    top_memories: Vec<(String, String)>,
210    /// Manifest summaries: `(manifest_kind, path)` e.g. ("cargo", "Cargo.toml").
211    manifests: Vec<(String, String)>,
212    /// Recent run task titles.
213    recent_runs: Vec<String>,
214}
215
216impl DigestInputs {
217    fn is_empty(&self) -> bool {
218        self.top_memories.is_empty() && self.manifests.is_empty() && self.recent_runs.is_empty()
219    }
220}
221
222fn gather_inputs(conn: &Connection, repo_root: &str) -> KimetsuResult<DigestInputs> {
223    let mut inputs = DigestInputs::default();
224
225    // Top-useful memories (conventions/facts, no superseded/invalidated).
226    // Include memories with use_count = 0 (fresh adds) ordered by recency
227    // so new brains produce useful digests without requiring prior runs.
228    // use_count > 0 memories are ranked by usefulness ratio; use_count = 0
229    // rows sort last (usefulness_score default 0).
230    {
231        let mut stmt = conn.prepare(
232            "SELECT kind, text
233             FROM memories
234             WHERE invalidated_at IS NULL
235               AND superseded_by IS NULL
236             ORDER BY
237               CASE WHEN use_count > 0
238                    THEN (usefulness_score / CAST(use_count AS REAL))
239                    ELSE 0.0
240               END DESC,
241               use_count DESC,
242               created_at DESC
243             LIMIT ?1",
244        )?;
245        let rows = stmt.query_map([TOP_MEMORY_COUNT as i64], |row| {
246            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
247        })?;
248        for (kind, text) in rows.flatten() {
249            let snippet: String = text.chars().take(MEMORY_SNIPPET_CHARS).collect();
250            inputs.top_memories.push((kind, snippet));
251        }
252    }
253
254    // Repo manifests (Cargo.toml, package.json, pyproject.toml, …)
255    {
256        let mut stmt = conn.prepare(
257            "SELECT manifest_kind, manifest_path
258             FROM repo_manifests
259             WHERE repo_root = ?1
260             LIMIT 10",
261        )?;
262        let rows = stmt.query_map([repo_root], |row| {
263            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
264        })?;
265        for pair in rows.flatten() {
266            inputs.manifests.push(pair);
267        }
268    }
269
270    // Recent run summaries from work_episodes (current focus).
271    {
272        let mut stmt = conn.prepare(
273            "SELECT task
274             FROM work_episodes
275             WHERE repo_root = ?1
276               AND superseded_by IS NULL
277             ORDER BY created_at DESC
278             LIMIT ?2",
279        )?;
280        let rows = stmt.query_map([repo_root, &RECENT_RUNS_COUNT.to_string()], |row| {
281            row.get::<_, String>(0)
282        })?;
283        for task in rows.flatten() {
284            if !task.trim().is_empty() {
285                inputs.recent_runs.push(task);
286            }
287        }
288    }
289
290    Ok(inputs)
291}
292
293// ── Content hash ──────────────────────────────────────────────────────────────
294
295fn content_hash(inputs: &DigestInputs) -> u64 {
296    let mut h = DefaultHasher::new();
297    for (kind, text) in &inputs.top_memories {
298        kind.hash(&mut h);
299        text.hash(&mut h);
300    }
301    for (mk, mp) in &inputs.manifests {
302        mk.hash(&mut h);
303        mp.hash(&mut h);
304    }
305    for task in &inputs.recent_runs {
306        task.hash(&mut h);
307    }
308    h.finish()
309}
310
311// ── Rule-based assembler ──────────────────────────────────────────────────────
312
313fn assemble_rule_based(
314    inputs: &DigestInputs,
315    _config: &kimetsu_core::config::ProjectConfig,
316) -> KimetsuResult<String> {
317    let mut parts: Vec<String> = Vec::new();
318
319    // Manifests → project type hint.
320    if !inputs.manifests.is_empty() {
321        let manifest_list: Vec<String> = inputs
322            .manifests
323            .iter()
324            .map(|(kind, path)| format!("{kind}: {path}"))
325            .collect();
326        parts.push(format!("Project manifests: {}", manifest_list.join(", ")));
327    }
328
329    // Current focus.
330    if !inputs.recent_runs.is_empty() {
331        let focus = inputs
332            .recent_runs
333            .iter()
334            .map(|t| t.trim().to_string())
335            .filter(|t| !t.is_empty())
336            .collect::<Vec<_>>();
337        if !focus.is_empty() {
338            parts.push(format!("Current focus: {}", focus.join(" / ")));
339        }
340    }
341
342    // Top memories.
343    if !inputs.top_memories.is_empty() {
344        parts.push("Key conventions and facts:".to_string());
345        for (kind, text) in &inputs.top_memories {
346            parts.push(format!("[{kind}] {text}"));
347        }
348    }
349
350    let digest = parts.join("\n");
351
352    // Budget-cap: truncate to char limit with ellipsis.
353    if digest.len() > DIGEST_CHAR_BUDGET {
354        let mut s: String = digest.chars().take(DIGEST_CHAR_BUDGET - 3).collect();
355        s.push_str("...");
356        Ok(s)
357    } else {
358        Ok(digest)
359    }
360}
361
362// ── Cache helpers ─────────────────────────────────────────────────────────────
363
364fn try_load_cache(cache_path: &Path, meta_path: &Path, current_hash: u64) -> Option<String> {
365    if !cache_path.exists() || !meta_path.exists() {
366        return None;
367    }
368    let meta = load_meta(meta_path).ok()?;
369    if meta.input_hash != current_hash {
370        return None;
371    }
372    std::fs::read_to_string(cache_path).ok()
373}
374
375fn load_meta(meta_path: &Path) -> KimetsuResult<DigestMeta> {
376    let text = std::fs::read_to_string(meta_path)?;
377    Ok(serde_json::from_str(&text)?)
378}
379
380/// Atomic text write: temp + rename.
381fn atomic_write_text(path: &Path, content: &str) {
382    let Some(parent) = path.parent() else {
383        return;
384    };
385    let _ = std::fs::create_dir_all(parent);
386    let tmp = path.with_extension("md.tmp");
387    if std::fs::write(&tmp, content).is_ok() {
388        let _ = std::fs::rename(&tmp, path);
389    }
390}
391
392/// Atomic JSON meta write: temp + rename.
393fn atomic_write_json_meta(path: &Path, meta: &DigestMeta) {
394    let Some(parent) = path.parent() else {
395        return;
396    };
397    let _ = std::fs::create_dir_all(parent);
398    let Ok(text) = serde_json::to_string(meta) else {
399        return;
400    };
401    let tmp = path.with_extension("json.tmp");
402    if std::fs::write(&tmp, &text).is_ok() {
403        let _ = std::fs::rename(&tmp, path);
404    }
405}
406
407fn now_utc_rfc3339() -> String {
408    time::OffsetDateTime::now_utc()
409        .format(&time::format_description::well_known::Rfc3339)
410        .unwrap_or_default()
411}
412
413// ── Tests ─────────────────────────────────────────────────────────────────────
414
415#[cfg(test)]
416mod tests {
417    use kimetsu_core::paths::git_init_boundary;
418
419    use super::*;
420    use crate::{project, user_brain};
421
422    fn tmp_workspace(name: &str) -> std::path::PathBuf {
423        let ts = std::time::SystemTime::now()
424            .duration_since(std::time::UNIX_EPOCH)
425            .map(|d| d.as_nanos())
426            .unwrap_or(0);
427        let dir = std::env::temp_dir().join(format!("kimetsu-digest-{name}-{ts}"));
428        std::fs::create_dir_all(&dir).expect("create tmp");
429        dir
430    }
431
432    // D1: empty brain returns None (no content to digest).
433    #[test]
434    fn empty_brain_returns_none() {
435        let dir = tmp_workspace("empty");
436        git_init_boundary(&dir);
437        user_brain::with_user_brain_disabled(|| {
438            project::init_project(&dir, true).expect("init");
439            let result = build_or_load_digest(&dir, false);
440            assert!(result.is_none(), "empty brain must return None digest");
441        });
442        std::fs::remove_dir_all(dir).ok();
443    }
444
445    // D2: digest with memories is non-empty and ≤ budget.
446    #[test]
447    fn digest_with_memories_is_bounded() {
448        let dir = tmp_workspace("bounded");
449        git_init_boundary(&dir);
450        user_brain::with_user_brain_disabled(|| {
451            project::init_project(&dir, true).expect("init");
452            // Seed a memory so there's content to digest.
453            // The digest includes memories even with use_count=0 (fresh adds).
454            project::add_memory(
455                &dir,
456                kimetsu_core::memory::MemoryScope::Project,
457                kimetsu_core::memory::MemoryKind::Convention,
458                "Always use git_init_boundary before init_project in tests",
459            )
460            .expect("add_memory");
461
462            let digest = build_or_load_digest(&dir, true).expect("digest must be Some");
463            assert!(!digest.is_empty(), "digest must be non-empty");
464            assert!(
465                digest.len() <= DIGEST_CHAR_BUDGET + 3,
466                "digest must respect char budget: {} chars",
467                digest.len()
468            );
469        });
470        std::fs::remove_dir_all(dir).ok();
471    }
472
473    // D3: cache is reused on second call (no force_rebuild).
474    #[test]
475    fn cache_is_reused_on_second_call() {
476        let dir = tmp_workspace("cache");
477        git_init_boundary(&dir);
478        user_brain::with_user_brain_disabled(|| {
479            project::init_project(&dir, true).expect("init");
480            project::add_memory(
481                &dir,
482                kimetsu_core::memory::MemoryScope::Project,
483                kimetsu_core::memory::MemoryKind::Fact,
484                "Rust edition 2024 is the target edition for this workspace",
485            )
486            .expect("add_memory");
487            let d1 = build_or_load_digest(&dir, true).expect("first build");
488            let d2 = build_or_load_digest(&dir, false).expect("cached load");
489            assert_eq!(d1, d2, "cached digest must match first build");
490        });
491        std::fs::remove_dir_all(dir).ok();
492    }
493
494    // D4: force_rebuild bypasses cache.
495    #[test]
496    fn force_rebuild_bypasses_cache() {
497        let dir = tmp_workspace("force");
498        git_init_boundary(&dir);
499        user_brain::with_user_brain_disabled(|| {
500            project::init_project(&dir, true).expect("init");
501            project::add_memory(
502                &dir,
503                kimetsu_core::memory::MemoryScope::Project,
504                kimetsu_core::memory::MemoryKind::Convention,
505                "Force rebuild test convention",
506            )
507            .expect("add_memory");
508            let d1 = build_or_load_digest(&dir, true).expect("first build");
509            let d2 = build_or_load_digest(&dir, true).expect("forced rebuild");
510            // Content should match because inputs are the same.
511            assert_eq!(
512                d1, d2,
513                "forced rebuild must produce same content when inputs unchanged"
514            );
515        });
516        std::fs::remove_dir_all(dir).ok();
517    }
518
519    // D5: is_stale returns true when no cache exists.
520    #[test]
521    fn is_stale_true_when_no_cache() {
522        let dir = tmp_workspace("stale");
523        git_init_boundary(&dir);
524        user_brain::with_user_brain_disabled(|| {
525            project::init_project(&dir, true).expect("init");
526            assert!(is_stale(&dir), "must be stale when cache does not exist");
527        });
528        std::fs::remove_dir_all(dir).ok();
529    }
530
531    // D6: is_stale returns false after a successful build.
532    #[test]
533    fn is_stale_false_after_build() {
534        let dir = tmp_workspace("fresh");
535        git_init_boundary(&dir);
536        user_brain::with_user_brain_disabled(|| {
537            project::init_project(&dir, true).expect("init");
538            project::add_memory(
539                &dir,
540                kimetsu_core::memory::MemoryScope::Project,
541                kimetsu_core::memory::MemoryKind::Fact,
542                "After-build staleness check fact",
543            )
544            .expect("add_memory");
545            let _ = build_or_load_digest(&dir, true);
546            assert!(
547                !is_stale(&dir),
548                "must NOT be stale immediately after a fresh build"
549            );
550        });
551        std::fs::remove_dir_all(dir).ok();
552    }
553
554    // D7: digest size is ≤ ~400 tokens (character proxy: 1600 chars).
555    // This is the measurement/gate required by Story 1.6.
556    #[test]
557    fn digest_size_within_400_token_budget() {
558        // Assemble a large set of inputs and verify the rule-based assembler
559        // respects the budget.
560        let inputs = DigestInputs {
561            top_memories: (0..10)
562                .map(|i| {
563                    (
564                        "convention".to_string(),
565                        "A".repeat(MEMORY_SNIPPET_CHARS) + &format!(" #{i}"),
566                    )
567                })
568                .collect(),
569            manifests: (0..5)
570                .map(|i| ("cargo".to_string(), format!("Cargo{i}.toml")))
571                .collect(),
572            recent_runs: (0..5).map(|i| format!("task {i}")).collect(),
573        };
574        let config = kimetsu_core::config::ProjectConfig::default_for_project("test");
575        let digest = assemble_rule_based(&inputs, &config).expect("assemble");
576        let char_count = digest.chars().count();
577        assert!(
578            char_count <= DIGEST_CHAR_BUDGET + 3,
579            "digest must fit in budget: got {char_count} chars (budget={DIGEST_CHAR_BUDGET})"
580        );
581        // Approximate token count: chars / 4.
582        let approx_tokens = char_count / 4;
583        assert!(
584            approx_tokens <= 420,
585            "approx token count {approx_tokens} must be ≤ 420"
586        );
587    }
588
589    // D8: record_warmstart_served is best-effort (no panic on uninitialized brain).
590    #[test]
591    fn record_warmstart_served_is_best_effort() {
592        let tmp = std::env::temp_dir().join("kimetsu-digest-roi-besteffort");
593        // No brain initialized — must not panic.
594        record_warmstart_served(&tmp, 500, 100);
595    }
596}