|
| 1 | +//! Auto-memory hook: extract session memories from transcript (Stop hook). |
| 2 | +
|
| 3 | +use std::collections::{HashMap, HashSet}; |
| 4 | +use std::env; |
| 5 | +use std::fs; |
| 6 | +use std::path::{Path, PathBuf}; |
| 7 | +use std::process::Command; |
| 8 | + |
| 9 | +use regex::Regex; |
| 10 | +use serde_json::Value; |
| 11 | + |
| 12 | +use super::common::{get_flow_dir, read_stdin_json}; |
| 13 | + |
| 14 | +pub fn cmd_auto_memory() { |
| 15 | + let hook_input = read_stdin_json(); |
| 16 | + |
| 17 | + let flow_dir = get_flow_dir(); |
| 18 | + if !flow_dir.exists() { |
| 19 | + std::process::exit(0); |
| 20 | + } |
| 21 | + if is_auto_memory_disabled(&flow_dir) { |
| 22 | + std::process::exit(0); |
| 23 | + } |
| 24 | + |
| 25 | + // Auto-init memory dir if missing |
| 26 | + let memory_dir = flow_dir.join("memory"); |
| 27 | + if !memory_dir.exists() { |
| 28 | + let _ = fs::create_dir_all(&memory_dir); |
| 29 | + for (fname, header) in [ |
| 30 | + ("pitfalls.md", "# Pitfalls\n\n<!-- Auto-captured by auto-memory hook -->\n"), |
| 31 | + ("conventions.md", "# Conventions\n\n<!-- Auto-captured by auto-memory hook -->\n"), |
| 32 | + ("decisions.md", "# Decisions\n\n<!-- Auto-captured by auto-memory hook -->\n"), |
| 33 | + ] { |
| 34 | + let _ = fs::write(memory_dir.join(fname), header); |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + let text = read_transcript(&hook_input); |
| 39 | + if text.len() < 200 { |
| 40 | + std::process::exit(0); |
| 41 | + } |
| 42 | + |
| 43 | + // Default: AI summarization via gemini; fallback: pattern matching |
| 44 | + let (memories, method) = { |
| 45 | + let gemini_result = summarize_with_gemini(&text); |
| 46 | + if !gemini_result.is_empty() { |
| 47 | + (gemini_result, "gemini") |
| 48 | + } else { |
| 49 | + (extract_by_pattern(&text), "pattern") |
| 50 | + } |
| 51 | + }; |
| 52 | + |
| 53 | + let saved = save_memories(&memories, &flow_dir); |
| 54 | + |
| 55 | + if saved > 0 { |
| 56 | + eprintln!("auto-memory: captured {saved} entries via {method}"); |
| 57 | + } |
| 58 | + |
| 59 | + std::process::exit(0); |
| 60 | +} |
| 61 | + |
| 62 | +/// Check if auto-memory is explicitly disabled in config. |
| 63 | +/// Default is ON -- only returns true if memory.auto is explicitly false. |
| 64 | +fn is_auto_memory_disabled(flow_dir: &Path) -> bool { |
| 65 | + let config_path = flow_dir.join("config.json"); |
| 66 | + if !config_path.exists() { |
| 67 | + return false; |
| 68 | + } |
| 69 | + let Ok(content) = fs::read_to_string(&config_path) else { |
| 70 | + return false; |
| 71 | + }; |
| 72 | + let Ok(config) = serde_json::from_str::<Value>(&content) else { |
| 73 | + return false; |
| 74 | + }; |
| 75 | + if let Some(mem) = config.get("memory") { |
| 76 | + if let Some(auto_val) = mem.get("auto") { |
| 77 | + return auto_val == &Value::Bool(false); |
| 78 | + } |
| 79 | + } |
| 80 | + false |
| 81 | +} |
| 82 | + |
| 83 | +/// Read assistant text from transcript JSONL file. |
| 84 | +fn read_transcript(hook_input: &Value) -> String { |
| 85 | + let transcript_path = hook_input |
| 86 | + .get("transcript_path") |
| 87 | + .and_then(|v| v.as_str()) |
| 88 | + .unwrap_or(""); |
| 89 | + if transcript_path.is_empty() { |
| 90 | + return String::new(); |
| 91 | + } |
| 92 | + let path = Path::new(transcript_path); |
| 93 | + if !path.exists() { |
| 94 | + return String::new(); |
| 95 | + } |
| 96 | + |
| 97 | + let content = match fs::read_to_string(path) { |
| 98 | + Ok(c) => c, |
| 99 | + Err(_) => return String::new(), |
| 100 | + }; |
| 101 | + |
| 102 | + let mut texts = Vec::new(); |
| 103 | + for line in content.lines() { |
| 104 | + let line = line.trim(); |
| 105 | + if line.is_empty() { |
| 106 | + continue; |
| 107 | + } |
| 108 | + let Ok(ev) = serde_json::from_str::<Value>(line) else { |
| 109 | + continue; |
| 110 | + }; |
| 111 | + if ev.get("role").and_then(|v| v.as_str()) != Some("assistant") { |
| 112 | + continue; |
| 113 | + } |
| 114 | + if let Some(blocks) = ev |
| 115 | + .get("message") |
| 116 | + .and_then(|m| m.get("content")) |
| 117 | + .and_then(|c| c.as_array()) |
| 118 | + { |
| 119 | + for blk in blocks { |
| 120 | + if blk.get("type").and_then(|v| v.as_str()) == Some("text") { |
| 121 | + if let Some(t) = blk.get("text").and_then(|v| v.as_str()) { |
| 122 | + texts.push(t.to_string()); |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + texts.join("\n") |
| 130 | +} |
| 131 | + |
| 132 | +// ── AI summarization ────────────────────────────────────────────────── |
| 133 | + |
| 134 | +const SUMMARIZE_PROMPT: &str = r#"Analyze this AI coding session transcript and extract the most important learnings. |
| 135 | +
|
| 136 | +Output ONLY a JSON array of objects, each with "type" and "content" fields. No markdown, no explanation. |
| 137 | +
|
| 138 | +Types: |
| 139 | +- "pitfall": things that went wrong, bugs found, things to avoid |
| 140 | +- "convention": project patterns discovered, coding conventions learned |
| 141 | +- "decision": architectural or design decisions made and why |
| 142 | +
|
| 143 | +Rules: |
| 144 | +- Max 5 entries (only the most important) |
| 145 | +- Each "content" should be one concise sentence (under 150 chars) |
| 146 | +- Skip trivial things (file reads, git commands, routine operations) |
| 147 | +- Focus on what would help a FUTURE session avoid mistakes or follow decisions |
| 148 | +- If nothing important happened, return empty array: [] |
| 149 | +
|
| 150 | +Example output: |
| 151 | +[{"type":"pitfall","content":"Django select_related needed on UserProfile queries to avoid N+1"},{"type":"decision","content":"Chose per-epic review mode over per-task for faster Ralph runs"}] |
| 152 | +
|
| 153 | +Transcript: |
| 154 | +"#; |
| 155 | + |
| 156 | +const VALID_MEMORY_TYPES: [&str; 3] = ["pitfall", "convention", "decision"]; |
| 157 | + |
| 158 | +fn summarize_with_gemini(text: &str) -> Vec<Memory> { |
| 159 | + // Truncate to ~50k chars to fit context |
| 160 | + let truncated = if text.len() > 50000 { |
| 161 | + format!( |
| 162 | + "{}\n...[truncated]...\n{}", |
| 163 | + &text[..25000], |
| 164 | + &text[text.len() - 25000..] |
| 165 | + ) |
| 166 | + } else { |
| 167 | + text.to_string() |
| 168 | + }; |
| 169 | + |
| 170 | + let prompt = format!("{SUMMARIZE_PROMPT}{truncated}"); |
| 171 | + |
| 172 | + let result = Command::new("gemini") |
| 173 | + .args(["-p", &prompt]) |
| 174 | + .output(); |
| 175 | + |
| 176 | + let output = match result { |
| 177 | + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), |
| 178 | + _ => return Vec::new(), |
| 179 | + }; |
| 180 | + |
| 181 | + // Extract JSON array from output (may have surrounding text) |
| 182 | + let re = Regex::new(r"(?s)\[.*\]").unwrap(); |
| 183 | + let json_str = match re.find(&output) { |
| 184 | + Some(m) => m.as_str(), |
| 185 | + None => return Vec::new(), |
| 186 | + }; |
| 187 | + |
| 188 | + let arr: Vec<Value> = match serde_json::from_str(json_str) { |
| 189 | + Ok(a) => a, |
| 190 | + Err(_) => return Vec::new(), |
| 191 | + }; |
| 192 | + |
| 193 | + let mut valid = Vec::new(); |
| 194 | + for m in &arr { |
| 195 | + if let (Some(t), Some(c)) = ( |
| 196 | + m.get("type").and_then(|v| v.as_str()), |
| 197 | + m.get("content").and_then(|v| v.as_str()), |
| 198 | + ) { |
| 199 | + if VALID_MEMORY_TYPES.contains(&t) { |
| 200 | + let content = if c.len() > 200 { &c[..200] } else { c }; |
| 201 | + valid.push(Memory { |
| 202 | + mem_type: t.to_string(), |
| 203 | + content: content.to_string(), |
| 204 | + }); |
| 205 | + } |
| 206 | + } |
| 207 | + if valid.len() >= 5 { |
| 208 | + break; |
| 209 | + } |
| 210 | + } |
| 211 | + valid |
| 212 | +} |
| 213 | + |
| 214 | +// ── Pattern matching fallback ───────────────────────────────────────── |
| 215 | + |
| 216 | +fn extract_by_pattern(text: &str) -> Vec<Memory> { |
| 217 | + if text.len() < 100 { |
| 218 | + return Vec::new(); |
| 219 | + } |
| 220 | + |
| 221 | + let patterns: Vec<(Regex, &str)> = vec![ |
| 222 | + ( |
| 223 | + Regex::new(r"(?i)(?:decided|chose|chose to|went with|using .+ instead of|switched to)\s+(.{20,150})").unwrap(), |
| 224 | + "decision", |
| 225 | + ), |
| 226 | + ( |
| 227 | + Regex::new(r"(?i)(?:found that|discovered|turns out|learned that|realized)\s+(.{20,150})").unwrap(), |
| 228 | + "convention", |
| 229 | + ), |
| 230 | + ( |
| 231 | + Regex::new(r#"(?i)(?:don'?t|avoid|careful with|gotcha|warning|bug:|issue:|never)\s+(.{20,150})"#).unwrap(), |
| 232 | + "pitfall", |
| 233 | + ), |
| 234 | + ( |
| 235 | + Regex::new(r"(?i)(?:fixed by|solved by|the (?:issue|problem|bug) was|root cause)\s+(.{20,150})").unwrap(), |
| 236 | + "pitfall", |
| 237 | + ), |
| 238 | + ]; |
| 239 | + |
| 240 | + let mut memories = Vec::new(); |
| 241 | + let mut seen = HashSet::new(); |
| 242 | + let ws_re = Regex::new(r"\s+").unwrap(); |
| 243 | + |
| 244 | + for line in text.lines() { |
| 245 | + let trimmed = line.trim(); |
| 246 | + if trimmed.len() < 20 { |
| 247 | + continue; |
| 248 | + } |
| 249 | + let line_lower = trimmed.to_lowercase(); |
| 250 | + for (pattern, mem_type) in &patterns { |
| 251 | + if let Some(m) = pattern.find(&line_lower) { |
| 252 | + let content_raw = m.as_str().trim(); |
| 253 | + // Collapse whitespace |
| 254 | + let collapsed = ws_re.replace_all(content_raw, " "); |
| 255 | + let key: String = collapsed.chars().take(50).collect(); |
| 256 | + if !seen.contains(&key) { |
| 257 | + seen.insert(key); |
| 258 | + let content: String = trimmed.chars().take(200).collect(); |
| 259 | + memories.push(Memory { |
| 260 | + mem_type: mem_type.to_string(), |
| 261 | + content, |
| 262 | + }); |
| 263 | + } |
| 264 | + } |
| 265 | + if memories.len() >= 5 { |
| 266 | + break; |
| 267 | + } |
| 268 | + } |
| 269 | + if memories.len() >= 5 { |
| 270 | + break; |
| 271 | + } |
| 272 | + } |
| 273 | + |
| 274 | + memories |
| 275 | +} |
| 276 | + |
| 277 | +// ── Save memories ───────────────────────────────────────────────────── |
| 278 | + |
| 279 | +struct Memory { |
| 280 | + mem_type: String, |
| 281 | + content: String, |
| 282 | +} |
| 283 | + |
| 284 | +fn save_memories(memories: &[Memory], flow_dir: &Path) -> usize { |
| 285 | + if memories.is_empty() { |
| 286 | + return 0; |
| 287 | + } |
| 288 | + |
| 289 | + // Find flowctl binary |
| 290 | + let flowctl = find_flowctl(flow_dir); |
| 291 | + |
| 292 | + match flowctl { |
| 293 | + Some(bin) => save_via_flowctl(memories, &bin), |
| 294 | + None => save_memories_direct(memories, flow_dir), |
| 295 | + } |
| 296 | +} |
| 297 | + |
| 298 | +fn find_flowctl(flow_dir: &Path) -> Option<PathBuf> { |
| 299 | + let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); |
| 300 | + let candidates = [ |
| 301 | + flow_dir.join("bin").join("flowctl"), |
| 302 | + cwd.join("scripts").join("ralph").join("flowctl"), |
| 303 | + cwd.join("scripts").join("auto-improve").join("flowctl"), |
| 304 | + ]; |
| 305 | + for c in &candidates { |
| 306 | + if c.exists() { |
| 307 | + return Some(c.clone()); |
| 308 | + } |
| 309 | + } |
| 310 | + None |
| 311 | +} |
| 312 | + |
| 313 | +fn save_via_flowctl(memories: &[Memory], flowctl: &Path) -> usize { |
| 314 | + let mut saved = 0; |
| 315 | + for mem in memories { |
| 316 | + let result = Command::new(flowctl) |
| 317 | + .args(["memory", "add", "--type", &mem.mem_type, &mem.content]) |
| 318 | + .output(); |
| 319 | + if let Ok(o) = result { |
| 320 | + if o.status.success() { |
| 321 | + saved += 1; |
| 322 | + } |
| 323 | + } |
| 324 | + } |
| 325 | + saved |
| 326 | +} |
| 327 | + |
| 328 | +fn save_memories_direct(memories: &[Memory], flow_dir: &Path) -> usize { |
| 329 | + let memory_dir = flow_dir.join("memory"); |
| 330 | + if !memory_dir.exists() { |
| 331 | + return 0; |
| 332 | + } |
| 333 | + |
| 334 | + let type_to_file: HashMap<&str, &str> = HashMap::from([ |
| 335 | + ("pitfall", "pitfalls.md"), |
| 336 | + ("convention", "conventions.md"), |
| 337 | + ("decision", "decisions.md"), |
| 338 | + ]); |
| 339 | + |
| 340 | + let mut saved = 0; |
| 341 | + for mem in memories { |
| 342 | + let filename = type_to_file.get(mem.mem_type.as_str()).unwrap_or(&"conventions.md"); |
| 343 | + let filepath = memory_dir.join(filename); |
| 344 | + if filepath.exists() { |
| 345 | + let content = fs::read_to_string(&filepath).unwrap_or_default(); |
| 346 | + let entry = format!("- {}\n", mem.content); |
| 347 | + if !content.contains(&entry) |
| 348 | + && fs::OpenOptions::new() |
| 349 | + .append(true) |
| 350 | + .open(&filepath) |
| 351 | + .and_then(|mut f| { |
| 352 | + use std::io::Write; |
| 353 | + f.write_all(entry.as_bytes()) |
| 354 | + }) |
| 355 | + .is_ok() |
| 356 | + { |
| 357 | + saved += 1; |
| 358 | + } |
| 359 | + } |
| 360 | + } |
| 361 | + saved |
| 362 | +} |
0 commit comments