use std::path::{Path, PathBuf};
use robit_ai::config::{resolve_memory_mode, MemoryMode, RobitConfig};
use crate::error::Result;
use crate::storage::resolve_memory_dir;
const MEMORY_PROMPT_TEMPLATE: &str = include_str!("../prompts/memory.md");
pub const MAX_MEMORY_INJECT_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone)]
pub struct MemorySettings {
pub mode: MemoryMode,
pub dir: std::path::PathBuf,
}
fn memory_dir_with_fallback(
working_dir: &Path,
resolved: crate::error::Result<PathBuf>,
) -> PathBuf {
match resolved {
Ok(dir) => dir,
Err(err) => {
tracing::warn!(
"Failed to resolve memory dir: {}; falling back to project-local .robit/memory",
err
);
crate::storage::resolve_memory_dir(working_dir, false)
.expect("project-local memory dir resolution cannot fail")
}
}
}
pub fn resolve_memory_settings(
config: &RobitConfig,
working_dir: &Path,
) -> Result<MemorySettings> {
let mode = resolve_memory_mode(config);
let global_storage = config
.app
.as_ref()
.and_then(|a| a.global_storage)
.unwrap_or(false);
let dir = memory_dir_with_fallback(
working_dir,
resolve_memory_dir(working_dir, global_storage),
);
Ok(MemorySettings { mode, dir })
}
pub fn build_memory_section(settings: &MemorySettings, date: &str) -> String {
match settings.mode {
MemoryMode::Tools | MemoryMode::Off => String::new(),
MemoryMode::File => build_file_section(settings, date),
}
}
fn build_file_section(settings: &MemorySettings, date: &str) -> String {
let dir_display = settings.dir.display().to_string();
let explanation = MEMORY_PROMPT_TEMPLATE
.replace("{memory_dir}", &dir_display)
.replace("{date}", date);
let mut section = format!("## Memory\n\n{}", explanation.trim());
match read_master_file(&settings.dir) {
Ok(None) => {} Ok(Some(content)) if !content.trim().is_empty() => {
section.push_str("\n\n---\n\n");
section.push_str(&content);
}
Ok(Some(_)) => {} Err(err) => {
tracing::warn!(
"Failed to read {}: {}",
settings.dir.join("memory.md").display(),
err
);
section.push_str(&format!(
"\n\n(Failed to read memory.md: {}. You can check the file later \
with the read tool.)",
err
));
}
}
section
}
fn read_master_file(dir: &Path) -> std::io::Result<Option<String>> {
let path = dir.join("memory.md");
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if bytes.len() > MAX_MEMORY_INJECT_BYTES {
let mut content =
String::from_utf8_lossy(&bytes[..MAX_MEMORY_INJECT_BYTES]).into_owned();
content.push_str(&format!(
"\n\n... (truncated; use the read tool to load the full \
{})",
dir.join("memory.md").display()
));
Ok(Some(content))
} else {
Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use robit_ai::config::{AppConfig, MemoryMode, RobitConfig};
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
fn config_with(mode: Option<MemoryMode>, global_storage: bool) -> RobitConfig {
RobitConfig {
default_model: None,
providers: HashMap::new(),
app: Some(AppConfig {
memory_mode: mode,
global_storage: Some(global_storage),
..Default::default()
}),
channels: None,
default_image_model: None,
image_providers: HashMap::new(),
}
}
#[test]
fn resolve_defaults_to_file_mode_local_dir() {
let tmp = TempDir::new().unwrap();
let settings = resolve_memory_settings(&config_with(None, false), tmp.path()).unwrap();
assert_eq!(settings.mode, MemoryMode::File);
assert_eq!(settings.dir, tmp.path().join(".robit/memory"));
}
#[test]
fn resolve_global_storage_uses_home() {
let tmp = TempDir::new().unwrap();
let settings = resolve_memory_settings(&config_with(None, true), tmp.path()).unwrap();
let home = dirs::home_dir().unwrap();
assert_eq!(settings.dir, home.join(".robit/memory"));
}
#[test]
fn memory_dir_fallback_passes_through_ok() {
let tmp = TempDir::new().unwrap();
let resolved = crate::storage::resolve_memory_dir(tmp.path(), false);
let dir = memory_dir_with_fallback(tmp.path(), resolved);
assert_eq!(dir, tmp.path().join(".robit").join("memory"));
}
#[test]
fn memory_dir_fallback_degrades_on_error() {
let tmp = TempDir::new().unwrap();
let err = crate::error::AgentError::InternalError("no home directory".to_string());
let dir = memory_dir_with_fallback(tmp.path(), Err(err));
assert_eq!(dir, tmp.path().join(".robit").join("memory"));
}
#[test]
fn section_empty_for_tools_and_off() {
let tmp = TempDir::new().unwrap();
for mode in [MemoryMode::Tools, MemoryMode::Off] {
let settings =
resolve_memory_settings(&config_with(Some(mode), false), tmp.path()).unwrap();
assert_eq!(build_memory_section(&settings, "2026-09-30"), "");
}
}
#[test]
fn file_section_contains_mechanism_explanation() {
let tmp = TempDir::new().unwrap();
let settings = resolve_memory_settings(
&config_with(Some(MemoryMode::File), false),
tmp.path(),
)
.unwrap();
let section = build_memory_section(&settings, "2026-09-30");
assert!(section.starts_with("## Memory\n"));
assert!(section.contains(
&tmp
.path()
.join(".robit")
.join("memory")
.display()
.to_string()
));
assert!(section.contains("memory-2026-09-30.md"));
assert!(!section.contains("---"));
}
#[test]
fn file_section_includes_master_content() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".robit/memory");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("memory.md"), "# 记忆\n\n- 用户使用中文交流\n").unwrap();
let settings = resolve_memory_settings(
&config_with(Some(MemoryMode::File), false),
tmp.path(),
)
.unwrap();
let section = build_memory_section(&settings, "2026-09-30");
assert!(section.contains("---"));
assert!(section.contains("用户使用中文交流"));
}
#[test]
fn file_section_no_separator_for_empty_master_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".robit/memory");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("memory.md"), "").unwrap();
let settings = resolve_memory_settings(
&config_with(Some(MemoryMode::File), false),
tmp.path(),
)
.unwrap();
let section = build_memory_section(&settings, "2026-09-30");
assert!(section.starts_with("## Memory\n"));
assert!(!section.contains("---"));
}
#[test]
fn file_section_truncates_oversized_master_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".robit/memory");
fs::create_dir_all(&dir).unwrap();
let big = "记".repeat(6000); fs::write(dir.join("memory.md"), &big).unwrap();
let settings = resolve_memory_settings(
&config_with(Some(MemoryMode::File), false),
tmp.path(),
)
.unwrap();
let section = build_memory_section(&settings, "2026-09-30");
assert!(section.contains("truncated"));
assert!(section.contains('\u{FFFD}'));
assert!(section.len() < MAX_MEMORY_INJECT_BYTES + 4096);
}
#[test]
fn file_section_degrades_when_master_file_unreadable() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".robit/memory");
fs::create_dir_all(dir.join("memory.md")).unwrap();
let settings = resolve_memory_settings(
&config_with(Some(MemoryMode::File), false),
tmp.path(),
)
.unwrap();
let section = build_memory_section(&settings, "2026-09-30");
assert!(section.starts_with("## Memory\n"));
assert!(section.contains("Failed to read memory.md"));
}
#[test]
fn master_content_placeholders_not_replaced() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".robit/memory");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("memory.md"), "占位符示例 {memory_dir} {date}").unwrap();
let settings = resolve_memory_settings(
&config_with(Some(MemoryMode::File), false),
tmp.path(),
)
.unwrap();
let section = build_memory_section(&settings, "2026-09-30");
assert!(section.contains("占位符示例 {memory_dir} {date}"));
}
}