use super::graph::{MemoryEntry, MemoryGraph, MemoryScope, load_graph, save_graph};
use crate::{core::settings_manager::agent_dir, extensions::util::project_key};
use std::{
io,
path::{Path, PathBuf},
};
pub(super) fn memory_dir() -> PathBuf {
agent_dir().join("extensions").join("memory")
}
pub(super) fn global_path() -> PathBuf {
memory_dir().join("global.json")
}
pub(super) fn project_path(cwd: &str) -> PathBuf {
memory_dir()
.join("projects")
.join(format!("{}.json", project_key(cwd)))
}
#[derive(Debug, Clone)]
pub(super) struct Located {
pub(super) path: PathBuf,
pub(super) graph: MemoryGraph,
}
impl Located {
pub(super) fn save(&self) -> io::Result<()> {
save_graph(&self.path, &self.graph)
}
}
#[derive(Debug, Clone)]
pub(super) struct Scoped {
pub(super) project: Option<Located>,
pub(super) global: Option<Located>,
}
impl Scoped {
pub(super) fn graph_mut(&mut self, scope: MemoryScope) -> Option<&mut MemoryGraph> {
match scope {
MemoryScope::Project => self.project.as_mut().map(|located| &mut located.graph),
MemoryScope::Global => self.global.as_mut().map(|located| &mut located.graph),
MemoryScope::All => None,
}
}
pub(super) fn save(&self) -> Result<(), String> {
if let Some(project) = &self.project {
project.save().map_err(|err| format!("{err}"))?;
}
if let Some(global) = &self.global {
global.save().map_err(|err| format!("{err}"))?;
}
Ok(())
}
pub(super) fn active_entries(&self) -> Vec<(MemoryScope, &MemoryEntry)> {
let mut out = Vec::new();
if let Some(project) = &self.project {
out.extend(
project
.graph
.active_memories()
.map(|entry| (MemoryScope::Project, entry)),
);
}
if let Some(global) = &self.global {
out.extend(
global
.graph
.active_memories()
.map(|entry| (MemoryScope::Global, entry)),
);
}
out
}
}
pub(super) fn read_scoped(scope: MemoryScope, cwd: Option<&str>) -> Result<Scoped, String> {
let project = if scope.includes_project() {
let cwd = cwd.filter(|dir| !dir.trim().is_empty()).ok_or_else(|| {
"project scope needs a working directory; pass `scope: \"global\"` explicitly"
.to_string()
})?;
let path = project_path(cwd);
Some(load_at(&path)?)
} else {
None
};
let global = if scope.includes_global() {
Some(load_at(&global_path())?)
} else {
None
};
Ok(Scoped { project, global })
}
pub(super) fn load_at(path: &Path) -> Result<Located, String> {
let graph = load_graph(path).map_err(|err| format!("{err}"))?;
Ok(Located {
path: path.to_path_buf(),
graph,
})
}
pub(super) fn read_global() -> Result<Located, String> {
load_at(&global_path())
}
pub(super) fn read_project(cwd: &str) -> Result<Located, String> {
if cwd.trim().is_empty() {
return Err(
"project scope needs a working directory; pass `scope: \"global\"` explicitly"
.to_string(),
);
}
load_at(&project_path(cwd))
}
#[cfg(test)]
mod tests {
use super::super::graph::MemoryCategory;
use super::*;
use crate::test_support::AgentDirGuard;
#[test]
fn project_path_follows_the_session_directory_encoding() {
let path = project_path("/Users/me/work/repo");
let name = path.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(name, "--Users-me-work-repo--.json");
assert!(path.starts_with(memory_dir().join("projects")));
assert_eq!(global_path(), memory_dir().join("global.json"));
}
#[test]
fn project_scope_without_cwd_fails_instead_of_writing_global() {
let _guard = AgentDirGuard::temp();
let err = read_scoped(MemoryScope::Project, None).unwrap_err();
assert!(err.contains("working directory"), "{err}");
let err = read_scoped(MemoryScope::All, Some(" ")).unwrap_err();
assert!(err.contains("working directory"), "{err}");
assert!(read_scoped(MemoryScope::Global, None).is_ok());
}
#[test]
fn scoped_reads_keep_the_two_stores_apart() {
let _guard = AgentDirGuard::temp();
let mut project = read_project("/tmp/project-a").unwrap();
project
.graph
.add_memory(MemoryEntry::new(MemoryCategory::Fact, "project note"));
project.save().unwrap();
let mut global = read_global().unwrap();
global
.graph
.add_memory(MemoryEntry::new(MemoryCategory::Fact, "global note"));
global.save().unwrap();
let scoped = read_scoped(MemoryScope::Project, Some("/tmp/project-a")).unwrap();
assert!(scoped.global.is_none(), "项目作用域不读全局");
assert_eq!(scoped.active_entries().len(), 1);
let scoped = read_scoped(MemoryScope::Global, Some("/tmp/project-a")).unwrap();
assert!(scoped.project.is_none(), "全局作用域不读项目");
assert_eq!(scoped.active_entries().len(), 1);
let scoped = read_scoped(MemoryScope::All, Some("/tmp/project-a")).unwrap();
assert_eq!(scoped.active_entries().len(), 2);
let other = read_scoped(MemoryScope::Project, Some("/tmp/project-b")).unwrap();
assert!(other.active_entries().is_empty());
}
}