use anyhow::{anyhow, Context, Result};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use super::local_copy::{
build_local_note_content, local_copy_enabled_override, resolve_local_note_path,
write_local_note,
};
use super::types::{LocalCopyResult, SaveMemoryNextStep, SaveMemoryRequest, SaveMemoryResult};
use crate::memory::claims::{claims_enabled, insert_memory_claim, ClaimWriteRequest};
use crate::memory::lesson::{save_lesson, SaveLessonRequest};
use crate::memory::lifecycle::MemoryLifecycleOp;
#[derive(Debug)]
pub struct LocalCopyError {
message: String,
}
impl From<anyhow::Error> for LocalCopyError {
fn from(err: anyhow::Error) -> Self {
Self {
message: err.to_string(),
}
}
}
impl std::fmt::Display for LocalCopyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for LocalCopyError {}
pub fn save_memory(conn: &Connection, req: &SaveMemoryRequest) -> Result<SaveMemoryResult> {
let project = req.project.as_deref().unwrap_or("manual");
let title = req.title.as_deref().unwrap_or("Memory");
let memory_type = req.memory_type.as_deref().unwrap_or("discovery");
let files_json = req
.files
.as_ref()
.and_then(|files| serde_json::to_string(files).ok());
let scope = req.scope.as_deref().unwrap_or("project");
let effective_topic_key = effective_topic_key(req, memory_type);
let mut local_copy = prepare_local_copy(project, title, req).map_err(LocalCopyError::from)?;
write_local_copy(&mut local_copy).map_err(LocalCopyError::from)?;
let save_result = if memory_type == "lesson" {
crate::memory::operation::with_operation_savepoint(conn, || {
let (operation_input, operation_plan) = crate::memory::operation::plan_direct_save(
conn,
"direct",
"save_memory",
project,
scope,
memory_type,
effective_topic_key.as_deref(),
title,
&req.text,
files_json.as_deref(),
req.branch.as_deref(),
None,
None,
)?;
let id = save_lesson(
conn,
&SaveLessonRequest {
session_id: req.session_id.as_deref(),
project,
topic_key: req.topic_key.as_deref(),
title,
content: &req.text,
confidence: 0.7,
source_evidence: None,
files: files_json.as_deref(),
branch: req.branch.as_deref(),
scope,
created_at_epoch: req.created_at_epoch,
stale_after_epoch: None,
},
)?;
let mut logged_plan = operation_plan.clone();
logged_plan.target_memory_id = Some(id);
if logged_plan.op == MemoryLifecycleOp::Noop {
logged_plan.op = MemoryLifecycleOp::Update;
logged_plan.noop_reason = None;
logged_plan.reason =
"existing lesson memory was reinforced by direct save".to_string();
}
crate::memory::operation::insert_operation_log(
conn,
&operation_input,
&logged_plan,
Some(id),
)?;
Ok((id, logged_plan.op))
})
} else {
crate::memory::operation::with_operation_savepoint(conn, || {
let (operation_input, operation_plan) = crate::memory::operation::plan_direct_save(
conn,
"direct",
"save_memory",
project,
scope,
memory_type,
effective_topic_key.as_deref(),
title,
&req.text,
files_json.as_deref(),
req.branch.as_deref(),
None,
None,
)?;
if operation_plan.op == MemoryLifecycleOp::Noop {
let id = operation_plan
.target_memory_id
.ok_or_else(|| anyhow!("noop memory operation missing existing memory id"))?;
crate::memory::operation::insert_operation_log(
conn,
&operation_input,
&operation_plan,
Some(id),
)?;
return Ok((id, MemoryLifecycleOp::Noop));
}
crate::memory::insert_memory_full_with_operation_log(
conn,
req.session_id.as_deref(),
project,
req.topic_key.as_deref(),
title,
&req.text,
memory_type,
files_json.as_deref(),
req.branch.as_deref(),
scope,
req.created_at_epoch,
&operation_input,
&operation_plan,
)
})
};
let (id, operation) = match save_result {
Ok(result) => result,
Err(err) => {
if let Err(cleanup_err) = cleanup_local_copy(&local_copy) {
return Err(err.context(format!(
"database save failed and local copy cleanup failed: {cleanup_err}"
)));
}
return Err(err);
}
};
discard_local_copy_backup(&local_copy);
let durable = load_durable_write_details(conn, id)?;
let local_copy_result = local_copy.result();
let claim_result = write_claim_after_durable_save(conn, id, req);
Ok(SaveMemoryResult {
id,
status: "saved".to_string(),
memory_type: durable.memory_type,
project: durable.project,
scope: durable.scope,
topic_key: durable.topic_key,
branch: durable.branch,
operation: operation.as_str().to_string(),
created_at_epoch: durable.created_at_epoch,
updated_at_epoch: durable.updated_at_epoch,
upserted: req.topic_key.is_some(),
local_status: local_copy_result.status.clone(),
local_path: local_copy_result.path.clone(),
local_copy: local_copy_result,
claim_status: claim_result.status,
claim_id: claim_result.id,
claim_error: claim_result.error,
next_step: SaveMemoryNextStep {
tool: "get_observations".to_string(),
ids: vec![id],
source: "memory".to_string(),
reason: format!(
"Verify the durable memory with get_observations(ids=[{id}], source='memory') or search(project='{}').",
durable_project_hint(project)
),
},
})
}
struct ClaimSaveResult {
status: String,
id: Option<i64>,
error: Option<String>,
}
fn write_claim_after_durable_save(
conn: &Connection,
memory_id: i64,
req: &SaveMemoryRequest,
) -> ClaimSaveResult {
if !claims_enabled(req.claim_enabled) {
return ClaimSaveResult {
status: "disabled".to_string(),
id: None,
error: None,
};
}
let claim_source = req.claim_source.as_deref().unwrap_or("manual_save");
match insert_memory_claim(
conn,
&ClaimWriteRequest {
memory_id,
session_id: req.session_id.as_deref(),
host: req.host.as_deref(),
claim_source,
},
) {
Ok(claim_id) => ClaimSaveResult {
status: "saved".to_string(),
id: Some(claim_id),
error: None,
},
Err(err) => {
let error = format!("{err:#}");
crate::log::error(
"memory-claim",
&format!("claim write failed memory_id={} error={}", memory_id, error),
);
ClaimSaveResult {
status: "failed".to_string(),
id: None,
error: Some(error),
}
}
}
}
struct LocalCopyPlan {
status: String,
path: Option<PathBuf>,
reason: Option<String>,
content: Option<String>,
backup: Option<LocalCopyBackup>,
}
impl LocalCopyPlan {
fn result(&self) -> LocalCopyResult {
LocalCopyResult {
status: self.status.clone(),
path: self.path.as_ref().map(|path| path.display().to_string()),
reason: self.reason.clone(),
}
}
}
struct LocalCopyBackup {
restore_path: PathBuf,
backup_path: PathBuf,
}
fn prepare_local_copy(
project: &str,
title: &str,
req: &SaveMemoryRequest,
) -> Result<LocalCopyPlan> {
if !local_copy_enabled_override(req.local_copy_enabled) {
return Ok(LocalCopyPlan {
status: "disabled".to_string(),
path: None,
reason: Some("local copy disabled by request or configuration".to_string()),
content: None,
backup: None,
});
}
let local_path =
resolve_local_note_path(project, req.title.as_deref(), req.local_path.as_deref())?;
let content = build_local_note_content(project, title, &req.text);
Ok(LocalCopyPlan {
status: "saved".to_string(),
path: Some(local_path),
reason: None,
content: Some(content),
backup: None,
})
}
fn effective_topic_key(req: &SaveMemoryRequest, memory_type: &str) -> Option<String> {
if memory_type == "lesson" {
return req.topic_key.clone().or_else(|| {
Some(format!(
"lesson-{}",
crate::memory::slugify_for_topic(&req.text, 64)
))
});
}
req.topic_key.clone()
}
struct DurableWriteDetails {
project: String,
scope: String,
topic_key: Option<String>,
branch: Option<String>,
memory_type: String,
created_at_epoch: i64,
updated_at_epoch: i64,
}
fn load_durable_write_details(conn: &Connection, id: i64) -> Result<DurableWriteDetails> {
conn.query_row(
"SELECT project, COALESCE(scope, 'project'), topic_key, branch, memory_type,
created_at_epoch, updated_at_epoch
FROM memories
WHERE id = ?1",
[id],
|row| {
Ok(DurableWriteDetails {
project: row.get(0)?,
scope: row.get(1)?,
topic_key: row.get(2)?,
branch: row.get(3)?,
memory_type: row.get(4)?,
created_at_epoch: row.get(5)?,
updated_at_epoch: row.get(6)?,
})
},
)
.with_context(|| format!("load durable write details for memory {id}"))
}
fn durable_project_hint(project: &str) -> String {
project.replace('\'', "\\'")
}
fn write_local_copy(local_copy: &mut LocalCopyPlan) -> Result<()> {
if let (Some(path), Some(content)) = (local_copy.path.as_deref(), local_copy.content.as_deref())
{
let backup = backup_existing_local_copy(path)?;
if let Err(err) = write_local_note(path, content) {
if let Err(restore_err) = restore_local_copy(backup.as_ref()) {
return Err(err.context(format!(
"write local copy failed and restore failed: {restore_err}"
)));
}
return Err(err);
}
local_copy.backup = backup;
}
Ok(())
}
fn cleanup_local_copy(local_copy: &LocalCopyPlan) -> Result<()> {
restore_local_copy(local_copy.backup.as_ref())?;
match (local_copy.path.as_deref(), local_copy.backup.as_ref()) {
(Some(path), None) => remove_local_copy_file(path),
_ => Ok(()),
}
}
fn backup_existing_local_copy(path: &Path) -> Result<Option<LocalCopyBackup>> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
let restore_path = backup_restore_path(path, &metadata)?;
let backup_path = allocate_backup_path(&restore_path);
std::fs::rename(&restore_path, &backup_path).with_context(|| {
format!(
"move existing local copy {} to backup {}",
restore_path.display(),
backup_path.display()
)
})?;
Ok(Some(LocalCopyBackup {
restore_path,
backup_path,
}))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(anyhow!(
"check existing local copy at {}: {err}",
path.display()
)),
}
}
fn backup_restore_path(path: &Path, metadata: &std::fs::Metadata) -> Result<PathBuf> {
let file_type = metadata.file_type();
if file_type.is_dir() {
return Err(anyhow!(
"local_path {} must reference a file, not a directory",
path.display()
));
}
if file_type.is_symlink() {
let target_path = path
.canonicalize()
.with_context(|| format!("resolve local_path symlink target at {}", path.display()))?;
if target_path.is_dir() {
return Err(anyhow!(
"local_path {} must reference a file, not a directory",
path.display()
));
}
return Ok(target_path);
}
Ok(path.to_path_buf())
}
fn restore_local_copy(backup: Option<&LocalCopyBackup>) -> Result<()> {
if let Some(backup) = backup {
remove_local_copy_file(&backup.restore_path)?;
std::fs::rename(&backup.backup_path, &backup.restore_path).with_context(|| {
format!(
"restore local copy from backup {} to {}",
backup.backup_path.display(),
backup.restore_path.display()
)
})?;
}
Ok(())
}
fn remove_local_copy_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("remove local copy at {}", path.display())),
}
}
fn discard_local_copy_backup(local_copy: &LocalCopyPlan) {
if let Some(backup) = local_copy.backup.as_ref() {
let _ = std::fs::remove_file(&backup.backup_path);
}
}
fn allocate_backup_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("local-copy");
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
parent.join(format!(
".{file_name}.remem-backup-{}-{timestamp}.tmp",
std::process::id()
))
}