Skip to main content

zeph_core/agent/
memory_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`zeph_commands::MemoryAccess`] implementation for [`Agent<C>`]: memory tier stats and
5//! promotion, the cross-thread key-value store, and compression guidelines.
6//!
7//! [`Agent<C>`]: super::Agent
8
9use std::fmt::Write as _;
10use std::future::Future;
11use std::pin::Pin;
12
13use tracing::Instrument as _;
14use zeph_commands::{CommandError, MemoryAccess};
15use zeph_memory::MessageId;
16
17use super::Agent;
18use crate::channel::Channel;
19
20impl<C: Channel + Send + 'static> MemoryAccess for Agent<C> {
21    // ----- /memory -----
22
23    fn memory_tiers<'a>(
24        &'a mut self,
25    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
26        Box::pin(
27            async move {
28                let Some(memory) = self.services.memory.persistence.memory.clone() else {
29                    return Ok("Memory not configured.".to_owned());
30                };
31                match memory.sqlite().count_messages_by_tier().await {
32                    Ok((episodic, semantic)) => {
33                        let mut out = String::new();
34                        let _ = writeln!(out, "Memory tiers:");
35                        let _ = writeln!(out, "  Working:  (current context window — virtual)");
36                        let _ = writeln!(out, "  Episodic: {episodic} messages");
37                        let _ = writeln!(out, "  Semantic: {semantic} facts");
38                        Ok(out.trim_end().to_owned())
39                    }
40                    Err(e) => Ok(format!("Failed to query tier stats: {e}")),
41                }
42            }
43            .instrument(tracing::info_span!("core.agent_access.memory_tiers")),
44        )
45    }
46
47    fn memory_promote<'a>(
48        &'a mut self,
49        ids_str: &'a str,
50    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
51        Box::pin(
52            async move {
53                let Some(memory) = self.services.memory.persistence.memory.clone() else {
54                    return Ok("Memory not configured.".to_owned());
55                };
56                let ids: Vec<MessageId> = ids_str
57                    .split_whitespace()
58                    .filter_map(|s| s.parse::<i64>().ok().map(MessageId))
59                    .collect();
60                if ids.is_empty() {
61                    return Ok(
62                        "Usage: /memory promote <id> [id...]\nExample: /memory promote 42 43 44"
63                            .to_owned(),
64                    );
65                }
66                match memory.sqlite().manual_promote(&ids).await {
67                    Ok(count) => Ok(format!("Promoted {count} message(s) to semantic tier.")),
68                    Err(e) => Ok(format!("Promotion failed: {e}")),
69                }
70            }
71            .instrument(tracing::info_span!("core.agent_access.memory_promote")),
72        )
73    }
74
75    // ----- /store -----
76
77    fn store_command<'a>(
78        &'a mut self,
79        args: &'a str,
80    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
81        Box::pin(
82            async move {
83                const USAGE: &str = "Usage: /store {get <ns> <key> | put <ns> <key> <value...> \
84                                      | list <ns_prefix> [limit] | delete <ns> <key>}";
85
86                let store_config = self.services.memory.persistence.store_config.clone();
87                if !store_config.enabled {
88                    return Ok(
89                        "Cross-thread store is disabled ([memory.store].enabled = false)."
90                            .to_owned(),
91                    );
92                }
93                let Some(memory) = self.services.memory.persistence.memory.clone() else {
94                    return Ok("Memory not configured.".to_owned());
95                };
96
97                let owner_key = self.services.session.owner_key.as_str();
98                let mut parts = args.split_whitespace();
99                let Some(sub) = parts.next() else {
100                    return Ok(USAGE.to_owned());
101                };
102
103                let result = match sub {
104                    "get" => {
105                        let (Some(ns), Some(key)) = (parts.next(), parts.next()) else {
106                            return Ok("Usage: /store get <namespace> <key>".to_owned());
107                        };
108                        match memory.sqlite().store_get(owner_key, ns, key).await {
109                            Ok(Some(item)) => item.value,
110                            Ok(None) => format!("No value found for {ns}/{key}."),
111                            Err(e) => return Err(CommandError::new(e.to_string())),
112                        }
113                    }
114                    "put" => {
115                        let (Some(ns), Some(key)) = (parts.next(), parts.next()) else {
116                            return Ok("Usage: /store put <namespace> <key> <value...>".to_owned());
117                        };
118                        let value = parts.collect::<Vec<_>>().join(" ");
119                        if value.is_empty() {
120                            return Ok("Usage: /store put <namespace> <key> <value...>".to_owned());
121                        }
122                        match memory
123                            .sqlite()
124                            .store_put(
125                                owner_key,
126                                ns,
127                                key,
128                                &value,
129                                store_config.max_value_bytes,
130                                None,
131                            )
132                            .await
133                        {
134                            Ok(item) => format!("Stored {ns}/{key} (version {}).", item.version),
135                            Err(e) => return Err(CommandError::new(e.to_string())),
136                        }
137                    }
138                    "list" => {
139                        let prefix = parts.next().unwrap_or("");
140                        let limit = parts
141                            .next()
142                            .and_then(|s| s.parse::<usize>().ok())
143                            .unwrap_or(0);
144                        match memory.sqlite().store_list(owner_key, prefix, limit).await {
145                            Ok(items) if items.is_empty() => "No rows found.".to_owned(),
146                            Ok(items) => items
147                                .iter()
148                                .map(|i| {
149                                    format!(
150                                        "{}/{} = {} (v{})",
151                                        i.namespace, i.key, i.value, i.version
152                                    )
153                                })
154                                .collect::<Vec<_>>()
155                                .join("\n"),
156                            Err(e) => return Err(CommandError::new(e.to_string())),
157                        }
158                    }
159                    "delete" => {
160                        let (Some(ns), Some(key)) = (parts.next(), parts.next()) else {
161                            return Ok("Usage: /store delete <namespace> <key>".to_owned());
162                        };
163                        match memory.sqlite().store_delete(owner_key, ns, key).await {
164                            Ok(true) => format!("Deleted {ns}/{key}."),
165                            Ok(false) => format!("No value found for {ns}/{key}."),
166                            Err(e) => return Err(CommandError::new(e.to_string())),
167                        }
168                    }
169                    _ => USAGE.to_owned(),
170                };
171                Ok(result)
172            }
173            .instrument(tracing::info_span!("core.agent_access.store_command")),
174        )
175    }
176
177    // ----- /guidelines -----
178
179    fn guidelines<'a>(
180        &'a mut self,
181    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
182        Box::pin(
183            async move {
184                const MAX_DISPLAY_CHARS: usize = 4096;
185
186                let Some(memory) = &self.services.memory.persistence.memory else {
187                    return Ok("No memory backend initialised.".to_owned());
188                };
189
190                let cid = self.services.memory.persistence.conversation_id;
191                let sqlite = memory.sqlite();
192
193                let (version, text) = sqlite
194                    .load_compression_guidelines(cid)
195                    .await
196                    .map_err(|e: zeph_memory::MemoryError| CommandError::new(e.to_string()))?;
197
198                if version == 0 || text.is_empty() {
199                    return Ok("No compression guidelines generated yet.".to_owned());
200                }
201
202                let (_, created_at) = sqlite
203                    .load_compression_guidelines_meta(cid)
204                    .await
205                    .unwrap_or((0, String::new()));
206
207                let (body, truncated) = if text.len() > MAX_DISPLAY_CHARS {
208                    let end = text.floor_char_boundary(MAX_DISPLAY_CHARS);
209                    (&text[..end], true)
210                } else {
211                    (text.as_str(), false)
212                };
213
214                let mut output =
215                    format!("Compression Guidelines (v{version}, updated {created_at}):\n\n{body}");
216                if truncated {
217                    output.push_str("\n\n[truncated]");
218                }
219                Ok(output)
220            }
221            .instrument(tracing::info_span!("core.agent_access.guidelines")),
222        )
223    }
224}