Skip to main content

zagens_topic_memory/
lib.rs

1//! Topic memory graph — incremental conversation topic map for prompt injection (B2).
2//!
3//! Port of [topic-memory-graph](https://github.com/didclawapp-ai/topic-memory-graph);
4//! reference TS: `docs/topic-memory-graph-main/src/`.
5
6mod engine;
7mod extract;
8mod graph;
9mod inject;
10mod metrics;
11mod retrieve;
12mod stopwords;
13
14pub use engine::{
15    DEFAULT_INJECT_INTERVAL_RUNS, GenerateMemorySectionOptions, apply_decay, empty_graph,
16    generate_memory_section, should_inject_memory, today_str, update_graph,
17};
18pub use extract::{detect_blocked_topics, detect_emotion, extract_topics};
19pub use graph::{
20    BlockedPoint, CognitiveTrail, EmotionMode, GRAPH_SCHEMA_VERSION, PheromoneEdge, PheromoneGraph,
21    PheromoneNode,
22};
23pub use inject::{DEFAULT_MARKERS, MemorySectionMarkers, inject_memory_section};
24pub use metrics::{
25    TopicMemoryEvalComparison, TopicMemoryEvalReport, TopicMemoryMetrics, compare_eval,
26    eval_report, load_metrics, metrics_path_for_graph, record_inject, record_turn_update,
27    save_metrics,
28};
29pub use retrieve::{
30    DEFAULT_RETRIEVE_K_HOPS, induced_subgraph, retrieve_for_query, retrieve_k_hop_subgraph,
31};
32
33use std::fs;
34use std::path::Path;
35
36/// Load graph from JSON path; returns empty graph when missing or invalid.
37#[must_use]
38pub fn load_graph(path: &Path) -> PheromoneGraph {
39    let Ok(raw) = fs::read_to_string(path) else {
40        return empty_graph();
41    };
42    serde_json::from_str(&raw).unwrap_or_else(|_| empty_graph())
43}
44
45/// Persist graph atomically (best-effort).
46pub fn save_graph(path: &Path, graph: &PheromoneGraph) -> std::io::Result<()> {
47    if let Some(parent) = path.parent() {
48        fs::create_dir_all(parent)?;
49    }
50    let json = serde_json::to_string_pretty(graph)?;
51    let tmp = path.with_extension("json.tmp");
52    fs::write(&tmp, json)?;
53    fs::rename(tmp, path)?;
54    Ok(())
55}
56
57/// Wrap markdown in `<topic_memory>` for system prompt assembly.
58#[must_use]
59pub fn as_system_block(content: &str, source: &Path) -> Option<String> {
60    let trimmed = content.trim();
61    if trimmed.is_empty() {
62        return None;
63    }
64    Some(format!(
65        "<topic_memory source=\"{}\">\n{trimmed}\n</topic_memory>",
66        source.display()
67    ))
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use tempfile::tempdir;
74
75    #[test]
76    fn round_trip_save_load() {
77        let dir = tempdir().expect("tempdir");
78        let path = dir.path().join("topic-memory.json");
79        let mut g = empty_graph();
80        g = update_graph(&g, "hello Rust", "hi there");
81        save_graph(&path, &g).expect("save");
82        let loaded = load_graph(&path);
83        assert_eq!(loaded.nodes.len(), g.nodes.len());
84    }
85}