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;