1use 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
31pub 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
51#[serde(default)]
52pub struct EpisodePayload {
53 pub task: String,
55 #[serde(default)]
57 pub identity: String,
58 pub summary: String,
60 pub open_threads: Vec<String>,
62 pub dead_ends: Vec<String>,
64 pub hypothesis: String,
66 #[serde(default, skip_serializing_if = "str::is_empty")]
68 pub note: String,
69 pub repo_root: String,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub memory_ids: Vec<String>,
74}
75
76#[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 pub superseded_by: Option<String>,
92}
93
94pub(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
130pub(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 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 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 for memory_id in &payload.memory_ids {
212 if memory_id.is_empty() {
213 continue;
214 }
215 projector::insert_memory_edge(conn, &episode_id, memory_id, "lesson_from", &ts)?;
218 }
219
220 Ok(())
221}
222
223type EpisodeDbRow = (
229 String,
230 String,
231 String,
232 String,
233 String,
234 String,
235 String,
236 String,
237 String,
238 Option<String>,
239);
240
241pub fn load_live_episode(conn: &Connection, repo_root: &str) -> KimetsuResult<Option<EpisodeRow>> {
244 load_live_episode_scoped(conn, repo_root, "")
245}
246
247pub 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
317pub 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
351fn 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
405pub 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 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 projector::apply_events(&conn, &[event])?;
432
433 Ok(event_id)
434}
435
436pub fn rule_based_episode(transcript_view: &str, repo_root: &str, note: &str) -> EpisodePayload {
450 let lines: Vec<&str> = transcript_view.lines().collect();
451
452 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 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 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 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
511pub 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#[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 #[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 #[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 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 ev2.ts = ev1.ts + time::Duration::seconds(1);
633
634 project_work_episode(&conn, &ev2).expect("project ev2");
635
636 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 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 #[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 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 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 #[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 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 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 #[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 assert!(
760 text.len() < 800,
761 "render output must be concise (< 800 chars), got {}",
762 text.len()
763 );
764 }
765
766 #[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 #[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 #[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}