Skip to main content

agentmux_srv/backend/
memory_adopt.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Adopting an agent's memory from its earlier accounts
5//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.4, phase M3c).
6//!
7//! An agent's memory folder is keyed by account, so every account it ever
8//! ran under left a folder behind. The agent's own live folder is adopted
9//! automatically at first sighting (§2.1.2); every other folder is only
10//! **offered**:
11//!
12//! - [`list`] enumerates the candidates — the same project folder under
13//!   every account on this machine, minus the agent's own and any folder
14//!   another agent uses — and issues the list a `list_id`. Paths never
15//!   leave the server.
16//! - [`adopt`] takes `(list_id, index, dir hash)` choices only. It is
17//!   reached through the host's confirmation window, never an agent-held
18//!   credential (the service gates it on the host secret). A folder that
19//!   changed since the list was issued is refused.
20//!
21//! Merging never replaces the agent's current memory: a file the record
22//! already has keeps its head, the adopted bodies are kept as its history;
23//! a file it doesn't have is adopted, newest by modification time as the
24//! head. The MEMORY.md indexes are unioned line by line, deduplicated by
25//! link target, so no adopted topic becomes invisible to Claude. The next
26//! spawn writes the result into the agent's folder.
27
28use std::collections::{BTreeMap, HashMap};
29use std::path::{Path, PathBuf};
30use std::sync::Mutex;
31use std::time::{Duration, Instant};
32
33use serde::Serialize;
34
35use crate::backend::memory_dir_claims as claims;
36use crate::backend::memory_record::{self as record, AppendOutcome, ImportedVersion, NewVersion};
37use crate::backend::storage::error::StoreError;
38use crate::backend::storage::filestore::FileStore;
39use crate::backend::storage::store::Store;
40
41const INDEX_FILE: &str = "MEMORY.md";
42/// How long an issued list stays valid.
43const LIST_TTL: Duration = Duration::from_secs(30 * 60);
44
45#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
46#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptionFile")]
47pub(crate) struct CandidateFile {
48    pub name: String,
49    #[ts(type = "number")]
50    pub size: u64,
51    #[ts(type = "number")]
52    pub modified_ms: i64,
53    pub sha256: String,
54}
55
56/// One folder offered for adoption. No path: the client refers to it by
57/// `index` and `dir_hash`.
58#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
59#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptionCandidate")]
60pub(crate) struct Candidate {
61    pub index: usize,
62    /// The account the folder belongs to (its identity folder), `default`
63    /// for the unlinked default home, or `held` for files in the agent's own
64    /// folder held at first sighting because another agent's record has
65    /// the same content (§2.1.2).
66    pub account: String,
67    /// Changes whenever a file in the folder is added, removed or changed.
68    pub dir_hash: String,
69    pub files: Vec<CandidateFile>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
73#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptionList")]
74pub(crate) struct AdoptionList {
75    pub list_id: String,
76    pub agent_uid: String,
77    pub candidates: Vec<Candidate>,
78}
79
80#[derive(Debug, Default, PartialEq, Serialize, ts_rs::TS)]
81#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptReport")]
82pub(crate) struct AdoptReport {
83    /// Files the record didn't have, now adopted.
84    pub files_added: usize,
85    /// Other bodies kept as history.
86    pub versions_kept: usize,
87    /// Lines added to MEMORY.md.
88    pub index_lines_added: usize,
89}
90
91#[derive(Debug, PartialEq)]
92pub(crate) enum AdoptError {
93    /// Unknown or expired list, or one issued for another agent.
94    UnknownList,
95    BadChoice,
96    /// A chosen folder changed (or became another agent's) since the list
97    /// was issued; the caller shows a fresh list.
98    Changed { index: usize },
99    /// A reconcile or capture for the agent is running; try again.
100    Busy,
101    Store(String),
102}
103
104impl From<StoreError> for AdoptError {
105    fn from(e: StoreError) -> Self {
106        AdoptError::Store(e.to_string())
107    }
108}
109
110/// One listed folder, as the server remembers it.
111struct IssuedDir {
112    path: PathBuf,
113    dir_hash: String,
114    account: String,
115    /// Only these files (the held ones in the agent's own folder).
116    only: Option<Vec<String>>,
117}
118
119struct Issued {
120    agent_uid: String,
121    dirs: Vec<IssuedDir>,
122    at: Instant,
123}
124
125fn issued() -> &'static Mutex<HashMap<String, Issued>> {
126    static ISSUED: std::sync::OnceLock<Mutex<HashMap<String, Issued>>> = std::sync::OnceLock::new();
127    ISSUED.get_or_init(|| Mutex::new(HashMap::new()))
128}
129
130/// Every account's projects folder on this machine: each linked account's
131/// (`identities/<acct>/claude/projects`), and the unlinked default home's.
132fn account_project_roots(shared: &Path) -> Vec<(String, PathBuf)> {
133    let mut roots: Vec<(String, PathBuf)> = std::fs::read_dir(shared.join("identities"))
134        .map(|d| {
135            d.flatten()
136                .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path().join("claude").join("projects")))
137                .collect()
138        })
139        .unwrap_or_default();
140    roots.sort();
141    roots.push(("default".into(), shared.join("providers").join("claude").join("projects")));
142    roots
143}
144
145/// A folder's memory files, sorted by name — only `only`'s, when given.
146/// Unreadable and oversized files are left out.
147fn folder_files(dir: &Path, only: Option<&[String]>) -> Vec<(CandidateFile, Vec<u8>)> {
148    let mut out: Vec<(CandidateFile, Vec<u8>)> = std::fs::read_dir(dir)
149        .map(|d| {
150            d.flatten()
151                .filter_map(|e| {
152                    let name = e.file_name().to_string_lossy().into_owned();
153                    crate::server::native_memory_handlers::validate_filename(&name).ok()?;
154                    if only.is_some_and(|o| !o.contains(&name)) {
155                        return None;
156                    }
157                    let meta = std::fs::metadata(e.path()).ok().filter(|m| m.is_file())?;
158                    if meta.len() as usize > record::MAX_BODY_BYTES {
159                        return None;
160                    }
161                    let body = std::fs::read(e.path()).ok()?;
162                    let modified_ms = meta
163                        .modified()
164                        .ok()
165                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
166                        .map_or(0, |d| d.as_millis() as i64);
167                    let file = CandidateFile { name, size: meta.len(), modified_ms, sha256: record::sha256_hex(&body) };
168                    Some((file, body))
169                })
170                .collect()
171        })
172        .unwrap_or_default();
173    out.sort_by(|a, b| a.0.name.cmp(&b.0.name));
174    out
175}
176
177fn dir_hash(files: &[(CandidateFile, Vec<u8>)]) -> String {
178    let listing: String = files.iter().map(|(f, _)| format!("{}\t{}\n", f.name, f.sha256)).collect();
179    record::sha256_hex(listing.as_bytes())
180}
181
182/// The candidates for `agent_uid`, whose own live folder is `own_dir`, among
183/// the accounts under `shared`.
184pub(crate) fn list_in(fs: &FileStore, mstore: &Store, agent_uid: &str, own_dir: &Path, shared: &Path) -> Result<AdoptionList, StoreError> {
185    // `<config>/projects/<name>/memory`: the same `<name>` under every account.
186    let name = own_dir.parent().and_then(Path::file_name).map(|n| n.to_os_string());
187    let own_id = claims::dir_id(own_dir);
188    let mut dirs = Vec::new();
189    let mut candidates = Vec::new();
190    if let Some(name) = name {
191        for (account, root) in account_project_roots(shared) {
192            let dir = root.join(&name).join("memory");
193            if !dir.is_dir() || claims::dir_id(&dir) == own_id || claims::used_by_another(fs, mstore, agent_uid, &dir)? {
194                continue;
195            }
196            let files = folder_files(&dir, None);
197            if files.is_empty() {
198                continue;
199            }
200            let hash = dir_hash(&files);
201            candidates.push(Candidate {
202                index: candidates.len(),
203                account: account.clone(),
204                dir_hash: hash.clone(),
205                files: files.into_iter().map(|(f, _)| f).collect(),
206            });
207            dirs.push(IssuedDir { path: dir, dir_hash: hash, account, only: None });
208        }
209    }
210    // Files held in the agent's own folder at first sighting, while they
211    // still hold what was held.
212    let heads = record::heads(fs, agent_uid)?;
213    if let Some(held) = heads.held.get(&own_id) {
214        let names: Vec<String> = held.keys().cloned().collect();
215        let files: Vec<_> = folder_files(own_dir, Some(&names))
216            .into_iter()
217            .filter(|(f, _)| held.get(&f.name) == Some(&f.sha256))
218            .collect();
219        if !files.is_empty() {
220            let hash = dir_hash(&files);
221            let only = files.iter().map(|(f, _)| f.name.clone()).collect();
222            candidates.push(Candidate {
223                index: candidates.len(),
224                account: "held".into(),
225                dir_hash: hash.clone(),
226                files: files.into_iter().map(|(f, _)| f).collect(),
227            });
228            dirs.push(IssuedDir { path: own_dir.to_path_buf(), dir_hash: hash, account: "held".into(), only: Some(only) });
229        }
230    }
231    let list_id = uuid::Uuid::new_v4().simple().to_string();
232    let mut map = issued().lock().unwrap_or_else(|e| e.into_inner());
233    map.retain(|_, i| i.at.elapsed() < LIST_TTL);
234    map.insert(list_id.clone(), Issued { agent_uid: agent_uid.to_string(), dirs, at: Instant::now() });
235    Ok(AdoptionList { list_id, agent_uid: agent_uid.to_string(), candidates })
236}
237
238/// The candidates for an agent, from its verified memory folder. `None`
239/// when the agent has none yet (no spawn, no working directory).
240pub(crate) fn list(fs: &FileStore, mstore: &Store, agent: &crate::backend::storage::AgentDefinition) -> Result<Option<AdoptionList>, StoreError> {
241    use crate::server::native_memory_handlers::{resolve_memory_dir_by_id, MemoryDirProvenance};
242    let Some(own) = resolve_memory_dir_by_id(mstore, agent).filter(|r| r.provenance != MemoryDirProvenance::Unverified) else {
243        return Ok(None);
244    };
245    let Some(shared) = crate::registry::resolve_global_shared_root() else { return Ok(None) };
246    list_in(fs, mstore, &agent.id, &own.path, &shared).map(Some)
247}
248
249/// The link target of an index line (`[title](target)`), if it has one.
250fn link_target(line: &str) -> Option<&str> {
251    let start = line.find("](")? + 2;
252    let end = line[start..].find(')')? + start;
253    Some(line[start..end].trim())
254}
255
256/// `base` with every line of `others` it lacks: a linked line when its
257/// target is new, any other line when no line of `base` equals it. Returns
258/// the union and how many lines were added.
259fn union_index(base: &str, others: &[&str]) -> (String, usize) {
260    let mut out = base.to_string();
261    let mut targets: std::collections::HashSet<String> = base.lines().filter_map(link_target).map(str::to_string).collect();
262    let mut plain: std::collections::HashSet<String> = base.lines().map(|l| l.trim().to_string()).collect();
263    let mut added = 0;
264    for other in others {
265        for line in other.lines() {
266            let new = match link_target(line) {
267                Some(t) => targets.insert(t.to_string()),
268                None => !line.trim().is_empty() && plain.insert(line.trim().to_string()),
269            };
270            if new {
271                if !out.is_empty() && !out.ends_with('\n') {
272                    out.push('\n');
273                }
274                out.push_str(line);
275                out.push('\n');
276                plain.insert(line.trim().to_string());
277                added += 1;
278            }
279        }
280    }
281    (out, added)
282}
283
284/// Adopt the chosen folders of list `list_id` into `agent_uid`'s record.
285/// Choices are `(index, dir_hash)` from the list; nothing else from the
286/// caller is trusted.
287pub(crate) fn adopt(
288    fs: &FileStore,
289    mstore: &Store,
290    agent_uid: &str,
291    list_id: &str,
292    choices: &[(usize, String)],
293) -> Result<AdoptReport, AdoptError> {
294    let chosen: Vec<(PathBuf, String, Option<Vec<String>>)> = {
295        let map = issued().lock().unwrap_or_else(|e| e.into_inner());
296        let list = map
297            .get(list_id)
298            .filter(|l| l.agent_uid == agent_uid && l.at.elapsed() < LIST_TTL)
299            .ok_or(AdoptError::UnknownList)?;
300        let mut out = Vec::new();
301        for (index, hash) in choices {
302            let d = list.dirs.get(*index).ok_or(AdoptError::BadChoice)?;
303            if &d.dir_hash != hash {
304                return Err(AdoptError::BadChoice);
305            }
306            out.push((d.path.clone(), d.account.clone(), d.only.clone()));
307        }
308        out
309    };
310    if chosen.is_empty() {
311        return Err(AdoptError::BadChoice);
312    }
313    let lease = crate::backend::memory_reconcile::take_pass_lease(fs, agent_uid, Instant::now() + Duration::from_secs(5))?
314        .ok_or(AdoptError::Busy)?;
315    let result = adopt_under_lease(fs, mstore, agent_uid, &chosen, choices);
316    crate::backend::memory_reconcile::release_pass_lease(fs, agent_uid, &lease);
317    if result.is_ok() {
318        issued().lock().unwrap_or_else(|e| e.into_inner()).remove(list_id);
319    }
320    result
321}
322
323fn adopt_under_lease(
324    fs: &FileStore,
325    mstore: &Store,
326    agent_uid: &str,
327    chosen: &[(PathBuf, String, Option<Vec<String>>)],
328    choices: &[(usize, String)],
329) -> Result<AdoptReport, AdoptError> {
330    // Read every chosen folder again, now: it must still be what was listed,
331    // and still no other agent's (the agent's own folder, for held files, is
332    // its own by its claim).
333    let mut by_file: BTreeMap<String, Vec<(Vec<u8>, i64, String)>> = BTreeMap::new();
334    let mut released: Vec<(PathBuf, Vec<String>)> = Vec::new();
335    for ((dir, account, only), (index, hash)) in chosen.iter().zip(choices) {
336        let files = folder_files(dir, only.as_deref());
337        let others = only.is_none() && claims::used_by_another(fs, mstore, agent_uid, dir)?;
338        if &dir_hash(&files) != hash || others {
339            return Err(AdoptError::Changed { index: *index });
340        }
341        if let Some(names) = only {
342            released.push((dir.clone(), names.clone()));
343        }
344        for (f, body) in files {
345            by_file.entry(f.name).or_default().push((body, f.modified_ms, account.clone()));
346        }
347    }
348    let heads = record::heads(fs, agent_uid)?;
349    let mut report = AdoptReport::default();
350    for (name, mut bodies) in by_file {
351        bodies.sort_by_key(|(_, mtime, _)| *mtime);
352        let history: Vec<ImportedVersion> = bodies
353            .iter()
354            .map(|(body, mtime, account)| ImportedVersion {
355                file: name.clone(),
356                body: body.clone(),
357                source: "adopted".into(),
358                source_detail: format!("earlier account {account}"),
359                created_at_ms: *mtime,
360            })
361            .collect();
362        let head = heads.files.get(&name);
363        let live_head = head.filter(|h| h.sha256.is_some());
364        if name == INDEX_FILE {
365            // Kept as history, then unioned into the current index (or the
366            // newest adopted one, when the record has none).
367            report.versions_kept += record::add_history(fs, agent_uid, &history)?;
368            let current = match live_head.and_then(|h| h.sha256.as_deref()) {
369                Some(sha) => record::body(fs, agent_uid, sha)?.map(|b| String::from_utf8_lossy(&b).into_owned()),
370                None => None,
371            };
372            let texts: Vec<String> = bodies.iter().rev().map(|(b, _, _)| String::from_utf8_lossy(b).into_owned()).collect();
373            let (base, rest) = match &current {
374                Some(c) => (c.as_str(), &texts[..]),
375                None => (texts[0].as_str(), &texts[1..]),
376            };
377            let (union, added) = union_index(base, &rest.iter().map(String::as_str).collect::<Vec<_>>());
378            if current.as_deref() != Some(union.as_str()) {
379                let outcome = record::append_version(
380                    fs,
381                    agent_uid,
382                    NewVersion {
383                        file: INDEX_FILE,
384                        body: Some(union.as_bytes()),
385                        expected_parent: head.map(|h| h.version.as_str()),
386                        merged_parent: None,
387                        conflicts_with: None,
388                        source: "adopted",
389                        source_detail: "index union with earlier accounts",
390                        project_to: None,
391                    },
392                )?;
393                if !matches!(outcome, AppendOutcome::Appended(_) | AppendOutcome::Unchanged(_)) {
394                    return Err(AdoptError::Busy);
395                }
396                report.index_lines_added += added;
397                if current.is_none() {
398                    report.files_added += 1;
399                }
400            }
401            continue;
402        }
403        if live_head.is_some() {
404            // The agent's current memory keeps its head.
405            report.versions_kept += record::add_history(fs, agent_uid, &history)?;
406            continue;
407        }
408        // New to the record: all but the newest as history, the newest as
409        // the head, on whatever the file had (nothing, or a tombstone).
410        let (newest, older) = history.split_last().expect("at least one body per file");
411        report.versions_kept += record::add_history(fs, agent_uid, older)?;
412        let outcome = record::append_version(
413            fs,
414            agent_uid,
415            NewVersion {
416                file: &name,
417                body: Some(&newest.body),
418                expected_parent: head.map(|h| h.version.as_str()),
419                merged_parent: None,
420                conflicts_with: None,
421                source: "adopted",
422                source_detail: &newest.source_detail,
423                project_to: None,
424            },
425        )?;
426        match outcome {
427            AppendOutcome::Appended(_) => report.files_added += 1,
428            AppendOutcome::Unchanged(_) => {}
429            _ => return Err(AdoptError::Busy),
430        }
431    }
432    for (dir, names) in released {
433        record::release_held(fs, agent_uid, &claims::dir_id(&dir), &names)?;
434    }
435    Ok(report)
436}
437
438#[cfg(test)]
439mod tests;