Skip to main content

agentmux_srv/server/app_api/
global_memory.rs

1// Copyright 2024-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Global Memory MuxAPI bodies, moved out of app_api/mod.rs unchanged
5//! (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md item 9).
6
7use super::*;
8
9/// Caller-supplied provenance for a `globalmemory.write` call — same shape
10/// as `MemoryWriteProvenance` above; kept as a distinct type since callers
11/// are conceptually different (an agent writing to a workspace-wide list
12/// every other agent inherits, not its own private memory) even though the
13/// wire shape is identical.
14pub(crate) struct GlobalMemoryWriteProvenance<'a> {
15    pub source: &'a str,
16    pub detail: &'a str,
17}
18
19/// Creates a new ordinary Global Memory entry, or updates an existing one
20/// by `id`. Backs the `GlobalMemoryWrite` MCP tool.
21pub(crate) fn global_memory_write_impl(
22    state: &AppState,
23    agent_id: &str,
24    written_by_uid: &str,
25    id: Option<&str>,
26    name: &str,
27    content: &str,
28    provenance: Option<GlobalMemoryWriteProvenance<'_>>,
29) -> Result<serde_json::Value, String> {
30    let name = name.trim();
31    if name.is_empty() {
32        return Err("globalmemory.write: name is required".to_string());
33    }
34    // Generous but bounded — Global Memory entries are meant to be readable
35    // instructions text, not arbitrary file storage (memory.write's own
36    // 10MB cap is for markdown notes files; this is an order of magnitude
37    // tighter since every agent's launch has to parse and inject this).
38    const MAX: usize = 1024 * 1024;
39    if content.len() > MAX {
40        return Err(format!("globalmemory.write: content too large ({} bytes, max {MAX})", content.len()));
41    }
42
43    let now = agentmux_common::time::now_ms();
44    let is_new = id.map(|v| v.is_empty()).unwrap_or(true);
45    let bundle = match id {
46        Some(existing_id) if !existing_id.is_empty() => {
47            let mut existing = state.id_store.bundle_get(existing_id)
48                .map_err(|e| format!("globalmemory.write: {e}"))?
49                .ok_or_else(|| format!("globalmemory.write: not found id={existing_id}"))?;
50            // Belt-and-suspenders with bundle_upsert's own guard (see this
51            // section's top-of-block comment) — refuse here too, before
52            // even attempting the write, so the error message is specific
53            // to this API rather than bundle_upsert's generic one.
54            if existing.is_system {
55                return Err(
56                    "globalmemory.write: cannot modify a system Global Memory entry through this API"
57                        .to_string(),
58                );
59            }
60            // reagent P0, PR #3237: `id` is caller-supplied and bundle ids
61            // for EVERY bundle in the system (not just global ones) are
62            // already enumerable by any agent via PresetList
63            // (bundle_list_impl, unfiltered). Without these two checks, an
64            // agent could target any other agent's private preset — or the
65            // seeded 'blank' singleton — by id, rename/replace its content,
66            // and force it into is_global=true, hijacking it into every
67            // agent's launch instructions workspace-wide. Mirrors
68            // upsertmemory's own already-fixed blank-singleton guard
69            // (agent_handlers/bundle.rs, reagent P1, 2026-05-08) and adds
70            // the is_global check that guard alone doesn't cover: this API
71            // may only ever EDIT an entry that was already a Global Memory
72            // entry BEFORE this call, never silently promote/adopt an
73            // unrelated bundle into one.
74            if existing.is_blank || existing.id == "blank" {
75                return Err("globalmemory.write: cannot mutate the blank Bundle singleton".to_string());
76            }
77            if !existing.is_global {
78                return Err(format!(
79                    "globalmemory.write: id={existing_id} is not a Global Memory entry — this API cannot adopt an unrelated bundle, only create a new entry (omit id) or edit an existing Global Memory one"
80                ));
81            }
82            existing.name = name.to_string();
83            existing.instructions = content.to_string();
84            existing.is_global = true;
85            existing.updated_at = now;
86            existing
87        }
88        _ => Bundle {
89            id: uuid::Uuid::new_v4().to_string(),
90            name: name.to_string(),
91            description: String::new(),
92            is_blank: false,
93            is_global: true,
94            provider: String::new(),
95            model: String::new(),
96            instructions: content.to_string(),
97            instructions_by_provider: "{}".to_string(),
98            context_files: "[]".to_string(),
99            mcp_servers: "[]".to_string(),
100            skills: "[]".to_string(),
101            sort_order: 0,
102            is_system: false,
103            created_at: now,
104            updated_at: now,
105        },
106    };
107
108    // `written_by` is the TRUSTED `agent_id` this function received
109    // (agentmux-mcp's own AGENTMUX_AGENT_ID, unforgeable from the agent's
110    // PTY — see this section's top-of-block comment) — never taken from
111    // caller-supplied `source`/`source_detail`, which stay free-form
112    // annotation. bundle_upsert_with_version records both, atomically with
113    // the bundle write itself (codex P2, PR #3237 — see that method's own
114    // doc comment for why two separate calls here would have been unsafe
115    // under concurrent writers). `written_by_uid` is the writer's UID from
116    // the request's `Caller` (identity M4c-1, spec §6.5.9) — its token,
117    // never a body field; `""` when Unattributed.
118    let (source, detail) = match &provenance {
119        Some(p) => (p.source, p.detail),
120        None => ("agent_inferred", "{}"),
121    };
122    let source_detail = if detail.is_empty() { "{}" } else { detail };
123    state.id_store
124        .bundle_upsert_with_version(&bundle, agent_id, written_by_uid, source, source_detail)
125        .map_err(|e| format!("globalmemory.write: {e}"))?;
126
127    // New entries sort LAST, matching Memory → Global's own "+ New section"
128    // behavior (global-bundle-model.ts's saveEdit) — without this, a fresh
129    // bundle_id defaults to sort_order=0 and could land ambiguously
130    // interleaved with any other 0-sort_order row instead of visibly
131    // appended at the end of the order the operator/agent expects.
132    if is_new {
133        let ordinary_ids: Vec<String> = state.id_store.bundle_list_global()
134            .map_err(|e| format!("globalmemory.write: {e}"))?
135            .into_iter()
136            .filter(|b| !b.is_system && b.id != bundle.id)
137            .map(|b| b.id)
138            .chain(std::iter::once(bundle.id.clone()))
139            .collect();
140        if let Err(e) = state.id_store.bundle_reorder(&ordinary_ids) {
141            tracing::warn!(agent_id, bundle_id = %bundle.id, error = %e, "globalmemory.write: append-to-order failed (non-fatal)");
142        }
143    }
144
145    state.broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
146    tracing::info!(agent_id, bundle_id = %bundle.id, name = %bundle.name, "globalmemory.write");
147    Ok(json!({ "id": bundle.id, "name": bundle.name }))
148}
149
150/// Every Global Memory entry Memory → Global shows — id/name/updated_at/system,
151/// no content (mirrors `memory_list_impl`'s summary shape), system rows first
152/// as `bundle_list_global` orders them. System-tier rows are listed so an empty
153/// result means "nothing" and not "nothing you may touch" (the pane lists them
154/// too, and their content is already injected into every agent's context); what
155/// stays structural is that read, write and remove refuse a system id. Backs
156/// the `GlobalMemoryList` MCP tool.
157/// docs/specs/SPEC_GLOBAL_MEMORY_LIST_SYSTEM_ROWS_2026_10_02.md
158pub(crate) fn global_memory_list_impl(state: &AppState) -> Result<serde_json::Value, String> {
159    let entries: Vec<_> = state.id_store.bundle_list_global()
160        .map_err(|e| format!("globalmemory.list: {e}"))?
161        .into_iter()
162        .map(|b| json!({ "id": b.id, "name": b.name, "updated_at": b.updated_at, "system": b.is_system }))
163        .collect();
164    Ok(json!({ "entries": entries }))
165}
166
167/// Full content of one ordinary Global Memory entry by id. Refuses a
168/// system-tier id, same reasoning as write. Backs the `GlobalMemoryRead`
169/// MCP tool.
170pub(crate) fn global_memory_read_impl(state: &AppState, id: &str) -> Result<serde_json::Value, String> {
171    let bundle = state.id_store.bundle_get(id)
172        .map_err(|e| format!("globalmemory.read: {e}"))?
173        .ok_or_else(|| format!("globalmemory.read: not found id={id}"))?;
174    if bundle.is_system {
175        return Err("globalmemory.read: cannot read a system Global Memory entry through this API".to_string());
176    }
177    if !bundle.is_global {
178        return Err(format!("globalmemory.read: {id} is not a Global Memory entry"));
179    }
180    Ok(json!({ "id": bundle.id, "name": bundle.name, "content": bundle.instructions }))
181}
182
183/// Demotes a Global Memory entry (clears `is_global`) — matches Memory →
184/// Global's own "Remove" semantics: the bundle row itself survives (still
185/// available in Memory → Bundles), only its Global Memory membership is
186/// cleared. Refuses a system-tier id, the blank singleton, or any id that
187/// wasn't ALREADY a Global Memory entry before this call — same ownership
188/// guards as `global_memory_write_impl`, for the identical reason: bundle
189/// ids are enumerable via `PresetList` regardless of `is_global`. Backs the
190/// `GlobalMemoryRemove` MCP tool.
191pub(crate) fn global_memory_remove_impl(state: &AppState, id: &str) -> Result<serde_json::Value, String> {
192    let mut bundle = state.id_store.bundle_get(id)
193        .map_err(|e| format!("globalmemory.remove: {e}"))?
194        .ok_or_else(|| format!("globalmemory.remove: not found id={id}"))?;
195    if bundle.is_system {
196        return Err("globalmemory.remove: cannot remove a system Global Memory entry through this API".to_string());
197    }
198    // reagent P1, PR #3237 (re-review): same missing-ownership-check bug
199    // already fixed for write above, left unfixed here. Bundle ids for
200    // every bundle are enumerable via PresetList (unfiltered), so without
201    // this an agent could call GlobalMemoryRemove with another agent's
202    // private preset id (or 'blank') — is_global is already false so the
203    // flag write is a no-op, but bundle_upsert still rewrites the row and
204    // bumps updated_at on a bundle this API has no business touching at
205    // all. This API may only ever act on an entry that was already Global
206    // Memory before the call.
207    if bundle.is_blank || bundle.id == "blank" {
208        return Err("globalmemory.remove: cannot mutate the blank Bundle singleton".to_string());
209    }
210    if !bundle.is_global {
211        return Err(format!("globalmemory.remove: id={id} is not a Global Memory entry"));
212    }
213    bundle.is_global = false;
214    bundle.updated_at = agentmux_common::time::now_ms();
215    state.id_store.bundle_upsert(&bundle).map_err(|e| format!("globalmemory.remove: {e}"))?;
216    state.broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
217    Ok(json!({ "ok": true }))
218}
219
220/// A version's metadata as JSON — `id`/`content_hash`/`parent_version_id`/
221/// `source`/`source_detail`/`written_by`/`created_at`, no `name`/
222/// `instructions`. Takes the five common-field accessors as plain values
223/// rather than borrowing a concrete struct so both `BundleVersionSummary`
224/// (the `history` list shape) and `BundleVersion` (the full-content shape
225/// `revert`'s freshly-inserted row comes back as) can share this one
226/// formatter without either type needing to convert into the other.
227fn bundle_version_meta_json(
228    id: &str,
229    content_hash: &str,
230    parent_version_id: &Option<String>,
231    source: &str,
232    source_detail: &str,
233    written_by: &str,
234    written_by_uid: &str,
235    created_at: i64,
236) -> serde_json::Value {
237    json!({
238        "id": id,
239        "content_hash": content_hash,
240        "parent_version_id": parent_version_id,
241        "source": source,
242        "source_detail": source_detail,
243        "written_by": written_by,
244        // Identity M4c-2d (spec §6.5.9): the writer's UID beside its name;
245        // empty when the write was Unattributed.
246        "written_by_uid": written_by_uid,
247        "created_at": created_at,
248    })
249}
250
251/// Loads `id`'s bundle and refuses it unless it is (a) not the blank
252/// singleton, (b) not a system-tier entry, and (c) currently a Global Memory
253/// entry (`is_global`) — the exact guard `global_memory_write_impl`/
254/// `global_memory_remove_impl` already apply, reused here rather than
255/// re-derived so `history`/`diff`/`revert` can never diverge from it. Bundle
256/// ids for EVERY bundle in the system (not just global ones) are enumerable
257/// via `PresetList` (reagent P0/P1, PR #3237) — without this, an agent could
258/// point any of these three new routes at another agent's private preset, or
259/// at AgentMux's own system-tier entries, and read/restore content that was
260/// never meant to be reachable through this API.
261fn load_ordinary_global_bundle(state: &AppState, id: &str, verb: &str) -> Result<Bundle, String> {
262    let bundle = state.id_store.bundle_get(id)
263        .map_err(|e| format!("globalmemory.{verb}: {e}"))?
264        .ok_or_else(|| format!("globalmemory.{verb}: not found id={id}"))?;
265    if bundle.is_system {
266        return Err(format!(
267            "globalmemory.{verb}: cannot {verb} a system Global Memory entry through this API"
268        ));
269    }
270    if bundle.is_blank || bundle.id == "blank" {
271        return Err(format!("globalmemory.{verb}: cannot {verb} the blank Bundle singleton"));
272    }
273    if !bundle.is_global {
274        return Err(format!("globalmemory.{verb}: id={id} is not a Global Memory entry"));
275    }
276    Ok(bundle)
277}
278
279/// Version history for one Global Memory entry, newest first — metadata
280/// only, no `name`/`instructions` (mirrors `memory_history_impl`'s summary
281/// shape and `global_memory_list_impl`'s "no content" posture). Refuses a
282/// system-tier, blank, or non-global id via `load_ordinary_global_bundle` —
283/// structurally the same exclusion `list`/`read`/`remove` already apply, so
284/// there is no path here that can ever surface a system-tier row's history.
285/// Backs the `GlobalMemoryHistory` MCP tool.
286pub(crate) fn global_memory_history_impl(state: &AppState, id: &str) -> Result<serde_json::Value, String> {
287    load_ordinary_global_bundle(state, id, "history")?;
288    let versions: Vec<_> = state.id_store.bundle_version_list(id)
289        .map_err(|e| format!("globalmemory.history: store: {e}"))?
290        .iter()
291        .map(|v| bundle_version_meta_json(
292            &v.id, &v.content_hash, &v.parent_version_id, &v.source, &v.source_detail, &v.written_by,
293            &v.written_by_uid, v.created_at,
294        ))
295        .collect();
296    Ok(json!({ "versions": versions }))
297}
298
299/// A line-based diff between two recorded versions of one Global Memory
300/// entry (mirrors `memory_diff_impl`). Both versions must belong to `id` —
301/// reagent P1 in `memory_diff_impl` fixed the identical "diff across two
302/// unrelated owners" gap for native memory; the same check applies here
303/// since a `version_id` alone does not otherwise prove which bundle (and
304/// therefore whether a system-tier one) it came from. A name change is
305/// surfaced as its own two-line entry ahead of the instructions diff, since
306/// `content_hash` is computed over `name` + `instructions` together
307/// (`bundle_versions.rs`'s own `content_hash` doc comment) — a rename with
308/// unchanged body would otherwise produce a "no differences" instructions
309/// diff that silently hid the one thing that actually changed. Backs the
310/// `GlobalMemoryDiff` MCP tool.
311pub(crate) fn global_memory_diff_impl(
312    state: &AppState,
313    id: &str,
314    from_version_id: &str,
315    to_version_id: &str,
316) -> Result<serde_json::Value, String> {
317    load_ordinary_global_bundle(state, id, "diff")?;
318    let from = state.id_store.bundle_version_get(from_version_id)
319        .map_err(|e| format!("globalmemory.diff: store: {e}"))?
320        .ok_or_else(|| format!("globalmemory.diff: version {from_version_id} not found"))?;
321    let to = state.id_store.bundle_version_get(to_version_id)
322        .map_err(|e| format!("globalmemory.diff: store: {e}"))?
323        .ok_or_else(|| format!("globalmemory.diff: version {to_version_id} not found"))?;
324    if from.bundle_id != id || to.bundle_id != id {
325        return Err(format!("globalmemory.diff: one or both versions do not belong to {id}"));
326    }
327    Ok(json!({ "diff": bundle_version_diff(&from, &to) }))
328}
329
330/// The diff text `global_memory_diff_impl` returns — a `- name:`/`+ name:`
331/// pair when the name changed (see that function's doc comment for why),
332/// then `line_diff` over the instructions. Shared with Memory → Global's
333/// `globalmemory:diff` WebSocket command (`agent_handlers/bundle.rs`) so the
334/// MCP tool and the UI can never render the same pair of versions two ways.
335pub(crate) fn bundle_version_diff(
336    from: &crate::backend::storage::BundleVersion,
337    to: &crate::backend::storage::BundleVersion,
338) -> String {
339    let mut diff = String::new();
340    if from.name != to.name {
341        diff.push_str(&format!("- name: {}\n+ name: {}\n", from.name, to.name));
342    }
343    diff.push_str(&crate::server::native_memory_handlers::line_diff(&from.instructions, &to.instructions));
344    diff
345}
346
347/// Restores a Global Memory entry's live `name`/`instructions` to a prior
348/// recorded version — same "revert never rewrites history, it records a new
349/// version" semantics as `memory_revert_impl` (`source: "revert"`, chained
350/// onto whatever the current latest version is). Reuses
351/// `Store::bundle_upsert_with_version` — the SAME primitive
352/// `global_memory_write_impl` already uses — so the live row and its new
353/// version are written atomically, in one transaction, exactly as that
354/// method's own doc comment requires (codex P2, PR #3237). `agent_id` is the
355/// TRUSTED caller identity (an agent's real `AGENTMUX_AGENT_ID`, resolved by
356/// `agentmux-mcp` from its own env, never caller-suppliable) recorded as
357/// `written_by` — never inferred from anything in the request body.
358/// `written_by_uid` is recorded beside it: the request's `Caller` UID
359/// (identity M4c-1), `""` when Unattributed. Backs the `GlobalMemoryRevert`
360/// MCP tool.
361pub(crate) fn global_memory_revert_impl(
362    state: &AppState,
363    agent_id: &str,
364    written_by_uid: &str,
365    id: &str,
366    version_id: &str,
367) -> Result<serde_json::Value, String> {
368    let mut bundle = load_ordinary_global_bundle(state, id, "revert")?;
369    let target = state.id_store.bundle_version_get(version_id)
370        .map_err(|e| format!("globalmemory.revert: store: {e}"))?
371        .ok_or_else(|| format!("globalmemory.revert: version {version_id} not found"))?;
372    if target.bundle_id != id {
373        return Err(format!("globalmemory.revert: version {version_id} does not belong to {id}"));
374    }
375
376    bundle.name = target.name.clone();
377    bundle.instructions = target.instructions.clone();
378    bundle.updated_at = agentmux_common::time::now_ms();
379
380    let detail = json!({ "reverted_to": version_id }).to_string();
381    let new_version = state.id_store
382        .bundle_upsert_with_version(&bundle, agent_id, written_by_uid, "revert", &detail)
383        .map_err(|e| format!("globalmemory.revert: {e}"))?
384        // `bundle.is_global` is guaranteed true by `load_ordinary_global_bundle`
385        // above, and `bundle_upsert_with_version` only ever returns `None` when
386        // `is_global` is false — so this branch is unreachable in practice, but
387        // failing loudly beats silently reporting success with no new version.
388        .ok_or_else(|| "globalmemory.revert: no version was recorded (unexpected: entry is not global)".to_string())?;
389
390    state.broker.publish_event::<crate::backend::mps_events::MemoriesChanged>(&(), vec![], 0);
391    tracing::info!(agent_id, bundle_id = %id, version_id, "globalmemory.revert");
392    Ok(json!({ "version": bundle_version_meta_json(
393        &new_version.id,
394        &new_version.content_hash,
395        &new_version.parent_version_id,
396        &new_version.source,
397        &new_version.source_detail,
398        &new_version.written_by,
399        &new_version.written_by_uid,
400        new_version.created_at,
401    ) }))
402}