Skip to main content

microscope_memory/
open_loops.rs

1//! Open Loops — track unresolved tasks / TODOs / open questions.
2//!
3//! Distinct from the regular long-term store: an open loop is a memory
4//! item the agent flagged as "still in progress". The agent can list open
5//! loops at session-start to remember what was left unfinished, and
6//! resolve them when the task completes.
7//!
8//! Storage: `output/open_loops.bin` (magic `OPL1`)
9//! Entry: 22 bytes header + text bytes
10//!   [u8 magic 4 = "OPL1"] (file header only)
11//!   [u64 id]
12//!   [u64 ts_ms]
13//!   [u8 importance]
14//!   [u8 status (0=open, 1=resolved, 2=archived)]
15//!   [u32 text_len]
16//!   [bytes text_len]
17//!
18//! The current max id is stored in `output/open_loops.idx` as plain text
19//! (decimal, with newline). Resolution works by appending a new entry
20//! with the same id and status=resolved — the log stays append-only and
21//! `read_open()` returns the latest status per id.
22
23use std::fs;
24use std::io::Write;
25use std::path::Path;
26
27use crate::timeline::now_epoch_ms;
28
29const MAGIC: &[u8; 4] = b"OPL1";
30const ENTRY_HEADER: usize = 8 + 8 + 1 + 1 + 4; // 22 bytes
31pub const MAX_TEXT_LEN: usize = 1024;
32
33pub const STATUS_OPEN: u8 = 0;
34pub const STATUS_RESOLVED: u8 = 1;
35pub const STATUS_ARCHIVED: u8 = 2;
36
37#[derive(Clone, Debug)]
38pub struct OpenLoopEntry {
39    pub id: u64,
40    pub ts_ms: u64,
41    pub importance: u8,
42    pub status: u8,
43    pub text: String,
44}
45
46/// Returns the next available id (max+1), starting at 1.
47/// Reads `output/open_loops.idx`. If missing, returns 1.
48pub fn next_id(dir: &Path) -> u64 {
49    let p = dir.join("open_loops.idx");
50    let raw = fs::read_to_string(&p).unwrap_or_default();
51    raw.trim().parse::<u64>().map(|n| n + 1).unwrap_or(1)
52}
53
54fn persist_next_id(dir: &Path, id: u64) -> Result<(), String> {
55    let p = dir.join("open_loops.idx");
56    let mut f = fs::OpenOptions::new()
57        .create(true)
58        .write(true)
59        .truncate(true)
60        .open(&p)
61        .map_err(|e| format!("open open_loops.idx: {}", e))?;
62    f.write_all(format!("{}\n", id).as_bytes())
63        .map_err(|e| format!("write open_loops.idx: {}", e))?;
64    Ok(())
65}
66
67/// Append a new loop. Assigns an id automatically, persists next-id,
68/// and returns the assigned id. Status is open by default.
69pub fn append_open(dir: &Path, text: &str, importance: u8) -> Result<u64, String> {
70    let id = next_id(dir);
71    let entry = OpenLoopEntry {
72        id,
73        ts_ms: now_epoch_ms(),
74        importance,
75        status: STATUS_OPEN,
76        text: text.to_string(),
77    };
78    write_entry(&dir.join("open_loops.bin"), &entry)?;
79    persist_next_id(dir, id)?;
80    Ok(id)
81}
82
83/// Mark a loop resolved. Returns Ok(true) if a matching open id was
84/// found, Ok(false) if not (already resolved or unknown id).
85pub fn resolve(dir: &Path, id: u64) -> Result<bool, String> {
86    let latest = read_latest(&dir.join("open_loops.bin"));
87    if let Some(entry) = latest.iter().find(|e| e.id == id) {
88        if entry.status == STATUS_RESOLVED {
89            return Ok(false);
90        }
91        let resolved = OpenLoopEntry {
92            id,
93            ts_ms: now_epoch_ms(),
94            importance: entry.importance,
95            status: STATUS_RESOLVED,
96            text: entry.text.clone(),
97        };
98        write_entry(&dir.join("open_loops.bin"), &resolved)?;
99        // Mirror the resolution to the timeline log so timeline queries
100        // reflect the latest status without scanning open_loops.
101        let timeline_entry = crate::timeline::TimelineEntry {
102            ts_ms: resolved.ts_ms,
103            layer_id: crate::layer_to_id("long_term"),
104            importance: resolved.importance,
105            depth: 4,
106            status: crate::timeline::STATUS_RESOLVED,
107            text: format!("[loop#{} resolved] {}", id, entry.text),
108        };
109        let _ = crate::timeline::append_entry(&dir.join("timeline.bin"), &timeline_entry);
110        Ok(true)
111    } else {
112        Ok(false)
113    }
114}
115
116/// Archive a loop (similar to resolve but distinguishable).
117pub fn archive(dir: &Path, id: u64) -> Result<bool, String> {
118    let latest = read_latest(&dir.join("open_loops.bin"));
119    if let Some(entry) = latest.iter().find(|e| e.id == id) {
120        if entry.status == STATUS_ARCHIVED {
121            return Ok(false);
122        }
123        let archived = OpenLoopEntry {
124            id,
125            ts_ms: now_epoch_ms(),
126            importance: entry.importance,
127            status: STATUS_ARCHIVED,
128            text: entry.text.clone(),
129        };
130        write_entry(&dir.join("open_loops.bin"), &archived)?;
131        Ok(true)
132    } else {
133        Ok(false)
134    }
135}
136
137/// Read all entries (raw log). Each id may appear multiple times if it
138/// was resolved/archived — use `read_latest` for current status.
139pub fn read_all(path: &Path) -> Vec<OpenLoopEntry> {
140    if !path.exists() {
141        return Vec::new();
142    }
143    let data = match fs::read(path) {
144        Ok(d) => d,
145        Err(_) => return Vec::new(),
146    };
147    if data.len() < 4 || &data[0..4] != MAGIC {
148        return Vec::new();
149    }
150
151    let mut entries = Vec::new();
152    let mut pos = 4;
153    while pos + ENTRY_HEADER <= data.len() {
154        let id = u64::from_le_bytes([
155            data[pos],
156            data[pos + 1],
157            data[pos + 2],
158            data[pos + 3],
159            data[pos + 4],
160            data[pos + 5],
161            data[pos + 6],
162            data[pos + 7],
163        ]);
164        let ts_ms = u64::from_le_bytes([
165            data[pos + 8],
166            data[pos + 9],
167            data[pos + 10],
168            data[pos + 11],
169            data[pos + 12],
170            data[pos + 13],
171            data[pos + 14],
172            data[pos + 15],
173        ]);
174        let importance = data[pos + 16];
175        let status = data[pos + 17];
176        let len = u32::from_le_bytes([
177            data[pos + 18],
178            data[pos + 19],
179            data[pos + 20],
180            data[pos + 21],
181        ]) as usize;
182        pos += ENTRY_HEADER;
183        if pos + len > data.len() {
184            break;
185        }
186        let text = String::from_utf8_lossy(&data[pos..pos + len]).to_string();
187        pos += len;
188
189        entries.push(OpenLoopEntry {
190            id,
191            ts_ms,
192            importance,
193            status,
194            text,
195        });
196    }
197    entries
198}
199
200/// Read current status of all known loops (latest entry per id wins).
201pub fn read_latest(path: &Path) -> Vec<OpenLoopEntry> {
202    let mut by_id: std::collections::HashMap<u64, OpenLoopEntry> = std::collections::HashMap::new();
203    for e in read_all(path) {
204        // Later writes win; loop preserves insertion order via iteration.
205        by_id.insert(e.id, e);
206    }
207    let mut out: Vec<OpenLoopEntry> = by_id.into_values().collect();
208    out.sort_by_key(|e| e.id);
209    out
210}
211
212/// Return only currently-open loops (latest status = open), sorted by
213/// importance desc, then ts desc.
214pub fn read_open(path: &Path) -> Vec<OpenLoopEntry> {
215    let mut v: Vec<OpenLoopEntry> = read_latest(path)
216        .into_iter()
217        .filter(|e| e.status == STATUS_OPEN)
218        .collect();
219    v.sort_by(|a, b| b.importance.cmp(&a.importance).then(b.ts_ms.cmp(&a.ts_ms)));
220    v
221}
222
223fn write_entry(path: &Path, entry: &OpenLoopEntry) -> Result<(), String> {
224    let needs_magic = !path.exists() || fs::metadata(path).map(|m| m.len() == 0).unwrap_or(true);
225
226    let mut file = fs::OpenOptions::new()
227        .create(true)
228        .append(true)
229        .open(path)
230        .map_err(|e| format!("open open_loops: {}", e))?;
231
232    if needs_magic {
233        file.write_all(MAGIC)
234            .map_err(|e| format!("write magic: {}", e))?;
235    }
236
237    let text_bytes = entry.text.as_bytes();
238    let len = text_bytes.len().min(MAX_TEXT_LEN);
239
240    file.write_all(&entry.id.to_le_bytes())
241        .map_err(|e| format!("write id: {}", e))?;
242    file.write_all(&entry.ts_ms.to_le_bytes())
243        .map_err(|e| format!("write ts: {}", e))?;
244    file.write_all(&[entry.importance])
245        .map_err(|e| format!("write imp: {}", e))?;
246    file.write_all(&[entry.status])
247        .map_err(|e| format!("write status: {}", e))?;
248    file.write_all(&(len as u32).to_le_bytes())
249        .map_err(|e| format!("write len: {}", e))?;
250    file.write_all(&text_bytes[..len])
251        .map_err(|e| format!("write text: {}", e))?;
252    Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_append_and_read_open() {
261        let dir = tempfile::tempdir().unwrap();
262        let id1 = append_open(dir.path(), "task A", 7).unwrap();
263        let id2 = append_open(dir.path(), "task B", 9).unwrap();
264        assert_eq!(id1, 1);
265        assert_eq!(id2, 2);
266
267        let open = read_open(&dir.path().join("open_loops.bin"));
268        assert_eq!(open.len(), 2);
269        // Importance desc → B first.
270        assert_eq!(open[0].text, "task B");
271        assert_eq!(open[1].text, "task A");
272    }
273
274    #[test]
275    fn test_resolve_hides_from_open() {
276        let dir = tempfile::tempdir().unwrap();
277        let id = append_open(dir.path(), "doomed task", 5).unwrap();
278        let changed = resolve(dir.path(), id).unwrap();
279        assert!(changed);
280
281        let open = read_open(&dir.path().join("open_loops.bin"));
282        assert!(open.is_empty());
283
284        // Idempotent: second resolve returns false.
285        let changed2 = resolve(dir.path(), id).unwrap();
286        assert!(!changed2);
287    }
288
289    #[test]
290    fn test_latest_status_per_id() {
291        let dir = tempfile::tempdir().unwrap();
292        let id = append_open(dir.path(), "test", 5).unwrap();
293        resolve(dir.path(), id).unwrap();
294
295        let latest = read_latest(&dir.path().join("open_loops.bin"));
296        assert_eq!(latest.len(), 1);
297        assert_eq!(latest[0].id, id);
298        assert_eq!(latest[0].status, STATUS_RESOLVED);
299
300        // Raw log should have 2 entries (open + resolved).
301        let raw = read_all(&dir.path().join("open_loops.bin"));
302        assert_eq!(raw.len(), 2);
303    }
304
305    #[test]
306    fn test_resolve_unknown_id_returns_false() {
307        let dir = tempfile::tempdir().unwrap();
308        let changed = resolve(dir.path(), 9999).unwrap();
309        assert!(!changed);
310    }
311}