Skip to main content

kimetsu_brain/
episode.rs

1//! Episodic work-resume: capture, store, and surface per-repo work episodes
2//! so the next session resumes instead of re-deriving state.
3//!
4//! # Design
5//!
6//! Episodes are event-sourced via `work.episode` events → the `work_episodes`
7//! projection table. One live episode per repo and explicit identity lane;
8//! each new capture supersedes the prior in the same lane only.
9//!
10//! ## Story coverage
11//! * **1.3** — episode event + table; auto-capture at SessionEnd; optional
12//!   cheap-model distillation with rule-based fallback when none is configured.
13//! * **kimetsu checkpoint** — manual mid-session capture (CLI wires this).
14//! * **1.4** — [`load_live_episode`] + [`render_resume_context`] (Pass B
15//!   SessionStart injection surface).
16//! * **1.7 (light)** — where the episode payload references concrete memory_id
17//!   strings, a `lesson_from` edge is inserted via `projector::insert_memory_edge`.
18//!   This is best-effort: if no memory ids are found in the payload the edge
19//!   plumbing is simply not invoked.
20
21use std::path::Path;
22
23use kimetsu_core::KimetsuResult;
24use kimetsu_core::ids::RunId;
25use rusqlite::{Connection, OptionalExtension, params};
26use time::format_description::well_known::Rfc3339;
27
28use crate::project::{load_project, load_project_readonly};
29use crate::projector;
30
31/// One caller-selected lane, preferring task, session, then worktree identity.
32/// Null, non-string and blank placeholders never hide a usable later key.
33pub fn requested_identity(payload: &serde_json::Value) -> Option<&str> {
34    ["task_id", "session_id", "worktree_id"]
35        .into_iter()
36        .find_map(|key| {
37            payload
38                .get(key)
39                .and_then(serde_json::Value::as_str)
40                .filter(|id| !id.trim().is_empty())
41        })
42}
43
44// ---------------------------------------------------------------------------
45// Episode data types
46// ---------------------------------------------------------------------------
47
48/// A serialized `work.episode` event payload (stored as JSON in the events
49/// table). An empty identity preserves the legacy unscoped lane.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
51#[serde(default)]
52pub struct EpisodePayload {
53    /// Human-readable task description / goal.
54    pub task: String,
55    /// Stable caller-selected task/session/worktree lane; empty is the legacy lane.
56    #[serde(default)]
57    pub identity: String,
58    /// Narrative summary of what was done.
59    pub summary: String,
60    /// Things that remain to be done.
61    pub open_threads: Vec<String>,
62    /// Dead-ends: failed approaches and why they failed.
63    pub dead_ends: Vec<String>,
64    /// Working hypothesis / current best theory.
65    pub hypothesis: String,
66    /// Optional note supplied by the user (e.g. from `kimetsu checkpoint note`).
67    #[serde(default, skip_serializing_if = "str::is_empty")]
68    pub note: String,
69    /// Canonical repo root used as the per-repo scope key.
70    pub repo_root: String,
71    /// memory_ids referenced in this episode (for 1.7 edge insertion).
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub memory_ids: Vec<String>,
74}
75
76/// A row from the `work_episodes` projection table.
77#[derive(Debug, Clone)]
78pub struct EpisodeRow {
79    pub episode_id: String,
80    pub identity: String,
81    pub repo_root: String,
82    pub task: String,
83    pub summary: String,
84    pub open_threads: Vec<String>,
85    pub dead_ends: Vec<String>,
86    pub hypothesis: String,
87    pub note: String,
88    pub created_at: String,
89    /// Set to the episode_id of the episode that superseded this one, or None
90    /// if this is the live episode.
91    pub superseded_by: Option<String>,
92}
93
94// ---------------------------------------------------------------------------
95// Schema helper: ensures `work_episodes` table exists (idempotent).
96// Called from the v4→v5 migration; also used by tests directly.
97// ---------------------------------------------------------------------------
98
99/// Create the `work_episodes` projection table and its indexes.
100///
101/// This function is called by the v4→v5 migration; it is idempotent
102/// (`IF NOT EXISTS`).  Do NOT issue BEGIN/COMMIT here — the migration runner
103/// owns the transaction.
104pub(crate) fn create_work_episodes_table(conn: &Connection) -> KimetsuResult<()> {
105    conn.execute_batch(
106        "
107        CREATE TABLE IF NOT EXISTS work_episodes (
108            episode_id      TEXT PRIMARY KEY,
109            repo_root       TEXT NOT NULL,
110            task            TEXT NOT NULL DEFAULT '',
111            summary         TEXT NOT NULL DEFAULT '',
112            open_threads    TEXT NOT NULL DEFAULT '[]',
113            dead_ends       TEXT NOT NULL DEFAULT '[]',
114            hypothesis      TEXT NOT NULL DEFAULT '',
115            note            TEXT NOT NULL DEFAULT '',
116            created_at      TEXT NOT NULL,
117            superseded_by   TEXT
118        );
119
120        CREATE INDEX IF NOT EXISTS idx_episodes_repo_live
121            ON work_episodes (repo_root, superseded_by);
122
123        CREATE INDEX IF NOT EXISTS idx_episodes_repo_created
124            ON work_episodes (repo_root, created_at DESC);
125        ",
126    )?;
127    Ok(())
128}
129
130// ---------------------------------------------------------------------------
131// Projection: project a `work.episode` event into `work_episodes`
132// ---------------------------------------------------------------------------
133
134/// Project a single `work.episode` event into the `work_episodes` table.
135///
136/// 1. Insert the new episode row (OR IGNORE — replay-safe).
137/// 2. Stamp `superseded_by = new_episode_id` on the prior live episode for
138///    this `repo_root` (if any).
139/// 3. Insert `lesson_from` edges for any `memory_ids` in the payload (1.7
140///    light — best-effort).
141pub(crate) fn project_work_episode(
142    conn: &Connection,
143    event: &kimetsu_core::event::Event,
144) -> KimetsuResult<()> {
145    let payload: EpisodePayload = serde_json::from_value(event.payload.clone()).unwrap_or_default();
146    let episode_id = event.event_id.to_string();
147    let ts = event
148        .ts
149        .format(&Rfc3339)
150        .map_err(|e| format!("format ts: {e}"))?;
151
152    let open_threads_json = serde_json::to_string(&payload.open_threads)?;
153    let dead_ends_json = serde_json::to_string(&payload.dead_ends)?;
154
155    // 1. Insert the new episode (OR IGNORE for replay-safety).
156    let inserted = conn.execute(
157        "
158        INSERT OR IGNORE INTO work_episodes (
159            episode_id, repo_root, task, summary, open_threads, dead_ends,
160            hypothesis, note, created_at, superseded_by, identity
161        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10)
162        ",
163        params![
164            episode_id,
165            payload.repo_root,
166            payload.task,
167            payload.summary,
168            open_threads_json,
169            dead_ends_json,
170            payload.hypothesis,
171            payload.note,
172            ts,
173            payload.identity,
174        ],
175    )?;
176
177    if inserted == 0 {
178        return Ok(());
179    }
180
181    // 2. Supersede the prior live episode for this repo_root (if any).
182    // We find the most-recent non-superseded episode that is NOT this one,
183    // and stamp superseded_by = episode_id.
184    let prior_id: Option<String> = conn
185        .query_row(
186            "
187            SELECT episode_id FROM work_episodes
188            WHERE repo_root = ?1
189              AND superseded_by IS NULL
190              AND episode_id != ?2
191              AND identity = ?3
192            ORDER BY created_at DESC
193            LIMIT 1
194            ",
195            params![payload.repo_root, episode_id, payload.identity],
196            |r| r.get(0),
197        )
198        .optional()?;
199
200    if let Some(prior) = &prior_id {
201        conn.execute(
202            "UPDATE work_episodes SET superseded_by = ?2 WHERE episode_id = ?1",
203            params![prior, episode_id],
204        )?;
205    }
206
207    // 3. Story 1.7 (light): insert `lesson_from` edges for referenced memories.
208    //    Best-effort — if the memory row doesn't exist yet (e.g. replay order) the
209    //    INSERT OR IGNORE is still safe; the edge simply points to a non-existent
210    //    dst (no FK constraint).
211    for memory_id in &payload.memory_ids {
212        if memory_id.is_empty() {
213            continue;
214        }
215        // episode_id is not a memory_id, so we use the episode_id as src
216        // and the memory as dst, edge type "lesson_from".
217        projector::insert_memory_edge(conn, &episode_id, memory_id, "lesson_from", &ts)?;
218    }
219
220    Ok(())
221}
222
223// ---------------------------------------------------------------------------
224// Read path: load the live episode for a repo_root
225// ---------------------------------------------------------------------------
226
227// Raw DB row type alias for load_live_episode.
228type EpisodeDbRow = (
229    String,
230    String,
231    String,
232    String,
233    String,
234    String,
235    String,
236    String,
237    String,
238    Option<String>,
239);
240
241/// Load the live (non-superseded) episode for `repo_root`, or `None` when
242/// none exists.
243pub fn load_live_episode(conn: &Connection, repo_root: &str) -> KimetsuResult<Option<EpisodeRow>> {
244    load_live_episode_scoped(conn, repo_root, "")
245}
246
247/// Exact identity selection: never falls back to another task or legacy lane.
248pub fn load_live_episode_scoped(
249    conn: &Connection,
250    repo_root: &str,
251    identity: &str,
252) -> KimetsuResult<Option<EpisodeRow>> {
253    let row: Option<EpisodeDbRow> = conn
254        .query_row(
255            "
256            SELECT episode_id, repo_root, task, summary, open_threads, dead_ends,
257                   hypothesis, note, created_at, superseded_by
258            FROM work_episodes
259            WHERE repo_root = ?1
260              AND identity = ?2
261              AND superseded_by IS NULL
262            ORDER BY created_at DESC
263            LIMIT 1
264            ",
265            params![repo_root, identity],
266            |r| {
267                Ok((
268                    r.get(0)?,
269                    r.get(1)?,
270                    r.get(2)?,
271                    r.get(3)?,
272                    r.get(4)?,
273                    r.get(5)?,
274                    r.get(6)?,
275                    r.get(7)?,
276                    r.get(8)?,
277                    r.get(9)?,
278                ))
279            },
280        )
281        .optional()?;
282
283    let Some((
284        episode_id,
285        repo_root_val,
286        task,
287        summary,
288        open_threads_json,
289        dead_ends_json,
290        hypothesis,
291        note,
292        created_at,
293        superseded_by,
294    )) = row
295    else {
296        return Ok(None);
297    };
298
299    let open_threads: Vec<String> = serde_json::from_str(&open_threads_json).unwrap_or_default();
300    let dead_ends: Vec<String> = serde_json::from_str(&dead_ends_json).unwrap_or_default();
301
302    Ok(Some(EpisodeRow {
303        identity: identity.to_string(),
304        episode_id,
305        repo_root: repo_root_val,
306        task,
307        summary,
308        open_threads,
309        dead_ends,
310        hypothesis,
311        note,
312        created_at,
313        superseded_by,
314    }))
315}
316
317// ---------------------------------------------------------------------------
318// 1.4: render_resume_context — Pass B SessionStart surface
319// ---------------------------------------------------------------------------
320
321/// Render the live episode for `workspace` as a concise injection string
322/// suitable for SessionStart context injection (≤ ~120 tokens of content).
323///
324/// Returns `None` when:
325/// - no Kimetsu project is found at `workspace`, or
326/// - no live episode exists for that repo.
327///
328/// The caller (Pass B SessionStart hook) should prepend this string to the
329/// user prompt or system context.  The format is intentionally compact:
330///
331/// ```text
332/// Last session in this repo: you were working on <task>.
333/// Done: <summary>.
334/// Open: <open_threads>.
335/// Avoid: <dead_ends>.
336/// Hypothesis: <hypothesis>.
337/// ```
338pub fn render_resume_context(workspace: &Path) -> Option<String> {
339    render_resume_context_scoped(workspace, "")
340}
341
342pub fn render_resume_context_scoped(workspace: &Path, identity: &str) -> Option<String> {
343    let (paths, _config, conn) = load_project_readonly(workspace).ok()?;
344    let repo_root = paths.repo_root.to_string_lossy().to_string();
345    let episode = load_live_episode_scoped(&conn, &repo_root, identity)
346        .ok()
347        .flatten()?;
348    Some(format_episode_for_context(&episode))
349}
350
351/// Format an episode row as the resume context string.
352fn format_episode_for_context(ep: &EpisodeRow) -> String {
353    let mut parts = Vec::new();
354
355    let task = ep.task.trim();
356    if !task.is_empty() {
357        parts.push(format!(
358            "Last session in this repo: you were working on {task}."
359        ));
360    } else {
361        parts.push("Last session in this repo: previous work captured below.".to_string());
362    }
363
364    let summary = ep.summary.trim();
365    if !summary.is_empty() {
366        parts.push(format!("Done: {summary}."));
367    }
368
369    if !ep.open_threads.is_empty() {
370        let threads = ep
371            .open_threads
372            .iter()
373            .filter(|s| !s.trim().is_empty())
374            .cloned()
375            .collect::<Vec<_>>();
376        if !threads.is_empty() {
377            parts.push(format!("Open: {}.", threads.join("; ")));
378        }
379    }
380
381    if !ep.dead_ends.is_empty() {
382        let de = ep
383            .dead_ends
384            .iter()
385            .filter(|s| !s.trim().is_empty())
386            .cloned()
387            .collect::<Vec<_>>();
388        if !de.is_empty() {
389            parts.push(format!("Avoid: {}.", de.join("; ")));
390        }
391    }
392
393    let hypothesis = ep.hypothesis.trim();
394    if !hypothesis.is_empty() {
395        parts.push(format!("Hypothesis: {hypothesis}."));
396    }
397
398    if !ep.note.trim().is_empty() {
399        parts.push(format!("Note: {}.", ep.note.trim()));
400    }
401
402    parts.join("\n")
403}
404
405// ---------------------------------------------------------------------------
406// Capture path: write a `work.episode` event
407// ---------------------------------------------------------------------------
408
409/// Write a `work.episode` event to the brain for `workspace`, projecting it
410/// immediately.  Returns the new `episode_id` (= the event_id ULID).
411///
412/// This is the single canonical write path used by:
413/// - The SessionEnd auto-capture (via `distiller::capture_episode`).
414/// - `kimetsu checkpoint [note]`.
415pub fn capture_episode(workspace: &Path, payload: EpisodePayload) -> KimetsuResult<String> {
416    let (paths, _config, conn) = load_project(workspace)?;
417    let _lock = crate::lock::ProjectLock::acquire(&paths, "episode capture", None)?;
418
419    // Always use the canonical repo_root from ProjectPaths so that
420    // load_live_episode_for_workspace (which also reads from paths.repo_root)
421    // always finds the same key — even if the caller supplied a non-canonical path.
422    let mut payload = payload;
423    payload.repo_root = paths.repo_root.to_string_lossy().to_string();
424
425    let run_id = RunId::new();
426    let event =
427        kimetsu_core::event::Event::new(run_id, "work.episode", serde_json::to_value(&payload)?);
428    let event_id = event.event_id.to_string();
429
430    // Write into events table and project.
431    projector::apply_events(&conn, &[event])?;
432
433    Ok(event_id)
434}
435
436// ---------------------------------------------------------------------------
437// Rule-based episode fallback (no cheap model configured)
438// ---------------------------------------------------------------------------
439
440/// Build an `EpisodePayload` using only the transcript view and git metadata —
441/// no model call.  Used when `config.cheap_model()` is `None`.
442///
443/// Strategy:
444/// - `task`: first non-empty user line of the transcript (the prompt).
445/// - `summary`: last assistant line (the final answer summary).
446/// - `open_threads`: any line containing "TODO", "still need", "next step", "follow-up".
447/// - `dead_ends`: any line containing "failed", "doesn't work", "error:", "gave up".
448/// - `hypothesis`: empty (rule-based has no reasoning to capture).
449pub fn rule_based_episode(transcript_view: &str, repo_root: &str, note: &str) -> EpisodePayload {
450    let lines: Vec<&str> = transcript_view.lines().collect();
451
452    // task: first user: line
453    let task = lines
454        .iter()
455        .find(|l| l.starts_with("user:"))
456        .map(|l| l.trim_start_matches("user:").trim().to_string())
457        .unwrap_or_default();
458
459    // summary: last assistant: line
460    let summary = lines
461        .iter()
462        .rev()
463        .find(|l| l.starts_with("assistant:"))
464        .map(|l| l.trim_start_matches("assistant:").trim().to_string())
465        .unwrap_or_default();
466
467    // open_threads: lines containing open-thread signals
468    let open_thread_signals = ["todo", "still need", "next step", "follow-up", "followup"];
469    let open_threads: Vec<String> = lines
470        .iter()
471        .filter(|l| {
472            let low = l.to_lowercase();
473            open_thread_signals.iter().any(|sig| low.contains(sig))
474        })
475        .take(3)
476        .map(|l| l.trim().to_string())
477        .collect();
478
479    // dead_ends: lines containing failure signals
480    let dead_end_signals = [
481        "failed",
482        "doesn't work",
483        "does not work",
484        "error:",
485        "gave up",
486        "won't work",
487    ];
488    let dead_ends: Vec<String> = lines
489        .iter()
490        .filter(|l| {
491            let low = l.to_lowercase();
492            dead_end_signals.iter().any(|sig| low.contains(sig))
493        })
494        .take(3)
495        .map(|l| l.trim().to_string())
496        .collect();
497
498    EpisodePayload {
499        identity: String::new(),
500        task: task.chars().take(200).collect(),
501        summary: summary.chars().take(300).collect(),
502        open_threads,
503        dead_ends,
504        hypothesis: String::new(),
505        note: note.to_string(),
506        repo_root: repo_root.to_string(),
507        memory_ids: Vec::new(),
508    }
509}
510
511// ---------------------------------------------------------------------------
512// Public query: load live episode by workspace path
513// ---------------------------------------------------------------------------
514
515/// Convenience wrapper: load the live episode for `workspace` (resolves the
516/// repo_root from `ProjectPaths`).  Used by `kimetsu resume`.
517pub fn load_live_episode_for_workspace(workspace: &Path) -> KimetsuResult<Option<EpisodeRow>> {
518    load_live_episode_for_workspace_scoped(workspace, "")
519}
520pub fn load_live_episode_for_workspace_scoped(
521    workspace: &Path,
522    identity: &str,
523) -> KimetsuResult<Option<EpisodeRow>> {
524    let (paths, _config, conn) = load_project_readonly(workspace)?;
525    let repo_root = paths.repo_root.to_string_lossy().to_string();
526    load_live_episode_scoped(&conn, &repo_root, identity)
527}
528
529// ---------------------------------------------------------------------------
530// Tests
531// ---------------------------------------------------------------------------
532
533#[cfg(test)]
534mod tests {
535    use kimetsu_core::paths::git_init_boundary;
536
537    use super::*;
538    use crate::{project, schema, user_brain};
539
540    fn tmp_workspace(name: &str) -> std::path::PathBuf {
541        let ts = std::time::SystemTime::now()
542            .duration_since(std::time::UNIX_EPOCH)
543            .map(|d| d.as_nanos())
544            .unwrap_or(0);
545        let dir = std::env::temp_dir().join(format!("kimetsu-ep-{name}-{ts}"));
546        std::fs::create_dir_all(&dir).expect("create tmp");
547        dir
548    }
549
550    fn make_in_memory_conn() -> Connection {
551        let conn = Connection::open_in_memory().expect("in-memory");
552        schema::initialize(&conn).expect("schema init");
553        conn
554    }
555
556    // ------------------------------------------------------------------
557    // E1: project_work_episode round-trips through an in-memory conn
558    // ------------------------------------------------------------------
559    #[test]
560    fn project_episode_round_trip() {
561        let conn = make_in_memory_conn();
562        let run_id = kimetsu_core::ids::RunId::new();
563        let payload = EpisodePayload {
564            identity: String::new(),
565            task: "fix the build".to_string(),
566            summary: "added missing feature flag".to_string(),
567            open_threads: vec!["still need tests".to_string()],
568            dead_ends: vec!["tried cmake, failed".to_string()],
569            hypothesis: "the linker needs explicit paths".to_string(),
570            note: "urgent".to_string(),
571            repo_root: "/repo/foo".to_string(),
572            memory_ids: vec![],
573        };
574        let event = kimetsu_core::event::Event::new(
575            run_id,
576            "work.episode",
577            serde_json::to_value(&payload).unwrap(),
578        );
579        project_work_episode(&conn, &event).expect("project_work_episode");
580
581        let ep = load_live_episode(&conn, "/repo/foo")
582            .expect("load_live_episode")
583            .expect("episode must exist");
584
585        assert_eq!(ep.task, "fix the build");
586        assert_eq!(ep.open_threads, vec!["still need tests".to_string()]);
587        assert_eq!(ep.dead_ends, vec!["tried cmake, failed".to_string()]);
588        assert_eq!(ep.hypothesis, "the linker needs explicit paths");
589        assert_eq!(ep.note, "urgent");
590        assert!(
591            ep.superseded_by.is_none(),
592            "new episode must not be superseded"
593        );
594    }
595
596    // ------------------------------------------------------------------
597    // E2: second episode supersedes the first
598    // ------------------------------------------------------------------
599    #[test]
600    fn second_episode_supersedes_first() {
601        let conn = make_in_memory_conn();
602        let run_id = kimetsu_core::ids::RunId::new();
603
604        let payload1 = EpisodePayload {
605            task: "task 1".to_string(),
606            repo_root: "/repo/bar".to_string(),
607            ..Default::default()
608        };
609        let ev1 = kimetsu_core::event::Event::new(
610            run_id,
611            "work.episode",
612            serde_json::to_value(&payload1).unwrap(),
613        );
614        project_work_episode(&conn, &ev1).expect("project ev1");
615
616        let ep1_id = ev1.event_id.to_string();
617
618        // Brief sleep not required — ULID monotonicity within same process
619        // is guaranteed by the ULID spec (millisecond monotonicity + random).
620        // Force strictly-later created_at by crafting a newer ts.
621        let mut ev2 = kimetsu_core::event::Event::new(
622            run_id,
623            "work.episode",
624            serde_json::to_value(EpisodePayload {
625                task: "task 2".to_string(),
626                repo_root: "/repo/bar".to_string(),
627                ..Default::default()
628            })
629            .unwrap(),
630        );
631        // Ensure ts is strictly after ev1.ts.
632        ev2.ts = ev1.ts + time::Duration::seconds(1);
633
634        project_work_episode(&conn, &ev2).expect("project ev2");
635
636        // ep1 must now be superseded.
637        let ep1: Option<String> = conn
638            .query_row(
639                "SELECT superseded_by FROM work_episodes WHERE episode_id = ?1",
640                [ep1_id],
641                |r| r.get(0),
642            )
643            .expect("query ep1");
644        assert!(
645            ep1.is_some(),
646            "first episode must be superseded after second"
647        );
648
649        // Only one live episode.
650        let live = load_live_episode(&conn, "/repo/bar")
651            .expect("load")
652            .expect("live episode");
653        assert_eq!(live.task, "task 2");
654        assert!(live.superseded_by.is_none());
655    }
656
657    // ------------------------------------------------------------------
658    // E3: reset_projection wipes work_episodes
659    // ------------------------------------------------------------------
660    #[test]
661    fn reset_projection_clears_episodes() {
662        let conn = make_in_memory_conn();
663        let run_id = kimetsu_core::ids::RunId::new();
664        let payload = EpisodePayload {
665            identity: String::new(),
666            task: "some task".to_string(),
667            repo_root: "/repo/reset".to_string(),
668            ..Default::default()
669        };
670        let event = kimetsu_core::event::Event::new(
671            run_id,
672            "work.episode",
673            serde_json::to_value(&payload).unwrap(),
674        );
675        // Use apply_events to go through the full stack.
676        crate::projector::apply_events(&conn, &[event]).expect("apply_events");
677
678        let count_before: i64 = conn
679            .query_row("SELECT COUNT(*) FROM work_episodes", [], |r| r.get(0))
680            .unwrap();
681        assert_eq!(count_before, 1, "episode must exist before reset");
682
683        // Reset must clear work_episodes.
684        conn.execute_batch(
685            "DELETE FROM runs; DELETE FROM sources; DELETE FROM memories; \
686            DELETE FROM memory_proposals; DELETE FROM memories_fts; DELETE FROM memory_citations; \
687            DELETE FROM memory_conflicts; DELETE FROM memory_edges; DELETE FROM work_episodes;",
688        )
689        .expect("manual reset");
690
691        let count_after: i64 = conn
692            .query_row("SELECT COUNT(*) FROM work_episodes", [], |r| r.get(0))
693            .unwrap();
694        assert_eq!(count_after, 0, "work_episodes must be cleared after reset");
695    }
696
697    // ------------------------------------------------------------------
698    // E4: rebuild_in_place re-projects work.episode events
699    // ------------------------------------------------------------------
700    #[test]
701    fn rebuild_in_place_reprojects_episodes() {
702        let conn = make_in_memory_conn();
703        let run_id = kimetsu_core::ids::RunId::new();
704        let payload = EpisodePayload {
705            identity: String::new(),
706            task: "rebuild test".to_string(),
707            repo_root: "/repo/rebuild".to_string(),
708            ..Default::default()
709        };
710        let event = kimetsu_core::event::Event::new(
711            run_id,
712            "work.episode",
713            serde_json::to_value(&payload).unwrap(),
714        );
715        crate::projector::apply_events(&conn, &[event]).expect("apply_events");
716
717        // Manually wipe the projection.
718        conn.execute("DELETE FROM work_episodes", []).unwrap();
719        let count_wiped: i64 = conn
720            .query_row("SELECT COUNT(*) FROM work_episodes", [], |r| r.get(0))
721            .unwrap();
722        assert_eq!(count_wiped, 0, "work_episodes wiped before rebuild");
723
724        // rebuild_in_place must restore it.
725        let replayed = crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
726        assert!(replayed >= 1, "at least 1 event replayed");
727
728        let ep = load_live_episode(&conn, "/repo/rebuild")
729            .expect("load")
730            .expect("episode restored");
731        assert_eq!(ep.task, "rebuild test");
732    }
733
734    // ------------------------------------------------------------------
735    // E5: render_resume_context returns formatted text
736    // ------------------------------------------------------------------
737    #[test]
738    fn render_resume_context_formats_episode() {
739        let ep = EpisodeRow {
740            identity: String::new(),
741            episode_id: "ep1".to_string(),
742            repo_root: "/r".to_string(),
743            task: "implement feature X".to_string(),
744            summary: "added the core logic".to_string(),
745            open_threads: vec!["write tests".to_string(), "update docs".to_string()],
746            dead_ends: vec!["tried approach A, OOM".to_string()],
747            hypothesis: "batching is the fix".to_string(),
748            note: String::new(),
749            created_at: "2026-01-01T00:00:00Z".to_string(),
750            superseded_by: None,
751        };
752        let text = format_episode_for_context(&ep);
753        assert!(text.contains("implement feature X"), "task in output");
754        assert!(text.contains("added the core logic"), "summary in output");
755        assert!(text.contains("write tests"), "open thread in output");
756        assert!(text.contains("tried approach A"), "dead-end in output");
757        assert!(text.contains("batching is the fix"), "hypothesis in output");
758        // Token budget: rough check that output is under 800 chars (~120 tokens).
759        assert!(
760            text.len() < 800,
761            "render output must be concise (< 800 chars), got {}",
762            text.len()
763        );
764    }
765
766    // ------------------------------------------------------------------
767    // E6: rule_based_episode parses transcript lines
768    // ------------------------------------------------------------------
769    #[test]
770    fn rule_based_episode_parses_transcript() {
771        let view = "user: implement the auth module\nassistant: I need to still need integration tests\n\
772            assistant: cargo build failed — error: linker not found\nassistant: implemented the core auth flow.";
773        let ep = rule_based_episode(view, "/r", "my note");
774        assert_eq!(ep.task, "implement the auth module");
775        assert!(!ep.summary.is_empty(), "summary extracted");
776        assert_eq!(ep.note, "my note");
777        assert_eq!(ep.repo_root, "/r");
778    }
779
780    // ------------------------------------------------------------------
781    // E7: capture_episode end-to-end with a temp project
782    // ------------------------------------------------------------------
783    #[test]
784    fn capture_episode_end_to_end() {
785        let dir = tmp_workspace("capture");
786        git_init_boundary(&dir);
787        user_brain::with_user_brain_disabled(|| {
788            project::init_project(&dir, true).expect("init");
789            let payload = EpisodePayload {
790                task: "e2e task".to_string(),
791                summary: "e2e summary".to_string(),
792                repo_root: dir.to_string_lossy().to_string(),
793                ..Default::default()
794            };
795            let id = capture_episode(&dir, payload).expect("capture_episode");
796            assert!(!id.is_empty(), "episode_id returned");
797
798            let ep = load_live_episode_for_workspace(&dir)
799                .expect("load")
800                .expect("live episode");
801            assert_eq!(ep.task, "e2e task");
802        });
803        std::fs::remove_dir_all(dir).ok();
804    }
805
806    // ------------------------------------------------------------------
807    // E8: 1.7 light — lesson_from edges inserted for memory_ids in payload
808    // ------------------------------------------------------------------
809    #[test]
810    fn episode_inserts_lesson_from_edges() {
811        let conn = make_in_memory_conn();
812        let run_id = kimetsu_core::ids::RunId::new();
813        let payload = EpisodePayload {
814            identity: String::new(),
815            task: "edge test".to_string(),
816            repo_root: "/repo/edges".to_string(),
817            memory_ids: vec!["mem-abc".to_string(), "mem-xyz".to_string()],
818            ..Default::default()
819        };
820        let event = kimetsu_core::event::Event::new(
821            run_id,
822            "work.episode",
823            serde_json::to_value(&payload).unwrap(),
824        );
825        let event_id_str = event.event_id.to_string();
826        crate::projector::apply_events(&conn, std::slice::from_ref(&event)).expect("apply_events");
827
828        let edge_count: i64 = conn
829            .query_row(
830                "SELECT COUNT(*) FROM memory_edges WHERE src_id = ?1 AND edge_type = 'lesson_from'",
831                [event_id_str],
832                |r| r.get(0),
833            )
834            .expect("query edges");
835        assert_eq!(edge_count, 2, "two lesson_from edges inserted");
836    }
837}