Skip to main content

agentmux_srv/server/
native_memory_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native memory RPCs — read/write the agent's `~/.claude/projects/<sanitized>/memory/`
5//! folder that Claude Code uses for autonomous, cross-session fact storage.
6//!
7//! Three commands:
8//!   agent:memory:list       — list *.md files in the memory dir
9//!   agent:memory:read_file  — read one file by filename (no path traversal)
10//!   agent:memory:write_file — write/create one file atomically (tmp→rename)
11//!
12//! Spec: docs/specs/SPEC_AGENT_PANE_MEMORY_IDENTITY_MODALS_2026_06_19.md §7
13//!
14//! All three also write through into `db_agent_native_memory` (via
15//! `state.id_store`) — a durable mirror keyed by the stable
16//! `AgentDefinition.id`, since the live filesystem path above is
17//! channel-relative by design and not the same across channels/instances for
18//! the same logical agent. `list`/`read_file` merge the live-FS view with
19//! the mirror so a file written from one channel stays visible from another.
20//! See docs/specs/SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md.
21
22use std::sync::Arc;
23use std::path::PathBuf;
24
25use crate::backend::base::expand_home_dir_safe;
26use crate::backend::rpc::engine::WshRpcEngine;
27use crate::backend::rpc_types::{
28    COMMAND_NATIVE_MEMORY_ADOPTION_LIST,
29    COMMAND_NATIVE_MEMORY_CLAIMS,
30    COMMAND_NATIVE_MEMORY_DIFF,
31    COMMAND_NATIVE_MEMORY_HISTORY,
32    COMMAND_NATIVE_MEMORY_LIST,
33    COMMAND_NATIVE_MEMORY_READ_FILE,
34    COMMAND_NATIVE_MEMORY_REVERT,
35    COMMAND_NATIVE_MEMORY_WRITE_FILE,
36    CommandNativeMemoryAdoptionListData,
37    CommandNativeMemoryClaimsData,
38    CommandNativeMemoryDiffData,
39    CommandNativeMemoryHistoryData,
40    CommandNativeMemoryListData,
41    CommandNativeMemoryReadFileData,
42    CommandNativeMemoryRevertData,
43    CommandNativeMemoryWriteFileData,
44    NativeMemoryAdoptionListResult,
45    NativeMemoryDiffResult,
46    NativeMemoryFileMeta,
47    NativeMemoryHistoryResult,
48    NativeMemoryListResult,
49    NativeMemoryReadFileResult,
50    NativeMemoryRevertResult,
51    NativeMemoryVersionMeta,
52};
53use crate::backend::storage::NativeMemoryVersion;
54
55use super::AppState;
56
57/// Compute `$CLAUDE_CONFIG_DIR/projects/<sanitized>/memory/` for the given
58/// working directory and Claude config dir.
59///
60/// `claude_config_dir` is the value of `CLAUDE_CONFIG_DIR` from the agent's
61/// stored env blob. When empty, falls back to
62/// `~/.agentmux/shared/providers/claude/` — the default isolated home that
63/// `app_api/agent_open.rs` sets at agent spawn time. We never write to the global
64/// `~/.claude/projects/` because AgentMux always sets `CLAUDE_CONFIG_DIR`.
65///
66/// The project folder is named by the CLI's own rules: after the directory
67/// memory is keyed by ([`crate::backend::claude_layout::memory_project_root`]
68/// — the repository's main checkout, for a subdirectory or a linked
69/// worktree), sanitized by [`crate::backend::claude_layout::project_dir_name`].
70pub(crate) fn memory_dir_for_cwd(claude_config_dir: &str, working_directory: &str) -> PathBuf {
71    // Claude names the project folder from the absolute cwd, so `~` must be
72    // expanded first — a spawn segment records the block's `cmd:cwd`
73    // unexpanded (`~/.agentmux/agents/<slug>` for an agent.open default),
74    // and `--agentmux-agents-…` is a folder Claude never writes (#3603).
75    let working_directory = expand_home_dir_safe(working_directory);
76    let root = crate::backend::claude_layout::memory_project_root(&working_directory);
77    let folder_name = crate::backend::claude_layout::project_dir_name(&root.to_string_lossy());
78
79    let base = if claude_config_dir.is_empty() {
80        expand_home_dir_safe("~/.agentmux/shared/providers/claude")
81    } else {
82        expand_home_dir_safe(claude_config_dir)
83    };
84    base.join("projects").join(folder_name).join("memory")
85}
86
87/// Extract `CLAUDE_CONFIG_DIR` from a `KEY=VALUE\n…` env blob.
88fn parse_claude_config_dir(env_blob: &str) -> String {
89    for line in env_blob.lines() {
90        if let Some(val) = line.strip_prefix("CLAUDE_CONFIG_DIR=") {
91            return val.to_string();
92        }
93    }
94    String::new()
95}
96
97/// The identity stores (`id_store`, `identity_store`), attached once at boot,
98/// so memory resolution can find the account an agent is linked to — the
99/// directory its spawn actually runs Claude in. Its callers hold only the
100/// channel store. Unattached (tests): the linked-account step is skipped.
101static IDENTITY_STORES: std::sync::OnceLock<(
102    std::sync::Arc<crate::backend::storage::store::Store>,
103    std::sync::Arc<crate::backend::storage::store::Store>,
104)> = std::sync::OnceLock::new();
105
106pub(crate) fn attach_identity_stores(
107    id_store: std::sync::Arc<crate::backend::storage::store::Store>,
108    identity_store: std::sync::Arc<crate::backend::storage::store::Store>,
109) {
110    let _ = IDENTITY_STORES.set((id_store, identity_store));
111}
112
113/// The Claude config dir an agent runs in, as its spawn decides it: the
114/// config dir of the Claude OAuth account it is linked to — the spawn sets
115/// `CLAUDE_CONFIG_DIR` to it over any env value (`resolver::inject`) — else
116/// its env override, else `""`, the shared default. #3603: an agent with no
117/// env override and a linked account resolved to the shared default, so
118/// `MemoryList` listed nothing while its memories sat in the account's dir.
119fn agent_claude_config_dir(mstore: &crate::backend::storage::store::Store, agent_id: &str) -> String {
120    let linked = IDENTITY_STORES.get().and_then(|(id_store, identity_store)| {
121        // Only an agent with a binding — its own, or its template's (the
122        // spawn's fallback) — reaches the resolver, which logs a warning for
123        // an unbound one; memory is read every sweep.
124        crate::identity::resolver::agent_has_identity_binding(mstore, identity_store, agent_id)
125            .then(|| {
126                crate::identity::resolver::resolve_bound_claude_config_dir_for_agent(
127                    mstore, id_store, identity_store, agent_id,
128                )
129            })
130            .flatten()
131    });
132    if let Some(dir) = linked {
133        return dir.to_string_lossy().into_owned();
134    }
135    mstore
136        .agent_content_get(agent_id, "env")
137        .ok()
138        .flatten()
139        .map(|c| parse_claude_config_dir(&c.content))
140        .unwrap_or_default()
141}
142
143/// Resolve the memory directory for `agent_id`. Reads the agent definition and
144/// its stored env blob to find `CLAUDE_CONFIG_DIR`. Returns an error only if
145/// the agent cannot be resolved at all — an instance row with a blank
146/// `working_directory` falls through to the registry rather than failing (see
147/// the inline note below; that short-circuit was a real bug).
148///
149/// Shared by `native_memory_handlers` and the `memory.*` MuxAPI handlers.
150pub(crate) fn memory_dir_for_agent(
151    mstore: &crate::backend::storage::store::Store,
152    agent_id: &str,
153) -> Result<std::path::PathBuf, String> {
154    // agent_id arriving from MuxAPI is the agent slug (AGENTMUX_AGENT_ID /
155    // bus:register id), not a UUID and not the literal display name — use
156    // instance_get_by_slug (agent_def_get queries by UUID and would always
157    // return None here; instance_get_by_name matches the display name, a
158    // different namespace — see that function's own doc comment).
159    if let Some(instance) = mstore
160        .instance_get_by_slug(agent_id)
161        .map_err(|e| format!("memory: store: {e}"))?
162    {
163        // The agent's own latest spawn knows its directory for certain
164        // (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2).
165        if let Ok(Some(def)) = mstore.agent_def_get(&instance.id) {
166            let gfs = crate::backend::agent_session::global_transcript_store().map(|a| a.as_ref());
167            if let Some(dir) = memory_dir_from_latest_segment(gfs, &def.id) {
168                return Ok(dir);
169            }
170        }
171        // Only trust the instance row when it actually carries a working
172        // directory. An empty one is NOT an error and must NOT short-circuit:
173        // `agent.open` substitutes a default (`~/.agentmux/agents/<slug>`)
174        // whenever `working_directory` is blank, so a blank row describes an
175        // agent that is nonetheless running — and writing memories — in a
176        // real directory. The registry fallback below is what knows that
177        // real directory (`source_agents_base` + `working_dir`).
178        //
179        // Returning Err here instead made Memory → Personal and the
180        // MemoryList MCP tool fail with "agent <x> has no working directory"
181        // for every such agent, while its memory files sat on disk perfectly
182        // intact — the common case, since `working_directory` is blank by
183        // default. See SPEC_FIX_PERSONAL_MEMORY_EMPTY_WORKDIR_2026_09_01.md.
184        if !instance.working_directory.is_empty() {
185            let config_dir = agent_claude_config_dir(mstore, &instance.id);
186            return Ok(memory_dir_for_cwd(&config_dir, &instance.working_directory));
187        }
188        // Resolve through the DEFINITION, not the instance row: `db_agents`
189        // stores `instance_name` as its own column, distinct from the
190        // definition's `name`, and it is empty for a definition-only row —
191        // so reading the name off `instance` here silently yielded "" and
192        // fell through to a not-found. `instance.id` IS the definition id in
193        // this consolidated table (see the note above).
194        if let Ok(Some(def)) = mstore.agent_def_get(&instance.id) {
195            if let Some(dir) =
196                memory_dir_for_blank_working_dir(mstore, &def.id, &def.name, &def.slug)
197            {
198                return Ok(dir);
199            }
200        }
201    }
202
203    // No persisted db_agents instance row. Running agents are tracked in the
204    // global named-agent registry, not db_agents (launching an agent does not
205    // create an instance row there), so the slug lookup above misses every live
206    // agent. Fall back to the registry, which records the slug → working_dir +
207    // identity binding needed to locate the agent's isolated memory dir.
208    // See issue #1836.
209    memory_dir_from_registry(agent_id)
210        .ok_or_else(|| format!("memory: agent {agent_id} not found"))
211}
212
213/// Resolve `agent_id` — the agent SLUG, per the App-API convention (see
214/// [`memory_dir_for_agent`]'s own doc comment) — to the same stable,
215/// canonical identifier (`AgentDefinition.id` / `db_agents.id`) the
216/// WebSocket RPC surface (`agent:memory:write_file` et al., which receives
217/// this id directly from the caller and resolves it via `agent_def_get`)
218/// already keys `db_agent_native_memory_versions` by.
219///
220/// reagent P1: without this, a version written through this App-API/MCP
221/// surface (previously slug-keyed, verbatim) lived in a disjoint keyspace
222/// from one written through the WS RPC surface for the exact same logical
223/// agent whenever `slug != id` — a version written via the `MemoryWrite`
224/// MCP tool was invisible to a WS-RPC-based `MemoryHistory`/`MemoryDiff`/
225/// `MemoryRevert` call and vice versa, silently defeating the point of
226/// having version history at all.
227///
228/// Mirrors `memory_dir_for_agent`'s own slug → instance → registry
229/// resolution order, but returns the id instead of a filesystem path:
230/// - Primary path (`instance_get_by_slug`): `db_agents` is the
231///   consolidated definition+instance table (Phase 3a) — its own query
232///   selects `id, id AS def_id`, i.e. the row's `id` already IS the
233///   definition id in this model, not a separate instance-only identity.
234/// - Registry fallback (a live agent not yet persisted to `db_agents`):
235///   the registry record's `definition_id` field is the one that matches
236///   `agent_def_get`'s namespace — its sibling `instance_id` is a
237///   different, launch-scoped identity, not what `write_file` keys by.
238pub(crate) fn resolve_agent_uuid(
239    mstore: &crate::backend::storage::store::Store,
240    agent_id: &str,
241) -> Result<String, String> {
242    crate::backend::agent_resolve::resolve_agent_id(mstore, agent_id)
243}
244
245/// Find the global named-agent registry's active record for `agent_id` —
246/// the `AGENTMUX_AGENT_ID` routing slug (`derive_slug(display_name)`),
247/// NOT the record's own `instance_name` (which keeps the original
248/// display casing, e.g. "AgentY"). Matching by raw string equality here
249/// used to mean this — and every App-API self-lookup endpoint that falls
250/// back to it (`memory.*`, and `bundle.self.get` once it's wired to use
251/// this too) — silently 404'd for any agent whose name wasn't already
252/// all-lowercase. `derive_slug` on both sides makes the comparison
253/// consistent with how the slug was actually derived in the first place.
254/// Shared so callers outside this module (e.g. `app_api::mod::
255/// bundle_self_get_impl`) don't reimplement the same lookup.
256pub(crate) fn find_active_registry_record_by_slug(
257    agent_id: &str,
258) -> Option<crate::registry::NamedAgentRecord> {
259    // Thin alias. The implementation moved to
260    // `backend::agent_registry_lookup` so `backend::history` could share it
261    // rather than keep a second copy of the slug-matching rules (reagentx P2,
262    // PR #3480). Kept as a named function here because this module's callers
263    // and its own tests refer to it, and the name says what it is at those
264    // call sites.
265    crate::backend::agent_registry_lookup::find_active_record_by_slug(agent_id)
266}
267
268/// Resolve a memory dir for `agent_id` from the global named-agent registry.
269///
270/// Each live agent is recorded by `instance_name` with a `working_dir` relative
271/// to `source_agents_base` (the channel/dev instance it lives in) and an
272/// `identity_id` that determines its `CLAUDE_CONFIG_DIR` root. Returns `None`
273/// when the registry is unavailable or no active record matches the slug.
274fn memory_dir_from_registry(agent_id: &str) -> Option<std::path::PathBuf> {
275    let rec = find_active_registry_record_by_slug(agent_id)?;
276    memory_dir_for_registry_record(&rec)
277}
278
279/// Resolve the memory dir for an agent whose persisted `working_directory`
280/// is blank — the DEFAULT state, so this is the common path, not an edge.
281///
282/// Two stages, in this order:
283///
284/// 1. **A registry record bound to this exact definition.** The registry
285///    records the working dir an agent was really launched with, which beats
286///    re-deriving it. `definition_id` is required to match: the plain
287///    slug lookup keys off `derive_slug(instance_name)` alone, so two agents
288///    whose display names slugify identically collide — and resolving the
289///    WRONG agent's memory dir would let list/read/write operations touch
290///    another agent's files (Codex P1, PR #2901).
291/// 2. **The derived default**, `default_agent_working_dir(name)` — the same
292///    path `agent.open` itself substitutes for a blank field. Needed because
293///    `agent.open` does NOT create a registry record, so stage 1 misses
294///    entirely for a freshly defined agent, which is exactly the case this
295///    whole fix targets (Codex P1, PR #2901).
296/// Takes the three identity fields explicitly rather than a struct: the two
297/// callers hold different types (`AgentInstance` from `instance_get_by_slug`,
298/// `AgentDefinition` from the by-id path) which don't share these field
299/// names. `definition_id` is the id the registry's own `definition_id`
300/// records — for `db_agents`, the consolidated definition+instance table,
301/// the instance row's `id` already IS that id (see `memory_dir_for_agent`'s
302/// own note).
303fn memory_dir_for_blank_working_dir(
304    mstore: &crate::backend::storage::store::Store,
305    definition_id: &str,
306    agent_name: &str,
307    slug: &str,
308) -> Option<std::path::PathBuf> {
309    if !slug.is_empty() {
310        // Disambiguates by definition_id *inside* the lookup rather than
311        // filtering after it. Same result when the slug is unique, but under a
312        // collision the slug-only lookup now refuses to guess and returns None
313        // (reagentx P1 on #3480) — this caller knows exactly which agent it
314        // means, so it can still resolve.
315        if let Some(rec) =
316            crate::backend::agent_registry_lookup::find_active_record_by_slug_and_definition(
317                slug,
318                definition_id,
319            )
320        {
321            if let Some(dir) = memory_dir_for_registry_record(&rec) {
322                return Some(dir);
323            }
324        }
325    }
326    if agent_name.is_empty() {
327        return None;
328    }
329    let config_dir = agent_claude_config_dir(mstore, definition_id);
330    // Claude records the absolute cwd, so the default `~/.agentmux/agents/…`
331    // must be expanded before it is turned into a project-folder name — the
332    // unexpanded form named a folder (`--agentmux-agents-…`) Claude never
333    // writes (#3603 review).
334    let work_dir = crate::backend::base::expand_home_dir_safe(
335        &crate::backend::storage::agents::default_agent_working_dir(agent_name),
336    );
337    Some(memory_dir_for_cwd(&config_dir, &work_dir.to_string_lossy()))
338}
339
340/// Reconstruct one registry record's absolute memory dir directly (no slug
341/// lookup) — shared by [`memory_dir_from_registry`] (single record, found by
342/// slug) and [`list_all_memory_targets`] (every active record, for the
343/// fs-watch drift detector's enumeration).
344///
345/// The working directory comes from
346/// [`crate::backend::agent_registry_lookup::working_dir_from_record`].
347fn memory_dir_for_registry_record(rec: &crate::registry::NamedAgentRecord) -> Option<std::path::PathBuf> {
348    let working_directory = crate::backend::agent_registry_lookup::working_dir_from_record(rec)?;
349
350    let config_dir = claude_config_dir_for_identity(rec.data.identity_id.as_deref());
351    Some(memory_dir_for_cwd(&config_dir, &working_directory))
352}
353
354/// Every agent whose memory directory is verified — found from its own
355/// spawn or its explicit working directory — for the drift detector and the
356/// first-sight backfill (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2,
357/// phase M1).
358///
359/// Unverified guesses (the registry record, the name-derived default for a
360/// blank working directory) are left out: a stale registry `identity_id`
361/// once made this list attribute one agent's files to another. A directory
362/// two agents resolve to is left out too — its files can't be attributed to
363/// either — until the memory record's claims decide it (phase M3).
364pub(crate) fn list_all_memory_targets(
365    mstore: &crate::backend::storage::store::Store,
366) -> Vec<(String, std::path::PathBuf)> {
367    let gfs = crate::backend::agent_session::global_transcript_store().map(|a| a.as_ref());
368    list_memory_targets_with(mstore, gfs)
369}
370
371fn list_memory_targets_with(
372    mstore: &crate::backend::storage::store::Store,
373    segments_fs: Option<&crate::backend::storage::filestore::FileStore>,
374) -> Vec<(String, std::path::PathBuf)> {
375    let Ok(agents) = mstore.agent_def_list() else {
376        return Vec::new();
377    };
378    let mut targets: Vec<(String, std::path::PathBuf)> = Vec::new();
379    let mut seen = std::collections::HashSet::new();
380    for agent in &agents {
381        if !seen.insert(agent.id.clone()) {
382            continue;
383        }
384        if let Some(r) = resolve_memory_dir_with(mstore, agent, segments_fs) {
385            if r.provenance != MemoryDirProvenance::Unverified {
386                targets.push((agent.id.clone(), r.path));
387            }
388        }
389    }
390    let key = |p: &std::path::PathBuf| p.canonicalize().unwrap_or_else(|_| p.clone());
391    let mut owners: std::collections::HashMap<std::path::PathBuf, usize> = std::collections::HashMap::new();
392    for (_, dir) in &targets {
393        *owners.entry(key(dir)).or_default() += 1;
394    }
395    targets.retain(|(agent_id, dir)| {
396        let shared = owners.get(&key(dir)).copied().unwrap_or(0) > 1;
397        if shared {
398            tracing::debug!(agent_id, dir = %dir.display(), "memory: directory resolved by more than one agent; not attributed");
399        }
400        !shared
401    });
402    targets
403}
404
405/// Compute the `CLAUDE_CONFIG_DIR` root for an agent bound to `identity_id`,
406/// mirroring the spawn-time isolated-home layout (`OAuthConfigDir`):
407///   - unbound / "default" → `<shared>/providers/claude` (the default home;
408///     returned as an empty string so `memory_dir_for_cwd` applies its own
409///     identical fallback)
410///   - an account → `<shared>/identities/<id>/claude`
411fn claude_config_dir_for_identity(identity_id: Option<&str>) -> String {
412    match identity_id {
413        Some(id) if !id.is_empty() && id != "default" => {
414            let shared = std::env::var_os("AGENTMUX_SHARED_DIR")
415                .map(PathBuf::from)
416                .or_else(|| dirs::home_dir().map(|h| h.join(".agentmux").join("shared")));
417            match shared {
418                Some(s) => s
419                    .join("identities")
420                    .join(id)
421                    .join("claude")
422                    .to_string_lossy()
423                    .to_string(),
424                None => String::new(),
425            }
426        }
427        _ => String::new(),
428    }
429}
430
431/// Validate a memory filename.
432pub(crate) fn validate_memory_filename(filename: &str) -> Result<(), String> {
433    validate_filename(filename)
434}
435
436/// Parse `metadata.type` from YAML frontmatter (re-exported for MuxAPI).
437pub(crate) fn parse_memory_frontmatter_type(content: &str) -> Option<String> {
438    parse_frontmatter_type(content)
439}
440
441/// Validate a filename: alphanumeric + `-_`, must end with `.md`, no path separators.
442pub(crate) fn validate_filename(filename: &str) -> Result<(), String> {
443    if filename.is_empty() {
444        return Err("filename must not be empty".to_string());
445    }
446    if !filename.ends_with(".md") {
447        return Err(format!("filename must end with .md, got: {filename}"));
448    }
449    if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
450        return Err(format!("filename must not contain path separators: {filename}"));
451    }
452    let stem = &filename[..filename.len() - 3];
453    if stem.is_empty() {
454        return Err("filename stem must not be empty (.md is not a valid name)".to_string());
455    }
456    // Tmp path is ".{filename}.{uuid}.tmp" (+42 chars); cap stem at 200 to stay
457    // well under the 255-byte filesystem limit and avoid ENAMETOOLONG.
458    if stem.len() > 200 {
459        return Err(format!("filename stem too long ({} chars, max 200)", stem.len()));
460    }
461    if !stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
462        return Err(format!(
463            "filename stem must be alphanumeric + '-_', got: {stem}"
464        ));
465    }
466    Ok(())
467}
468
469/// Extract `metadata.type` from YAML frontmatter.
470/// Claude Code memory files nest the type under `metadata:`:
471///   metadata:
472///     type: user
473/// A top-level `type:` key is NOT the correct field.
474fn parse_frontmatter_type(content: &str) -> Option<String> {
475    let content = content.trim_start();
476    if !content.starts_with("---") {
477        return None;
478    }
479    let rest = content.strip_prefix("---")?.trim_start_matches('\n');
480    let end = rest.find("\n---")?;
481    let frontmatter = &rest[..end];
482    let mut in_metadata = false;
483    for line in frontmatter.lines() {
484        let trimmed = line.trim_end();
485        if trimmed == "metadata:" {
486            in_metadata = true;
487            continue;
488        }
489        if in_metadata {
490            // A non-indented, non-empty line exits the metadata block.
491            if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
492                break;
493            }
494            if let Some(val) = line.trim_start().strip_prefix("type:") {
495                let val = val.trim().trim_matches('"').trim_matches('\'');
496                if !val.is_empty() {
497                    return Some(val.to_string());
498                }
499            }
500        }
501    }
502    None
503}
504
505/// Cap for both live-FS reads and mirror upserts — one shared limit so a
506/// file that's readable stays within the size the mirror can also store.
507const MAX_MEMORY_FILE_BYTES: u64 = 10 * 1024 * 1024;
508
509/// Refresh `db_agent_native_memory` from the live filesystem for `agent_id`
510/// — the same read-then-upsert-if-changed logic `agent:memory:list`
511/// performs inline (compare each live file's size+mtime against the
512/// mirror, and upsert only when it's actually changed). This is a NEW,
513/// standalone function with the same shape, not a shared implementation
514/// `list`'s handler was refactored to call — reagent P2, PR #2527
515/// (second round): `list`'s own inline copy (this file, `~agent:memory:
516/// list`'s handler) still exists separately and has already diverged in
517/// one detail (it hard-errors on a `file_type()` failure; this function
518/// silently skips the entry instead). Deduplicating the two into one
519/// shared implementation is a legitimate follow-up, not done here to
520/// keep this PR's diff scoped to what ABF v0.2 §2.3 actually needs.
521///
522/// ABF v0.2 §2.3 (`bundle.export_for_agent`) needs this: `list`/`read_file`
523/// only sync the mirror on a Stash Bundle tab open, so exporting straight
524/// from `db_agent_native_memory` without refreshing first would silently
525/// omit anything Claude wrote autonomously since the tab was last opened
526/// (or if it was never opened at all) — see
527/// `SPEC_ABF_V0_2_PROVIDER_AWARE_COMPONENTS_AND_NATIVE_MEMORY_2026_08_10.md`
528/// §2.3's second revision note.
529///
530/// Errors only on a genuine `read_dir` failure (permissions, I/O) — a
531/// missing directory (never written, or wiped) is not an error, matching
532/// `list`'s own treatment. A per-file upsert failure is logged and
533/// swallowed (non-fatal), same as `list`.
534///
535/// Returns the filenames of any file whose real on-disk size exceeded
536/// [`MAX_MEMORY_FILE_BYTES`] — reagent P2, PR #2527: `list`'s original
537/// inline version of this logic silently truncates via
538/// `take(MAX_MEMORY_FILE_BYTES)` with no signal that it happened, so an
539/// export→import round trip through `bundle.export_for_agent` could
540/// permanently lose the tail of a large file with no warning anywhere.
541/// This function still truncates the same way (the cap itself is
542/// unchanged — still generous for any legitimate memory file), but now
543/// reports which files it happened to, so a caller that surfaces
544/// warnings (like `bundle.export_for_agent`) can tell the user.
545pub(crate) fn refresh_memory_mirror_from_live_fs(
546    agent_id: &str,
547    memory_dir: &std::path::Path,
548    id_store: &crate::backend::storage::store::Store,
549) -> Result<Vec<String>, String> {
550    let mirrored_meta: std::collections::HashMap<String, (i64, i64)> = id_store
551        .agent_native_memory_list_meta(agent_id)
552        .unwrap_or_default()
553        .into_iter()
554        .map(|row| (row.filename, (row.size_bytes, row.last_seen_mtime_ms)))
555        .collect();
556
557    let entries = match std::fs::read_dir(memory_dir) {
558        Ok(e) => e,
559        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
560        Err(e) => return Err(format!("refresh_memory_mirror_from_live_fs: read_dir: {e}")),
561    };
562
563    let mut truncated_files: Vec<String> = Vec::new();
564    for entry in entries {
565        let entry = entry.map_err(|e| format!("refresh_memory_mirror_from_live_fs: read_dir entry: {e}"))?;
566        let name = entry.file_name().to_string_lossy().into_owned();
567        if !name.ends_with(".md") {
568            continue;
569        }
570        let file_type = match entry.file_type() {
571            Ok(t) => t,
572            Err(_) => continue, // TOCTOU-deleted between read_dir and here; skip, not fatal.
573        };
574        if !file_type.is_file() {
575            continue;
576        }
577        let meta = match entry.metadata() {
578            Ok(m) => m,
579            Err(_) => continue,
580        };
581        let size_bytes = meta.len();
582        let modified_at = meta
583            .modified()
584            .ok()
585            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
586            .map(|d| d.as_millis() as i64)
587            .unwrap_or(0);
588
589        // reagent P2, PR #2527 (second round): checked BEFORE the
590        // unchanged-since-last-mirror short-circuit below — an oversized
591        // file that hasn't changed since it was last mirrored still
592        // exports/imports its truncated content on every subsequent
593        // call, so the warning must fire every time too, not just on the
594        // one call that actually re-reads and re-upserts it.
595        if size_bytes > MAX_MEMORY_FILE_BYTES {
596            truncated_files.push(name.clone());
597        }
598
599        let unchanged_since_last_mirror = mirrored_meta.get(&name) == Some(&(size_bytes as i64, modified_at));
600        if unchanged_since_last_mirror {
601            continue;
602        }
603        let full_content_read = {
604            use std::io::Read;
605            std::fs::File::open(entry.path()).and_then(|f| {
606                let mut buf = Vec::new();
607                f.take(MAX_MEMORY_FILE_BYTES).read_to_end(&mut buf)?;
608                Ok(buf)
609            })
610        };
611        match full_content_read {
612            Ok(buf) => {
613                let full_content = String::from_utf8_lossy(&buf).into_owned();
614                let full_metadata_type = parse_frontmatter_type(&full_content);
615                if let Err(e) = id_store.agent_native_memory_upsert(
616                    agent_id,
617                    &name,
618                    &full_content,
619                    full_metadata_type.as_deref(),
620                    &entry.path().to_string_lossy(),
621                    size_bytes as i64,
622                    modified_at,
623                ) {
624                    tracing::warn!(agent_id, filename = %name, error = %e, "refresh_memory_mirror_from_live_fs: mirror upsert failed (non-fatal)");
625                }
626            }
627            Err(e) => {
628                tracing::warn!(agent_id, filename = %name, error = %e, "refresh_memory_mirror_from_live_fs: full-content read failed, skipping this round (non-fatal)");
629            }
630        }
631    }
632    Ok(truncated_files)
633}
634
635/// Resolve the live memory directory for an agent identified by its
636/// `AgentDefinition.id` (UUID) — the same identifier convention
637/// `agent:memory:list`/`read_file`/`write_file` use (as opposed to
638/// [`memory_dir_for_agent`]'s slug-based lookup, used by different, App-API
639/// callers). Shared by those three handlers and `bundle.export_for_agent`/
640/// `bundle.import_for_agent` (ABF v0.2 §2.3) so all five resolve identically.
641pub(crate) fn memory_dir_for_agent_by_id(
642    mstore: &crate::backend::storage::store::Store,
643    agent: &crate::backend::storage::AgentDefinition,
644) -> Option<std::path::PathBuf> {
645    resolve_memory_dir_by_id(mstore, agent).map(|r| r.path)
646}
647
648/// How an agent's memory directory was found
649/// (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.2, phase M1).
650#[derive(Clone, Copy, Debug, PartialEq, Eq)]
651pub(crate) enum MemoryDirProvenance {
652    /// The config dir and working dir the agent's own latest spawn used,
653    /// from its continuity segment.
654    Spawn,
655    /// The agent's explicit working directory under its linked account.
656    WorkingDirectory,
657    /// A blank working directory with no spawn on record: the registry
658    /// record or the name-derived default. A guess — the stale registry
659    /// `identity_id` behind it once read another agent's files — so it is
660    /// read-only and never swept for drift.
661    Unverified,
662}
663
664pub(crate) struct ResolvedMemoryDir {
665    pub(crate) path: std::path::PathBuf,
666    pub(crate) provenance: MemoryDirProvenance,
667}
668
669/// The memory dir of the agent's latest Claude spawn, from its continuity
670/// segments in the global transcript store.
671fn memory_dir_from_latest_segment(
672    fs: Option<&crate::backend::storage::filestore::FileStore>,
673    agent_uid: &str,
674) -> Option<std::path::PathBuf> {
675    let latest = crate::backend::continuity_segments::segments(fs?, agent_uid)
676        .into_iter()
677        .filter(|s| s.start.provider == "claude" && !s.start.cwd.is_empty())
678        .max_by_key(|s| s.start.started_at_ms)?;
679    Some(memory_dir_for_cwd(latest.start.config_dir.as_deref().unwrap_or(""), &latest.start.cwd))
680}
681
682/// Resolve an agent's memory dir with how it was found: its latest spawn
683/// first, then its explicit working directory, then the blank-working-dir
684/// guess (read-only).
685pub(crate) fn resolve_memory_dir_by_id(
686    mstore: &crate::backend::storage::store::Store,
687    agent: &crate::backend::storage::AgentDefinition,
688) -> Option<ResolvedMemoryDir> {
689    let gfs = crate::backend::agent_session::global_transcript_store().map(|a| a.as_ref());
690    resolve_memory_dir_with(mstore, agent, gfs)
691}
692
693fn resolve_memory_dir_with(
694    mstore: &crate::backend::storage::store::Store,
695    agent: &crate::backend::storage::AgentDefinition,
696    segments_fs: Option<&crate::backend::storage::filestore::FileStore>,
697) -> Option<ResolvedMemoryDir> {
698    if let Some(path) = memory_dir_from_latest_segment(segments_fs, &agent.id) {
699        return Some(ResolvedMemoryDir { path, provenance: MemoryDirProvenance::Spawn });
700    }
701    legacy_memory_dir_by_id(mstore, agent).map(|(path, provenance)| ResolvedMemoryDir { path, provenance })
702}
703
704/// The memory dir of `agent_uid`'s latest spawn, if one is on record — for a
705/// caller that knows the agent's id but has no local row for it.
706pub(crate) fn memory_dir_from_spawn(agent_uid: &str) -> Option<std::path::PathBuf> {
707    let gfs = crate::backend::agent_session::global_transcript_store().map(|a| a.as_ref());
708    memory_dir_from_latest_segment(gfs, agent_uid)
709}
710
711/// The memory dir an AgentMux write may go to: never an unverified guess.
712pub(crate) fn memory_dir_for_write_by_id(
713    mstore: &crate::backend::storage::store::Store,
714    agent: &crate::backend::storage::AgentDefinition,
715) -> Result<std::path::PathBuf, String> {
716    match resolve_memory_dir_by_id(mstore, agent) {
717        Some(r) if r.provenance != MemoryDirProvenance::Unverified => Ok(r.path),
718        Some(_) => Err(format!(
719            "agent {} has no verified memory directory yet (no working directory and no spawn on record); \
720             it becomes writable after the agent's next launch",
721            agent.id
722        )),
723        None => Err(format!("agent {} has no resolvable memory directory", agent.id)),
724    }
725}
726
727/// Today's resolution without spawn records: explicit working directory,
728/// else the blank-working-dir guess.
729fn legacy_memory_dir_by_id(
730    mstore: &crate::backend::storage::store::Store,
731    agent: &crate::backend::storage::AgentDefinition,
732) -> Option<(std::path::PathBuf, MemoryDirProvenance)> {
733    // Same blank-working_directory fallthrough as `memory_dir_for_agent`
734    // (see its own note) — a blank field is "this row can't answer", not
735    // "there is no memory dir". `agent.open` substitutes a default whenever
736    // it's blank, so such an agent still has memories on disk in a directory
737    // only the registry knows.
738    //
739    // Short-circuiting to None here was worse than the sibling's early Err,
740    // because both callers read None as a benign "no memory dir" rather than
741    // a failure (ReAgent P1, PR #2901):
742    //   - `bundle.rs`'s export_for_agent silently exports an EMPTY memory
743    //     file list, losing the agent's memories from the bundle;
744    //   - `bundle.rs`'s import_for_agent silently skips the live-fs mirror
745    //     refresh that exists specifically to avoid overwriting unmirrored
746    //     memory (reagent P0, PR #2527) — i.e. the blank-workdir case
747    //     bypassed a data-loss guard.
748    // Both for the common case, since `working_directory` is blank by default.
749    if !agent.working_directory.is_empty() {
750        let config_dir = agent_claude_config_dir(mstore, &agent.id);
751        return Some((
752            memory_dir_for_cwd(&config_dir, &agent.working_directory),
753            MemoryDirProvenance::WorkingDirectory,
754        ));
755    }
756    memory_dir_for_blank_working_dir(mstore, &agent.id, &agent.name, &agent.slug)
757        .map(|dir| (dir, MemoryDirProvenance::Unverified))
758}
759
760fn version_summary_to_meta(v: crate::backend::storage::NativeMemoryVersionSummary) -> NativeMemoryVersionMeta {
761    NativeMemoryVersionMeta {
762        id: v.id,
763        content_hash: v.content_hash,
764        parent_version_id: v.parent_version_id,
765        source: v.source,
766        source_detail: v.source_detail,
767        session_id: v.session_id,
768        created_at: v.created_at,
769    }
770}
771
772fn version_to_meta(v: &NativeMemoryVersion) -> NativeMemoryVersionMeta {
773    NativeMemoryVersionMeta {
774        id: v.id.clone(),
775        content_hash: v.content_hash.clone(),
776        parent_version_id: v.parent_version_id.clone(),
777        source: v.source.clone(),
778        source_detail: v.source_detail.clone(),
779        session_id: v.session_id.clone(),
780        created_at: v.created_at,
781    }
782}
783
784/// Cap on the LCS table's cell COUNT (from_lines.len() * to_lines.len()),
785/// not on either side's line count independently — reagent P1: the
786/// original per-side-only cap (20,000 lines each) bounded neither
787/// dimension against the other, so two files each under that cap could
788/// still produce a ~3.2GB `usize` table (20_000 * 20_000 * 8 bytes) in the
789/// shared agentmux-srv process. 4,000,000 cells keeps the table under
790/// ~32MB (`* size_of::<usize>()`) regardless of how the two side lengths
791/// are distributed — generous for memory files (markdown notes, not logs)
792/// while still bounded for any combination of sizes.
793const MAX_DIFF_CELLS: usize = 4_000_000;
794
795/// A minimal unified-diff-style line comparison: longest-common-subsequence
796/// based, output lines prefixed `"  "` (context), `"- "` (removed, `from`
797/// only), or `"+ "` (added, `to` only). No `@@` hunk headers or context
798/// trimming in v1 — every line is included, which is fine for memory files.
799pub(crate) fn line_diff(from: &str, to: &str) -> String {
800    let from_lines: Vec<&str> = from.lines().collect();
801    let to_lines: Vec<&str> = to.lines().collect();
802
803    if from_lines.len().saturating_mul(to_lines.len()) > MAX_DIFF_CELLS {
804        return format!(
805            "(diff omitted: {} x {} lines exceeds the {MAX_DIFF_CELLS}-cell comparison cap)",
806            from_lines.len(),
807            to_lines.len(),
808        );
809    }
810
811    let n = from_lines.len();
812    let m = to_lines.len();
813    let cols = m + 1;
814    // A single flat allocation, not `n + 1` separate `Vec<usize>` rows —
815    // reagent P2: MAX_DIFF_CELLS bounds the cell COUNT (n * m), but a
816    // maximally lopsided diff (e.g. ~4,000,000 short lines vs. 1 line)
817    // stays under that cap while `vec![vec![...]; n + 1]` would still
818    // perform ~4,000,001 individual heap allocations — one per row — whose
819    // allocator overhead and allocation-count latency dwarf the actual
820    // cell-data cost the cap was meant to bound. lcs[i][j] (length of the
821    // longest common subsequence of from_lines[i..] and to_lines[j..])
822    // lives at flat index i * cols + j.
823    let mut lcs = vec![0usize; (n + 1) * cols];
824    let idx = |i: usize, j: usize| i * cols + j;
825    for i in (0..n).rev() {
826        for j in (0..m).rev() {
827            lcs[idx(i, j)] = if from_lines[i] == to_lines[j] {
828                lcs[idx(i + 1, j + 1)] + 1
829            } else {
830                lcs[idx(i + 1, j)].max(lcs[idx(i, j + 1)])
831            };
832        }
833    }
834
835    let mut out = String::new();
836    let (mut i, mut j) = (0, 0);
837    while i < n && j < m {
838        if from_lines[i] == to_lines[j] {
839            out.push_str("  ");
840            out.push_str(from_lines[i]);
841            out.push('\n');
842            i += 1;
843            j += 1;
844        } else if lcs[idx(i + 1, j)] >= lcs[idx(i, j + 1)] {
845            out.push_str("- ");
846            out.push_str(from_lines[i]);
847            out.push('\n');
848            i += 1;
849        } else {
850            out.push_str("+ ");
851            out.push_str(to_lines[j]);
852            out.push('\n');
853            j += 1;
854        }
855    }
856    while i < n {
857        out.push_str("- ");
858        out.push_str(from_lines[i]);
859        out.push('\n');
860        i += 1;
861    }
862    while j < m {
863        out.push_str("+ ");
864        out.push_str(to_lines[j]);
865        out.push('\n');
866        j += 1;
867    }
868    out
869}
870
871/// The content `agent:memory:read_file` would return for `path` right now —
872/// live FS first (lossy UTF-8, capped at [`MAX_MEMORY_FILE_BYTES`]), the
873/// durable mirror only when the live file is genuinely absent — WITHOUT
874/// that handler's mirror-upsert side effect. `Ok(None)` means the file
875/// exists in neither place. Used by `agent:memory:write_file`'s
876/// `base_sha256` check, which must hash exactly what the caller's draft was
877/// based on (the frontend hashes the `read_file` response), or every
878/// conditional save of a large or non-UTF-8 file would look like a conflict.
879fn current_memory_content(
880    id_store: &crate::backend::storage::store::Store,
881    agent_id: &str,
882    path: &std::path::Path,
883    filename: &str,
884) -> Result<Option<String>, String> {
885    match std::fs::symlink_metadata(path) {
886        Ok(meta) if meta.file_type().is_file() => {
887            let mut buf = Vec::new();
888            std::fs::File::open(path)
889                .and_then(|f| {
890                    use std::io::Read;
891                    f.take(MAX_MEMORY_FILE_BYTES).read_to_end(&mut buf)
892                })
893                .map_err(|e| format!("{filename}: {e}"))?;
894            Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
895        }
896        Ok(_) => Err(format!("{filename} is not a regular file")),
897        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{filename}: {e}")),
898        Err(_) => id_store
899            .agent_native_memory_read(agent_id, filename)
900            .map_err(|e| format!("{filename}: mirror lookup failed: {e}")),
901    }
902}
903
904/// The optimistic-concurrency check behind `base_sha256`
905/// (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.4): `Ok` when `current`
906/// still hashes to `base`, otherwise an error starting `conflict:` — the
907/// marker the frontend keys its "changed since you started editing" banner
908/// on. A file that no longer exists is a conflict too: the draft's base is
909/// gone, and silently re-creating it would undo someone else's delete.
910fn check_memory_base_sha256(filename: &str, base: &str, current: Option<&str>) -> Result<(), String> {
911    match current {
912        None => Err(format!(
913            "conflict: {filename} no longer exists (your edit was based on {base}); not written"
914        )),
915        Some(content) => {
916            let current_hash = crate::backend::storage::agent_native_memory_versions::content_hash(content);
917            if current_hash.eq_ignore_ascii_case(base) {
918                Ok(())
919            } else {
920                Err(format!(
921                    "conflict: {filename} changed since your edit began (base {base}, current {current_hash}); not written"
922                ))
923            }
924        }
925    }
926}
927
928pub fn register_native_memory_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
929    let mstore_list = state.mstore.clone();
930    let id_store_list = state.id_store.clone();
931    engine.register_typed(
932        COMMAND_NATIVE_MEMORY_LIST,
933        move |cmd: CommandNativeMemoryListData, _ctx| {
934            let mstore = mstore_list.clone();
935            let id_store = id_store_list.clone();
936            async move {
937
938                let agent = mstore
939                    .agent_def_get(&cmd.agent_id)
940                    .map_err(|e| format!("agent:memory:list: store: {e}"))?
941                    .ok_or_else(|| format!("agent:memory:list: agent {} not found", cmd.agent_id))?;
942
943                // A blank working_directory is the DEFAULT state, not a
944                // reason to short-circuit: `agent.open` substitutes a real
945                // directory whenever this field is blank, so a blank row
946                // still describes an agent with real memories on disk. The
947                // old inline "blank → Ok(files: [])" here was a duplicate,
948                // un-synced copy of the exact blank-workdir bug
949                // SPEC_FIX_PERSONAL_MEMORY_EMPTY_WORKDIR_2026_09_01.md
950                // (#2901) already fixed once in `memory_dir_for_agent_by_id`
951                // itself — that fix never propagated to this handler (or to
952                // read_file/write_file/revert below), so it silently kept
953                // reporting "no memories" for every agent #2901 was
954                // supposed to have fixed. See
955                // SPEC_MEMORY_RPC_HANDLERS_BLANK_WORKDIR_2026_09_02.md.
956                let resolved = resolve_memory_dir_by_id(&mstore, &agent).ok_or_else(|| {
957                    format!("agent:memory:list: agent {} has no resolvable memory directory", cmd.agent_id)
958                })?;
959                let unverified = resolved.provenance == MemoryDirProvenance::Unverified;
960                let memory_dir = resolved.path;
961
962                // Existing mirror metadata (no content) for this agent, keyed by
963                // filename — lets the loop below skip the expensive full-content
964                // read+upsert for a file that hasn't changed since it was last
965                // mirrored, instead of doing it unconditionally on every list
966                // call (reagent P1 on PR #2459: `list` fires on every Stash
967                // Bundle tab open/refresh, so an unconditional full read + SQLite
968                // write per file would mean synchronous, potentially many-MB
969                // disk I/O on a call meant to be a lightweight metadata listing).
970                //
971                // Compares BOTH size and mtime, not size alone: a same-byte-length
972                // edit is common (e.g. correcting a typo) and size-only comparison
973                // would silently leave the mirror stale for it. This matters for
974                // exactly the case this table exists for — a channel with no live
975                // copy of a file relies entirely on the mirror (reagent P1 on
976                // PR #2459's first pass: read_file only "always re-reads fresh"
977                // on the channel that still HAS a live copy; a channel that never
978                // did has nothing to self-correct with, so a stale mirror row
979                // there is permanent, not "briefly stale").
980                let mirrored_meta: std::collections::HashMap<String, (i64, i64)> = id_store
981                    .agent_native_memory_list_meta(&agent.id)
982                    .unwrap_or_default()
983                    .into_iter()
984                    .map(|row| (row.filename, (row.size_bytes, row.last_seen_mtime_ms)))
985                    .collect();
986
987                let mut files: Vec<NativeMemoryFileMeta> = Vec::new();
988                let mut live_filenames: std::collections::HashSet<String> = std::collections::HashSet::new();
989                // Only a missing directory (never-yet-written, or wiped after this
990                // channel last had files) is treated as "no live files" — any
991                // other read_dir error (permissions, I/O) propagates, matching the
992                // pre-mirror behavior (reagent P2 on PR #2459: silently swallowing
993                // every error here would hide a real access problem behind what
994                // looks like an empty listing). The mirror merge below still runs
995                // regardless, so a wiped live folder doesn't lose durability for
996                // files it mirrored previously.
997                let entries = match std::fs::read_dir(&memory_dir) {
998                    Ok(e) => Some(e),
999                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1000                    Err(e) => return Err(format!("agent:memory:list: read_dir: {e}")),
1001                };
1002
1003                if let Some(entries) = entries {
1004                    for entry in entries {
1005                        let entry = entry.map_err(|e| format!("agent:memory:list: read_dir entry: {e}"))?;
1006                        let name = entry.file_name().to_string_lossy().into_owned();
1007                        if !name.ends_with(".md") {
1008                            continue;
1009                        }
1010                        // Reject symlinks — entry.file_type() does NOT follow symlinks.
1011                        let file_type = entry
1012                            .file_type()
1013                            .map_err(|e| format!("agent:memory:list: file_type {name}: {e}"))?;
1014                        if !file_type.is_file() {
1015                            continue;
1016                        }
1017                        // The file may be deleted after read_dir but before metadata —
1018                        // skip the entry on NotFound rather than aborting the whole listing.
1019                        let meta = match entry.metadata() {
1020                            Ok(m) => m,
1021                            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
1022                            Err(e) => return Err(format!("agent:memory:list: metadata {name}: {e}")),
1023                        };
1024                        let size_bytes = meta.len();
1025                        let modified_at = meta
1026                            .modified()
1027                            .ok()
1028                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1029                            .map(|d| d.as_millis() as i64)
1030                            .unwrap_or(0);
1031
1032                        // Read up to 512 bytes for frontmatter type parsing — same
1033                        // cheap preview the pre-mirror code used. Take + read_to_end
1034                        // loops internally to fill the buffer.
1035                        let preview_content = {
1036                            use std::io::Read;
1037                            std::fs::File::open(entry.path())
1038                                .map(|f| {
1039                                    let mut buf = Vec::with_capacity(512);
1040                                    f.take(512).read_to_end(&mut buf).ok();
1041                                    String::from_utf8_lossy(&buf).into_owned()
1042                                })
1043                                .unwrap_or_default()
1044                        };
1045                        let metadata_type = parse_frontmatter_type(&preview_content);
1046                        let is_index = name == "MEMORY.md";
1047
1048                        let unchanged_since_last_mirror =
1049                            mirrored_meta.get(&name) == Some(&(size_bytes as i64, modified_at));
1050                        if !unchanged_since_last_mirror {
1051                            // A read failure here (permission change, TOCTOU
1052                            // delete, AV/NFS lock, concurrent editor) must NOT
1053                            // upsert an empty string — that would overwrite any
1054                            // previously-durable mirrored content, destroying the
1055                            // exact cross-channel durability guarantee this table
1056                            // exists for on the very first transient read hiccup
1057                            // (reagent P0 on PR #2459). Skip the upsert entirely
1058                            // this round instead; it retries on the next list().
1059                            let full_content_read = {
1060                                use std::io::Read;
1061                                std::fs::File::open(entry.path()).and_then(|f| {
1062                                    let mut buf = Vec::new();
1063                                    f.take(MAX_MEMORY_FILE_BYTES).read_to_end(&mut buf)?;
1064                                    Ok(buf)
1065                                })
1066                            };
1067                            match full_content_read {
1068                                Ok(buf) => {
1069                                    let full_content = String::from_utf8_lossy(&buf).into_owned();
1070                                    let full_metadata_type = parse_frontmatter_type(&full_content);
1071                                    if let Err(e) = id_store.agent_native_memory_upsert(
1072                                        &agent.id,
1073                                        &name,
1074                                        &full_content,
1075                                        full_metadata_type.as_deref(),
1076                                        &entry.path().to_string_lossy(),
1077                                        size_bytes as i64,
1078                                        modified_at,
1079                                    ) {
1080                                        tracing::warn!(agent_id = %agent.id, filename = %name, error = %e, "agent:memory:list: mirror upsert failed (non-fatal)");
1081                                    }
1082                                }
1083                                Err(e) => {
1084                                    tracing::warn!(agent_id = %agent.id, filename = %name, error = %e, "agent:memory:list: full-content read failed, skipping mirror upsert this round (non-fatal)");
1085                                }
1086                            }
1087                        }
1088                        live_filenames.insert(name.clone());
1089
1090                        files.push(NativeMemoryFileMeta {
1091                            filename: name,
1092                            is_index,
1093                            metadata_type,
1094                            size_bytes,
1095                            modified_at,
1096                        });
1097                    }
1098                }
1099
1100                // Merge in mirror-only files — present in a different channel's
1101                // write (or the live folder was wiped) but not on this channel's
1102                // live FS. Served transparently, with no distinguishing treatment.
1103                match id_store.agent_native_memory_list_meta(&agent.id) {
1104                    Ok(mirrored) => {
1105                        for row in mirrored {
1106                            if live_filenames.contains(&row.filename) {
1107                                continue;
1108                            }
1109                            files.push(NativeMemoryFileMeta {
1110                                is_index: row.filename == "MEMORY.md",
1111                                metadata_type: row.metadata_type,
1112                                size_bytes: row.size_bytes as u64,
1113                                modified_at: row.last_seen_mtime_ms,
1114                                filename: row.filename,
1115                            });
1116                        }
1117                    }
1118                    Err(e) => {
1119                        tracing::warn!(agent_id = %agent.id, error = %e, "agent:memory:list: mirror list failed (non-fatal)");
1120                    }
1121                }
1122
1123                // MEMORY.md first, then alphabetical
1124                files.sort_by(|a, b| {
1125                    b.is_index.cmp(&a.is_index).then(a.filename.cmp(&b.filename))
1126                });
1127
1128                Ok(NativeMemoryListResult { files, unverified })
1129            }
1130        },
1131    );
1132
1133    let mstore_read = state.mstore.clone();
1134    let id_store_read = state.id_store.clone();
1135    engine.register_typed(
1136        COMMAND_NATIVE_MEMORY_READ_FILE,
1137        move |cmd: CommandNativeMemoryReadFileData, _ctx| {
1138            let mstore = mstore_read.clone();
1139            let id_store = id_store_read.clone();
1140            async move {
1141
1142                validate_filename(&cmd.filename)
1143                    .map_err(|e| format!("agent:memory:read_file: {e}"))?;
1144
1145                let agent = mstore
1146                    .agent_def_get(&cmd.agent_id)
1147                    .map_err(|e| format!("agent:memory:read_file: store: {e}"))?
1148                    .ok_or_else(|| format!("agent:memory:read_file: agent {} not found", cmd.agent_id))?;
1149
1150                // See the identical comment on the list handler above — a
1151                // blank working_directory is not "no memory dir".
1152                let path = memory_dir_for_agent_by_id(&mstore, &agent)
1153                    .ok_or_else(|| {
1154                        format!("agent:memory:read_file: agent {} has no resolvable memory directory", cmd.agent_id)
1155                    })?
1156                    .join(&cmd.filename);
1157
1158                // Live FS is the freshest copy when present — Claude may have
1159                // written moments ago, before this call's mirror upsert even
1160                // runs. Fall back to the mirror only when the live file is
1161                // genuinely absent (a different channel's write, or the live
1162                // folder was wiped) — a live path that exists but fails to
1163                // read for another reason (permissions, non-regular file)
1164                // still surfaces as an error rather than silently masking it
1165                // with stale mirrored content.
1166                // Distinguish "genuinely absent" (fall back to the mirror) from
1167                // every other symlink_metadata outcome, matching the
1168                // pre-durable-sync behavior exactly: a real access error
1169                // (permissions, I/O) must surface as an error, not silently
1170                // fall back to possibly-stale mirrored content, and an existing
1171                // non-regular-file path must be explicitly rejected, not treated
1172                // as "absent" either (reagent P1 on PR #2459, second pass —
1173                // collapsing every outcome into "absent" the first time around
1174                // could serve stale content or a misleading "not found" for a
1175                // path that actually exists but errored or is the wrong type).
1176                let content = match std::fs::symlink_metadata(&path) {
1177                    Ok(live_meta) if live_meta.file_type().is_file() => {
1178                        let mut buf = Vec::new();
1179                        std::fs::File::open(&path)
1180                            .and_then(|f| {
1181                                use std::io::Read;
1182                                f.take(MAX_MEMORY_FILE_BYTES).read_to_end(&mut buf)
1183                            })
1184                            .map_err(|e| format!("agent:memory:read_file: {}: {e}", cmd.filename))?;
1185                        let content = String::from_utf8_lossy(&buf).into_owned();
1186
1187                        let metadata_type = parse_frontmatter_type(&content);
1188                        let mtime_ms = live_meta
1189                            .modified()
1190                            .ok()
1191                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1192                            .map(|d| d.as_millis() as i64)
1193                            .unwrap_or(0);
1194                        if let Err(e) = id_store.agent_native_memory_upsert(
1195                            &agent.id,
1196                            &cmd.filename,
1197                            &content,
1198                            metadata_type.as_deref(),
1199                            &path.to_string_lossy(),
1200                            live_meta.len() as i64,
1201                            mtime_ms,
1202                        ) {
1203                            tracing::warn!(agent_id = %agent.id, filename = %cmd.filename, error = %e, "agent:memory:read_file: mirror upsert failed (non-fatal)");
1204                        }
1205                        content
1206                    }
1207                    Ok(_) => {
1208                        return Err(format!("agent:memory:read_file: {} is not a regular file", cmd.filename));
1209                    }
1210                    Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
1211                        return Err(format!("agent:memory:read_file: {}: {e}", cmd.filename));
1212                    }
1213                    Err(_) => match id_store.agent_native_memory_read(&agent.id, &cmd.filename) {
1214                        Ok(Some(mirrored)) => mirrored,
1215                        Ok(None) => {
1216                            return Err(format!("agent:memory:read_file: {}: not found", cmd.filename));
1217                        }
1218                        Err(e) => {
1219                            return Err(format!("agent:memory:read_file: {}: not found on this channel and mirror lookup failed: {e}", cmd.filename));
1220                        }
1221                    }
1222                };
1223
1224                Ok(NativeMemoryReadFileResult { content })
1225            }
1226        },
1227    );
1228
1229    let mstore_write = state.mstore.clone();
1230    let id_store_write = state.id_store.clone();
1231    let broker_write = state.broker.clone();
1232    engine.register_typed(
1233        COMMAND_NATIVE_MEMORY_WRITE_FILE,
1234        move |cmd: CommandNativeMemoryWriteFileData, _ctx| {
1235            let mstore = mstore_write.clone();
1236            let id_store = id_store_write.clone();
1237            let broker = broker_write.clone();
1238            async move {
1239
1240                validate_filename(&cmd.filename)
1241                    .map_err(|e| format!("agent:memory:write_file: {e}"))?;
1242
1243                const MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
1244                if cmd.content.len() > MAX_CONTENT_BYTES {
1245                    return Err(format!(
1246                        "agent:memory:write_file: content too large ({} bytes, max {})",
1247                        cmd.content.len(),
1248                        MAX_CONTENT_BYTES,
1249                    ));
1250                }
1251
1252                let agent = mstore
1253                    .agent_def_get(&cmd.agent_id)
1254                    .map_err(|e| format!("agent:memory:write_file: store: {e}"))?
1255                    .ok_or_else(|| format!("agent:memory:write_file: agent {} not found", cmd.agent_id))?;
1256
1257                // See the identical comment on the list handler above — a
1258                // blank working_directory is not "no memory dir".
1259                let dir = memory_dir_for_write_by_id(&mstore, &agent)
1260                    .map_err(|e| format!("agent:memory:write_file: {e}"))?;
1261                std::fs::create_dir_all(&dir)
1262                    .map_err(|e| format!("agent:memory:write_file: mkdir: {e}"))?;
1263
1264                // Conditional save (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md
1265                // §2.4): a caller that says which content its draft started
1266                // from gets a refusal, not an overwrite, when that content
1267                // has since moved. Checked before the version row below so a
1268                // refused write leaves no trace in history. Not atomic with
1269                // the rename further down — a write landing in between still
1270                // wins — but it closes the window from minutes (a human
1271                // editing) to microseconds, which is the case this exists for.
1272                if let Some(base) = cmd.base_sha256.as_deref() {
1273                    let current = current_memory_content(&id_store, &agent.id, &dir.join(&cmd.filename), &cmd.filename)
1274                        .map_err(|e| format!("agent:memory:write_file: {e}"))?;
1275                    check_memory_base_sha256(&cmd.filename, base, current.as_deref())
1276                        .map_err(|e| format!("agent:memory:write_file: {e}"))?;
1277                }
1278
1279                // Version history — recorded BEFORE the live-file write below,
1280                // not after. reagent P1: the drift detector's fast path
1281                // (native_memory_drift.rs) subscribes to fs-watch events on
1282                // this same directory; if the version row were inserted AFTER
1283                // the write (as an earlier revision of this handler did), a
1284                // fs-watch event for the write below can be processed before
1285                // this version exists, see a hash that doesn't match anything
1286                // recorded yet, and log this legitimate RPC write as a
1287                // spurious "external_fs_write". Recording the version first
1288                // establishes a real happens-before: the file-modify event
1289                // that write can possibly generate cannot fire until the
1290                // write below actually executes, by which point this version
1291                // already exists to match against. Non-fatal on failure — a
1292                // durability/review layer on top of the write, not the write
1293                // itself (mirrors the mirror-upsert failure handling below).
1294                let (version_source, version_detail) = match &cmd.provenance {
1295                    Some(p) => (p.source.as_str(), p.detail.to_string()),
1296                    None => ("agent_inferred", "{}".to_string()),
1297                };
1298                if let Err(e) = id_store.agent_native_memory_version_insert(
1299                    &agent.id,
1300                    &cmd.filename,
1301                    &cmd.content,
1302                    version_source,
1303                    &version_detail,
1304                    "",
1305                ) {
1306                    tracing::warn!(agent_id = %agent.id, filename = %cmd.filename, error = %e, "agent:memory:write_file: version insert failed (non-fatal)");
1307                }
1308                // And into the agent's memory record, likewise first
1309                // (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.1).
1310                crate::backend::memory_reconcile::record_agentmux_write(
1311                    &agent.id,
1312                    &dir,
1313                    &cmd.filename,
1314                    cmd.content.as_bytes(),
1315                    version_source,
1316                    &version_detail,
1317                );
1318
1319                let dest = dir.join(&cmd.filename);
1320                // Per-write UUID suffix prevents concurrent writes to the same
1321                // filename from sharing a tmp path and silently corrupting each
1322                // other's content (reagent P1 on PR #1588).
1323                let tmp = dir.join(format!(".{}.{}.tmp", cmd.filename, uuid::Uuid::new_v4()));
1324
1325                // Clean up tmp on both write failure (partial file) and rename failure.
1326                if let Err(e) = std::fs::write(&tmp, &cmd.content) {
1327                    let _ = std::fs::remove_file(&tmp);
1328                    return Err(format!("agent:memory:write_file: write tmp: {e}"));
1329                }
1330                if let Err(e) = std::fs::rename(&tmp, &dest) {
1331                    let _ = std::fs::remove_file(&tmp);
1332                    return Err(format!("agent:memory:write_file: rename: {e}"));
1333                }
1334
1335                let metadata_type = parse_frontmatter_type(&cmd.content);
1336                // Re-stat the just-written file for its real on-disk size/mtime
1337                // rather than deriving them from cmd.content here — keeps this
1338                // in exact agreement with what a subsequent list() will compute,
1339                // so the size+mtime change check there doesn't spuriously treat
1340                // this write as "changed again" due to clock/precision drift.
1341                let dest_meta = std::fs::metadata(&dest).ok();
1342                let size_bytes = dest_meta.as_ref().map(|m| m.len() as i64).unwrap_or(cmd.content.len() as i64);
1343                let mtime_ms = dest_meta
1344                    .and_then(|m| m.modified().ok())
1345                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1346                    .map(|d| d.as_millis() as i64)
1347                    .unwrap_or(0);
1348                if let Err(e) = id_store.agent_native_memory_upsert(
1349                    &agent.id,
1350                    &cmd.filename,
1351                    &cmd.content,
1352                    metadata_type.as_deref(),
1353                    &dest.to_string_lossy(),
1354                    size_bytes,
1355                    mtime_ms,
1356                ) {
1357                    tracing::warn!(agent_id = %agent.id, filename = %cmd.filename, error = %e, "agent:memory:write_file: mirror upsert failed (non-fatal)");
1358                }
1359
1360                // Reactive Memory updates (SPEC_ARMORY_REACTIVE_UPDATES_2026_09_02.md):
1361                // same event, same `agent:memory:changed:{canonical_uuid}`
1362                // naming convention `app_api::mod`'s write/revert already use
1363                // for the MemoryWrite MCP tool's own path — keying by
1364                // `agent.id` (the canonical UUID this whole file's other
1365                // handlers already key by, per `resolve_agent_uuid`'s own
1366                // doc comment on why a slug-keyed event here would silently
1367                // miss frontend subscribers that only ever have the UUID).
1368                broker.publish_event_for::<crate::backend::mps_events::AgentMemoryChanged>(&agent.id, &(), vec![], 0);
1369
1370                tracing::info!(
1371                    agent_id = %cmd.agent_id,
1372                    filename = %cmd.filename,
1373                    bytes = cmd.content.len(),
1374                    "agent:memory:write_file"
1375                );
1376                // `register_typed` always sends a `data` field, so this `()`
1377                // puts `"data": null` on the wire where the old untyped
1378                // `Ok(None)` omitted the key entirely. All three frontend
1379                // callers (agent-native-memory-model.ts's draft `save` and
1380                // `createFile`, NativeMemoryFileView.tsx's draft `save`) await
1381                // and discard the result, and no other crate invokes this
1382                // command, so the difference is unobservable.
1383                Ok(())
1384            }
1385        },
1386    );
1387
1388    // Offers only: adopting a listed folder goes through the host's
1389    // confirmation window (`service/memory_adopt.rs`), never this RPC — every
1390    // agent holds the key this one is called with.
1391    let mstore_adoption = state.mstore.clone();
1392    engine.register_typed(
1393        COMMAND_NATIVE_MEMORY_ADOPTION_LIST,
1394        move |cmd: CommandNativeMemoryAdoptionListData, _ctx| {
1395            let mstore = mstore_adoption.clone();
1396            async move {
1397                let agent = mstore
1398                    .agent_def_get(&cmd.agent_id)
1399                    .map_err(|e| format!("agent:memory:adoption_list: store: {e}"))?
1400                    .ok_or_else(|| format!("agent:memory:adoption_list: agent {} not found", cmd.agent_id))?;
1401                let Some(fs) = crate::backend::agent_session::global_transcript_store() else {
1402                    return Ok(NativeMemoryAdoptionListResult { list: None });
1403                };
1404                let list = tokio::task::spawn_blocking(move || crate::backend::memory_adopt::list(fs, &mstore, &agent))
1405                    .await
1406                    .map_err(|e| format!("agent:memory:adoption_list: {e}"))?
1407                    .map_err(|e| format!("agent:memory:adoption_list: {e}"))?;
1408                Ok(NativeMemoryAdoptionListResult { list })
1409            }
1410        },
1411    );
1412
1413    // Offers only, like the adoption list: releasing goes through the host.
1414    let mstore_claims = state.mstore.clone();
1415    engine.register_typed(
1416        COMMAND_NATIVE_MEMORY_CLAIMS,
1417        move |cmd: CommandNativeMemoryClaimsData, _ctx| {
1418            let mstore = mstore_claims.clone();
1419            async move {
1420                let agent = mstore
1421                    .agent_def_get(&cmd.agent_id)
1422                    .map_err(|e| format!("agent:memory:claims: store: {e}"))?
1423                    .ok_or_else(|| format!("agent:memory:claims: agent {} not found", cmd.agent_id))?;
1424                let fs = crate::backend::agent_session::global_transcript_store()
1425                    .ok_or_else(|| "agent:memory:claims: memory record store unavailable".to_string())?;
1426                tokio::task::spawn_blocking(move || crate::backend::memory_release::list(fs, &agent.id))
1427                    .await
1428                    .map_err(|e| format!("agent:memory:claims: {e}"))?
1429                    .map_err(|e| format!("agent:memory:claims: {e}"))
1430            }
1431        },
1432    );
1433
1434    let mstore_history = state.mstore.clone();
1435    let id_store_history = state.id_store.clone();
1436    engine.register_typed(
1437        COMMAND_NATIVE_MEMORY_HISTORY,
1438        move |cmd: CommandNativeMemoryHistoryData, _ctx| {
1439            let mstore = mstore_history.clone();
1440            let id_store = id_store_history.clone();
1441            async move {
1442
1443                validate_filename(&cmd.filename)
1444                    .map_err(|e| format!("agent:memory:history: {e}"))?;
1445
1446                // Resolve to the same canonical agent.id write_file keys
1447                // by, for the same reason app_api::memory_history_impl
1448                // does (reagent P1) — cmd.agent_id is expected to already
1449                // be that id for this surface (the frontend passes
1450                // AgentDefinition.id), but resolving explicitly here
1451                // rather than trusting it verbatim matches write_file's
1452                // own validation and closes the gap if that assumption
1453                // ever stops holding for some caller.
1454                let agent = mstore
1455                    .agent_def_get(&cmd.agent_id)
1456                    .map_err(|e| format!("agent:memory:history: store: {e}"))?
1457                    .ok_or_else(|| format!("agent:memory:history: agent {} not found", cmd.agent_id))?;
1458
1459                let versions = id_store
1460                    .agent_native_memory_version_list(&agent.id, &cmd.filename)
1461                    .map_err(|e| format!("agent:memory:history: store: {e}"))?
1462                    .into_iter()
1463                    .map(version_summary_to_meta)
1464                    .collect();
1465
1466                Ok(NativeMemoryHistoryResult { versions })
1467            }
1468        },
1469    );
1470
1471    let mstore_diff = state.mstore.clone();
1472    let id_store_diff = state.id_store.clone();
1473    engine.register_typed(
1474        COMMAND_NATIVE_MEMORY_DIFF,
1475        move |cmd: CommandNativeMemoryDiffData, _ctx| {
1476            let mstore = mstore_diff.clone();
1477            let id_store = id_store_diff.clone();
1478            async move {
1479
1480                // Resolve to the same canonical agent.id write_file keys by
1481                // — see the identical comment on the history handler above.
1482                let agent = mstore
1483                    .agent_def_get(&cmd.agent_id)
1484                    .map_err(|e| format!("agent:memory:diff: store: {e}"))?
1485                    .ok_or_else(|| format!("agent:memory:diff: agent {} not found", cmd.agent_id))?;
1486
1487                let from = id_store
1488                    .agent_native_memory_version_get(&cmd.from_version_id)
1489                    .map_err(|e| format!("agent:memory:diff: store: {e}"))?
1490                    .ok_or_else(|| format!("agent:memory:diff: version {} not found", cmd.from_version_id))?;
1491                let to = id_store
1492                    .agent_native_memory_version_get(&cmd.to_version_id)
1493                    .map_err(|e| format!("agent:memory:diff: store: {e}"))?
1494                    .ok_or_else(|| format!("agent:memory:diff: version {} not found", cmd.to_version_id))?;
1495                // reagent P1: unlike list/read/write/history/revert, this
1496                // handler has no other agent-scoping — every caller shares
1497                // one instance-wide X-AuthKey, so without this check any
1498                // caller could read any other agent's memory content by
1499                // version id.
1500                if from.agent_id != agent.id || to.agent_id != agent.id {
1501                    return Err(format!(
1502                        "agent:memory:diff: one or both versions do not belong to {}",
1503                        cmd.agent_id
1504                    ));
1505                }
1506                // reagent P2: ownership alone isn't enough — from/to must
1507                // also be versions of the SAME file, or the "diff" is a
1508                // meaningless line-by-line comparison of two unrelated
1509                // files with no error to signal that.
1510                if from.filename != to.filename {
1511                    return Err(format!(
1512                        "agent:memory:diff: from_version_id and to_version_id are versions of different files ({} vs {})",
1513                        from.filename, to.filename
1514                    ));
1515                }
1516
1517                let diff = line_diff(&from.content, &to.content);
1518                Ok(NativeMemoryDiffResult { diff })
1519            }
1520        },
1521    );
1522
1523    let mstore_revert = state.mstore.clone();
1524    let id_store_revert = state.id_store.clone();
1525    let broker_revert = state.broker.clone();
1526    engine.register_typed(
1527        COMMAND_NATIVE_MEMORY_REVERT,
1528        move |cmd: CommandNativeMemoryRevertData, _ctx| {
1529            let mstore = mstore_revert.clone();
1530            let id_store = id_store_revert.clone();
1531            let broker = broker_revert.clone();
1532            async move {
1533
1534                validate_filename(&cmd.filename)
1535                    .map_err(|e| format!("agent:memory:revert: {e}"))?;
1536
1537                // Resolve to the same canonical agent.id write_file keys by
1538                // — see the identical comment on the history handler above.
1539                // Resolved BEFORE the ownership check below (not after, as
1540                // an earlier revision of this handler did) so that check
1541                // compares against the same id the version was actually
1542                // stored under, not the raw, possibly-different cmd.agent_id.
1543                let agent = mstore
1544                    .agent_def_get(&cmd.agent_id)
1545                    .map_err(|e| format!("agent:memory:revert: store: {e}"))?
1546                    .ok_or_else(|| format!("agent:memory:revert: agent {} not found", cmd.agent_id))?;
1547
1548                let target = id_store
1549                    .agent_native_memory_version_get(&cmd.target_version_id)
1550                    .map_err(|e| format!("agent:memory:revert: store: {e}"))?
1551                    .ok_or_else(|| format!("agent:memory:revert: version {} not found", cmd.target_version_id))?;
1552                if target.agent_id != agent.id || target.filename != cmd.filename {
1553                    return Err(format!(
1554                        "agent:memory:revert: version {} does not belong to {}/{}",
1555                        cmd.target_version_id, cmd.agent_id, cmd.filename
1556                    ));
1557                }
1558
1559                // Revert is implemented as a NEW write through the same
1560                // path as agent:memory:write_file (live file + mirror +
1561                // version), not a rewrite of history — this is the
1562                // git-revert-not-git-reset guarantee from the spec's §4.3.
1563                // See the identical comment on the list handler above — a
1564                // blank working_directory is not "no memory dir".
1565                let dir = memory_dir_for_write_by_id(&mstore, &agent).map_err(|e| {
1566                    format!("agent:memory:revert: {e}")
1567                })?;
1568                std::fs::create_dir_all(&dir)
1569                    .map_err(|e| format!("agent:memory:revert: mkdir: {e}"))?;
1570
1571                // Version recorded BEFORE the live-file write below — same
1572                // fs-watch-race rationale as agent:memory:write_file's own
1573                // handler above (reagent P1). Unlike that handler, a failure
1574                // here IS fatal: the RPC's whole contract is "return the new
1575                // version," so silently reverting the file but not returning
1576                // a version would leave the caller with no way to know what
1577                // they just reverted to.
1578                let detail = serde_json::json!({ "reverted_to": cmd.target_version_id }).to_string();
1579                let new_version = id_store
1580                    .agent_native_memory_version_insert(&agent.id, &cmd.filename, &target.content, "revert", &detail, "")
1581                    .map_err(|e| format!("agent:memory:revert: version insert: {e}"))?;
1582                crate::backend::memory_reconcile::record_agentmux_write(
1583                    &agent.id,
1584                    &dir,
1585                    &cmd.filename,
1586                    target.content.as_bytes(),
1587                    "revert",
1588                    &detail,
1589                );
1590
1591                let dest = dir.join(&cmd.filename);
1592                let tmp = dir.join(format!(".{}.{}.tmp", cmd.filename, uuid::Uuid::new_v4()));
1593                if let Err(e) = std::fs::write(&tmp, &target.content) {
1594                    let _ = std::fs::remove_file(&tmp);
1595                    return Err(format!("agent:memory:revert: write tmp: {e}"));
1596                }
1597                if let Err(e) = std::fs::rename(&tmp, &dest) {
1598                    let _ = std::fs::remove_file(&tmp);
1599                    return Err(format!("agent:memory:revert: rename: {e}"));
1600                }
1601
1602                let metadata_type = parse_frontmatter_type(&target.content);
1603                let dest_meta = std::fs::metadata(&dest).ok();
1604                let size_bytes = dest_meta.as_ref().map(|m| m.len() as i64).unwrap_or(target.content.len() as i64);
1605                let mtime_ms = dest_meta
1606                    .and_then(|m| m.modified().ok())
1607                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1608                    .map(|d| d.as_millis() as i64)
1609                    .unwrap_or(0);
1610                if let Err(e) = id_store.agent_native_memory_upsert(
1611                    &agent.id,
1612                    &cmd.filename,
1613                    &target.content,
1614                    metadata_type.as_deref(),
1615                    &dest.to_string_lossy(),
1616                    size_bytes,
1617                    mtime_ms,
1618                ) {
1619                    tracing::warn!(agent_id = %agent.id, filename = %cmd.filename, error = %e, "agent:memory:revert: mirror upsert failed (non-fatal)");
1620                }
1621
1622                // See the identical comment on write_file's own handler above.
1623                broker.publish_event_for::<crate::backend::mps_events::AgentMemoryChanged>(&agent.id, &(), vec![], 0);
1624
1625                tracing::info!(
1626                    agent_id = %cmd.agent_id,
1627                    filename = %cmd.filename,
1628                    target_version_id = %cmd.target_version_id,
1629                    "agent:memory:revert"
1630                );
1631                Ok(NativeMemoryRevertResult { version: version_to_meta(&new_version) })
1632            }
1633        },
1634    );
1635}
1636
1637// Request-shape tests for the six `agent:memory:*` commands.
1638//
1639// These exist because NOTHING else catches a Req/payload mismatch: `tsc` only
1640// checks the frontend against the GENERATED types, and
1641// `scripts/check-rpc-bindings.sh` only checks that a generated type exists for
1642// each command — neither one ever deserializes a real payload into the Rust
1643// struct. A `Req` that cannot parse what the stub sends compiles, typechecks,
1644// passes the binding gate, and then fails on every call at runtime. That is
1645// exactly the bug found on `bookmarks.list` (`Req = ()` rejects `{}`), so each
1646// command below is pinned to the literal JSON its call site actually sends.
1647#[cfg(test)]
1648#[path = "tests/native_memory_handlers/req_shape_tests.rs"]
1649mod req_shape_tests;
1650
1651#[cfg(test)]
1652#[path = "tests/native_memory_handlers/tests.rs"]
1653mod tests;