Skip to main content

nexus_memory_agent/
runtime_state.rs

1//! Runtime state persistence, session key derivation, and helper types.
2
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use nexus_core::{CognitiveLevel, CognitiveMetadata, MemoryCategory};
7use nexus_storage::repository::{MemoryRepository, NamespaceRepository, StoreMemoryParams};
8use nexus_storage::StorageManager;
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11
12use crate::error::AgentError;
13
14// ── Public enums ──────────────────────────────────────────────────────
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum RuntimeMode {
18    SessionScoped,
19    Persistent,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RuntimeShutdownReason {
24    SessionEnded,
25    IdleTimeout,
26    Manual,
27}
28
29// ── Internal serialisation types ──────────────────────────────────────
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub(crate) struct RuntimeState {
33    pub agent_type: String,
34    pub session_key: String,
35    pub mode: RuntimeModeSerde,
36    pub started_at: DateTime<Utc>,
37    pub updated_at: DateTime<Utc>,
38}
39
40pub(crate) struct RuntimeMarker<'a> {
41    pub agent_type: &'a str,
42    pub session_key: Option<&'a str>,
43    pub cwd: Option<&'a str>,
44    pub event: &'a str,
45    pub detail: &'a str,
46    pub agent_namespace: &'a str,
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub(crate) enum RuntimeModeSerde {
52    SessionScoped,
53    Persistent,
54}
55
56impl From<RuntimeMode> for RuntimeModeSerde {
57    fn from(value: RuntimeMode) -> Self {
58        match value {
59            RuntimeMode::SessionScoped => Self::SessionScoped,
60            RuntimeMode::Persistent => Self::Persistent,
61        }
62    }
63}
64
65// ── State persistence helpers ─────────────────────────────────────────
66
67pub(crate) fn state_root() -> PathBuf {
68    if let Some(dir) = dirs::state_dir() {
69        dir.join("nexus-memory-system").join("runtime")
70    } else {
71        std::env::var("HOME")
72            .map(|h| PathBuf::from(h).join(".local/state/nexus-memory-system/runtime"))
73            .unwrap_or_else(|_| PathBuf::from(".nexus-runtime"))
74    }
75}
76
77pub(crate) fn state_file_path(agent_type: &str, session_key: &str) -> Result<PathBuf, AgentError> {
78    let root = state_root().join("sessions");
79    std::fs::create_dir_all(&root)?;
80    Ok(root.join(format!(
81        "{}__{}.json",
82        sanitize_component(agent_type),
83        sanitize_component(session_key)
84    )))
85}
86
87pub(crate) fn read_runtime_state(path: &Path) -> Result<Option<RuntimeState>, AgentError> {
88    if !path.exists() {
89        return Ok(None);
90    }
91
92    let contents = std::fs::read_to_string(path)?;
93    let state =
94        serde_json::from_str(&contents).map_err(|e| AgentError::Supervisor(e.to_string()))?;
95    Ok(Some(state))
96}
97
98pub(crate) fn write_runtime_state(path: &Path, state: &RuntimeState) -> Result<(), AgentError> {
99    let contents =
100        serde_json::to_string_pretty(state).map_err(|e| AgentError::Supervisor(e.to_string()))?;
101    std::fs::write(path, contents)?;
102    Ok(())
103}
104
105// ── Session key derivation ────────────────────────────────────────────
106
107pub fn derive_session_key(
108    agent_type: &str,
109    session_key: Option<&str>,
110    cwd: Option<&str>,
111) -> String {
112    if let Some(value) = session_key.filter(|value| !value.trim().is_empty()) {
113        return value.to_string();
114    }
115
116    let canonical_agent = nexus_core::canonicalize_agent_type(agent_type);
117    let fallback_scope = cwd
118        .filter(|value| !value.trim().is_empty())
119        .map(nexus_core::normalize_project_path)
120        .unwrap_or_else(|| "unknown-cwd".to_string());
121
122    let derived_key = format!(
123        "derived-{}-{}",
124        sanitize_component(&canonical_agent),
125        sanitize_component(&fallback_scope)
126    );
127
128    if derived_key.len() <= 128 {
129        derived_key
130    } else {
131        // FxHash-style inline hash (same pattern as soul_content_hash)
132        let inline_hash = |input: &str| -> u64 {
133            let mut h: u64 = 0;
134            for chunk in input.as_bytes().chunks(8) {
135                let mut buf = [0u8; 8];
136                buf[..chunk.len()].copy_from_slice(chunk);
137                h = h
138                    .wrapping_mul(0x517cc1b727220a95)
139                    .wrapping_add(u64::from_le_bytes(buf));
140            }
141            h
142        };
143
144        let path_hash = inline_hash(&sanitize_component(&fallback_scope));
145        let agent = sanitize_component(&canonical_agent);
146        // "derived-" (8) + agent + "-" (1) + hex (16) = 25 + agent.len()
147        if 25 + agent.len() <= 128 {
148            format!("derived-{}-{:016x}", agent, path_hash)
149        } else {
150            // Agent component too long — hash it too
151            let agent_hash = inline_hash(&agent);
152            format!("derived-{:016x}-{:016x}", agent_hash, path_hash)
153        }
154    }
155}
156
157pub(crate) fn sanitize_component(value: &str) -> String {
158    value
159        .chars()
160        .map(|ch| {
161            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
162                ch
163            } else {
164                '_'
165            }
166        })
167        .collect()
168}
169
170pub(crate) fn runtime_reason_label(reason: RuntimeShutdownReason) -> &'static str {
171    match reason {
172        RuntimeShutdownReason::SessionEnded => "session-ended",
173        RuntimeShutdownReason::IdleTimeout => "idle-timeout",
174        RuntimeShutdownReason::Manual => "manual",
175    }
176}
177
178pub(crate) async fn namespace_id_for(
179    agent_type: &str,
180    storage: &StorageManager,
181) -> Result<i64, AgentError> {
182    let canonical = nexus_core::canonicalize_agent_type(agent_type);
183    let namespace_repo = NamespaceRepository::new(storage.pool().clone());
184    namespace_repo
185        .get_or_create(&canonical, &canonical)
186        .await
187        .map(|namespace| namespace.id)
188        .map_err(|error| AgentError::Storage(error.to_string()))
189}
190
191pub(crate) async fn store_runtime_marker(
192    memory_repo: &MemoryRepository,
193    namespace_id: i64,
194    marker: RuntimeMarker<'_>,
195) -> Result<(), AgentError> {
196    let session_tag = derive_session_key(marker.agent_type, marker.session_key, marker.cwd);
197    let content = format!(
198        "Runtime {} for {} [session:{}] ({})",
199        marker.event.replace('_', " "),
200        marker.agent_type,
201        session_tag,
202        marker.detail
203    );
204    let metadata = json!({
205        "runtime": {
206            "event": marker.event,
207            "detail": marker.detail,
208            "session_key": marker.session_key,
209            "derived_session_key": session_tag,
210            "cwd": marker.cwd,
211            "agent_type": marker.agent_type,
212            "agent_namespace": marker.agent_namespace,
213            "captured_at": Utc::now(),
214        }
215    });
216    let mut cognitive = CognitiveMetadata::new(
217        CognitiveLevel::Explicit,
218        marker.agent_type,
219        marker.agent_type,
220        Some(session_tag.clone()),
221        "runtime_controller",
222    );
223    cognitive.confidence = Some(1.0);
224    cognitive.times_reinforced = 0;
225    cognitive.times_contradicted = 0;
226    cognitive.derived_at = Some(Utc::now());
227    cognitive.generated_by = Some("runtime_controller".to_string());
228    let metadata = cognitive.merge_into(&metadata);
229
230    memory_repo
231        .store(StoreMemoryParams {
232            namespace_id,
233            content: &content,
234            category: &MemoryCategory::Session,
235            memory_lane_type: None,
236            labels: &[
237                "runtime".to_string(),
238                "session".to_string(),
239                marker.event.to_string(),
240            ],
241            metadata: &metadata,
242            embedding: None,
243            embedding_model: None,
244        })
245        .await
246        .map_err(|e| AgentError::Storage(e.to_string()))?;
247
248    Ok(())
249}