1use edda_index::{fetch_store_line, read_index_tail, IndexRecordV1};
2use edda_ledger::view::DecisionView;
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::path::Path;
6
7const DEFAULT_INDEX_TAIL_LINES: usize = 5000;
8const DEFAULT_INDEX_TAIL_MAX_BYTES: u64 = 8 * 1024 * 1024; const DEFAULT_PACK_TURNS: usize = 12;
10const DEFAULT_PACK_BUDGET_CHARS: usize = 12000;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Turn {
16 pub user_uuid: String,
17 pub assistant_uuid: String,
18 pub user_text: String,
19 pub assistant_texts: Vec<String>,
20 pub tool_uses: Vec<ToolUse>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ToolUse {
25 pub id: Option<String>,
26 pub name: String,
27 pub command: Option<String>,
28 pub description: Option<String>,
29 pub file_path: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackMetadata {
34 pub project_id: String,
35 pub session_id: String,
36 pub git_branch: String,
37 pub turn_count: usize,
38 pub budget_chars: usize,
39}
40
41pub fn build_turns(
45 project_dir: &Path,
46 session_id: &str,
47 max_turns: usize,
48) -> anyhow::Result<Vec<Turn>> {
49 let tail_lines: usize = std::env::var("EDDA_INDEX_TAIL_LINES")
50 .ok()
51 .and_then(|v| v.parse().ok())
52 .unwrap_or(DEFAULT_INDEX_TAIL_LINES);
53 let tail_bytes: u64 = std::env::var("EDDA_INDEX_TAIL_MAX_BYTES")
54 .ok()
55 .and_then(|v| v.parse().ok())
56 .unwrap_or(DEFAULT_INDEX_TAIL_MAX_BYTES);
57 let pack_turns: usize = std::env::var("EDDA_PACK_TURNS")
58 .ok()
59 .and_then(|v| v.parse().ok())
60 .unwrap_or(DEFAULT_PACK_TURNS);
61 let max_turns = max_turns.min(pack_turns);
62
63 let index_path = project_dir
64 .join("index")
65 .join(format!("{session_id}.jsonl"));
66 let records = read_index_tail(&index_path, tail_lines, tail_bytes)?;
67
68 if records.is_empty() {
69 return Ok(vec![]);
70 }
71
72 let by_uuid: HashMap<String, &IndexRecordV1> =
74 records.iter().map(|r| (r.uuid.clone(), r)).collect();
75
76 let assistants: Vec<&IndexRecordV1> = records
78 .iter()
79 .filter(|r| r.record_type == "assistant")
80 .collect();
81
82 let store_path = project_dir
83 .join("transcripts")
84 .join(format!("{session_id}.jsonl"));
85
86 let mut turns = Vec::new();
87 let mut seen_user_uuids = HashSet::new();
88
89 for asst_rec in assistants.iter().rev() {
91 if turns.len() >= max_turns {
92 break;
93 }
94
95 let mut current_parent = asst_rec.parent_uuid.as_deref();
100 let mut chain_tool_uses: Vec<ToolUse> = Vec::new();
101 let mut real_user_uuid = String::new();
102 let mut real_user_text = String::new();
103
104 let mut depth = 0;
105 const MAX_CHAIN_DEPTH: usize = 50;
106
107 while let Some(parent_id) = current_parent {
108 if depth >= MAX_CHAIN_DEPTH {
109 break;
110 }
111 depth += 1;
112
113 let parent_rec = match by_uuid.get(parent_id) {
114 Some(r) => r,
115 None => break,
116 };
117
118 if parent_rec.record_type == "user" {
119 if let Ok(raw) =
121 fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
122 {
123 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
124 let text = extract_user_text(&json);
125 if !text.is_empty() {
126 real_user_uuid = parent_rec.uuid.clone();
127 real_user_text = text;
128 break; }
130 }
131 }
132 current_parent = parent_rec.parent_uuid.as_deref();
134 } else if parent_rec.record_type == "assistant" {
135 if let Ok(raw) =
137 fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
138 {
139 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
140 let (_, tus) = parse_assistant_content(&json);
141 chain_tool_uses.extend(tus);
142 }
143 }
144 current_parent = parent_rec.parent_uuid.as_deref();
145 } else {
146 break; }
148 }
149
150 if real_user_text.is_empty() || real_user_uuid.is_empty() {
151 continue;
152 }
153
154 if !seen_user_uuids.insert(real_user_uuid.clone()) {
156 continue;
157 }
158
159 let asst_raw =
161 match fetch_store_line(&store_path, asst_rec.store_offset, asst_rec.store_len) {
162 Ok(r) => r,
163 Err(_) => continue,
164 };
165 let asst_json: serde_json::Value = match serde_json::from_slice(&asst_raw) {
166 Ok(v) => v,
167 Err(_) => continue,
168 };
169 let (assistant_texts, final_tool_uses) = parse_assistant_content(&asst_json);
170
171 chain_tool_uses.reverse();
173 chain_tool_uses.extend(final_tool_uses);
174
175 turns.push(Turn {
176 user_uuid: real_user_uuid,
177 assistant_uuid: asst_rec.uuid.clone(),
178 user_text: real_user_text,
179 assistant_texts,
180 tool_uses: chain_tool_uses,
181 });
182 }
183
184 Ok(turns)
185}
186
187fn extract_user_text(user_json: &serde_json::Value) -> String {
191 let content = match user_json.get("message").and_then(|m| m.get("content")) {
192 Some(c) => c,
193 None => return String::new(),
194 };
195
196 if let Some(s) = content.as_str() {
198 return s.to_string();
199 }
200
201 if let Some(arr) = content.as_array() {
203 let has_tool_result = arr
205 .iter()
206 .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
207 if has_tool_result {
208 return String::new();
209 }
210
211 let texts: Vec<&str> = arr
213 .iter()
214 .filter_map(|b| {
215 if b.get("type").and_then(|t| t.as_str()) == Some("text") {
216 b.get("text").and_then(|t| t.as_str())
217 } else {
218 None
219 }
220 })
221 .collect();
222 if !texts.is_empty() {
223 return texts.join(" ");
224 }
225 }
226
227 String::new()
228}
229
230fn parse_assistant_content(asst_json: &serde_json::Value) -> (Vec<String>, Vec<ToolUse>) {
231 let mut texts = Vec::new();
232 let mut tool_uses = Vec::new();
233
234 let content = asst_json.get("message").and_then(|m| m.get("content"));
235
236 if let Some(arr) = content.and_then(|c| c.as_array()) {
237 for block in arr {
238 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
239 match block_type {
240 "text" => {
241 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
242 texts.push(text.to_string());
243 }
244 }
245 "tool_use" => {
246 let name = block
247 .get("name")
248 .and_then(|v| v.as_str())
249 .unwrap_or("")
250 .to_string();
251 let id = block.get("id").and_then(|v| v.as_str()).map(|s| s.into());
252 let input = block.get("input");
253 let command = input
254 .and_then(|i| i.get("command"))
255 .and_then(|c| c.as_str())
256 .map(|s| s.into());
257 let description = input
258 .and_then(|i| i.get("description"))
259 .and_then(|d| d.as_str())
260 .map(|s| s.into());
261 let file_path = input
262 .and_then(|i| i.get("file_path"))
263 .and_then(|f| f.as_str())
264 .map(|s| s.into());
265
266 tool_uses.push(ToolUse {
267 id,
268 name,
269 command,
270 description,
271 file_path,
272 });
273 }
274 _ => {}
275 }
276 }
277 } else if let Some(text) = content.and_then(|c| c.as_str()) {
278 texts.push(text.to_string());
279 }
280
281 (texts, tool_uses)
282}
283
284pub fn render_pack(turns: &[Turn], metadata: &PackMetadata, budget_chars: usize) -> String {
288 let budget = if budget_chars == 0 {
289 std::env::var("EDDA_PACK_BUDGET_CHARS")
290 .ok()
291 .and_then(|v| v.parse().ok())
292 .unwrap_or(DEFAULT_PACK_BUDGET_CHARS)
293 } else {
294 budget_chars
295 };
296
297 let mut out = String::new();
298 out.push_str("# edda memory pack (hot)\n\n");
299 out.push_str(&format!("- project_id: {}\n", metadata.project_id));
300 out.push_str(&format!("- session_id: {}\n", metadata.session_id));
301 out.push_str(&format!("- git_branch: {}\n", metadata.git_branch));
302 out.push_str(&format!("- turns: {}\n\n", turns.len()));
303 out.push_str("## Recent Turns (deterministic)\n\n");
304
305 for (i, turn) in turns.iter().enumerate() {
307 let mut section = String::new();
308 let user_preview = truncate_str(&turn.user_text, 200);
309 section.push_str(&format!("### Turn {} (newest first)\n", i + 1));
310 section.push_str(&format!("- User: {user_preview}\n"));
311
312 for tu in &turn.tool_uses {
313 let cmd_str = tu
314 .command
315 .as_deref()
316 .map(|c| format!(" `{}`", truncate_str(c, 80)))
317 .unwrap_or_default();
318 let desc_str = tu
319 .description
320 .as_deref()
321 .map(|d| format!(" ({})", truncate_str(d, 60)))
322 .unwrap_or_default();
323 let file_str = tu
324 .file_path
325 .as_deref()
326 .map(|f| format!(" file={f}"))
327 .unwrap_or_default();
328 section.push_str(&format!(
329 " - ToolUse: {}{}{}{}\n",
330 tu.name, cmd_str, desc_str, file_str
331 ));
332 }
333
334 for text in &turn.assistant_texts {
335 let preview = truncate_str(text, 300);
336 section.push_str(&format!(" - Assistant: {preview}\n"));
337 }
338 section.push('\n');
339
340 if out.len() + section.len() > budget {
341 out.push_str(&format!(
342 "... ({} more turns truncated by budget)\n",
343 turns.len() - i
344 ));
345 break;
346 }
347 out.push_str(§ion);
348 }
349
350 out
351}
352
353fn truncate_str(s: &str, max: usize) -> String {
354 if s.len() <= max {
355 s.replace('\n', " ")
356 } else {
357 let mut end = max;
359 while end > 0 && !s.is_char_boundary(end) {
360 end -= 1;
361 }
362 format!("{}...", s[..end].replace('\n', " "))
363 }
364}
365
366pub fn write_pack(project_dir: &Path, pack_md: &str, meta: &PackMetadata) -> anyhow::Result<()> {
368 let packs_dir = project_dir.join("packs");
369 std::fs::create_dir_all(&packs_dir)?;
370
371 edda_store::write_atomic(&packs_dir.join("hot.md"), pack_md.as_bytes())?;
372
373 let meta_json = serde_json::to_string_pretty(meta)?;
374 edda_store::write_atomic(&packs_dir.join("hot.meta.json"), meta_json.as_bytes())?;
375
376 Ok(())
377}
378
379const DEFAULT_DOCTRINE_FILE: &str = ".havamal-pack.md";
382
383pub fn read_doctrine_pack(repo_root: &Path, budget: usize) -> Option<String> {
399 let path = match std::env::var("EDDA_DOCTRINE_PATH") {
400 Ok(p) if !p.trim().is_empty() => {
401 let pb = std::path::PathBuf::from(p.trim());
402 if pb.is_absolute() {
403 pb
404 } else {
405 repo_root.join(pb)
406 }
407 }
408 _ => repo_root.join(DEFAULT_DOCTRINE_FILE),
409 };
410
411 let raw = std::fs::read_to_string(&path).ok()?;
412 let trimmed = raw.trim();
413 if trimmed.is_empty() {
414 return None;
415 }
416
417 let mut body = trimmed.to_string();
418 if body.len() > budget {
419 let mut end = budget;
420 while end > 0 && !body.is_char_boundary(end) {
421 end -= 1;
422 }
423 body.truncate(end);
424 body.push_str("\n<!-- doctrine truncated by EDDA_DOCTRINE_BUDGET_CHARS -->");
425 }
426
427 Some(format!("## Doctrine (judgment layer)\n\n{body}"))
428}
429
430#[derive(Debug, Clone)]
434pub struct DecisionPack {
435 pub groups: Vec<DecisionGroup>,
437 pub total: usize,
439 pub branch: String,
441}
442
443#[derive(Debug, Clone)]
445pub struct DecisionGroup {
446 pub domain: String,
448 pub decisions: Vec<DecisionSummary>,
450}
451
452#[derive(Debug, Clone)]
454pub struct DecisionSummary {
455 pub key: String,
456 pub value: String,
457 pub reason: String,
458 pub status: String,
459 pub authority: String,
460 pub reversibility: String,
461 pub affected_paths: Vec<String>,
462}
463
464impl From<&DecisionView> for DecisionSummary {
465 fn from(v: &DecisionView) -> Self {
466 Self {
467 key: v.key.clone(),
468 value: v.value.clone(),
469 reason: v.reason.clone(),
470 status: v.status.clone(),
471 authority: v.authority.clone(),
472 reversibility: v.reversibility.clone(),
473 affected_paths: v.affected_paths.clone(),
474 }
475 }
476}
477
478pub fn build_decision_pack(repo_root: &Path, branch: &str, max_items: usize) -> DecisionPack {
485 let views: Vec<DecisionView> = match edda_ledger::Ledger::open(repo_root) {
486 Ok(ledger) => ledger
487 .active_decisions_limited(None, None, None, None, max_items)
488 .unwrap_or_default(),
489 Err(_) => Vec::new(),
490 };
491
492 if views.is_empty() {
493 return DecisionPack {
494 groups: Vec::new(),
495 total: 0,
496 branch: branch.to_string(),
497 };
498 }
499
500 let mut by_domain: BTreeMap<String, Vec<DecisionSummary>> = BTreeMap::new();
502 let mut count = 0;
503
504 for d in &views {
505 if count >= max_items {
506 break;
507 }
508 by_domain
509 .entry(d.domain.clone())
510 .or_default()
511 .push(DecisionSummary::from(d));
512 count += 1;
513 }
514
515 let groups = by_domain
516 .into_iter()
517 .map(|(domain, mut decisions)| {
518 decisions.sort_by(|a, b| a.key.cmp(&b.key));
519 DecisionGroup { domain, decisions }
520 })
521 .collect();
522
523 DecisionPack {
524 groups,
525 total: count,
526 branch: branch.to_string(),
527 }
528}
529
530pub fn render_decision_pack_md(pack: &DecisionPack) -> String {
534 if pack.total == 0 {
535 return String::new();
536 }
537
538 let mut lines = vec![format!(
539 "## Active Decisions ({} on `{}`)",
540 pack.total, pack.branch
541 )];
542
543 for group in &pack.groups {
544 lines.push(format!("\n### {}", group.domain));
545 for d in &group.decisions {
546 let mut entry = format!("- **`{}={}`**", d.key, d.value);
547 if !d.reason.is_empty() {
548 entry.push_str(&format!(" — {}", d.reason));
549 }
550 if !d.affected_paths.is_empty() {
551 entry.push_str(&format!("\n paths: `{}`", d.affected_paths.join("`, `")));
552 }
553 lines.push(entry);
554 }
555 }
556
557 lines.join("\n")
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn render_pack_basic() {
566 let turns = vec![Turn {
567 user_uuid: "u1".into(),
568 assistant_uuid: "a1".into(),
569 user_text: "How do I sort a list?".into(),
570 assistant_texts: vec!["Use the sort() method.".into()],
571 tool_uses: vec![ToolUse {
572 id: Some("tu1".into()),
573 name: "Bash".into(),
574 command: Some("ls -la".into()),
575 description: Some("List files".into()),
576 file_path: None,
577 }],
578 }];
579
580 let meta = PackMetadata {
581 project_id: "abc123".into(),
582 session_id: "s1".into(),
583 git_branch: "main".into(),
584 turn_count: 1,
585 budget_chars: 12000,
586 };
587
588 let md = render_pack(&turns, &meta, 12000);
589 assert!(md.contains("# edda memory pack (hot)"));
590 assert!(md.contains("How do I sort a list?"));
591 assert!(md.contains("ToolUse: Bash"));
592 assert!(md.contains("Use the sort() method."));
593 }
594
595 #[test]
596 fn render_pack_budget_truncation() {
597 let turns: Vec<Turn> = (0..20)
598 .map(|i| Turn {
599 user_uuid: format!("u{i}"),
600 assistant_uuid: format!("a{i}"),
601 user_text: format!("Question {i} with some extra text padding to fill space"),
602 assistant_texts: vec![format!(
603 "Answer {i} with a reasonably long response text to consume budget"
604 )],
605 tool_uses: vec![],
606 })
607 .collect();
608
609 let meta = PackMetadata {
610 project_id: "abc".into(),
611 session_id: "s1".into(),
612 git_branch: "main".into(),
613 turn_count: 20,
614 budget_chars: 500,
615 };
616
617 let md = render_pack(&turns, &meta, 500);
618 assert!(md.contains("truncated by budget"));
619 assert!(md.len() <= 600); }
621
622 #[test]
623 fn write_pack_creates_files() {
624 let tmp = tempfile::tempdir().unwrap();
625 let meta = PackMetadata {
626 project_id: "test".into(),
627 session_id: "s1".into(),
628 git_branch: "main".into(),
629 turn_count: 0,
630 budget_chars: 12000,
631 };
632
633 write_pack(tmp.path(), "# pack content", &meta).unwrap();
634
635 let hot = tmp.path().join("packs").join("hot.md");
636 assert!(hot.exists());
637 assert_eq!(std::fs::read_to_string(&hot).unwrap(), "# pack content");
638
639 let meta_path = tmp.path().join("packs").join("hot.meta.json");
640 assert!(meta_path.exists());
641 }
642
643 #[test]
646 fn doctrine_pack_missing_returns_none() {
647 let tmp = tempfile::tempdir().unwrap();
648 assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
649 }
650
651 #[test]
652 fn doctrine_pack_reads_default_file() {
653 let tmp = tempfile::tempdir().unwrap();
654 std::fs::write(
655 tmp.path().join(".havamal-pack.md"),
656 "## L1 — MUST STAY TRUE\nClaims never close work.",
657 )
658 .unwrap();
659 let md = read_doctrine_pack(tmp.path(), 4000).unwrap();
660 assert!(md.starts_with("## Doctrine (judgment layer)"));
661 assert!(md.contains("Claims never close work."));
662 }
663
664 #[test]
665 fn doctrine_pack_empty_file_returns_none() {
666 let tmp = tempfile::tempdir().unwrap();
667 std::fs::write(tmp.path().join(".havamal-pack.md"), " \n\n").unwrap();
668 assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
669 }
670
671 #[test]
672 fn doctrine_pack_truncates_at_budget() {
673 let tmp = tempfile::tempdir().unwrap();
674 let long = "x".repeat(500);
675 std::fs::write(tmp.path().join(".havamal-pack.md"), &long).unwrap();
676 let md = read_doctrine_pack(tmp.path(), 100).unwrap();
677 assert!(md.contains("doctrine truncated"));
678 assert!(md.len() < 300);
679 }
680
681 fn make_summary(key: &str, value: &str, reason: &str, paths: Vec<&str>) -> DecisionSummary {
684 DecisionSummary {
685 key: key.to_string(),
686 value: value.to_string(),
687 reason: reason.to_string(),
688 status: "active".to_string(),
689 authority: "human".to_string(),
690 reversibility: "medium".to_string(),
691 affected_paths: paths.into_iter().map(|s| s.to_string()).collect(),
692 }
693 }
694
695 fn make_pack(groups: Vec<(&str, Vec<DecisionSummary>)>, branch: &str) -> DecisionPack {
696 let total: usize = groups.iter().map(|(_, ds)| ds.len()).sum();
697 DecisionPack {
698 groups: groups
699 .into_iter()
700 .map(|(domain, decisions)| DecisionGroup {
701 domain: domain.to_string(),
702 decisions,
703 })
704 .collect(),
705 total,
706 branch: branch.to_string(),
707 }
708 }
709
710 #[test]
711 fn test_empty_pack() {
712 let pack = DecisionPack {
713 groups: Vec::new(),
714 total: 0,
715 branch: "main".to_string(),
716 };
717 let md = render_decision_pack_md(&pack);
718 assert!(md.is_empty());
719 }
720
721 #[test]
722 fn test_full_pack_grouped_by_domain() {
723 let pack = make_pack(
724 vec![
725 (
726 "auth",
727 vec![make_summary("auth.strategy", "JWT", "stateless", vec![])],
728 ),
729 (
730 "db",
731 vec![
732 make_summary("db.engine", "sqlite", "embedded", vec![]),
733 make_summary("db.pool", "r2d2", "connection pooling", vec![]),
734 ],
735 ),
736 (
737 "error",
738 vec![
739 make_summary("error.lib", "thiserror", "typed errors", vec![]),
740 make_summary("error.pattern", "enum", "exhaustive", vec![]),
741 ],
742 ),
743 ],
744 "main",
745 );
746
747 assert_eq!(pack.groups.len(), 3);
748 assert_eq!(pack.total, 5);
749
750 let md = render_decision_pack_md(&pack);
751 assert!(md.contains("## Active Decisions (5 on `main`)"));
752 assert!(md.contains("### auth"));
753 assert!(md.contains("### db"));
754 assert!(md.contains("### error"));
755 }
756
757 #[test]
758 fn test_domain_grouping_order() {
759 let pack = make_pack(
760 vec![
761 ("a_first", vec![make_summary("a_first.x", "1", "r", vec![])]),
762 (
763 "m_middle",
764 vec![make_summary("m_middle.x", "2", "r", vec![])],
765 ),
766 ("z_test", vec![make_summary("z_test.x", "3", "r", vec![])]),
767 ],
768 "main",
769 );
770
771 let md = render_decision_pack_md(&pack);
772 let a_pos = md.find("### a_first").unwrap();
773 let m_pos = md.find("### m_middle").unwrap();
774 let z_pos = md.find("### z_test").unwrap();
775 assert!(a_pos < m_pos);
776 assert!(m_pos < z_pos);
777 }
778
779 #[test]
780 fn test_render_with_paths() {
781 let pack = make_pack(
782 vec![(
783 "db",
784 vec![make_summary(
785 "db.engine",
786 "sqlite",
787 "embedded",
788 vec!["crates/foo/**", "src/**"],
789 )],
790 )],
791 "main",
792 );
793
794 let md = render_decision_pack_md(&pack);
795 assert!(md.contains("paths: `crates/foo/**`, `src/**`"));
796 }
797
798 #[test]
799 fn test_render_without_reason() {
800 let pack = make_pack(
801 vec![("db", vec![make_summary("db.engine", "sqlite", "", vec![])])],
802 "main",
803 );
804
805 let md = render_decision_pack_md(&pack);
806 assert!(md.contains("**`db.engine=sqlite`**"));
807 assert!(!md.contains(" — "));
808 }
809
810 #[test]
811 fn test_build_decision_pack_nonexistent_repo() {
812 let pack = build_decision_pack(Path::new("/nonexistent/path"), "main", 7);
814 assert_eq!(pack.total, 0);
815 assert!(pack.groups.is_empty());
816 assert_eq!(render_decision_pack_md(&pack), "");
817 }
818}