Skip to main content

microscope_memory/
timeline.rs

1//! Timeline — chronological memory log with time-window queries.
2//!
3//! Every store appends a TimelineEntry alongside the main append.bin,
4//! giving the agent the ability to recall "what happened today/yesterday/
5//! last session" without relying on similarity ranking.
6//!
7//! Binary format: `output/timeline.bin` (magic `TML1`)
8//! Entry: 25 bytes header + text bytes
9//!   [u8 magic 4 = "TML1"]
10//!   [u64 ts_ms]
11//!   [u8 layer_id]
12//!   [u8 importance]
13//!   [u8 depth]
14//!   [u8 status (0=open, 1=resolved, 2=archived, 3=normal)]
15//!   [u32 text_len]
16//!   [bytes text_len]
17//!
18//! Magic is written only once (file header). Subsequent entries are
19//! concatenated. Resolved/archived status flips are written as new
20//! entries so the log stays append-only.
21
22use std::fs;
23use std::io::Write;
24use std::path::Path;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27const MAGIC: &[u8; 4] = b"TML1";
28const ENTRY_HEADER: usize = 8 + 1 + 1 + 1 + 1 + 4; // 16 bytes
29pub const MAX_TEXT_LEN: usize = 4096;
30
31/// Status byte in TimelineEntry.
32pub const STATUS_NORMAL: u8 = 3;
33pub const STATUS_OPEN: u8 = 0;
34pub const STATUS_RESOLVED: u8 = 1;
35pub const STATUS_ARCHIVED: u8 = 2;
36
37/// One chronological memory entry.
38#[derive(Clone, Debug)]
39pub struct TimelineEntry {
40    pub ts_ms: u64,
41    pub layer_id: u8,
42    pub importance: u8,
43    pub depth: u8,
44    pub status: u8,
45    pub text: String,
46}
47
48/// Append a TimelineEntry. Thread-safe-ish via O_APPEND on POSIX; on Windows
49/// the append mode also appends, but we serialize via FileLock at the caller.
50pub fn append_entry(path: &Path, entry: &TimelineEntry) -> Result<(), String> {
51    let needs_magic = !path.exists() || fs::metadata(path).map(|m| m.len() == 0).unwrap_or(true);
52
53    let mut file = fs::OpenOptions::new()
54        .create(true)
55        .append(true)
56        .open(path)
57        .map_err(|e| format!("open timeline: {}", e))?;
58
59    if needs_magic {
60        file.write_all(MAGIC)
61            .map_err(|e| format!("write timeline magic: {}", e))?;
62    }
63
64    let text_bytes = entry.text.as_bytes();
65    let len = text_bytes.len().min(MAX_TEXT_LEN);
66
67    file.write_all(&entry.ts_ms.to_le_bytes())
68        .map_err(|e| format!("write ts: {}", e))?;
69    file.write_all(&[entry.layer_id])
70        .map_err(|e| format!("write layer: {}", e))?;
71    file.write_all(&[entry.importance])
72        .map_err(|e| format!("write importance: {}", e))?;
73    file.write_all(&[entry.depth])
74        .map_err(|e| format!("write depth: {}", e))?;
75    file.write_all(&[entry.status])
76        .map_err(|e| format!("write status: {}", e))?;
77    file.write_all(&(len as u32).to_le_bytes())
78        .map_err(|e| format!("write len: {}", e))?;
79    file.write_all(&text_bytes[..len])
80        .map_err(|e| format!("write text: {}", e))?;
81
82    Ok(())
83}
84
85/// Read all entries from the timeline log. Returns them in append order
86/// (oldest first) so the caller can sort/filter by window.
87pub fn read_all(path: &Path) -> Vec<TimelineEntry> {
88    if !path.exists() {
89        return Vec::new();
90    }
91    let data = match fs::read(path) {
92        Ok(d) => d,
93        Err(_) => return Vec::new(),
94    };
95    if data.len() < 4 || &data[0..4] != MAGIC {
96        return Vec::new();
97    }
98
99    let mut entries = Vec::new();
100    let mut pos = 4;
101
102    while pos + ENTRY_HEADER <= data.len() {
103        let ts_ms = u64::from_le_bytes([
104            data[pos],
105            data[pos + 1],
106            data[pos + 2],
107            data[pos + 3],
108            data[pos + 4],
109            data[pos + 5],
110            data[pos + 6],
111            data[pos + 7],
112        ]);
113        let layer_id = data[pos + 8];
114        let importance = data[pos + 9];
115        let depth = data[pos + 10];
116        let status = data[pos + 11];
117        let len = u32::from_le_bytes([
118            data[pos + 12],
119            data[pos + 13],
120            data[pos + 14],
121            data[pos + 15],
122        ]) as usize;
123        pos += ENTRY_HEADER;
124
125        if pos + len > data.len() {
126            break;
127        }
128        let text = String::from_utf8_lossy(&data[pos..pos + len]).to_string();
129        pos += len;
130
131        entries.push(TimelineEntry {
132            ts_ms,
133            layer_id,
134            importance,
135            depth,
136            status,
137            text,
138        });
139    }
140
141    entries
142}
143
144/// A time window descriptor used for filtering.
145#[derive(Clone, Debug, PartialEq)]
146pub enum TimeWindow {
147    /// Today (00:00 local-time approximate — we use UTC for simplicity).
148    Today,
149    /// Yesterday.
150    Yesterday,
151    /// Last N days (inclusive of today).
152    LastDays(u64),
153    /// Since a specific epoch_ms.
154    Since(u64),
155    /// Only entries newer than the last session boundary (we approximate
156    /// this as the largest gap between consecutive entries, > 6h).
157    LastSession,
158    /// All entries.
159    All,
160}
161
162impl TimeWindow {
163    /// Parse a window string. Accepts:
164    ///   "today", "yesterday", "last_N_days" (e.g. "last_3_days"),
165    ///   "since:<iso8601 or epoch_ms>", "last_session", "all".
166    pub fn parse(s: &str) -> Result<Self, String> {
167        let s = s.trim().to_lowercase();
168        match s.as_str() {
169            "today" => Ok(TimeWindow::Today),
170            "yesterday" => Ok(TimeWindow::Yesterday),
171            "last_session" => Ok(TimeWindow::LastSession),
172            "all" => Ok(TimeWindow::All),
173            other if other.starts_with("last_") && other.ends_with("_days") => {
174                let n: u64 = other[5..other.len() - 5]
175                    .parse()
176                    .map_err(|_| format!("invalid window: {}", s))?;
177                if n == 0 || n > 365 {
178                    return Err(format!("window days out of range: {}", n));
179                }
180                Ok(TimeWindow::LastDays(n))
181            }
182            other if other.starts_with("since:") => {
183                let v = &other[6..];
184                if let Ok(ms) = v.parse::<u64>() {
185                    Ok(TimeWindow::Since(ms))
186                } else {
187                    // Try ISO-8601 (YYYY-MM-DDTHH:MM:SS or YYYY-MM-DD HH:MM:SS)
188                    let ms = parse_iso8601_to_epoch_ms(v)
189                        .ok_or_else(|| format!("invalid since date: {}", v))?;
190                    Ok(TimeWindow::Since(ms))
191                }
192            }
193            _ => Err(format!(
194                "unknown window: {} (use today|yesterday|last_N_days|since:<date>|last_session|all)",
195                s
196            )),
197        }
198    }
199
200    /// Resolve to a (start_ms, end_ms) window relative to now.
201    /// LastSession returns (0, u64::MAX) — the actual session boundary is
202    /// computed by the caller from the entry list.
203    pub fn to_range(&self, now_ms: u64) -> (u64, u64) {
204        const DAY_MS: u64 = 86_400_000;
205        match self {
206            TimeWindow::Today => {
207                let start = now_ms - (now_ms % DAY_MS);
208                (start, now_ms)
209            }
210            TimeWindow::Yesterday => {
211                let today_start = now_ms - (now_ms % DAY_MS);
212                (today_start - DAY_MS, today_start)
213            }
214            TimeWindow::LastDays(n) => {
215                let n = (*n).max(1);
216                let start = now_ms - n * DAY_MS;
217                (start, now_ms)
218            }
219            TimeWindow::Since(ms) => (*ms, u64::MAX),
220            TimeWindow::LastSession => (0, u64::MAX),
221            TimeWindow::All => (0, u64::MAX),
222        }
223    }
224}
225
226/// Filter entries by time window. For `LastSession`, finds the last gap
227/// > 6 hours between consecutive entries and returns everything after it.
228/// > `now_ms` is injected so tests can use a fixed timestamp.
229pub fn filter(entries: &[TimelineEntry], window: &TimeWindow) -> Vec<TimelineEntry> {
230    filter_at(entries, window, now_epoch_ms())
231}
232
233/// Same as `filter` but with explicit `now_ms` for deterministic testing.
234pub fn filter_at(
235    entries: &[TimelineEntry],
236    window: &TimeWindow,
237    now_ms: u64,
238) -> Vec<TimelineEntry> {
239    if entries.is_empty() {
240        return Vec::new();
241    }
242    match window {
243        TimeWindow::LastSession => {
244            // Find the largest gap; that's the session boundary.
245            let mut last_gap_pos = 0usize;
246            let mut last_gap_ms: u64 = 0;
247            for i in 1..entries.len() {
248                let gap = entries[i].ts_ms.saturating_sub(entries[i - 1].ts_ms);
249                if gap > last_gap_ms {
250                    last_gap_ms = gap;
251                    last_gap_pos = i;
252                }
253            }
254            // 6h threshold: if the biggest gap is < 6h, fall back to "all".
255            const SIX_H_MS: u64 = 6 * 3_600_000;
256            if last_gap_ms < SIX_H_MS {
257                entries.to_vec()
258            } else {
259                entries[last_gap_pos..].to_vec()
260            }
261        }
262        _ => {
263            let (start, end) = window.to_range(now_ms);
264            entries
265                .iter()
266                .filter(|e| e.ts_ms >= start && e.ts_ms <= end)
267                .cloned()
268                .collect()
269        }
270    }
271}
272
273/// Current epoch in milliseconds.
274pub fn now_epoch_ms() -> u64 {
275    SystemTime::now()
276        .duration_since(UNIX_EPOCH)
277        .unwrap_or_default()
278        .as_millis() as u64
279}
280
281/// Format an epoch_ms into "YYYY-MM-DD HH:MM" using a small pure-Rust
282/// calendar (UTC). Good enough for human display in tool output.
283pub fn format_ts(ms: u64) -> String {
284    let secs = ms / 1000;
285    let total_days = secs / 86400;
286    let mut y = 1970u64;
287    let mut remaining = total_days;
288    loop {
289        let diy = if is_leap(y) { 366 } else { 365 };
290        if remaining < diy {
291            break;
292        }
293        remaining -= diy;
294        y += 1;
295    }
296    let leap = is_leap(y);
297    let mdays = [
298        31u64,
299        if leap { 29 } else { 28 },
300        31,
301        30,
302        31,
303        30,
304        31,
305        31,
306        30,
307        31,
308        30,
309        31,
310    ];
311    let mut mo = 1u64;
312    for &md in &mdays {
313        if remaining < md {
314            break;
315        }
316        remaining -= md;
317        mo += 1;
318    }
319    let day = remaining + 1;
320    let secs_in_day = secs % 86400;
321    let h = secs_in_day / 3600;
322    let m = (secs_in_day % 3600) / 60;
323    format!("{:04}-{:02}-{:02} {:02}:{:02}", y, mo, day, h, m)
324}
325
326fn is_leap(y: u64) -> bool {
327    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
328}
329
330/// Minimal ISO-8601 parser. Accepts:
331///   YYYY-MM-DD
332///   YYYY-MM-DDTHH:MM
333///   YYYY-MM-DDTHH:MM:SS
334///   YYYY-MM-DD HH:MM:SS
335/// Returns epoch_ms (UTC) or None.
336pub fn parse_iso8601_to_epoch_ms(s: &str) -> Option<u64> {
337    let s = s.trim().replace(' ', "T");
338    let mut parts = s.split('T');
339    let date_part = parts.next()?;
340    let time_part = parts.next();
341
342    let date: Vec<u64> = date_part
343        .split('-')
344        .filter_map(|p| p.parse().ok())
345        .collect();
346    if date.len() != 3 {
347        return None;
348    }
349    let (y, mo, d) = (date[0], date[1], date[2]);
350    if !(1970..=3000).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
351        return None;
352    }
353
354    let (mut h, mut mi, mut sec) = (0u64, 0u64, 0u64);
355    if let Some(t) = time_part {
356        let tps: Vec<u64> = t.split(':').filter_map(|p| p.parse().ok()).collect();
357        if !tps.is_empty() {
358            h = tps[0];
359        }
360        if tps.len() >= 2 {
361            mi = tps[1];
362        }
363        if tps.len() >= 3 {
364            sec = tps[2];
365        }
366    }
367    if h > 23 || mi > 59 || sec > 60 {
368        return None;
369    }
370
371    // Days from 1970-01-01 to (y, mo, d)
372    let mut days: u64 = 0;
373    for yr in 1970..y {
374        days += if is_leap(yr) { 366 } else { 365 };
375    }
376    let leap = is_leap(y);
377    let mdays = [
378        31u64,
379        if leap { 29 } else { 28 },
380        31,
381        30,
382        31,
383        30,
384        31,
385        31,
386        30,
387        31,
388        30,
389        31,
390    ];
391    for &dm in mdays.iter().take((mo - 1) as usize) {
392        days += dm;
393    }
394    days += d - 1;
395
396    Some(days * 86_400_000 + h * 3_600_000 + mi * 60_000 + sec * 1000)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn entry(text: &str, ts_ms: u64, status: u8) -> TimelineEntry {
404        TimelineEntry {
405            ts_ms,
406            layer_id: 1,
407            importance: 5,
408            depth: 4,
409            status,
410            text: text.to_string(),
411        }
412    }
413
414    #[test]
415    fn test_append_read_roundtrip() {
416        let dir = tempfile::tempdir().unwrap();
417        let p = dir.path().join("timeline.bin");
418        append_entry(&p, &entry("first", 1_700_000_000_000, STATUS_NORMAL)).unwrap();
419        append_entry(&p, &entry("second", 1_700_001_000_000, STATUS_OPEN)).unwrap();
420        let got = read_all(&p);
421        assert_eq!(got.len(), 2);
422        assert_eq!(got[0].text, "first");
423        assert_eq!(got[1].status, STATUS_OPEN);
424    }
425
426    #[test]
427    fn test_window_parse() {
428        assert_eq!(TimeWindow::parse("today").unwrap(), TimeWindow::Today);
429        assert_eq!(
430            TimeWindow::parse("last_3_days").unwrap(),
431            TimeWindow::LastDays(3)
432        );
433        assert_eq!(
434            TimeWindow::parse("last_session").unwrap(),
435            TimeWindow::LastSession
436        );
437        assert!(TimeWindow::parse("nonsense").is_err());
438        assert!(TimeWindow::parse("last_0_days").is_err());
439        assert!(TimeWindow::parse("last_1000_days").is_err());
440    }
441
442    #[test]
443    fn test_filter_today() {
444        let now = 1_700_000_000_000u64; // 2023-11-14 22:13:20 UTC
445        let today_start = now - (now % 86_400_000);
446        let entries = vec![
447            entry("yesterday_late", today_start - 3_600_000, STATUS_NORMAL),
448            entry("today_early", today_start + 1_000, STATUS_NORMAL),
449            entry("now", now, STATUS_NORMAL),
450        ];
451        let f = filter_at(&entries, &TimeWindow::Today, now);
452        assert_eq!(f.len(), 2);
453        assert_eq!(f[0].text, "today_early");
454    }
455
456    #[test]
457    fn test_filter_last_session() {
458        // Three entries separated by a 10h gap.
459        let entries = vec![
460            entry("old", 1_700_000_000_000, STATUS_NORMAL),
461            entry("older", 1_700_000_000_000 - 10 * 3_600_000, STATUS_NORMAL),
462            // 10h gap here
463            entry("recent", 1_700_000_000_000 + 1000, STATUS_NORMAL),
464        ];
465        let f = filter(&entries, &TimeWindow::LastSession);
466        assert_eq!(f.len(), 1);
467        assert_eq!(f[0].text, "recent");
468    }
469
470    #[test]
471    fn test_iso8601_parse() {
472        let ms = parse_iso8601_to_epoch_ms("2026-06-22T10:30:00").unwrap();
473        // Just verify it's a sensible positive number.
474        assert!(ms > 1_700_000_000_000);
475        assert!(ms < 2_000_000_000_000);
476    }
477
478    #[test]
479    fn test_format_ts() {
480        // 2023-11-14 22:13:20 UTC
481        let s = format_ts(1_700_000_000_000);
482        assert_eq!(s, "2023-11-14 22:13");
483    }
484
485    #[test]
486    fn test_window_last_days_range() {
487        let now = 1_700_000_000_000u64;
488        let (start, end) = TimeWindow::LastDays(2).to_range(now);
489        assert!(start < now);
490        assert_eq!(end, now);
491        assert!(now - start <= 2 * 86_400_000);
492    }
493}