1use crate::output::compact_whitespace;
2use crate::parser::{EventKind, ParsedSession};
3use std::collections::HashSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum MemoryKind {
7 Decision,
8 Task,
9 Fact,
10 OpenQuestion,
11 Blocker,
12}
13
14impl MemoryKind {
15 pub fn as_str(self) -> &'static str {
16 match self {
17 MemoryKind::Decision => "decision",
18 MemoryKind::Task => "task",
19 MemoryKind::Fact => "fact",
20 MemoryKind::OpenQuestion => "open_question",
21 MemoryKind::Blocker => "blocker",
22 }
23 }
24
25 pub fn parse(value: &str) -> Option<Self> {
26 match value {
27 "decision" => Some(Self::Decision),
28 "task" => Some(Self::Task),
29 "fact" => Some(Self::Fact),
30 "open_question" => Some(Self::OpenQuestion),
31 "blocker" => Some(Self::Blocker),
32 _ => None,
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ExtractedMemory {
39 pub id: String,
40 pub kind: MemoryKind,
41 pub summary: String,
42 pub normalized_text: String,
43 pub event_kind: EventKind,
44 pub source_line_number: usize,
45 pub source_timestamp: Option<String>,
46 pub evidence_text: String,
47}
48
49pub fn extract_memories(parsed: &ParsedSession) -> Vec<ExtractedMemory> {
50 let mut extracted = Vec::new();
51 let mut seen = HashSet::new();
52
53 for event in &parsed.events {
54 if matches!(event.kind, EventKind::Command) {
55 continue;
56 }
57
58 for line in event.text.lines() {
59 let Some((kind, summary, normalized_text)) = classify_line(line) else {
60 continue;
61 };
62 let dedupe_key = format!(
63 "{}\u{1f}{}\u{1f}{}",
64 kind.as_str(),
65 normalized_text,
66 event.source_line_number
67 );
68 if !seen.insert(dedupe_key) {
69 continue;
70 }
71
72 let evidence_text = compact_whitespace(line);
73 extracted.push(ExtractedMemory {
74 id: build_memory_id(kind, &normalized_text),
75 kind,
76 summary,
77 normalized_text,
78 event_kind: event.kind,
79 source_line_number: event.source_line_number,
80 source_timestamp: event.source_timestamp.clone(),
81 evidence_text,
82 });
83 }
84 }
85
86 extracted
87}
88
89pub fn build_memory_id(kind: MemoryKind, normalized_text: &str) -> String {
90 format!(
91 "mem_{}_{:016x}",
92 kind.as_str(),
93 fnv1a64(normalized_text.as_bytes())
94 )
95}
96
97fn classify_line(line: &str) -> Option<(MemoryKind, String, String)> {
98 let line = compact_whitespace(line);
99 let line = line.trim();
100 if line.is_empty() || line.starts_with('$') || line == "[truncated]" {
101 return None;
102 }
103
104 if let Some(summary) = strip_prefix_ci(line, "decision:") {
105 return memory_from_summary(MemoryKind::Decision, summary);
106 }
107 if let Some(summary) = strip_prefix_ci(line, "task:") {
108 return memory_from_summary(MemoryKind::Task, summary);
109 }
110 if let Some(summary) = strip_prefix_ci(line, "fact:") {
111 return memory_from_summary(MemoryKind::Fact, summary);
112 }
113 if let Some(summary) = strip_prefix_ci(line, "question:") {
114 return memory_from_summary(MemoryKind::OpenQuestion, summary);
115 }
116 if let Some(summary) = strip_prefix_ci(line, "blocked:") {
117 return memory_from_summary(MemoryKind::Blocker, summary);
118 }
119 if let Some(summary) = strip_prefix_ci(line, "blocker:") {
120 return memory_from_summary(MemoryKind::Blocker, summary);
121 }
122 if let Some(summary) = strip_prefix_ci(line, "next step:") {
123 return memory_from_summary(MemoryKind::Task, summary);
124 }
125 if let Some(summary) = strip_prefix_ci(line, "confirmed:") {
126 return memory_from_summary(MemoryKind::Fact, summary);
127 }
128
129 let lower = line.to_ascii_lowercase();
130 if lower.ends_with('?') {
131 return memory_from_summary(MemoryKind::OpenQuestion, line);
132 }
133 if lower.contains("blocked by")
134 || lower.contains("waiting on")
135 || lower.contains("login required")
136 || lower.contains("no token found")
137 || lower.contains("cannot ")
138 || lower.contains("can't ")
139 {
140 return memory_from_summary(MemoryKind::Blocker, line);
141 }
142 if lower.contains("next step")
143 || lower.contains("need to ")
144 || lower.contains("needs to ")
145 || lower.contains("follow up")
146 || lower.contains("todo")
147 {
148 return memory_from_summary(MemoryKind::Task, line);
149 }
150 if lower.contains("we decided")
151 || lower.contains("decided to")
152 || lower.contains(" will stay ")
153 || lower.contains(" should remain ")
154 || lower.starts_with("keep ")
155 || lower.contains(" stays ")
156 || lower.contains(" prefer ")
157 || lower.contains(" the fix is ")
158 {
159 return memory_from_summary(MemoryKind::Decision, line);
160 }
161 if lower.contains("confirmed")
162 || lower.contains(" is currently ")
163 || lower.contains(" still ")
164 || lower.contains(" passed")
165 || lower.contains(" passes")
166 || lower.contains(" failed")
167 || lower.contains(" returns ")
168 || lower.contains(" shows ")
169 {
170 return memory_from_summary(MemoryKind::Fact, line);
171 }
172
173 None
174}
175
176fn memory_from_summary(kind: MemoryKind, summary: &str) -> Option<(MemoryKind, String, String)> {
177 let summary = summary.trim().trim_matches('-').trim();
178 if summary.is_empty() {
179 return None;
180 }
181
182 let normalized_text = normalize_memory_text(summary);
183 if normalized_text.is_empty() {
184 return None;
185 }
186
187 Some((kind, summary.to_owned(), normalized_text))
188}
189
190fn normalize_memory_text(value: &str) -> String {
191 let mut normalized = String::new();
192 let mut last_was_space = false;
193 for ch in value.chars().flat_map(|ch| ch.to_lowercase()) {
194 if ch.is_ascii_alphanumeric() {
195 normalized.push(ch);
196 last_was_space = false;
197 } else if !last_was_space {
198 normalized.push(' ');
199 last_was_space = true;
200 }
201 }
202 normalized.trim().to_owned()
203}
204
205fn strip_prefix_ci<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
206 if value.len() < prefix.len() {
207 return None;
208 }
209 if value[..prefix.len()].eq_ignore_ascii_case(prefix) {
210 return Some(value[prefix.len()..].trim());
211 }
212 None
213}
214
215fn fnv1a64(bytes: &[u8]) -> u64 {
216 let mut hash = 0xcbf29ce484222325u64;
217 for byte in bytes {
218 hash ^= u64::from(*byte);
219 hash = hash.wrapping_mul(0x100000001b3);
220 }
221 hash
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::parser::{ParsedEvent, SessionMetadata};
228 use std::path::PathBuf;
229
230 #[test]
231 fn extracts_prefixed_memory_objects() {
232 let source = PathBuf::from("/tmp/session.jsonl");
233 let parsed = ParsedSession {
234 session: SessionMetadata {
235 id: "session-1".to_owned(),
236 timestamp: "2026-04-13T01:00:00Z".to_owned(),
237 cwd: "/Users/me/project".to_owned(),
238 cli_version: None,
239 source_file_path: source.clone(),
240 },
241 events: vec![ParsedEvent {
242 session_id: "session-1".to_owned(),
243 kind: EventKind::AssistantMessage,
244 role: Some("assistant".to_owned()),
245 text: "Decision: Keep MCP resources JSON-only.\nNext step: wire delta cursors."
246 .to_owned(),
247 command: None,
248 cwd: None,
249 exit_code: None,
250 source_timestamp: Some("2026-04-13T01:00:01Z".to_owned()),
251 source_file_path: source,
252 source_line_number: 2,
253 }],
254 };
255
256 let extracted = extract_memories(&parsed);
257 assert_eq!(extracted.len(), 2);
258 assert_eq!(extracted[0].kind, MemoryKind::Decision);
259 assert_eq!(extracted[0].summary, "Keep MCP resources JSON-only.");
260 assert!(extracted[0].id.starts_with("mem_decision_"));
261 assert_eq!(extracted[1].kind, MemoryKind::Task);
262 }
263}