Skip to main content

codex_recall/
memory.rs

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}