use edda_index::{fetch_store_line, read_index_tail, IndexRecordV1};
use edda_ledger::view::DecisionView;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
const DEFAULT_INDEX_TAIL_LINES: usize = 5000;
const DEFAULT_INDEX_TAIL_MAX_BYTES: u64 = 8 * 1024 * 1024; const DEFAULT_PACK_TURNS: usize = 12;
const DEFAULT_PACK_BUDGET_CHARS: usize = 12000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Turn {
pub user_uuid: String,
pub assistant_uuid: String,
pub user_text: String,
pub assistant_texts: Vec<String>,
pub tool_uses: Vec<ToolUse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolUse {
pub id: Option<String>,
pub name: String,
pub command: Option<String>,
pub description: Option<String>,
pub file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackMetadata {
pub project_id: String,
pub session_id: String,
pub git_branch: String,
pub turn_count: usize,
pub budget_chars: usize,
}
pub fn build_turns(
project_dir: &Path,
session_id: &str,
max_turns: usize,
) -> anyhow::Result<Vec<Turn>> {
let tail_lines: usize = std::env::var("EDDA_INDEX_TAIL_LINES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_INDEX_TAIL_LINES);
let tail_bytes: u64 = std::env::var("EDDA_INDEX_TAIL_MAX_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_INDEX_TAIL_MAX_BYTES);
let pack_turns: usize = std::env::var("EDDA_PACK_TURNS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_PACK_TURNS);
let max_turns = max_turns.min(pack_turns);
let index_path = project_dir
.join("index")
.join(format!("{session_id}.jsonl"));
let records = read_index_tail(&index_path, tail_lines, tail_bytes)?;
if records.is_empty() {
return Ok(vec![]);
}
let by_uuid: HashMap<String, &IndexRecordV1> =
records.iter().map(|r| (r.uuid.clone(), r)).collect();
let assistants: Vec<&IndexRecordV1> = records
.iter()
.filter(|r| r.record_type == "assistant")
.collect();
let store_path = project_dir
.join("transcripts")
.join(format!("{session_id}.jsonl"));
let mut turns = Vec::new();
let mut seen_user_uuids = HashSet::new();
for asst_rec in assistants.iter().rev() {
if turns.len() >= max_turns {
break;
}
let mut current_parent = asst_rec.parent_uuid.as_deref();
let mut chain_tool_uses: Vec<ToolUse> = Vec::new();
let mut real_user_uuid = String::new();
let mut real_user_text = String::new();
let mut depth = 0;
const MAX_CHAIN_DEPTH: usize = 50;
while let Some(parent_id) = current_parent {
if depth >= MAX_CHAIN_DEPTH {
break;
}
depth += 1;
let parent_rec = match by_uuid.get(parent_id) {
Some(r) => r,
None => break,
};
if parent_rec.record_type == "user" {
if let Ok(raw) =
fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
{
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
let text = extract_user_text(&json);
if !text.is_empty() {
real_user_uuid = parent_rec.uuid.clone();
real_user_text = text;
break; }
}
}
current_parent = parent_rec.parent_uuid.as_deref();
} else if parent_rec.record_type == "assistant" {
if let Ok(raw) =
fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
{
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
let (_, tus) = parse_assistant_content(&json);
chain_tool_uses.extend(tus);
}
}
current_parent = parent_rec.parent_uuid.as_deref();
} else {
break; }
}
if real_user_text.is_empty() || real_user_uuid.is_empty() {
continue;
}
if !seen_user_uuids.insert(real_user_uuid.clone()) {
continue;
}
let asst_raw =
match fetch_store_line(&store_path, asst_rec.store_offset, asst_rec.store_len) {
Ok(r) => r,
Err(_) => continue,
};
let asst_json: serde_json::Value = match serde_json::from_slice(&asst_raw) {
Ok(v) => v,
Err(_) => continue,
};
let (assistant_texts, final_tool_uses) = parse_assistant_content(&asst_json);
chain_tool_uses.reverse();
chain_tool_uses.extend(final_tool_uses);
turns.push(Turn {
user_uuid: real_user_uuid,
assistant_uuid: asst_rec.uuid.clone(),
user_text: real_user_text,
assistant_texts,
tool_uses: chain_tool_uses,
});
}
Ok(turns)
}
fn extract_user_text(user_json: &serde_json::Value) -> String {
let content = match user_json.get("message").and_then(|m| m.get("content")) {
Some(c) => c,
None => return String::new(),
};
if let Some(s) = content.as_str() {
return s.to_string();
}
if let Some(arr) = content.as_array() {
let has_tool_result = arr
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
if has_tool_result {
return String::new();
}
let texts: Vec<&str> = arr
.iter()
.filter_map(|b| {
if b.get("type").and_then(|t| t.as_str()) == Some("text") {
b.get("text").and_then(|t| t.as_str())
} else {
None
}
})
.collect();
if !texts.is_empty() {
return texts.join(" ");
}
}
String::new()
}
fn parse_assistant_content(asst_json: &serde_json::Value) -> (Vec<String>, Vec<ToolUse>) {
let mut texts = Vec::new();
let mut tool_uses = Vec::new();
let content = asst_json.get("message").and_then(|m| m.get("content"));
if let Some(arr) = content.and_then(|c| c.as_array()) {
for block in arr {
let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
texts.push(text.to_string());
}
}
"tool_use" => {
let name = block
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let id = block.get("id").and_then(|v| v.as_str()).map(|s| s.into());
let input = block.get("input");
let command = input
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
.map(|s| s.into());
let description = input
.and_then(|i| i.get("description"))
.and_then(|d| d.as_str())
.map(|s| s.into());
let file_path = input
.and_then(|i| i.get("file_path"))
.and_then(|f| f.as_str())
.map(|s| s.into());
tool_uses.push(ToolUse {
id,
name,
command,
description,
file_path,
});
}
_ => {}
}
}
} else if let Some(text) = content.and_then(|c| c.as_str()) {
texts.push(text.to_string());
}
(texts, tool_uses)
}
pub fn render_pack(turns: &[Turn], metadata: &PackMetadata, budget_chars: usize) -> String {
let budget = if budget_chars == 0 {
std::env::var("EDDA_PACK_BUDGET_CHARS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_PACK_BUDGET_CHARS)
} else {
budget_chars
};
let mut out = String::new();
out.push_str("# edda memory pack (hot)\n\n");
out.push_str(&format!("- project_id: {}\n", metadata.project_id));
out.push_str(&format!("- session_id: {}\n", metadata.session_id));
out.push_str(&format!("- git_branch: {}\n", metadata.git_branch));
out.push_str(&format!("- turns: {}\n\n", turns.len()));
out.push_str("## Recent Turns (deterministic)\n\n");
for (i, turn) in turns.iter().enumerate() {
let mut section = String::new();
let user_preview = truncate_str(&turn.user_text, 200);
section.push_str(&format!("### Turn {} (newest first)\n", i + 1));
section.push_str(&format!("- User: {user_preview}\n"));
for tu in &turn.tool_uses {
let cmd_str = tu
.command
.as_deref()
.map(|c| format!(" `{}`", truncate_str(c, 80)))
.unwrap_or_default();
let desc_str = tu
.description
.as_deref()
.map(|d| format!(" ({})", truncate_str(d, 60)))
.unwrap_or_default();
let file_str = tu
.file_path
.as_deref()
.map(|f| format!(" file={f}"))
.unwrap_or_default();
section.push_str(&format!(
" - ToolUse: {}{}{}{}\n",
tu.name, cmd_str, desc_str, file_str
));
}
for text in &turn.assistant_texts {
let preview = truncate_str(text, 300);
section.push_str(&format!(" - Assistant: {preview}\n"));
}
section.push('\n');
if out.len() + section.len() > budget {
out.push_str(&format!(
"... ({} more turns truncated by budget)\n",
turns.len() - i
));
break;
}
out.push_str(§ion);
}
out
}
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
s.replace('\n', " ")
} else {
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", s[..end].replace('\n', " "))
}
}
pub fn write_pack(project_dir: &Path, pack_md: &str, meta: &PackMetadata) -> anyhow::Result<()> {
let packs_dir = project_dir.join("packs");
std::fs::create_dir_all(&packs_dir)?;
edda_store::write_atomic(&packs_dir.join("hot.md"), pack_md.as_bytes())?;
let meta_json = serde_json::to_string_pretty(meta)?;
edda_store::write_atomic(&packs_dir.join("hot.meta.json"), meta_json.as_bytes())?;
Ok(())
}
const DEFAULT_DOCTRINE_FILE: &str = ".havamal-pack.md";
pub fn read_doctrine_pack(repo_root: &Path, budget: usize) -> Option<String> {
let path = match std::env::var("EDDA_DOCTRINE_PATH") {
Ok(p) if !p.trim().is_empty() => {
let pb = std::path::PathBuf::from(p.trim());
if pb.is_absolute() {
pb
} else {
repo_root.join(pb)
}
}
_ => repo_root.join(DEFAULT_DOCTRINE_FILE),
};
let raw = std::fs::read_to_string(&path).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut body = trimmed.to_string();
if body.len() > budget {
let mut end = budget;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
body.truncate(end);
body.push_str("\n<!-- doctrine truncated by EDDA_DOCTRINE_BUDGET_CHARS -->");
}
Some(format!("## Doctrine (judgment layer)\n\n{body}"))
}
#[derive(Debug, Clone)]
pub struct DecisionPack {
pub groups: Vec<DecisionGroup>,
pub total: usize,
pub branch: String,
}
#[derive(Debug, Clone)]
pub struct DecisionGroup {
pub domain: String,
pub decisions: Vec<DecisionSummary>,
}
#[derive(Debug, Clone)]
pub struct DecisionSummary {
pub key: String,
pub value: String,
pub reason: String,
pub status: String,
pub authority: String,
pub reversibility: String,
pub affected_paths: Vec<String>,
}
impl From<&DecisionView> for DecisionSummary {
fn from(v: &DecisionView) -> Self {
Self {
key: v.key.clone(),
value: v.value.clone(),
reason: v.reason.clone(),
status: v.status.clone(),
authority: v.authority.clone(),
reversibility: v.reversibility.clone(),
affected_paths: v.affected_paths.clone(),
}
}
}
pub fn build_decision_pack(repo_root: &Path, branch: &str, max_items: usize) -> DecisionPack {
let views: Vec<DecisionView> = match edda_ledger::Ledger::open(repo_root) {
Ok(ledger) => ledger
.active_decisions_limited(None, None, None, None, max_items)
.unwrap_or_default(),
Err(_) => Vec::new(),
};
if views.is_empty() {
return DecisionPack {
groups: Vec::new(),
total: 0,
branch: branch.to_string(),
};
}
let mut by_domain: BTreeMap<String, Vec<DecisionSummary>> = BTreeMap::new();
let mut count = 0;
for d in &views {
if count >= max_items {
break;
}
by_domain
.entry(d.domain.clone())
.or_default()
.push(DecisionSummary::from(d));
count += 1;
}
let groups = by_domain
.into_iter()
.map(|(domain, mut decisions)| {
decisions.sort_by(|a, b| a.key.cmp(&b.key));
DecisionGroup { domain, decisions }
})
.collect();
DecisionPack {
groups,
total: count,
branch: branch.to_string(),
}
}
pub fn render_decision_pack_md(pack: &DecisionPack) -> String {
if pack.total == 0 {
return String::new();
}
let mut lines = vec![format!(
"## Active Decisions ({} on `{}`)",
pack.total, pack.branch
)];
for group in &pack.groups {
lines.push(format!("\n### {}", group.domain));
for d in &group.decisions {
let mut entry = format!("- **`{}={}`**", d.key, d.value);
if !d.reason.is_empty() {
entry.push_str(&format!(" — {}", d.reason));
}
if !d.affected_paths.is_empty() {
entry.push_str(&format!("\n paths: `{}`", d.affected_paths.join("`, `")));
}
lines.push(entry);
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_pack_basic() {
let turns = vec![Turn {
user_uuid: "u1".into(),
assistant_uuid: "a1".into(),
user_text: "How do I sort a list?".into(),
assistant_texts: vec!["Use the sort() method.".into()],
tool_uses: vec![ToolUse {
id: Some("tu1".into()),
name: "Bash".into(),
command: Some("ls -la".into()),
description: Some("List files".into()),
file_path: None,
}],
}];
let meta = PackMetadata {
project_id: "abc123".into(),
session_id: "s1".into(),
git_branch: "main".into(),
turn_count: 1,
budget_chars: 12000,
};
let md = render_pack(&turns, &meta, 12000);
assert!(md.contains("# edda memory pack (hot)"));
assert!(md.contains("How do I sort a list?"));
assert!(md.contains("ToolUse: Bash"));
assert!(md.contains("Use the sort() method."));
}
#[test]
fn render_pack_budget_truncation() {
let turns: Vec<Turn> = (0..20)
.map(|i| Turn {
user_uuid: format!("u{i}"),
assistant_uuid: format!("a{i}"),
user_text: format!("Question {i} with some extra text padding to fill space"),
assistant_texts: vec![format!(
"Answer {i} with a reasonably long response text to consume budget"
)],
tool_uses: vec![],
})
.collect();
let meta = PackMetadata {
project_id: "abc".into(),
session_id: "s1".into(),
git_branch: "main".into(),
turn_count: 20,
budget_chars: 500,
};
let md = render_pack(&turns, &meta, 500);
assert!(md.contains("truncated by budget"));
assert!(md.len() <= 600); }
#[test]
fn write_pack_creates_files() {
let tmp = tempfile::tempdir().unwrap();
let meta = PackMetadata {
project_id: "test".into(),
session_id: "s1".into(),
git_branch: "main".into(),
turn_count: 0,
budget_chars: 12000,
};
write_pack(tmp.path(), "# pack content", &meta).unwrap();
let hot = tmp.path().join("packs").join("hot.md");
assert!(hot.exists());
assert_eq!(std::fs::read_to_string(&hot).unwrap(), "# pack content");
let meta_path = tmp.path().join("packs").join("hot.meta.json");
assert!(meta_path.exists());
}
#[test]
fn doctrine_pack_missing_returns_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
}
#[test]
fn doctrine_pack_reads_default_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(".havamal-pack.md"),
"## L1 — MUST STAY TRUE\nClaims never close work.",
)
.unwrap();
let md = read_doctrine_pack(tmp.path(), 4000).unwrap();
assert!(md.starts_with("## Doctrine (judgment layer)"));
assert!(md.contains("Claims never close work."));
}
#[test]
fn doctrine_pack_empty_file_returns_none() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".havamal-pack.md"), " \n\n").unwrap();
assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
}
#[test]
fn doctrine_pack_truncates_at_budget() {
let tmp = tempfile::tempdir().unwrap();
let long = "x".repeat(500);
std::fs::write(tmp.path().join(".havamal-pack.md"), &long).unwrap();
let md = read_doctrine_pack(tmp.path(), 100).unwrap();
assert!(md.contains("doctrine truncated"));
assert!(md.len() < 300);
}
fn make_summary(key: &str, value: &str, reason: &str, paths: Vec<&str>) -> DecisionSummary {
DecisionSummary {
key: key.to_string(),
value: value.to_string(),
reason: reason.to_string(),
status: "active".to_string(),
authority: "human".to_string(),
reversibility: "medium".to_string(),
affected_paths: paths.into_iter().map(|s| s.to_string()).collect(),
}
}
fn make_pack(groups: Vec<(&str, Vec<DecisionSummary>)>, branch: &str) -> DecisionPack {
let total: usize = groups.iter().map(|(_, ds)| ds.len()).sum();
DecisionPack {
groups: groups
.into_iter()
.map(|(domain, decisions)| DecisionGroup {
domain: domain.to_string(),
decisions,
})
.collect(),
total,
branch: branch.to_string(),
}
}
#[test]
fn test_empty_pack() {
let pack = DecisionPack {
groups: Vec::new(),
total: 0,
branch: "main".to_string(),
};
let md = render_decision_pack_md(&pack);
assert!(md.is_empty());
}
#[test]
fn test_full_pack_grouped_by_domain() {
let pack = make_pack(
vec![
(
"auth",
vec![make_summary("auth.strategy", "JWT", "stateless", vec![])],
),
(
"db",
vec![
make_summary("db.engine", "sqlite", "embedded", vec![]),
make_summary("db.pool", "r2d2", "connection pooling", vec![]),
],
),
(
"error",
vec![
make_summary("error.lib", "thiserror", "typed errors", vec![]),
make_summary("error.pattern", "enum", "exhaustive", vec![]),
],
),
],
"main",
);
assert_eq!(pack.groups.len(), 3);
assert_eq!(pack.total, 5);
let md = render_decision_pack_md(&pack);
assert!(md.contains("## Active Decisions (5 on `main`)"));
assert!(md.contains("### auth"));
assert!(md.contains("### db"));
assert!(md.contains("### error"));
}
#[test]
fn test_domain_grouping_order() {
let pack = make_pack(
vec![
("a_first", vec![make_summary("a_first.x", "1", "r", vec![])]),
(
"m_middle",
vec![make_summary("m_middle.x", "2", "r", vec![])],
),
("z_test", vec![make_summary("z_test.x", "3", "r", vec![])]),
],
"main",
);
let md = render_decision_pack_md(&pack);
let a_pos = md.find("### a_first").unwrap();
let m_pos = md.find("### m_middle").unwrap();
let z_pos = md.find("### z_test").unwrap();
assert!(a_pos < m_pos);
assert!(m_pos < z_pos);
}
#[test]
fn test_render_with_paths() {
let pack = make_pack(
vec![(
"db",
vec![make_summary(
"db.engine",
"sqlite",
"embedded",
vec!["crates/foo/**", "src/**"],
)],
)],
"main",
);
let md = render_decision_pack_md(&pack);
assert!(md.contains("paths: `crates/foo/**`, `src/**`"));
}
#[test]
fn test_render_without_reason() {
let pack = make_pack(
vec![("db", vec![make_summary("db.engine", "sqlite", "", vec![])])],
"main",
);
let md = render_decision_pack_md(&pack);
assert!(md.contains("**`db.engine=sqlite`**"));
assert!(!md.contains(" — "));
}
#[test]
fn test_build_decision_pack_nonexistent_repo() {
let pack = build_decision_pack(Path::new("/nonexistent/path"), "main", 7);
assert_eq!(pack.total, 0);
assert!(pack.groups.is_empty());
assert_eq!(render_decision_pack_md(&pack), "");
}
}