Skip to main content

microscope_memory/
auto_context.rs

1//! Auto-Context — assemble a compact, always-fresh session snapshot.
2//!
3//! Built once at session start (or any time the agent asks "what's my
4//! current state?"). Combines:
5//!   * timeline[last_session] — top-N chronological stores
6//!   * open_loops — top-N unresolved tasks
7//!   * emotional_field — current mood centroid + energy
8//!   * system_state — block count + depth distribution
9//!
10//! The output is plain text formatted for direct inclusion in an LLM
11//! prompt. Every MCP client gets the same format because the assembly
12//! happens server-side.
13
14use std::path::Path;
15
16use crate::open_loops;
17use crate::timeline;
18use crate::LAYER_NAMES;
19
20/// How many items to include in the auto-context snapshot.
21pub const TIMELINE_TOP_N: usize = 5;
22pub const LOOPS_TOP_N: usize = 5;
23/// Max core memories to embed (full text, not just metadata).
24pub const CORE_MEMORIES_TOP_N: usize = 3;
25/// Importance threshold for core memories (0-10).
26pub const CORE_MEMORIES_MIN_IMP: u8 = 7;
27/// Max chars per core memory.
28pub const CORE_MEMORY_MAX_CHARS: usize = 500;
29
30/// A core memory entry with full text content.
31#[derive(Debug, Clone, Default)]
32pub struct CoreMemory {
33    pub text: String,
34    pub layer: String,
35    pub importance: u8,
36    pub depth: u8,
37}
38
39/// Assembled auto-context, ready to format for an LLM prompt.
40#[derive(Debug, Clone, Default)]
41pub struct AutoContext {
42    pub timeline_entries: Vec<timeline::TimelineEntry>,
43    pub open_loops: Vec<open_loops::OpenLoopEntry>,
44    pub block_count: usize,
45    pub depth_breakdown: Vec<(u8, usize)>,
46    pub append_log_size: usize,
47    pub last_session_anchor_ms: Option<u64>,
48    /// Full-text top memories for direct LLM consumption.
49    pub core_memories: Vec<CoreMemory>,
50}
51
52/// Build the auto-context from disk + in-memory state.
53pub fn build(output_dir: &Path, reader: &crate::reader::MicroscopeReader) -> AutoContext {
54    let mut ctx = AutoContext::default();
55
56    // Timeline (last session, capped to TIMELINE_TOP_N).
57    let entries = timeline::read_all(&output_dir.join("timeline.bin"));
58    let filtered = timeline::filter(&entries, &timeline::TimeWindow::LastSession);
59    let mut rev: Vec<timeline::TimelineEntry> = filtered.iter().rev().cloned().collect();
60    rev.truncate(TIMELINE_TOP_N);
61    ctx.last_session_anchor_ms = filtered.first().map(|e| e.ts_ms);
62    ctx.timeline_entries = rev;
63
64    // Open loops (top N by importance).
65    let mut open = open_loops::read_open(&output_dir.join("open_loops.bin"));
66    open.truncate(LOOPS_TOP_N);
67    ctx.open_loops = open;
68
69    // System state.
70    ctx.block_count = reader.block_count;
71    for (d, &(_start, count)) in reader.depth_ranges.iter().enumerate() {
72        if count > 0 {
73            ctx.depth_breakdown.push((d as u8, count as usize));
74        }
75    }
76
77    // Append log size.
78    let append_path = output_dir.join("append.bin");
79    if append_path.exists() {
80        if let Ok(meta) = std::fs::metadata(&append_path) {
81            // Rough entry estimate: 4 (magic) + 19 (header) + ~128 (avg text)
82            const AVG_ENTRY: u64 = 147;
83            let total = meta.len();
84            ctx.append_log_size = (total / AVG_ENTRY) as usize;
85        }
86    }
87
88    // Core memories: find top-importance blocks and embed their full text.
89    // Scan append log first (most recent stores with importance field).
90    let appended = crate::reader::read_append_log(&append_path);
91    let mut candidates: Vec<CoreMemory> = appended
92        .iter()
93        .filter(|e| e.importance >= CORE_MEMORIES_MIN_IMP)
94        .map(|e| {
95            let layer = LAYER_NAMES
96                .get(e.layer_id as usize)
97                .unwrap_or(&"?")
98                .to_string();
99            CoreMemory {
100                text: crate::safe_truncate(&e.text, CORE_MEMORY_MAX_CHARS).to_string(),
101                layer,
102                importance: e.importance,
103                depth: 0,
104            }
105        })
106        .collect();
107    // Sort by importance desc, then take top N.
108    candidates.sort_by_key(|a| std::cmp::Reverse(a.importance));
109    candidates.truncate(CORE_MEMORIES_TOP_N);
110    ctx.core_memories = candidates;
111
112    ctx
113}
114
115/// Short header label used to embed auto-context into other tool outputs.
116pub const AUTO_CONTEXT_HEADER: &str = "[AUTO-CONTEXT]";
117
118/// Render the auto-context as a compact text block (good for LLM prompts).
119pub fn render(ctx: &AutoContext) -> String {
120    let mut out = String::new();
121    out.push_str(&format!(
122        "{}\n╔═══════════════════════════════════════════════════════╗\n",
123        AUTO_CONTEXT_HEADER
124    ));
125
126    // System state
127    out.push_str(&format!(
128        "║ STATE: {} blocks | {} depths active | append log ~{} entries\n",
129        ctx.block_count,
130        ctx.depth_breakdown.len(),
131        ctx.append_log_size,
132    ));
133    if ctx.last_session_anchor_ms.is_some() {
134        out.push_str(&format!(
135            "║ SESSION STARTED: {}\n",
136            ctx.last_session_anchor_ms
137                .map(timeline::format_ts)
138                .unwrap_or_else(|| "?".to_string())
139        ));
140    }
141
142    // Timeline
143    if !ctx.timeline_entries.is_empty() {
144        out.push_str("║\n║ LAST SESSION TIMELINE (newest first):\n");
145        for e in &ctx.timeline_entries {
146            let layer = LAYER_NAMES.get(e.layer_id as usize).unwrap_or(&"?");
147            let status_label = match e.status {
148                timeline::STATUS_OPEN => "OPEN",
149                timeline::STATUS_RESOLVED => "RESOLVED",
150                timeline::STATUS_ARCHIVED => "ARCHIVED",
151                _ => "",
152            };
153            let status_str = if status_label.is_empty() {
154                String::new()
155            } else {
156                format!(" [{}]", status_label)
157            };
158            out.push_str(&format!(
159                "║   {} {} D{} [{}] imp={}{} {}\n",
160                timeline::format_ts(e.ts_ms),
161                "",
162                e.depth,
163                layer,
164                e.importance,
165                status_str,
166                crate::safe_truncate(&e.text, 90),
167            ));
168        }
169    } else {
170        out.push_str("║ LAST SESSION: empty (no stores yet)\n");
171    }
172
173    // Open loops
174    if !ctx.open_loops.is_empty() {
175        out.push_str(&format!(
176            "║\n║ OPEN LOOPS ({} pending):\n",
177            ctx.open_loops.len()
178        ));
179        for l in &ctx.open_loops {
180            out.push_str(&format!(
181                "║   #{} {} imp={} {}\n",
182                l.id,
183                timeline::format_ts(l.ts_ms),
184                l.importance,
185                crate::safe_truncate(&l.text, 100),
186            ));
187        }
188    } else {
189        out.push_str("║ OPEN LOOPS: none\n");
190    }
191
192    // Core memories — full text for direct LLM consumption (no hallucination needed)
193    if !ctx.core_memories.is_empty() {
194        out.push_str(&format!(
195            "║\n║ CORE MEMORIES (imp>={}, full text):\n",
196            CORE_MEMORIES_MIN_IMP
197        ));
198        for (i, m) in ctx.core_memories.iter().enumerate() {
199            out.push_str(&format!(
200                "║ [{}/{}] [{}] imp={} >>>\n",
201                i + 1,
202                ctx.core_memories.len(),
203                m.layer,
204                m.importance,
205            ));
206            // Indent each line of the memory text
207            for line in m.text.lines() {
208                out.push_str(&format!("║   {}\n", line));
209            }
210            out.push_str("║   <<<\n");
211        }
212    }
213
214    out.push_str("╚═══════════════════════════════════════════════════════╝\n");
215    out
216}
217
218/// Compact version of the auto-context (for embedding in recall output).
219pub fn render_compact(ctx: &AutoContext) -> String {
220    let mut out = String::new();
221    if !ctx.timeline_entries.is_empty() {
222        out.push_str(&format!(
223            "{} last session: {} stores.\n",
224            AUTO_CONTEXT_HEADER,
225            ctx.timeline_entries.len()
226        ));
227        for e in ctx.timeline_entries.iter().take(3) {
228            let layer = LAYER_NAMES.get(e.layer_id as usize).unwrap_or(&"?");
229            let status_label = match e.status {
230                timeline::STATUS_OPEN => "[OPEN]",
231                timeline::STATUS_RESOLVED => "[RESOLVED]",
232                _ => "",
233            };
234            out.push_str(&format!(
235                "  • {} [{}] imp={} {} {}\n",
236                timeline::format_ts(e.ts_ms),
237                layer,
238                e.importance,
239                status_label,
240                crate::safe_truncate(&e.text, 70),
241            ));
242        }
243    }
244    if !ctx.open_loops.is_empty() {
245        out.push_str(&format!(
246            "{} {} open loop{}:\n",
247            AUTO_CONTEXT_HEADER,
248            ctx.open_loops.len(),
249            if ctx.open_loops.len() == 1 { "" } else { "s" }
250        ));
251        for l in ctx.open_loops.iter().take(3) {
252            out.push_str(&format!(
253                "  • #{} imp={} {}\n",
254                l.id,
255                l.importance,
256                crate::safe_truncate(&l.text, 80),
257            ));
258        }
259    }
260    out
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_render_empty() {
269        let ctx = AutoContext::default();
270        let s = render(&ctx);
271        assert!(s.contains(AUTO_CONTEXT_HEADER));
272        assert!(s.contains("OPEN LOOPS: none"));
273        assert!(s.contains("empty"));
274    }
275
276    #[test]
277    fn test_render_compact_includes_timeline_and_loops() {
278        let mut ctx = AutoContext::default();
279        ctx.timeline_entries.push(timeline::TimelineEntry {
280            ts_ms: 1_700_000_000_000,
281            layer_id: 1,
282            importance: 7,
283            depth: 4,
284            status: timeline::STATUS_NORMAL,
285            text: "compacted".to_string(),
286        });
287        ctx.open_loops.push(open_loops::OpenLoopEntry {
288            id: 1,
289            ts_ms: 1_700_000_000_000,
290            importance: 8,
291            status: open_loops::STATUS_OPEN,
292            text: "todo".to_string(),
293        });
294        let s = render_compact(&ctx);
295        assert!(s.contains("compacted"));
296        assert!(s.contains("#1"));
297        assert!(s.contains("todo"));
298    }
299}