Skip to main content

agentmux_srv/server/app_api/
memory.rs

1use super::*;
2
3/// `memory.list`: the agent whose memory files to list.
4#[derive(Debug, serde::Deserialize, ts_rs::TS)]
5#[ts(export, export_to = "../../../frontend/types/rpc/")]
6pub struct MemoryListReq {
7    pub agent_id: String,
8}
9
10/// `memory.read`: one of the agent's memory files.
11#[derive(Debug, serde::Deserialize, ts_rs::TS)]
12#[ts(export, export_to = "../../../frontend/types/rpc/")]
13pub struct MemoryReadReq {
14    pub agent_id: String,
15    pub filename: String,
16}
17
18/// `memory.write`: one of the agent's memory files, and its new content.
19#[derive(Debug, serde::Deserialize, ts_rs::TS)]
20#[ts(export, export_to = "../../../frontend/types/rpc/")]
21pub struct MemoryWriteReq {
22    pub agent_id: String,
23    pub filename: String,
24    pub content: String,
25}
26
27pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
28    register_memory_list(engine, state);
29    register_memory_read(engine, state);
30    register_memory_write(engine, state);
31}
32
33fn register_memory_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
34    let state = state.clone();
35    engine.register_typed(COMMAND_MEMORY_LIST, move |req: MemoryListReq, ctx| {
36        let state = state.clone();
37        async move {
38            check_s1(&state.mstore, &ctx, &req.agent_id)?;
39            memory_list_impl(&state, &req.agent_id)
40        }
41    });
42}
43
44fn register_memory_read(engine: &Arc<WshRpcEngine>, state: &AppState) {
45    let state = state.clone();
46    engine.register_typed(COMMAND_MEMORY_READ, move |req: MemoryReadReq, ctx| {
47        let state = state.clone();
48        async move {
49            check_s1(&state.mstore, &ctx, &req.agent_id)?;
50            memory_read_impl(&state, &req.agent_id, &req.filename)
51        }
52    });
53}
54
55fn register_memory_write(engine: &Arc<WshRpcEngine>, state: &AppState) {
56    let state = state.clone();
57    engine.register_typed(COMMAND_MEMORY_WRITE, move |req: MemoryWriteReq, ctx| {
58        let state = state.clone();
59        async move {
60            check_s1(&state.mstore, &ctx, &req.agent_id)?;
61            memory_write_impl(&state, &req.agent_id, &req.filename, &req.content, None)
62        }
63    });
64}
65
66pub(crate) fn memory_list_impl<'o>(
67    state: &AppState,
68    owner: impl Into<SelfOwner<'o>>,
69) -> Result<NativeMemoryListResult, String> {
70    let owner = owner.into();
71    let memory_dir = owner.dir(&state.mstore).map_err(|e| format!("memory.list: {e}"))?;
72
73    let mut files: Vec<NativeMemoryFileMeta> = Vec::new();
74    let entries = match std::fs::read_dir(&memory_dir) {
75        Ok(e) => e,
76        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
77            return Ok(NativeMemoryListResult { files: Vec::new(), unverified: false });
78        }
79        Err(e) => return Err(format!("memory.list: read_dir: {e}")),
80    };
81    for entry in entries {
82        let entry = entry.map_err(|e| format!("memory.list: {e}"))?;
83        let name = entry.file_name().to_string_lossy().into_owned();
84        if !name.ends_with(".md") { continue; }
85        let file_type = entry.file_type()
86            .map_err(|e| format!("memory.list: file_type {name}: {e}"))?;
87        if !file_type.is_file() { continue; }
88        let meta = match entry.metadata() {
89            Ok(m) => m,
90            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
91            Err(e) => return Err(format!("memory.list: metadata {name}: {e}")),
92        };
93        let preview_content = {
94            use std::io::Read;
95            std::fs::File::open(entry.path())
96                .map(|f| {
97                    let mut buf = Vec::with_capacity(512);
98                    f.take(512).read_to_end(&mut buf).ok();
99                    String::from_utf8_lossy(&buf).into_owned()
100                })
101                .unwrap_or_default()
102        };
103        files.push(NativeMemoryFileMeta {
104            is_index: name == "MEMORY.md",
105            metadata_type: crate::server::native_memory_handlers::parse_memory_frontmatter_type(&preview_content),
106            size_bytes: meta.len(),
107            modified_at: meta.modified().ok()
108                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
109                .map(|d| d.as_millis() as i64)
110                .unwrap_or(0),
111            filename: name,
112        });
113    }
114    files.sort_by(|a, b| b.is_index.cmp(&a.is_index).then(a.filename.cmp(&b.filename)));
115    Ok(NativeMemoryListResult { files, unverified: false })
116}
117
118pub(crate) fn memory_read_impl<'o>(
119    state: &AppState,
120    owner: impl Into<SelfOwner<'o>>,
121    filename: &str,
122) -> Result<NativeMemoryReadFileResult, String> {
123    let owner = owner.into();
124    crate::server::native_memory_handlers::validate_memory_filename(filename)
125        .map_err(|e| format!("memory.read: {e}"))?;
126    let path = owner.dir(&state.mstore).map_err(|e| format!("memory.read: {e}"))?.join(filename);
127
128    let file_type = std::fs::symlink_metadata(&path)
129        .map_err(|e| format!("memory.read: {filename}: {e}"))?.file_type();
130    if !file_type.is_file() {
131        return Err(format!("memory.read: {filename} is not a regular file"));
132    }
133    const MAX: u64 = 10 * 1024 * 1024;
134    let mut buf = Vec::new();
135    std::fs::File::open(&path)
136        .and_then(|f| { use std::io::Read; f.take(MAX).read_to_end(&mut buf) })
137        .map_err(|e| format!("memory.read: {filename}: {e}"))?;
138    let content = String::from_utf8_lossy(&buf).into_owned();
139    Ok(NativeMemoryReadFileResult { content })
140}
141
142/// Caller-supplied provenance for a `memory.write` call — mirrors
143/// `NativeMemoryWriteProvenance` (the WebSocket RPC's own wire shape) but
144/// kept as plain `&str`s here rather than importing that type, since this
145/// impl fn is also called directly from tests without going through the
146/// RPC layer at all. See
147/// docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md §4.1.
148pub(crate) struct MemoryWriteProvenance<'a> {
149    pub source: &'a str,
150    pub detail: &'a str,
151}
152
153pub(crate) fn memory_write_impl<'o>(
154    state: &AppState,
155    owner: impl Into<SelfOwner<'o>>,
156    filename: &str,
157    content: &str,
158    provenance: Option<MemoryWriteProvenance<'_>>,
159) -> Result<(), String> {
160    let owner = owner.into();
161    let agent_id = owner.label();
162    crate::server::native_memory_handlers::validate_memory_filename(filename)
163        .map_err(|e| format!("memory.write: {e}"))?;
164    const MAX: usize = 10 * 1024 * 1024;
165    if content.len() > MAX {
166        return Err(format!("memory.write: content too large ({} bytes, max {MAX})", content.len()));
167    }
168    let dir = owner.dir_for_write(&state.mstore).map_err(|e| format!("memory.write: {e}"))?;
169    std::fs::create_dir_all(&dir).map_err(|e| format!("memory.write: mkdir: {e}"))?;
170
171    // Version history recorded BEFORE the live-file write below — reagent
172    // P1: closes the same fs-watch race described in
173    // native_memory_handlers.rs's write_file handler. This is the path the
174    // MemoryWrite MCP tool actually calls in production — instrumenting it
175    // matters at least as much as the WebSocket RPC path, since it's what
176    // an agent's own MemoryWrite tool call hits. Non-fatal on failure — a
177    // durability/review layer on top of the write, not the write itself.
178    //
179    // Keyed by the RESOLVED canonical id, not the raw `agent_id` (slug)
180    // parameter — reagent P1: this surface previously stored versions
181    // slug-keyed while the WS RPC surface stores them keyed by the
182    // resolved `AgentDefinition.id`, so a version written here was
183    // invisible to a WS-RPC-based history/diff/revert call for the same
184    // logical agent whenever slug != id. See `resolve_agent_uuid`'s own
185    // doc for the full resolution-order rationale.
186    //
187    // Hard-fails on resolution failure — reagent P2 (re-review): this used
188    // to silently fall back to the raw slug via `unwrap_or_else`, while
189    // memory_history_impl/memory_diff_impl/memory_revert_impl all hard-fail
190    // on the identical call. The owner's directory above already resolved
191    // (proving it resolves via at least one lookup path), so a
192    // failure here is very likely transient (e.g. a registry-file I/O
193    // hiccup) rather than "unknown agent" — but silently keying this
194    // version by the raw slug on that failure would reintroduce the exact
195    // disjoint-keyspace bug (a write invisible to history/diff/revert) this
196    // PR exists to fix. A visible, retriable write failure is strictly
197    // safer than a silent data-integrity split.
198    let version_agent_id = owner.owner_id(&state.mstore)
199        .map_err(|e| format!("memory.write: {e}"))?;
200    let (source, detail) = match &provenance {
201        Some(p) => (p.source, p.detail),
202        None => ("agent_inferred", "{}"),
203    };
204    if let Err(e) = state.id_store.agent_native_memory_version_insert(&version_agent_id, filename, content, source, detail, "") {
205        tracing::warn!(agent_id, filename, error = %e, "memory.write: version insert failed (non-fatal)");
206    }
207    // And into the agent's memory record, likewise first
208    // (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.1).
209    crate::backend::memory_reconcile::record_agentmux_write(&version_agent_id, &dir, filename, content.as_bytes(), source, detail);
210
211    let dest = dir.join(filename);
212    let tmp = dir.join(format!(".{}.{}.tmp", filename, uuid::Uuid::new_v4()));
213    if let Err(e) = std::fs::write(&tmp, content) {
214        let _ = std::fs::remove_file(&tmp);
215        return Err(format!("memory.write: write tmp: {e}"));
216    }
217    if let Err(e) = std::fs::rename(&tmp, &dest) {
218        let _ = std::fs::remove_file(&tmp);
219        return Err(format!("memory.write: rename: {e}"));
220    }
221
222    // reagent P1 on PR #2674 (found on memory_revert_impl, same gap exists
223    // here): this App-API path backing the `MemoryWrite` MCP tool never
224    // updated the `db_agent_native_memory` mirror row, unlike its WS-RPC
225    // sibling `agent:memory:write_file` in native_memory_handlers.rs. Per
226    // `read_file`'s own fallback logic, a channel with no live copy of the
227    // file falls back to the mirror and treats a stale row as permanent —
228    // so a write issued through the MCP tool never propagated cross-channel.
229    let metadata_type = crate::server::native_memory_handlers::parse_memory_frontmatter_type(content);
230    let dest_meta = std::fs::metadata(&dest).ok();
231    let size_bytes = dest_meta.as_ref().map(|m| m.len() as i64).unwrap_or(content.len() as i64);
232    let mtime_ms = dest_meta
233        .and_then(|m| m.modified().ok())
234        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
235        .map(|d| d.as_millis() as i64)
236        .unwrap_or(0);
237    if let Err(e) = state.id_store.agent_native_memory_upsert(
238        &version_agent_id,
239        filename,
240        content,
241        metadata_type.as_deref(),
242        &dest.to_string_lossy(),
243        size_bytes,
244        mtime_ms,
245    ) {
246        tracing::warn!(agent_id, filename, error = %e, "memory.write: mirror upsert failed (non-fatal)");
247    }
248
249    // Keyed by `version_agent_id` (the resolved canonical UUID), not the raw
250    // `agent_id` slug parameter — SPEC_ARMORY_REACTIVE_UPDATES_2026_09_02.md:
251    // the WS RPC surface (native_memory_handlers.rs) publishes this same
252    // event keyed by `agent.id`, and a frontend subscriber only ever has
253    // that UUID (`AgentDefinition.id`), never the App-API-only slug
254    // namespace. Slug-keying here would silently split writes made through
255    // this surface (the MemoryWrite MCP tool) into a keyspace no UI
256    // subscriber can ever match — the exact class of bug this file's own
257    // `resolve_agent_uuid` doc comment already warns about for version
258    // storage; the same reasoning applies to this event.
259    state.broker.publish_event_for::<crate::backend::mps_events::AgentMemoryChanged>(&version_agent_id, &(), vec![], 0);
260    Ok(())
261}
262
263// ---- Global Memory (agent-facing) — SPEC_AGENT_FACING_GLOBAL_MEMORY_API_
264// 2026_09_15.md Phase 1. Unlike native memory above (per-agent, filesystem-
265// backed), Global Memory is a pure `db_bundles` row an agent can read/write
266// through here for the FIRST time — previously only reachable via the
267// authenticated WebSocket RPC channel the frontend uses
268// (`agent_handlers/bundle.rs`'s `upsertmemory`), which `agentmux-mcp`
269// cannot reach at all. Every function here calls the SAME
270// `Store::bundle_upsert`/`bundle_get`/`bundle_list_global` the human-facing
271// Memory editor already uses — no new storage-layer write path, no new way
272// to touch a row this app didn't already know how to touch.
273//
274// The one invariant every function below shares: NONE of them ever read or
275// set `is_system` from caller input, and none of them accept/return a
276// system-tier row. This is enforced twice, independently, on purpose:
277// `Store::bundle_upsert` itself already refuses to touch an existing
278// `is_system=1` row (SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md), and
279// separately, the code below never even constructs a request/response that
280// could carry `is_system=true` in the first place. A future refactor would
281// have to break BOTH independently to let an agent reach the system tier
282// through this surface.
283
284
285pub(crate) fn memory_history_impl<'o>(
286    state: &AppState,
287    owner: impl Into<SelfOwner<'o>>,
288    filename: &str,
289) -> Result<serde_json::Value, String> {
290    let owner = owner.into();
291    crate::server::native_memory_handlers::validate_memory_filename(filename)
292        .map_err(|e| format!("memory.history: {e}"))?;
293    // See memory_write_impl's own comment — must key by the same resolved
294    // canonical id that write used, not the raw slug.
295    let version_agent_id = owner.owner_id(&state.mstore)
296        .map_err(|e| format!("memory.history: {e}"))?;
297    let versions: Vec<crate::backend::rpc_types::NativeMemoryVersionMeta> = state
298        .id_store
299        .agent_native_memory_version_list(&version_agent_id, filename)
300        .map_err(|e| format!("memory.history: store: {e}"))?
301        .into_iter()
302        .map(|v| crate::backend::rpc_types::NativeMemoryVersionMeta {
303            id: v.id,
304            content_hash: v.content_hash,
305            parent_version_id: v.parent_version_id,
306            source: v.source,
307            source_detail: v.source_detail,
308            session_id: v.session_id,
309            created_at: v.created_at,
310        })
311        .collect();
312    serde_json::to_value(crate::backend::rpc_types::NativeMemoryHistoryResult { versions })
313        .map_err(|e| e.to_string())
314}
315
316pub(crate) fn memory_diff_impl<'o>(
317    state: &AppState,
318    owner: impl Into<SelfOwner<'o>>,
319    from_version_id: &str,
320    to_version_id: &str,
321) -> Result<serde_json::Value, String> {
322    let owner = owner.into();
323    let agent_id = owner.label();
324    // See memory_write_impl's own comment — must compare against the same
325    // resolved canonical id that write used, not the raw slug.
326    let version_agent_id = owner.owner_id(&state.mstore)
327        .map_err(|e| format!("memory.diff: {e}"))?;
328    let from = state
329        .id_store
330        .agent_native_memory_version_get(from_version_id)
331        .map_err(|e| format!("memory.diff: store: {e}"))?
332        .ok_or_else(|| format!("memory.diff: version {from_version_id} not found"))?;
333    let to = state
334        .id_store
335        .agent_native_memory_version_get(to_version_id)
336        .map_err(|e| format!("memory.diff: store: {e}"))?
337        .ok_or_else(|| format!("memory.diff: version {to_version_id} not found"))?;
338    // reagent P1 — see the identical check in native_memory_handlers.rs's
339    // WS RPC handler for the full rationale.
340    if from.agent_id != version_agent_id || to.agent_id != version_agent_id {
341        return Err(format!("memory.diff: one or both versions do not belong to {agent_id}"));
342    }
343    // reagent P2 — see the identical check in native_memory_handlers.rs's
344    // WS RPC handler for the full rationale.
345    if from.filename != to.filename {
346        return Err(format!(
347            "memory.diff: from_version_id and to_version_id are versions of different files ({} vs {})",
348            from.filename, to.filename
349        ));
350    }
351    let diff = crate::server::native_memory_handlers::line_diff(&from.content, &to.content);
352    serde_json::to_value(crate::backend::rpc_types::NativeMemoryDiffResult { diff }).map_err(|e| e.to_string())
353}
354
355pub(crate) fn memory_revert_impl<'o>(
356    state: &AppState,
357    owner: impl Into<SelfOwner<'o>>,
358    filename: &str,
359    target_version_id: &str,
360) -> Result<serde_json::Value, String> {
361    let owner = owner.into();
362    let agent_id = owner.label();
363    crate::server::native_memory_handlers::validate_memory_filename(filename)
364        .map_err(|e| format!("memory.revert: {e}"))?;
365
366    // See memory_write_impl's own comment — must key/compare against the
367    // same resolved canonical id that write used, not the raw slug.
368    let version_agent_id = owner.owner_id(&state.mstore)
369        .map_err(|e| format!("memory.revert: {e}"))?;
370
371    let target = state
372        .id_store
373        .agent_native_memory_version_get(target_version_id)
374        .map_err(|e| format!("memory.revert: store: {e}"))?
375        .ok_or_else(|| format!("memory.revert: version {target_version_id} not found"))?;
376    if target.agent_id != version_agent_id || target.filename != filename {
377        return Err(format!(
378            "memory.revert: version {target_version_id} does not belong to {agent_id}/{filename}"
379        ));
380    }
381
382    let dir = owner.dir_for_write(&state.mstore).map_err(|e| format!("memory.revert: {e}"))?;
383    std::fs::create_dir_all(&dir).map_err(|e| format!("memory.revert: mkdir: {e}"))?;
384
385    // Version recorded BEFORE the live-file write below — same fs-watch-race
386    // rationale as memory_write_impl above (reagent P1). Fatal on failure,
387    // same reasoning as the WS RPC revert handler: silently reverting the
388    // file but failing to record what it was reverted to would leave the
389    // caller with no way to know.
390    let detail = json!({ "reverted_to": target_version_id }).to_string();
391    let new_version = state
392        .id_store
393        .agent_native_memory_version_insert(&version_agent_id, filename, &target.content, "revert", &detail, "")
394        .map_err(|e| format!("memory.revert: version insert: {e}"))?;
395
396    let dest = dir.join(filename);
397    let tmp = dir.join(format!(".{}.{}.tmp", filename, uuid::Uuid::new_v4()));
398    if let Err(e) = std::fs::write(&tmp, &target.content) {
399        let _ = std::fs::remove_file(&tmp);
400        return Err(format!("memory.revert: write tmp: {e}"));
401    }
402    if let Err(e) = std::fs::rename(&tmp, &dest) {
403        let _ = std::fs::remove_file(&tmp);
404        return Err(format!("memory.revert: rename: {e}"));
405    }
406
407    // reagent P1 on PR #2674: this App-API path backing the `MemoryRevert`
408    // MCP tool reverted the live file but never updated the
409    // `db_agent_native_memory` mirror row, unlike its WS-RPC sibling
410    // `agent:memory:revert` in native_memory_handlers.rs. Per `read_file`'s
411    // own fallback logic, a channel with no live copy of the file falls
412    // back to the mirror and treats a stale mirror row as permanent, not
413    // briefly stale — so a revert issued through the actual `MemoryRevert`
414    // tool silently failed to propagate cross-channel, leaving other
415    // channels still showing the pre-revert (fabricated) content
416    // indefinitely.
417    let metadata_type = crate::server::native_memory_handlers::parse_memory_frontmatter_type(&target.content);
418    let dest_meta = std::fs::metadata(&dest).ok();
419    let size_bytes = dest_meta.as_ref().map(|m| m.len() as i64).unwrap_or(target.content.len() as i64);
420    let mtime_ms = dest_meta
421        .and_then(|m| m.modified().ok())
422        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
423        .map(|d| d.as_millis() as i64)
424        .unwrap_or(0);
425    if let Err(e) = state.id_store.agent_native_memory_upsert(
426        &version_agent_id,
427        filename,
428        &target.content,
429        metadata_type.as_deref(),
430        &dest.to_string_lossy(),
431        size_bytes,
432        mtime_ms,
433    ) {
434        tracing::warn!(agent_id, filename, error = %e, "memory.revert: mirror upsert failed (non-fatal)");
435    }
436
437    // See memory_write_impl's own comment on why this is version_agent_id
438    // (canonical UUID), not the raw agent_id slug parameter.
439    state.broker.publish_event_for::<crate::backend::mps_events::AgentMemoryChanged>(&version_agent_id, &(), vec![], 0);
440
441    serde_json::to_value(crate::backend::rpc_types::NativeMemoryRevertResult {
442        version: crate::backend::rpc_types::NativeMemoryVersionMeta {
443            id: new_version.id,
444            content_hash: new_version.content_hash,
445            parent_version_id: new_version.parent_version_id,
446            source: new_version.source,
447            source_detail: new_version.source_detail,
448            session_id: new_version.session_id,
449            created_at: new_version.created_at,
450        },
451    })
452    .map_err(|e| e.to_string())
453}