agentmux_srv\server/
native_memory_handlers.rs1use std::sync::Arc;
15use std::path::PathBuf;
16
17use crate::backend::base::expand_home_dir_safe;
18use crate::backend::rpc::engine::WshRpcEngine;
19use crate::backend::rpc_types::{
20 COMMAND_NATIVE_MEMORY_LIST,
21 COMMAND_NATIVE_MEMORY_READ_FILE,
22 COMMAND_NATIVE_MEMORY_WRITE_FILE,
23 CommandNativeMemoryListData,
24 CommandNativeMemoryReadFileData,
25 CommandNativeMemoryWriteFileData,
26 NativeMemoryFileMeta,
27 NativeMemoryListResult,
28 NativeMemoryReadFileResult,
29};
30
31use super::AppState;
32
33fn memory_dir_for_cwd(working_directory: &str) -> PathBuf {
40 let sanitized: String = working_directory
41 .chars()
42 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
43 .collect();
44
45 let folder_name = if sanitized.len() > 200 {
47 let hash = djb2_hash(working_directory);
48 let truncated = &sanitized[..200];
49 format!("{truncated}-{}", radix_36(hash))
50 } else {
51 sanitized
52 };
53
54 expand_home_dir_safe("~/.claude/projects")
55 .join(folder_name)
56 .join("memory")
57}
58
59fn djb2_hash(s: &str) -> u32 {
64 let mut hash: i32 = 0;
65 for unit in s.encode_utf16() {
66 hash = hash.wrapping_shl(5).wrapping_sub(hash).wrapping_add(unit as i32);
67 }
68 hash.unsigned_abs()
69}
70
71fn radix_36(mut n: u32) -> String {
73 if n == 0 {
74 return "0".to_string();
75 }
76 const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
77 let mut buf = Vec::new();
78 while n > 0 {
79 buf.push(DIGITS[(n % 36) as usize]);
80 n /= 36;
81 }
82 buf.reverse();
83 String::from_utf8(buf).unwrap_or_else(|_| "0".to_string())
84}
85
86fn validate_filename(filename: &str) -> Result<(), String> {
88 if filename.is_empty() {
89 return Err("filename must not be empty".to_string());
90 }
91 if !filename.ends_with(".md") {
92 return Err(format!("filename must end with .md, got: {filename}"));
93 }
94 if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
95 return Err(format!("filename must not contain path separators: {filename}"));
96 }
97 let stem = &filename[..filename.len() - 3];
98 if stem.is_empty() {
99 return Err("filename stem must not be empty (.md is not a valid name)".to_string());
100 }
101 if stem.len() > 200 {
104 return Err(format!("filename stem too long ({} chars, max 200)", stem.len()));
105 }
106 if !stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
107 return Err(format!(
108 "filename stem must be alphanumeric + '-_', got: {stem}"
109 ));
110 }
111 Ok(())
112}
113
114fn parse_frontmatter_type(content: &str) -> Option<String> {
120 let content = content.trim_start();
121 if !content.starts_with("---") {
122 return None;
123 }
124 let rest = content.strip_prefix("---")?.trim_start_matches('\n');
125 let end = rest.find("\n---")?;
126 let frontmatter = &rest[..end];
127 let mut in_metadata = false;
128 for line in frontmatter.lines() {
129 let trimmed = line.trim_end();
130 if trimmed == "metadata:" {
131 in_metadata = true;
132 continue;
133 }
134 if in_metadata {
135 if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
137 break;
138 }
139 if let Some(val) = line.trim_start().strip_prefix("type:") {
140 let val = val.trim().trim_matches('"').trim_matches('\'');
141 if !val.is_empty() {
142 return Some(val.to_string());
143 }
144 }
145 }
146 }
147 None
148}
149
150pub fn register_native_memory_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
151 let wstore_list = state.wstore.clone();
152 engine.register_handler(
153 COMMAND_NATIVE_MEMORY_LIST,
154 Box::new(move |data, _ctx| {
155 let wstore = wstore_list.clone();
156 Box::pin(async move {
157 let cmd: CommandNativeMemoryListData = serde_json::from_value(data)
158 .map_err(|e| format!("agent:memory:list: {e}"))?;
159
160 let agent = wstore
161 .agent_def_get(&cmd.agent_id)
162 .map_err(|e| format!("agent:memory:list: store: {e}"))?
163 .ok_or_else(|| format!("agent:memory:list: agent {} not found", cmd.agent_id))?;
164
165 if agent.working_directory.is_empty() {
168 return Ok(Some(serde_json::to_value(NativeMemoryListResult { files: vec![] }).map_err(|e| e.to_string())?));
169 }
170
171 let memory_dir = memory_dir_for_cwd(&agent.working_directory);
172
173 let mut files: Vec<NativeMemoryFileMeta> = Vec::new();
174 let entries = match std::fs::read_dir(&memory_dir) {
177 Ok(e) => e,
178 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
179 return Ok(Some(serde_json::to_value(NativeMemoryListResult { files: vec![] }).map_err(|e| e.to_string())?));
180 }
181 Err(e) => return Err(format!("agent:memory:list: read_dir: {e}")),
182 };
183
184 for entry in entries {
185 let entry = entry.map_err(|e| format!("agent:memory:list: read_dir entry: {e}"))?;
186 let name = entry.file_name().to_string_lossy().into_owned();
187 if !name.ends_with(".md") {
188 continue;
189 }
190 let file_type = entry
192 .file_type()
193 .map_err(|e| format!("agent:memory:list: file_type {name}: {e}"))?;
194 if !file_type.is_file() {
195 continue;
196 }
197 let meta = match entry.metadata() {
200 Ok(m) => m,
201 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
202 Err(e) => return Err(format!("agent:memory:list: metadata {name}: {e}")),
203 };
204 let size_bytes = meta.len();
205 let modified_at = meta
206 .modified()
207 .ok()
208 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
209 .map(|d| d.as_millis() as i64)
210 .unwrap_or(0);
211
212 let preview_content = {
216 use std::io::Read;
217 std::fs::File::open(entry.path())
218 .map(|f| {
219 let mut buf = Vec::with_capacity(512);
220 f.take(512).read_to_end(&mut buf).ok();
221 String::from_utf8_lossy(&buf).into_owned()
222 })
223 .unwrap_or_default()
224 };
225 let metadata_type = parse_frontmatter_type(&preview_content);
226 let is_index = name == "MEMORY.md";
227
228 files.push(NativeMemoryFileMeta {
229 filename: name,
230 is_index,
231 metadata_type,
232 size_bytes,
233 modified_at,
234 });
235 }
236
237 files.sort_by(|a, b| {
239 b.is_index.cmp(&a.is_index).then(a.filename.cmp(&b.filename))
240 });
241
242 Ok(Some(serde_json::to_value(NativeMemoryListResult { files }).map_err(|e| e.to_string())?))
243 })
244 }),
245 );
246
247 let wstore_read = state.wstore.clone();
248 engine.register_handler(
249 COMMAND_NATIVE_MEMORY_READ_FILE,
250 Box::new(move |data, _ctx| {
251 let wstore = wstore_read.clone();
252 Box::pin(async move {
253 let cmd: CommandNativeMemoryReadFileData = serde_json::from_value(data)
254 .map_err(|e| format!("agent:memory:read_file: {e}"))?;
255
256 validate_filename(&cmd.filename)
257 .map_err(|e| format!("agent:memory:read_file: {e}"))?;
258
259 let agent = wstore
260 .agent_def_get(&cmd.agent_id)
261 .map_err(|e| format!("agent:memory:read_file: store: {e}"))?
262 .ok_or_else(|| format!("agent:memory:read_file: agent {} not found", cmd.agent_id))?;
263
264 if agent.working_directory.is_empty() {
265 return Err(format!("agent:memory:read_file: agent {} has no configured working directory", cmd.agent_id));
266 }
267
268 let path = memory_dir_for_cwd(&agent.working_directory).join(&cmd.filename);
269 let file_type = std::fs::symlink_metadata(&path)
271 .map_err(|e| format!("agent:memory:read_file: {}: {e}", cmd.filename))?
272 .file_type();
273 if !file_type.is_file() {
274 return Err(format!("agent:memory:read_file: {} is not a regular file", cmd.filename));
275 }
276 const MAX_READ_BYTES: u64 = 10 * 1024 * 1024;
279 let mut buf = Vec::new();
280 std::fs::File::open(&path)
281 .and_then(|f| {
282 use std::io::Read;
283 f.take(MAX_READ_BYTES).read_to_end(&mut buf)
284 })
285 .map_err(|e| format!("agent:memory:read_file: {}: {e}", cmd.filename))?;
286 let content = String::from_utf8_lossy(&buf).into_owned();
287
288 Ok(Some(serde_json::to_value(NativeMemoryReadFileResult { content }).map_err(|e| e.to_string())?))
289 })
290 }),
291 );
292
293 let wstore_write = state.wstore.clone();
294 engine.register_handler(
295 COMMAND_NATIVE_MEMORY_WRITE_FILE,
296 Box::new(move |data, _ctx| {
297 let wstore = wstore_write.clone();
298 Box::pin(async move {
299 let cmd: CommandNativeMemoryWriteFileData = serde_json::from_value(data)
300 .map_err(|e| format!("agent:memory:write_file: {e}"))?;
301
302 validate_filename(&cmd.filename)
303 .map_err(|e| format!("agent:memory:write_file: {e}"))?;
304
305 const MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; if cmd.content.len() > MAX_CONTENT_BYTES {
307 return Err(format!(
308 "agent:memory:write_file: content too large ({} bytes, max {})",
309 cmd.content.len(),
310 MAX_CONTENT_BYTES,
311 ));
312 }
313
314 let agent = wstore
315 .agent_def_get(&cmd.agent_id)
316 .map_err(|e| format!("agent:memory:write_file: store: {e}"))?
317 .ok_or_else(|| format!("agent:memory:write_file: agent {} not found", cmd.agent_id))?;
318
319 if agent.working_directory.is_empty() {
320 return Err(format!("agent:memory:write_file: agent {} has no configured working directory", cmd.agent_id));
321 }
322
323 let dir = memory_dir_for_cwd(&agent.working_directory);
324 std::fs::create_dir_all(&dir)
325 .map_err(|e| format!("agent:memory:write_file: mkdir: {e}"))?;
326
327 let dest = dir.join(&cmd.filename);
328 let tmp = dir.join(format!(".{}.{}.tmp", cmd.filename, uuid::Uuid::new_v4()));
332
333 if let Err(e) = std::fs::write(&tmp, &cmd.content) {
335 let _ = std::fs::remove_file(&tmp);
336 return Err(format!("agent:memory:write_file: write tmp: {e}"));
337 }
338 if let Err(e) = std::fs::rename(&tmp, &dest) {
339 let _ = std::fs::remove_file(&tmp);
340 return Err(format!("agent:memory:write_file: rename: {e}"));
341 }
342
343 tracing::info!(
344 agent_id = %cmd.agent_id,
345 filename = %cmd.filename,
346 bytes = cmd.content.len(),
347 "agent:memory:write_file"
348 );
349 Ok(None)
350 })
351 }),
352 );
353}