Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 4c6311f

Browse files
z23ccclaude
andcommitted
refactor(hook): split hook.rs into focused sub-modules
Split the 1810-line monolithic hook.rs into a hook/ directory with 6 focused sub-modules: common (shared helpers), auto_memory, ralph_guard, commit_gate, compact (pre-compact + subagent context + task completed), and rtk_rewrite. Public API (HookCmd enum + dispatch function) unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1efbac2 commit 4c6311f

8 files changed

Lines changed: 1853 additions & 1810 deletions

File tree

flowctl/crates/flowctl-cli/src/commands/hook.rs

Lines changed: 0 additions & 1810 deletions
This file was deleted.
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
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

Comments
 (0)