use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Serialize;
use crate::core::memory_import::{parse_memory_file, remember};
pub const MIGRATION_TAG: &str = "migrated-from-auto-memory";
pub const IMPORT_KEY_PREFIX: &str = "import-key:";
pub const INDEX_FILE: &str = "MEMORY.md";
#[derive(Debug, Clone)]
pub struct AutoImportOptions {
pub project_dir: PathBuf,
pub config_dir: PathBuf,
pub palace: String,
pub memory_socket: Option<PathBuf>,
pub archive_stamp: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoImportStatus {
Stored,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct AutoFileResult {
pub file: String,
pub status: AutoImportStatus,
pub drawer_id: Option<String>,
pub tags: Vec<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AutoImportReport {
pub dir: String,
pub palace: String,
pub archive: Option<String>,
pub total: usize,
pub stored: usize,
pub failed: usize,
pub index_cleared: bool,
pub files: Vec<AutoFileResult>,
}
pub fn resolve_auto_import_options(
project_dir: &Path,
palace: Option<String>,
memory_socket: Option<PathBuf>,
) -> anyhow::Result<AutoImportOptions> {
let config_dir = crate::core::trusty_tools_config::managed_claude_config_dir().context(
"cannot resolve the tm-managed Claude config dir, so there is no auto-memory store to read",
)?;
resolve_in(project_dir, config_dir, palace, memory_socket)
}
pub(crate) fn resolve_in(
project_dir: &Path,
config_dir: PathBuf,
palace: Option<String>,
memory_socket: Option<PathBuf>,
) -> anyhow::Result<AutoImportOptions> {
let palace = match palace {
Some(palace) => palace,
None => crate::core::session_launch::resolve_palace_slug(project_dir, None).with_context(
|| {
format!(
"cannot derive a palace slug for {} — pass --palace",
project_dir.display()
)
},
)?,
};
Ok(AutoImportOptions {
project_dir: project_dir.to_path_buf(),
config_dir,
palace,
memory_socket,
archive_stamp: chrono::Local::now().format("%Y%m%d").to_string(),
})
}
pub fn auto_memory_dir(config_dir: &Path, project_dir: &Path) -> PathBuf {
config_dir
.join("projects")
.join(crate::runtime::encode_project_dir(project_dir))
.join("memory")
}
pub fn index_has_content(memory_dir: &Path) -> bool {
std::fs::read_to_string(memory_dir.join(INDEX_FILE))
.map(|text| !text.trim().is_empty())
.unwrap_or(false)
}
pub async fn run_auto_memory_import(opts: &AutoImportOptions) -> anyhow::Result<AutoImportReport> {
let memory_dir = auto_memory_dir(&opts.config_dir, &opts.project_dir);
let mut report = AutoImportReport {
dir: memory_dir.display().to_string(),
palace: opts.palace.clone(),
archive: None,
total: 0,
stored: 0,
failed: 0,
index_cleared: false,
files: Vec::new(),
};
if !memory_dir.is_dir() {
return Ok(report);
}
let facts = fact_files(&memory_dir)?;
report.total = facts.len();
if facts.is_empty() && !index_has_content(&memory_dir) {
return Ok(report);
}
let socket = match &opts.memory_socket {
Some(socket) => socket.clone(),
None => trusty_common::memory_rpc::resolve_memory_socket()
.context("resolve the trusty-memory daemon socket")?,
};
let archive = archive_dir(&memory_dir, &opts.archive_stamp);
for path in facts {
report
.files
.push(migrate_one(&socket, opts, &path, &archive).await);
}
report.stored = report
.files
.iter()
.filter(|f| f.status == AutoImportStatus::Stored)
.count();
report.failed = report.files.len() - report.stored;
if report.stored > 0 {
report.archive = Some(archive.display().to_string());
}
if report.failed == 0 && index_has_content(&memory_dir) {
clear_index(&memory_dir, &archive)?;
report.archive = Some(archive.display().to_string());
report.index_cleared = true;
}
Ok(report)
}
const STORED_MARKER_SUFFIX: &str = ".stored";
fn stored_marker(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(STORED_MARKER_SUFFIX);
PathBuf::from(name)
}
fn stored_drawer_id(path: &Path) -> Option<String> {
let text = std::fs::read_to_string(stored_marker(path)).ok()?;
let id = text.trim();
(!id.is_empty()).then(|| id.to_string())
}
#[derive(Debug)]
enum MarkerVerdict {
Live(String),
Gone,
}
async fn verify_marker(
socket: &Path,
palace: &str,
path: &Path,
key: &str,
) -> anyhow::Result<MarkerVerdict> {
match drawer_with_tag(socket, palace, key).await? {
Some(id) => Ok(MarkerVerdict::Live(id)),
None => {
let _ = std::fs::remove_file(stored_marker(path));
Ok(MarkerVerdict::Gone)
}
}
}
async fn migrate_one(
socket: &Path,
opts: &AutoImportOptions,
path: &Path,
archive: &Path,
) -> AutoFileResult {
let file = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let failed = |tags: Vec<String>, drawer_id: Option<String>, error: String| AutoFileResult {
file: file.clone(),
status: AutoImportStatus::Failed,
drawer_id,
tags,
error: Some(error),
};
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(e) => return failed(Vec::new(), None, format!("read failed: {e}")),
};
let parsed = match parse_memory_file(&source) {
Ok(Some(parsed)) => parsed,
Ok(None) => return failed(Vec::new(), None, "no YAML frontmatter".to_string()),
Err(e) => return failed(Vec::new(), None, format!("parse failed: {e:#}")),
};
let key = import_key(path, &source);
let mut tags = migration_tags(&parsed.name, &parsed.kind);
tags.push(key.clone());
let drawer_id = match stored_drawer_id(path) {
Some(marked) => match verify_marker(socket, &opts.palace, path, &key).await {
Ok(MarkerVerdict::Live(id)) => id,
Ok(MarkerVerdict::Gone) => {
tracing::warn!(
file = %file,
drawer_id = %marked,
palace = %opts.palace,
"auto-memory fact was marked stored but its drawer is gone from the palace; \
the marker was cleared and the file left in place (#7752)"
);
return failed(
tags,
None,
format!(
"marked as stored in drawer {marked}, but palace {} no longer holds it — \
the marker was cleared and the file left in place; re-run to import it \
again",
opts.palace
),
);
}
Err(e) => return failed(tags, Some(marked), format!("lookup failed: {e:#}")),
},
None => {
match drawer_with_tag(socket, &opts.palace, &key).await {
Ok(Some(id)) => return finish(path, archive, file.clone(), tags, id),
Ok(None) => {}
Err(e) => return failed(tags, None, format!("lookup failed: {e:#}")),
}
let id = match remember(socket, &opts.palace, &parsed.text, &tags, true).await {
Ok(id) => id,
Err(e) => return failed(tags, None, format!("store failed: {e:#}")),
};
if let Err(e) = std::fs::write(stored_marker(path), &id) {
return failed(tags, Some(id), format!("stored but not marked: {e}"));
}
id
}
};
finish(path, archive, file.clone(), tags, drawer_id)
}
fn finish(
path: &Path,
archive: &Path,
file: String,
tags: Vec<String>,
drawer_id: String,
) -> AutoFileResult {
if let Err(e) = archive_file(path, archive, &file) {
let error = format!("stored as {drawer_id} but not archived: {e:#}");
return AutoFileResult {
file,
status: AutoImportStatus::Failed,
drawer_id: Some(drawer_id),
tags,
error: Some(error),
};
}
let _ = std::fs::remove_file(stored_marker(path));
AutoFileResult {
file,
status: AutoImportStatus::Stored,
drawer_id: Some(drawer_id),
tags,
error: None,
}
}
fn import_key(path: &Path, source: &str) -> String {
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
hasher.update([0u8]);
hasher.update(source.as_bytes());
let digest = format!("{:x}", hasher.finalize());
format!("{IMPORT_KEY_PREFIX}{}", &digest[..32])
}
async fn drawer_with_tag(socket: &Path, palace: &str, tag: &str) -> anyhow::Result<Option<String>> {
let result = trusty_common::memory_rpc::call_memory_tool_at(
socket,
"memory_list",
serde_json::json!({ "palace": palace, "tag": tag, "limit": 1 }),
)
.await?;
let drawers = result
.get("drawers")
.and_then(serde_json::Value::as_array)
.context("memory_list answered without a drawers array")?;
Ok(drawers
.iter()
.find_map(|d| d.get("drawer_id").and_then(serde_json::Value::as_str))
.map(str::to_string))
}
fn migration_tags(name: &str, kind: &str) -> Vec<String> {
let mut tags = vec![MIGRATION_TAG.to_string(), format!("name:{name}")];
if !kind.trim().is_empty() {
tags.push(format!("type:{kind}"));
}
tags
}
fn archive_dir(memory_dir: &Path, stamp: &str) -> PathBuf {
let name = format!("memory.archived-{stamp}");
match memory_dir.parent() {
Some(parent) => parent.join(name),
None => memory_dir.join(name),
}
}
fn archive_file(path: &Path, archive: &Path, file: &str) -> anyhow::Result<()> {
std::fs::create_dir_all(archive)
.with_context(|| format!("create archive dir {}", archive.display()))?;
std::fs::rename(path, archive.join(file))
.with_context(|| format!("archive {} into {}", path.display(), archive.display()))
}
fn clear_index(memory_dir: &Path, archive: &Path) -> anyhow::Result<()> {
let index = memory_dir.join(INDEX_FILE);
std::fs::create_dir_all(archive)
.with_context(|| format!("create archive dir {}", archive.display()))?;
std::fs::copy(&index, archive.join(INDEX_FILE))
.with_context(|| format!("archive {}", index.display()))?;
std::fs::write(&index, "").with_context(|| format!("empty {}", index.display()))
}
fn fact_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let entries = std::fs::read_dir(dir)
.with_context(|| format!("read auto-memory directory {}", dir.display()))?;
let mut paths = Vec::new();
for entry in entries {
let path = entry
.with_context(|| format!("read entry in {}", dir.display()))?
.path();
if !path.is_file() {
continue;
}
let is_md = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"));
let is_index = path.file_name().and_then(|n| n.to_str()) == Some(INDEX_FILE);
if is_md && !is_index {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
#[cfg(test)]
#[path = "auto_memory_import_tests.rs"]
mod tests;