Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nomi-memory"
|
||||
description = "Long-term memory system for Nomi: cross-session storage of user preferences, feedback, project context, and external references"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomi-config.workspace = true
|
||||
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
rstest.workspace = true
|
||||
serial_test.workspace = true
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Post-session memory distillation: hand a serializable transcript of a
|
||||
//! finished work session to an LLM and turn its high-signal output into
|
||||
//! file-based memory entries.
|
||||
//!
|
||||
//! This module holds only **pure, synchronous functions**: build the prompt,
|
||||
//! parse the model JSON, write entries to disk, and parse citation filenames.
|
||||
//! The LLM call itself and the origin/companion gating live in
|
||||
//! `nomifun-ai-agent` (it owns the provider and the tokio runtime). Keeping
|
||||
//! the file logic here makes it unit-testable without a live backend.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::index::{append_index_entry, read_index};
|
||||
use crate::paths::{ensure_memory_dir, memory_entrypoint};
|
||||
use crate::store::write_memory;
|
||||
use crate::types::{MemoryEntry, MemoryType};
|
||||
|
||||
/// One batch of distillation output (strict JSON). A no-op session yields an
|
||||
/// empty `memories` array.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct DistillOutput {
|
||||
#[serde(default)]
|
||||
pub memories: Vec<DistilledMemory>,
|
||||
}
|
||||
|
||||
/// A single distilled memory candidate. `r#type` is validated against the
|
||||
/// four memory types on apply; unrecognized values cause the entry to be
|
||||
/// dropped.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DistilledMemory {
|
||||
/// One of `user|feedback|project|reference` (invalid → entry dropped).
|
||||
pub r#type: String,
|
||||
/// Short name used to derive the filename.
|
||||
pub name: String,
|
||||
/// One-line hook for the MEMORY.md index entry.
|
||||
pub description: String,
|
||||
/// The memory body.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// System prompt for distillation. Carries codex `stage_one_system`'s
|
||||
/// high-signal + no-op-gate spirit, but its output contract directly produces
|
||||
/// the four file-based memory types (no raw/summary intermediate stage), and
|
||||
/// it forbids storing secrets (redaction is the second gate, applied by the
|
||||
/// orchestrator before write).
|
||||
pub const DISTILL_SYSTEM: &str = r#"你是 nomi 的记忆蒸馏器。读完一段已结束的工作会话转写,提炼出对"未来会话"有持久价值的记忆。
|
||||
|
||||
只保留高信号记忆(满足才写,否则宁缺毋滥):
|
||||
- 稳定的用户偏好/操作习惯(用户反复要求或纠正的)
|
||||
- 高杠杆的过程知识/失败护盾(symptom→cause→fix、关键路径/命令)
|
||||
- 项目背景(代码/git 推断不出来的「为什么」)
|
||||
- 外部系统指针(bug 在哪个看板、文档在哪)
|
||||
|
||||
绝不写:
|
||||
- 能从当前代码/git 读出来的(结构、约定、文件路径、谁改了什么)
|
||||
- 一次性任务细节、临时状态、当前会话上下文
|
||||
- 已在 AGENTS.md 记录的东西
|
||||
- 任何密钥/令牌/密码(即使会话里出现也不要复述)
|
||||
|
||||
No-op 闸门:先问「未来 agent 会因为这条记忆而表现更好吗?」若否,该条不写。
|
||||
若整段会话无可留之物,返回 {"memories":[]}。
|
||||
|
||||
只输出一个 JSON 对象,无任何额外文字:
|
||||
{"memories":[{"type":"user|feedback|project|reference","name":"短名","description":"一句话索引钩子","content":"正文"}]}"#;
|
||||
|
||||
/// Wrap a rendered transcript into the user-side prompt. `transcript` is
|
||||
/// produced by the orchestrator from already-redacted messages.
|
||||
pub fn build_distill_prompt(transcript: &str) -> String {
|
||||
format!(
|
||||
"以下是一段已结束的工作会话转写。请蒸馏记忆。\n\n<transcript>\n{transcript}\n</transcript>"
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse the model output (tolerant of ```json fences and surrounding prose).
|
||||
pub fn parse_distill_output(raw: &str) -> std::result::Result<DistillOutput, String> {
|
||||
let slice = extract_json_object(raw).ok_or_else(|| "no JSON object found".to_string())?;
|
||||
serde_json::from_str::<DistillOutput>(slice).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Write the distilled entries to disk: one memory file each, plus an index
|
||||
/// line in MEMORY.md. Returns the number of entries written.
|
||||
///
|
||||
/// The caller guarantees `content` / `description` are already redacted.
|
||||
/// Entries with an unknown type or an empty body are skipped, as are entries
|
||||
/// whose description already appears in the index (lightweight dedup).
|
||||
pub fn apply_distilled(dir: &Path, out: &DistillOutput) -> Result<usize> {
|
||||
if out.memories.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
ensure_memory_dir(dir)?;
|
||||
let entrypoint = memory_entrypoint(dir);
|
||||
// Read the index once; track descriptions added in this batch so two
|
||||
// candidates with the same description don't both get written.
|
||||
let mut index_snapshot = read_index(&entrypoint);
|
||||
|
||||
let mut written = 0usize;
|
||||
for m in &out.memories {
|
||||
let Some(ty) = MemoryType::parse(&m.r#type) else {
|
||||
continue;
|
||||
};
|
||||
if m.content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let desc = m.description.trim();
|
||||
if !desc.is_empty() && index_has_description(&index_snapshot, desc) {
|
||||
continue; // dedup: this hook is already in the index
|
||||
}
|
||||
|
||||
let entry = MemoryEntry::build(&m.name, &m.description, ty, &m.content);
|
||||
let path = write_memory(dir, &entry)?;
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let title = if m.name.trim().is_empty() {
|
||||
filename.clone()
|
||||
} else {
|
||||
m.name.trim().to_owned()
|
||||
};
|
||||
append_index_entry(&entrypoint, &title, &filename, &m.description)?;
|
||||
// Keep the in-memory snapshot current so later candidates in this
|
||||
// same batch dedup against just-written entries too. Mirror the index
|
||||
// line format (`… \u{2014} <desc>`) so `index_has_description` matches.
|
||||
index_snapshot.push('\n');
|
||||
index_snapshot.push_str(&format!("- [{title}]({filename}) \u{2014} {}", m.description));
|
||||
written += 1;
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Parse the filenames cited inside a `<nomi-mem-citation>` block in the
|
||||
/// assistant's final text. Each non-empty line inside the block contributes
|
||||
/// the token before its first `|` (or the whole trimmed line if there is no
|
||||
/// `|`). Returns the cited filenames in order, de-duplicated.
|
||||
///
|
||||
/// A missing block, an empty block, or stray text yields an empty vec.
|
||||
pub fn parse_citation_filenames(text: &str) -> Vec<String> {
|
||||
const OPEN: &str = "<nomi-mem-citation>";
|
||||
const CLOSE: &str = "</nomi-mem-citation>";
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// Support more than one block, just in case.
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find(OPEN) {
|
||||
let after_open = &rest[start + OPEN.len()..];
|
||||
let Some(end) = after_open.find(CLOSE) else {
|
||||
break;
|
||||
};
|
||||
let block = &after_open[..end];
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let token = line.split('|').next().unwrap_or(line).trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(token.to_owned()) {
|
||||
out.push(token.to_owned());
|
||||
}
|
||||
}
|
||||
rest = &after_open[end + CLOSE.len()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the first balanced top-level `{...}` JSON object from `raw`,
|
||||
/// tolerating ```json fences and surrounding prose. String-literal aware so
|
||||
/// braces inside JSON strings don't throw off the balance count.
|
||||
fn extract_json_object(raw: &str) -> Option<&str> {
|
||||
let bytes = raw.as_bytes();
|
||||
let start = raw.find('{')?;
|
||||
|
||||
let mut depth = 0i32;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for i in start..bytes.len() {
|
||||
let c = bytes[i];
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == b'\\' {
|
||||
escaped = true;
|
||||
} else if c == b'"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
b'"' => in_string = true,
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(&raw[start..=i]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the index text already contains an entry with the given
|
||||
/// description hook. Matches the `\u{2014} <desc>` tail that
|
||||
/// `append_index_entry` writes, so substring collisions on shorter
|
||||
/// descriptions are avoided.
|
||||
fn index_has_description(index: &str, description: &str) -> bool {
|
||||
let desc = description.trim();
|
||||
if desc.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let needle = format!("\u{2014} {desc}");
|
||||
index.lines().any(|line| line.trim_end().ends_with(&needle))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Unit tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::paths::ENTRYPOINT_NAME;
|
||||
|
||||
// -- parse_distill_output ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_plain_json() {
|
||||
let raw = r#"{"memories":[{"type":"user","name":"role","description":"d","content":"c"}]}"#;
|
||||
let out = parse_distill_output(raw).unwrap();
|
||||
assert_eq!(out.memories.len(), 1);
|
||||
assert_eq!(out.memories[0].r#type, "user");
|
||||
assert_eq!(out.memories[0].name, "role");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_fenced() {
|
||||
let raw = "```json\n{\"memories\":[{\"type\":\"feedback\",\"name\":\"n\",\"description\":\"d\",\"content\":\"c\"}]}\n```";
|
||||
let out = parse_distill_output(raw).unwrap();
|
||||
assert_eq!(out.memories.len(), 1);
|
||||
assert_eq!(out.memories[0].r#type, "feedback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_surrounding_prose() {
|
||||
let raw = "Here is the distilled output:\n{\"memories\":[]}\nThat's all.";
|
||||
let out = parse_distill_output(raw).unwrap();
|
||||
assert!(out.memories.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_memories_is_noop() {
|
||||
let out = parse_distill_output(r#"{"memories":[]}"#).unwrap();
|
||||
assert!(out.memories.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_memories_defaults_empty() {
|
||||
let out = parse_distill_output(r#"{}"#).unwrap();
|
||||
assert!(out.memories.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_braces_inside_string_dont_confuse_extractor() {
|
||||
let raw = r#"{"memories":[{"type":"project","name":"n","description":"uses {braces}","content":"a } b { c"}]}"#;
|
||||
let out = parse_distill_output(raw).unwrap();
|
||||
assert_eq!(out.memories.len(), 1);
|
||||
assert_eq!(out.memories[0].content, "a } b { c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_json_errors() {
|
||||
let err = parse_distill_output("no json here").unwrap_err();
|
||||
assert!(err.contains("no JSON object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_malformed_json_errors() {
|
||||
let err = parse_distill_output(r#"{"memories": [ broken"#).unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
}
|
||||
|
||||
// -- build_distill_prompt ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_prompt_wraps_transcript() {
|
||||
let p = build_distill_prompt("[user] hi\n[assistant] hello");
|
||||
assert!(p.contains("<transcript>"));
|
||||
assert!(p.contains("</transcript>"));
|
||||
assert!(p.contains("[user] hi"));
|
||||
}
|
||||
|
||||
// -- apply_distilled -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn apply_writes_files_and_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = DistillOutput {
|
||||
memories: vec![
|
||||
DistilledMemory {
|
||||
r#type: "user".into(),
|
||||
name: "role".into(),
|
||||
description: "senior Go engineer".into(),
|
||||
content: "User has deep Go expertise.".into(),
|
||||
},
|
||||
DistilledMemory {
|
||||
r#type: "feedback".into(),
|
||||
name: "testing".into(),
|
||||
description: "integration tests hit a real DB".into(),
|
||||
content: "Do not mock the database.".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let written = apply_distilled(tmp.path(), &out).unwrap();
|
||||
assert_eq!(written, 2);
|
||||
assert!(tmp.path().join("user_role.md").exists());
|
||||
assert!(tmp.path().join("feedback_testing.md").exists());
|
||||
|
||||
let index = std::fs::read_to_string(tmp.path().join(ENTRYPOINT_NAME)).unwrap();
|
||||
assert!(index.contains("user_role.md"));
|
||||
assert!(index.contains("senior Go engineer"));
|
||||
assert!(index.contains("feedback_testing.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_skips_invalid_type() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = DistillOutput {
|
||||
memories: vec![DistilledMemory {
|
||||
r#type: "nonsense".into(),
|
||||
name: "x".into(),
|
||||
description: "d".into(),
|
||||
content: "c".into(),
|
||||
}],
|
||||
};
|
||||
let written = apply_distilled(tmp.path(), &out).unwrap();
|
||||
assert_eq!(written, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_skips_empty_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = DistillOutput {
|
||||
memories: vec![DistilledMemory {
|
||||
r#type: "project".into(),
|
||||
name: "x".into(),
|
||||
description: "d".into(),
|
||||
content: " ".into(),
|
||||
}],
|
||||
};
|
||||
let written = apply_distilled(tmp.path(), &out).unwrap();
|
||||
assert_eq!(written, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_dedup_skips_existing_description() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Pre-seed the index with a matching description hook.
|
||||
ensure_memory_dir(tmp.path()).unwrap();
|
||||
append_index_entry(
|
||||
&memory_entrypoint(tmp.path()),
|
||||
"Role",
|
||||
"user_role.md",
|
||||
"senior Go engineer",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = DistillOutput {
|
||||
memories: vec![DistilledMemory {
|
||||
r#type: "user".into(),
|
||||
name: "role2".into(),
|
||||
description: "senior Go engineer".into(),
|
||||
content: "dup".into(),
|
||||
}],
|
||||
};
|
||||
let written = apply_distilled(tmp.path(), &out).unwrap();
|
||||
assert_eq!(written, 0, "duplicate description should be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_dedup_within_same_batch() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = DistillOutput {
|
||||
memories: vec![
|
||||
DistilledMemory {
|
||||
r#type: "user".into(),
|
||||
name: "a".into(),
|
||||
description: "same hook".into(),
|
||||
content: "first".into(),
|
||||
},
|
||||
DistilledMemory {
|
||||
r#type: "user".into(),
|
||||
name: "b".into(),
|
||||
description: "same hook".into(),
|
||||
content: "second".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let written = apply_distilled(tmp.path(), &out).unwrap();
|
||||
assert_eq!(written, 1, "second entry with same hook deduped in-batch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_empty_output_is_noop_no_dir_created() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = tmp.path().join("memory");
|
||||
let written = apply_distilled(&target, &DistillOutput::default()).unwrap();
|
||||
assert_eq!(written, 0);
|
||||
assert!(!target.exists(), "no-op must not create the memory dir");
|
||||
}
|
||||
|
||||
// -- parse_citation_filenames --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn citation_parses_multiple_lines() {
|
||||
let text = "Here is my answer.\n\n<nomi-mem-citation>\nuser_role.md|note=[adjusted for Go expertise]\nfeedback_testing.md|note=[no DB mocks]\n</nomi-mem-citation>";
|
||||
let files = parse_citation_filenames(text);
|
||||
assert_eq!(files, vec!["user_role.md", "feedback_testing.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_empty_block_yields_nothing() {
|
||||
let text = "answer\n<nomi-mem-citation>\n\n</nomi-mem-citation>";
|
||||
assert!(parse_citation_filenames(text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_no_block_yields_nothing() {
|
||||
assert!(parse_citation_filenames("just a plain answer").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_line_without_pipe_uses_whole_token() {
|
||||
let text = "<nomi-mem-citation>\nproject_status.md\n</nomi-mem-citation>";
|
||||
assert_eq!(parse_citation_filenames(text), vec!["project_status.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_dedups_repeated_filenames() {
|
||||
let text = "<nomi-mem-citation>\nuser_role.md|note=[a]\nuser_role.md|note=[b]\n</nomi-mem-citation>";
|
||||
assert_eq!(parse_citation_filenames(text), vec!["user_role.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_unterminated_block_yields_nothing() {
|
||||
let text = "<nomi-mem-citation>\nuser_role.md|note=[x]\n(no close tag)";
|
||||
assert!(parse_citation_filenames(text).is_empty());
|
||||
}
|
||||
|
||||
// -- index_has_description -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn index_has_description_matches_tail_only() {
|
||||
let index = "- [Role](user_role.md) \u{2014} senior Go engineer\n";
|
||||
assert!(index_has_description(index, "senior Go engineer"));
|
||||
// A shorter substring of the hook must not match (tail anchored).
|
||||
assert!(!index_has_description(index, "senior Go"));
|
||||
assert!(!index_has_description(index, "absent hook"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_has_description_empty_is_false() {
|
||||
assert!(!index_has_description("- [A](a.md) \u{2014} x\n", " "));
|
||||
}
|
||||
|
||||
// -- extract_json_object -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_first_balanced_object() {
|
||||
assert_eq!(extract_json_object("x {\"a\":1} y"), Some("{\"a\":1}"));
|
||||
assert_eq!(extract_json_object("{\"a\":{\"b\":2}}"), Some("{\"a\":{\"b\":2}}"));
|
||||
assert_eq!(extract_json_object("no braces"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Errors that can occur within the memory system.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MemoryError {
|
||||
/// File I/O error.
|
||||
#[error("memory I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// YAML frontmatter failed to parse.
|
||||
#[error("failed to parse frontmatter in {path}: {source}")]
|
||||
FrontmatterParse {
|
||||
path: PathBuf,
|
||||
source: serde_yaml::Error,
|
||||
},
|
||||
|
||||
/// Memory path failed security validation.
|
||||
#[error("path validation failed: {0}")]
|
||||
PathValidation(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, MemoryError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn io_error_display() {
|
||||
let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
|
||||
let err = MemoryError::Io(inner);
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("I/O"), "should mention I/O: {msg}");
|
||||
assert!(msg.contains("gone"), "should contain inner message: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_from_conversion() {
|
||||
let inner = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
|
||||
let err: MemoryError = inner.into();
|
||||
assert!(matches!(err, MemoryError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_validation_display() {
|
||||
let err = MemoryError::PathValidation("relative path".into());
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("relative path"),
|
||||
"should contain reason: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("validation"),
|
||||
"should mention validation: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_parse_display() {
|
||||
// Trigger a real serde_yaml error
|
||||
let yaml_err = serde_yaml::from_str::<serde_yaml::Value>(":\n :\n---").unwrap_err();
|
||||
let err = MemoryError::FrontmatterParse {
|
||||
path: PathBuf::from("/tmp/test.md"),
|
||||
source: yaml_err,
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("/tmp/test.md"), "should contain path: {msg}");
|
||||
assert!(
|
||||
msg.contains("frontmatter"),
|
||||
"should mention frontmatter: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// MEMORY.md index management and truncation.
|
||||
//
|
||||
// The index file (`MEMORY.md`) is a lightweight directory of all memory
|
||||
// topic files. Each entry is a single Markdown link line:
|
||||
//
|
||||
// - [Title](filename.md) — one-line summary
|
||||
//
|
||||
// The index has hard caps (lines and bytes) to prevent unbounded growth.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::types::IndexTruncation;
|
||||
|
||||
/// Maximum number of lines before truncation.
|
||||
pub const MAX_INDEX_LINES: usize = 200;
|
||||
|
||||
/// Maximum byte count before truncation (~25 KB).
|
||||
pub const MAX_INDEX_BYTES: usize = 25_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read the MEMORY.md index file at `path`.
|
||||
///
|
||||
/// Returns the raw content as a string. If the file does not exist or
|
||||
/// cannot be read, returns an empty string (silent fallback — the index
|
||||
/// is informational and its absence is not an error).
|
||||
pub fn read_index(path: &Path) -> String {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Truncate index content to the line AND byte caps.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Trim whitespace from both ends.
|
||||
/// 2. Check original line count and byte count against limits.
|
||||
/// 3. If within both limits, return as-is.
|
||||
/// 4. Line-truncate first (slice to first `MAX_INDEX_LINES` lines).
|
||||
/// 5. If still over `MAX_INDEX_BYTES`, byte-truncate at the last newline
|
||||
/// before the cap so we never cut mid-line.
|
||||
/// 6. Append a diagnostic warning naming which cap(s) fired.
|
||||
pub fn truncate_index(raw: &str) -> IndexTruncation {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return IndexTruncation {
|
||||
content: String::new(),
|
||||
line_count: 0,
|
||||
byte_count: 0,
|
||||
was_truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = trimmed.split('\n').collect();
|
||||
let line_count = lines.len();
|
||||
let byte_count = trimmed.len();
|
||||
|
||||
let was_line_truncated = line_count > MAX_INDEX_LINES;
|
||||
// Check original byte count — long lines are the failure mode the
|
||||
// byte cap targets, so post-line-truncation size would understate.
|
||||
let was_byte_truncated = byte_count > MAX_INDEX_BYTES;
|
||||
|
||||
if !was_line_truncated && !was_byte_truncated {
|
||||
return IndexTruncation {
|
||||
content: trimmed.to_owned(),
|
||||
line_count,
|
||||
byte_count,
|
||||
was_truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1: line truncation
|
||||
let mut truncated = if was_line_truncated {
|
||||
lines[..MAX_INDEX_LINES].join("\n")
|
||||
} else {
|
||||
trimmed.to_owned()
|
||||
};
|
||||
|
||||
// Step 2: byte truncation (on the possibly line-truncated result)
|
||||
if truncated.len() > MAX_INDEX_BYTES {
|
||||
let cut_at = truncated[..MAX_INDEX_BYTES]
|
||||
.rfind('\n')
|
||||
.filter(|&pos| pos > 0);
|
||||
let boundary = cut_at.unwrap_or(MAX_INDEX_BYTES);
|
||||
truncated.truncate(boundary);
|
||||
}
|
||||
|
||||
// Build the warning message
|
||||
let reason = match (was_line_truncated, was_byte_truncated) {
|
||||
(true, false) => format!("{line_count} lines (limit: {MAX_INDEX_LINES})"),
|
||||
(false, true) => format!(
|
||||
"{} (limit: {}) \u{2014} index entries are too long",
|
||||
format_size(byte_count),
|
||||
format_size(MAX_INDEX_BYTES),
|
||||
),
|
||||
_ => format!("{line_count} lines and {}", format_size(byte_count),),
|
||||
};
|
||||
|
||||
truncated.push_str(&format!(
|
||||
"\n\n> WARNING: MEMORY.md is {reason}. \
|
||||
Only part of it was loaded. \
|
||||
Keep index entries to one line under ~200 chars; \
|
||||
move detail into topic files."
|
||||
));
|
||||
|
||||
IndexTruncation {
|
||||
content: truncated,
|
||||
line_count,
|
||||
byte_count,
|
||||
was_truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Append
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Append an entry to the MEMORY.md index file.
|
||||
///
|
||||
/// Format: `- [title](filename) — summary`
|
||||
///
|
||||
/// Creates the file (and parent directories) if it doesn't exist.
|
||||
/// Ensures a newline separator before the new entry.
|
||||
pub fn append_index_entry(path: &Path, title: &str, filename: &str, summary: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let entry = format!("- [{title}]({filename}) \u{2014} {summary}");
|
||||
|
||||
let mut content = fs::read_to_string(path).unwrap_or_default();
|
||||
if !content.is_empty() && !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(&entry);
|
||||
content.push('\n');
|
||||
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remove
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Remove the index entry that references `filename`.
|
||||
///
|
||||
/// Scans the index for any line containing `(filename)` and removes it.
|
||||
/// Idempotent — silently succeeds if the file doesn't exist or the
|
||||
/// entry is not found.
|
||||
pub fn remove_index_entry(path: &Path, filename: &str) -> Result<()> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let needle = format!("({filename})");
|
||||
let filtered: Vec<&str> = content
|
||||
.lines()
|
||||
.filter(|line| !line.contains(&needle))
|
||||
.collect();
|
||||
|
||||
// Preserve trailing newline if original had one
|
||||
let mut result = filtered.join("\n");
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
fs::write(path, result)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a byte count as a human-readable size string.
|
||||
fn format_size(bytes: usize) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
let kb = bytes as f64 / 1024.0;
|
||||
format!("{kb:.1} KB")
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Unit tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- format_size ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_size_bytes() {
|
||||
assert_eq!(format_size(500), "500 B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_size_kilobytes() {
|
||||
assert_eq!(format_size(25_000), "24.4 KB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_size_zero() {
|
||||
assert_eq!(format_size(0), "0 B");
|
||||
}
|
||||
|
||||
// -- truncate_index: no truncation ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_truncation_small_content() {
|
||||
let content = "- [A](a.md) — summary\n- [B](b.md) — summary\n";
|
||||
let result = truncate_index(content);
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 2);
|
||||
assert_eq!(result.content, content.trim());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_truncation_empty() {
|
||||
let result = truncate_index("");
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 0);
|
||||
assert_eq!(result.byte_count, 0);
|
||||
assert_eq!(result.content, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_truncation_whitespace_only() {
|
||||
let result = truncate_index(" \n \n ");
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.content, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_truncation_exactly_200_lines() {
|
||||
let content = (0..200)
|
||||
.map(|i| format!("- line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let result = truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_truncation_exactly_25000_bytes() {
|
||||
// 100 lines (under 200 limit) totalling exactly 25000 bytes.
|
||||
// 100 lines joined by 99 newlines: each line = (25000 - 99) / 100 = 249 chars,
|
||||
// remainder 1 added to last line.
|
||||
let per_line = (MAX_INDEX_BYTES - 99) / 100; // 249
|
||||
let remainder = MAX_INDEX_BYTES - 99 - per_line * 100;
|
||||
let mut lines: Vec<String> = (0..100).map(|_| "x".repeat(per_line)).collect();
|
||||
if remainder > 0 {
|
||||
lines.last_mut().unwrap().push_str(&"x".repeat(remainder));
|
||||
}
|
||||
let content = lines.join("\n");
|
||||
assert_eq!(content.len(), MAX_INDEX_BYTES);
|
||||
let result = truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
}
|
||||
|
||||
// -- truncate_index: line truncation --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn line_truncation_250_lines() {
|
||||
let lines: Vec<String> = (0..250).map(|i| format!("- line {i}")).collect();
|
||||
let content = lines.join("\n");
|
||||
let result = truncate_index(&content);
|
||||
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 250);
|
||||
// Content should contain only first 200 lines (before warning)
|
||||
let content_before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
let output_lines: Vec<&str> = content_before_warning.split('\n').collect();
|
||||
assert_eq!(output_lines.len(), 200);
|
||||
assert!(result.content.contains("250 lines"));
|
||||
assert!(result.content.contains("WARNING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_truncation_201_lines() {
|
||||
let lines: Vec<String> = (0..201).map(|i| format!("- line {i}")).collect();
|
||||
let content = lines.join("\n");
|
||||
let result = truncate_index(&content);
|
||||
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 201);
|
||||
}
|
||||
|
||||
// -- truncate_index: byte truncation --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn byte_truncation_long_lines() {
|
||||
// 100 lines, each 300 bytes = 30000 bytes > 25000
|
||||
let lines: Vec<String> = (0..100)
|
||||
.map(|i| format!("{i:03}: {}", "x".repeat(296)))
|
||||
.collect();
|
||||
let content = lines.join("\n");
|
||||
assert!(content.len() > MAX_INDEX_BYTES);
|
||||
|
||||
let result = truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 100);
|
||||
// Warning should mention byte size, not line count
|
||||
assert!(result.content.contains("index entries are too long"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_truncation_cuts_at_newline() {
|
||||
// Create content just over the byte limit
|
||||
let line = "a".repeat(250);
|
||||
let lines: Vec<String> = (0..110).map(|_| line.clone()).collect();
|
||||
let content = lines.join("\n");
|
||||
assert!(content.len() > MAX_INDEX_BYTES);
|
||||
|
||||
let result = truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
|
||||
// Content before warning should end at a line boundary
|
||||
let before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
// Every line should be complete (not cut mid-content)
|
||||
for line in before_warning.lines() {
|
||||
assert!(
|
||||
line.len() == 250 || line.is_empty(),
|
||||
"unexpected line length: {} for {:?}",
|
||||
line.len(),
|
||||
&line[..line.len().min(40)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- truncate_index: both limits ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn both_line_and_byte_truncation() {
|
||||
// 300 lines of 200 bytes each = 60000 bytes; both limits exceeded
|
||||
let lines: Vec<String> = (0..300)
|
||||
.map(|i| format!("{i:03}: {}", "y".repeat(196)))
|
||||
.collect();
|
||||
let content = lines.join("\n");
|
||||
|
||||
let result = truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 300);
|
||||
// Warning should mention both lines and bytes
|
||||
assert!(result.content.contains("300 lines"));
|
||||
assert!(result.content.contains("KB"));
|
||||
}
|
||||
|
||||
// -- truncate_index: single long line (no newline to cut at) ---------------
|
||||
|
||||
#[test]
|
||||
fn single_long_line_fallback() {
|
||||
let content = "z".repeat(30_000);
|
||||
let result = truncate_index(&content);
|
||||
|
||||
assert!(result.was_truncated);
|
||||
// Should truncate at MAX_INDEX_BYTES
|
||||
let before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
assert_eq!(before_warning.len(), MAX_INDEX_BYTES);
|
||||
}
|
||||
|
||||
// -- truncate_index: preserves content integrity ---------------------------
|
||||
|
||||
#[test]
|
||||
fn truncation_preserves_first_200_lines() {
|
||||
let lines: Vec<String> = (0..250)
|
||||
.map(|i| format!("- [{i}](file_{i}.md) \u{2014} memory number {i}"))
|
||||
.collect();
|
||||
let content = lines.join("\n");
|
||||
let result = truncate_index(&content);
|
||||
|
||||
// First line should be present
|
||||
assert!(result.content.contains("- [0](file_0.md)"));
|
||||
// Line 199 should be present
|
||||
assert!(result.content.contains("- [199](file_199.md)"));
|
||||
// Line 200 should NOT be present (0-indexed, so that's the 201st)
|
||||
assert!(!result.content.contains("- [200](file_200.md)"));
|
||||
}
|
||||
|
||||
// -- append entry (unit-level, using temp files) --------------------------
|
||||
|
||||
#[test]
|
||||
fn append_to_new_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
|
||||
append_index_entry(&path, "Role", "user_role.md", "user role info").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "- [Role](user_role.md) \u{2014} user role info\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_to_existing_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(&path, "- [A](a.md) \u{2014} first\n").unwrap();
|
||||
|
||||
append_index_entry(&path, "B", "b.md", "second").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
"- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_to_file_without_trailing_newline() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(&path, "- [A](a.md) \u{2014} first").unwrap();
|
||||
|
||||
append_index_entry(&path, "B", "b.md", "second").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
// Should have a newline between entries
|
||||
assert!(content.contains("first\n- [B]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_creates_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("sub").join("dir").join("MEMORY.md");
|
||||
|
||||
append_index_entry(&path, "Test", "test.md", "testing").unwrap();
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
// -- remove entry (unit-level, using temp files) --------------------------
|
||||
|
||||
#[test]
|
||||
fn remove_existing_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n- [C](c.md) \u{2014} third\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
remove_index_entry(&path, "b.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
"- [A](a.md) \u{2014} first\n- [C](c.md) \u{2014} third\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_entry_is_noop() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
let original = "- [A](a.md) \u{2014} first\n";
|
||||
fs::write(&path, original).unwrap();
|
||||
|
||||
remove_index_entry(&path, "nonexistent.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_from_nonexistent_file_is_ok() {
|
||||
let path = Path::new("/nonexistent/MEMORY.md");
|
||||
// Should not error
|
||||
remove_index_entry(path, "anything.md").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_entry_leaves_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(&path, "- [A](a.md) \u{2014} only\n").unwrap();
|
||||
|
||||
remove_index_entry(&path, "a.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "");
|
||||
}
|
||||
|
||||
// -- read_index (unit-level) ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn read_nonexistent_returns_empty() {
|
||||
let result = read_index(Path::new("/nonexistent/MEMORY.md"));
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_existing_returns_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(&path, "# Index\n- [A](a.md)\n").unwrap();
|
||||
|
||||
let result = read_index(&path);
|
||||
assert_eq!(result, "# Index\n- [A](a.md)\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Long-term memory system for Nomi.
|
||||
//
|
||||
// Provides cross-session storage of user preferences, feedback,
|
||||
// project context, and external references.
|
||||
|
||||
pub mod error;
|
||||
pub mod index;
|
||||
pub mod paths;
|
||||
pub mod prompt;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
pub mod distill;
|
||||
@@ -0,0 +1,491 @@
|
||||
// Path resolution and directory management for the memory system.
|
||||
//
|
||||
// Provides functions to compute memory directory locations, validate
|
||||
// paths for security, and ensure directories exist.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::error::{MemoryError, Result};
|
||||
|
||||
/// MEMORY.md entrypoint filename.
|
||||
pub const ENTRYPOINT_NAME: &str = "MEMORY.md";
|
||||
|
||||
/// Maximum length for sanitized directory names before truncation.
|
||||
const MAX_SANITIZED_LENGTH: usize = 200;
|
||||
|
||||
/// Environment variable to override the memory base directory.
|
||||
const MEMORY_DIR_ENV: &str = "NOMI_MEMORY_DIR";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base directory resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the base directory for memory storage.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `NOMI_MEMORY_DIR` environment variable (explicit override)
|
||||
/// 2. `app_config_dir()` from `nomi-config` (platform-aware default)
|
||||
///
|
||||
/// Returns `None` only when both the env var is unset AND the platform
|
||||
/// cannot determine a config directory (e.g. no home directory).
|
||||
pub fn memory_base_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var(MEMORY_DIR_ENV)
|
||||
&& !dir.is_empty()
|
||||
{
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
nomi_config::config::app_config_dir()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project-specific memory directory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the auto-memory directory for a specific project.
|
||||
///
|
||||
/// Path: `<base>/projects/<sanitized_project_root>/memory/`
|
||||
///
|
||||
/// The project root is sanitized to produce a safe directory name:
|
||||
/// all non-alphanumeric characters become hyphens, and long paths
|
||||
/// are truncated with a hash suffix for uniqueness.
|
||||
pub fn auto_memory_dir(project_root: &Path) -> Option<PathBuf> {
|
||||
let base = memory_base_dir()?;
|
||||
let sanitized = sanitize_path(&project_root.to_string_lossy());
|
||||
Some(base.join("projects").join(sanitized).join("memory"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the MEMORY.md entrypoint path within a memory directory.
|
||||
pub fn memory_entrypoint(memory_dir: &Path) -> PathBuf {
|
||||
memory_dir.join(ENTRYPOINT_NAME)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path membership check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether `path` belongs to the given memory directory.
|
||||
///
|
||||
/// Both paths are canonicalized (via `dunce::canonicalize` fallback to
|
||||
/// `std::fs::canonicalize`) to prevent traversal bypasses through `..`
|
||||
/// segments or symlinks.
|
||||
///
|
||||
/// Returns `false` if either path cannot be resolved (e.g. doesn't exist).
|
||||
pub fn is_memory_path(path: &Path, memory_dir: &Path) -> bool {
|
||||
let Ok(normalized_path) = normalize_path(path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(normalized_dir) = normalize_path(memory_dir) else {
|
||||
return false;
|
||||
};
|
||||
normalized_path.starts_with(&normalized_dir)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ensure a memory directory exists, creating it and all parent
|
||||
/// directories if necessary. Idempotent — safe to call repeatedly.
|
||||
pub fn ensure_memory_dir(dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate a path for use as a memory file location.
|
||||
///
|
||||
/// Security checks:
|
||||
/// - Must be an absolute path
|
||||
/// - Must be at least 3 components long (rejects root `/` and near-root)
|
||||
/// - Must not contain null bytes
|
||||
/// - Must not contain `..` traversal segments
|
||||
///
|
||||
/// Returns the normalized path on success.
|
||||
pub fn validate_memory_path(path: &Path) -> Result<PathBuf> {
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err(MemoryError::PathValidation("path must be absolute".into()));
|
||||
}
|
||||
|
||||
// Count only Normal segments (skip Prefix, RootDir) so the threshold is
|
||||
// consistent across platforms: Unix `/a` → 1 Normal, Windows `C:\a` → 1 Normal.
|
||||
let depth = path
|
||||
.components()
|
||||
.filter(|c| matches!(c, Component::Normal(_)))
|
||||
.count();
|
||||
if depth < 2 {
|
||||
return Err(MemoryError::PathValidation("path is too short".into()));
|
||||
}
|
||||
|
||||
if path_str.contains('\0') {
|
||||
return Err(MemoryError::PathValidation(
|
||||
"path contains null byte".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if contains_traversal(&path_str) {
|
||||
return Err(MemoryError::PathValidation(
|
||||
"path contains traversal (..)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(normalize_lexical(path))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path sanitization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Make a string safe for use as a directory name.
|
||||
///
|
||||
/// Replaces all non-alphanumeric characters with hyphens. If the result
|
||||
/// exceeds `MAX_SANITIZED_LENGTH`, truncates and appends a hash suffix
|
||||
/// to preserve uniqueness.
|
||||
pub fn sanitize_path(name: &str) -> String {
|
||||
let sanitized: String = name
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
|
||||
if sanitized.len() <= MAX_SANITIZED_LENGTH {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
let hash = simple_hash(name);
|
||||
format!("{}-{hash}", &sanitized[..MAX_SANITIZED_LENGTH])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether a path string contains `..` traversal segments.
|
||||
fn contains_traversal(path: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|seg| seg == "..")
|
||||
}
|
||||
|
||||
/// Lexical path normalization without filesystem access.
|
||||
///
|
||||
/// Collapses `.` and redundant separators. Does NOT resolve `..`
|
||||
/// (that's rejected before we get here) or symlinks.
|
||||
fn normalize_lexical(path: &Path) -> PathBuf {
|
||||
let mut result = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::CurDir => {} // skip `.`
|
||||
_ => result.push(component),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalize a path for comparison: try filesystem canonicalization first,
|
||||
/// fall back to lexical normalization if the path doesn't exist yet.
|
||||
///
|
||||
/// Returns `Err(())` when the path cannot be safely resolved — including
|
||||
/// when canonicalization fails AND the path contains `..` segments
|
||||
/// (lexical normalization cannot safely resolve parent references).
|
||||
fn normalize_path(path: &Path) -> std::result::Result<PathBuf, ()> {
|
||||
if let Ok(canonical) = fs::canonicalize(path) {
|
||||
return Ok(canonical);
|
||||
}
|
||||
// Path doesn't exist on disk. Lexical normalization is only safe when
|
||||
// there are no `..` segments — those require real filesystem state to
|
||||
// resolve correctly (symlinks, mount points, etc.).
|
||||
if contains_traversal(&path.to_string_lossy()) {
|
||||
return Err(());
|
||||
}
|
||||
let normalized = normalize_lexical(path);
|
||||
if normalized.as_os_str().is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Simple hash function for path truncation suffix.
|
||||
fn simple_hash(s: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
s.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
format!("{hash:x}")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Unit tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::path::Path;
|
||||
|
||||
// -- sanitize_path --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sanitize_simple_path() {
|
||||
assert_eq!(sanitize_path("/home/user/project"), "-home-user-project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_alphanumeric() {
|
||||
assert_eq!(sanitize_path("abc123"), "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_replaces_special_chars() {
|
||||
assert_eq!(sanitize_path("a/b:c d"), "a-b-c-d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_long_path_truncates_with_hash() {
|
||||
let long_path = "/".to_string() + &"a".repeat(300);
|
||||
let result = sanitize_path(&long_path);
|
||||
assert!(result.len() > MAX_SANITIZED_LENGTH); // truncated + hash
|
||||
assert!(result.len() < MAX_SANITIZED_LENGTH + 20); // hash isn't huge
|
||||
assert!(result.contains('-')); // has separator before hash
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_two_long_paths_produce_different_results() {
|
||||
let path_a = "/".to_string() + &"a".repeat(300);
|
||||
let path_b = "/".to_string() + &"b".repeat(300);
|
||||
assert_ne!(sanitize_path(&path_a), sanitize_path(&path_b));
|
||||
}
|
||||
|
||||
// -- contains_traversal ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn traversal_detected() {
|
||||
assert!(contains_traversal("../foo"));
|
||||
assert!(contains_traversal("foo/../bar"));
|
||||
assert!(contains_traversal("/foo/.."));
|
||||
assert!(contains_traversal("foo\\..\\bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_not_detected_for_safe_paths() {
|
||||
assert!(!contains_traversal("/foo/bar"));
|
||||
assert!(!contains_traversal("foo.bar"));
|
||||
assert!(!contains_traversal("foo...bar"));
|
||||
assert!(!contains_traversal("/tmp/test.md"));
|
||||
}
|
||||
|
||||
// -- validate_memory_path -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_relative_path() {
|
||||
let err = validate_memory_path(Path::new("relative/path")).unwrap_err();
|
||||
assert!(matches!(err, MemoryError::PathValidation(_)));
|
||||
assert!(err.to_string().contains("absolute"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_rejects_short_path() {
|
||||
let err = validate_memory_path(Path::new("/a")).unwrap_err();
|
||||
assert!(matches!(err, MemoryError::PathValidation(_)));
|
||||
assert!(err.to_string().contains("short"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_rejects_short_path() {
|
||||
let err = validate_memory_path(Path::new("C:\\a")).unwrap_err();
|
||||
assert!(matches!(err, MemoryError::PathValidation(_)));
|
||||
assert!(err.to_string().contains("short"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_rejects_traversal() {
|
||||
let err = validate_memory_path(Path::new("/tmp/../../../etc/passwd")).unwrap_err();
|
||||
assert!(matches!(err, MemoryError::PathValidation(_)));
|
||||
assert!(err.to_string().contains("traversal"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_rejects_traversal() {
|
||||
let err = validate_memory_path(Path::new("C:\\tmp\\..\\..\\..\\etc\\passwd")).unwrap_err();
|
||||
assert!(matches!(err, MemoryError::PathValidation(_)));
|
||||
assert!(err.to_string().contains("traversal"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_accepts_normal_absolute_path() {
|
||||
let result = validate_memory_path(Path::new("/tmp/memory/test.md"));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), PathBuf::from("/tmp/memory/test.md"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_accepts_normal_absolute_path() {
|
||||
let result = validate_memory_path(Path::new("C:\\tmp\\memory\\test.md"));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), PathBuf::from("C:\\tmp\\memory\\test.md"));
|
||||
}
|
||||
|
||||
// -- memory_entrypoint ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn entrypoint_appends_memory_md() {
|
||||
let dir = Path::new("/base/memory");
|
||||
assert_eq!(
|
||||
memory_entrypoint(dir),
|
||||
PathBuf::from("/base/memory/MEMORY.md")
|
||||
);
|
||||
}
|
||||
|
||||
// -- is_memory_path -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn is_memory_path_inside() {
|
||||
// Use temp dir so paths actually exist for canonicalization
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let file = mem_dir.join("test.md");
|
||||
fs::write(&file, "").unwrap();
|
||||
|
||||
assert!(is_memory_path(&file, &mem_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_memory_path_outside() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let outside = tmp.path().join("other.md");
|
||||
fs::write(&outside, "").unwrap();
|
||||
|
||||
assert!(!is_memory_path(&outside, &mem_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_memory_path_nonexistent_returns_false() {
|
||||
// Non-existent paths with no common prefix
|
||||
assert!(!is_memory_path(
|
||||
Path::new("/nonexistent/a/b.md"),
|
||||
Path::new("/different/dir"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_memory_path_traversal_in_nonexistent_path_returns_false() {
|
||||
// Non-existent path with `..` must not bypass membership check
|
||||
// (regression test for review-1.3 ISSUE-1)
|
||||
assert!(!is_memory_path(
|
||||
Path::new("/base/memory/../../../etc/passwd"),
|
||||
Path::new("/base/memory"),
|
||||
));
|
||||
}
|
||||
|
||||
// -- ensure_memory_dir ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_nested_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let deep = tmp.path().join("a").join("b").join("c");
|
||||
assert!(!deep.exists());
|
||||
ensure_memory_dir(&deep).unwrap();
|
||||
assert!(deep.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("memory");
|
||||
ensure_memory_dir(&dir).unwrap();
|
||||
// Second call should not error
|
||||
ensure_memory_dir(&dir).unwrap();
|
||||
assert!(dir.is_dir());
|
||||
}
|
||||
|
||||
// -- memory_base_dir (env override) ---------------------------------------
|
||||
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn base_dir_env_override() {
|
||||
let key = MEMORY_DIR_ENV;
|
||||
let original = std::env::var(key).ok();
|
||||
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(key, "/custom/memory") };
|
||||
let result = memory_base_dir();
|
||||
assert_eq!(result, Some(PathBuf::from("/custom/memory")));
|
||||
|
||||
restore_env(key, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn base_dir_empty_env_falls_through() {
|
||||
let key = MEMORY_DIR_ENV;
|
||||
let original = std::env::var(key).ok();
|
||||
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(key, "") };
|
||||
let result = memory_base_dir();
|
||||
// Should fall through to app_config_dir
|
||||
assert_ne!(result, Some(PathBuf::from("")));
|
||||
|
||||
restore_env(key, original);
|
||||
}
|
||||
|
||||
// -- auto_memory_dir ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn auto_memory_dir_structure() {
|
||||
let key = MEMORY_DIR_ENV;
|
||||
let original = std::env::var(key).ok();
|
||||
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(key, "/base") };
|
||||
let dir = auto_memory_dir(Path::new("/home/user/project")).unwrap();
|
||||
assert_eq!(
|
||||
dir,
|
||||
PathBuf::from("/base/projects/-home-user-project/memory")
|
||||
);
|
||||
|
||||
restore_env(key, original);
|
||||
}
|
||||
|
||||
fn restore_env(key: &str, saved: Option<String>) {
|
||||
// SAFETY: only called from #[serial(env)] tests.
|
||||
unsafe {
|
||||
match saved {
|
||||
Some(v) => std::env::set_var(key, v),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- normalize_lexical ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn normalize_collapses_dot() {
|
||||
let input = Path::new("/foo/./bar/./baz");
|
||||
assert_eq!(normalize_lexical(input), PathBuf::from("/foo/bar/baz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_absolute() {
|
||||
let input = Path::new("/foo/bar");
|
||||
assert_eq!(normalize_lexical(input), PathBuf::from("/foo/bar"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
// Memory system prompt construction.
|
||||
//
|
||||
// Builds the behavioral instructions and MEMORY.md content that get
|
||||
// injected into the agent's system prompt so it knows how to read,
|
||||
// write, and manage the persistent memory system.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::index::{MAX_INDEX_LINES, read_index, truncate_index};
|
||||
use crate::paths::ENTRYPOINT_NAME;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DISPLAY_NAME: &str = "auto memory";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory existence guidance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Guidance appended to the memory directory prompt line so the model
|
||||
/// doesn't waste turns on `ls` / `mkdir -p` before writing.
|
||||
const DIR_EXISTS_GUIDANCE: &str = "This directory already exists \u{2014} \
|
||||
write to it directly with the Write tool \
|
||||
(do not run mkdir or check for its existence).";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type taxonomy (individual-only, no team/private scope tags)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYPES_SECTION: &str = "\
|
||||
## Types of memory
|
||||
|
||||
There are several discrete types of memory that you can store in your memory system:
|
||||
|
||||
<types>
|
||||
<type>
|
||||
<name>user</name>
|
||||
<description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>
|
||||
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
|
||||
<how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
|
||||
<examples>
|
||||
user: I'm a data scientist investigating what logging we have in place
|
||||
assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
|
||||
|
||||
user: I've been writing Go for ten years but this is my first time touching the React side of this repo
|
||||
assistant: [saves user memory: deep Go expertise, new to React and this project's frontend \u{2014} frame frontend explanations in terms of backend analogues]
|
||||
</examples>
|
||||
</type>
|
||||
<type>
|
||||
<name>feedback</name>
|
||||
<description>Guidance the user has given you about how to approach work \u{2014} both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>
|
||||
<when_to_save>Any time the user corrects your approach (\"no not that\", \"don't\", \"stop doing X\") OR confirms a non-obvious approach worked (\"yes exactly\", \"perfect, keep doing that\", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter \u{2014} watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>
|
||||
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
|
||||
<body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave \u{2014} often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>
|
||||
<examples>
|
||||
user: don't mock the database in these tests \u{2014} we got burned last quarter when mocked tests passed but the prod migration failed
|
||||
assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
|
||||
|
||||
user: stop summarizing what you just did at the end of every response, I can read the diff
|
||||
assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
|
||||
|
||||
user: yeah the single bundled PR was the right call here, splitting this one would've just been churn
|
||||
assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach \u{2014} a validated judgment call, not a correction]
|
||||
</examples>
|
||||
</type>
|
||||
<type>
|
||||
<name>project</name>
|
||||
<description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
|
||||
<when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., \"Thursday\" \u{2192} \"2026-03-05\"), so the memory remains interpretable after time passes.</when_to_save>
|
||||
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
|
||||
<body_structure>Lead with the fact or decision, then a **Why:** line (the motivation \u{2014} often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>
|
||||
<examples>
|
||||
user: we're freezing all non-critical merges after Thursday \u{2014} mobile team is cutting a release branch
|
||||
assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
|
||||
|
||||
user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
|
||||
assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup \u{2014} scope decisions should favor compliance over ergonomics]
|
||||
</examples>
|
||||
</type>
|
||||
<type>
|
||||
<name>reference</name>
|
||||
<description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
|
||||
<when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
|
||||
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
|
||||
<examples>
|
||||
user: check the Linear project \"INGEST\" if you want context on these tickets, that's where we track all pipeline bugs
|
||||
assistant: [saves reference memory: pipeline bugs are tracked in Linear project \"INGEST\"]
|
||||
|
||||
user: the Grafana board at grafana.internal/d/api-latency is what oncall watches \u{2014} if you're touching request handling, that's the thing that'll page someone
|
||||
assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard \u{2014} check it when editing request-path code]
|
||||
</examples>
|
||||
</type>
|
||||
</types>
|
||||
";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What NOT to save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WHAT_NOT_TO_SAVE: &str = "\
|
||||
## What NOT to save in memory
|
||||
|
||||
- Code patterns, conventions, architecture, file paths, or project structure \u{2014} these can be derived by reading the current project state.
|
||||
- Git history, recent changes, or who-changed-what \u{2014} `git log` / `git blame` are authoritative.
|
||||
- Debugging solutions or fix recipes \u{2014} the fix is in the code; the commit message has the context.
|
||||
- Anything already documented in AGENTS.md files.
|
||||
- Ephemeral task details: in-progress work, temporary state, current conversation context.
|
||||
|
||||
These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it \u{2014} that is the part worth keeping.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// How to save (two-step process with MEMORY.md index)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn how_to_save_section() -> String {
|
||||
format!(
|
||||
"\
|
||||
## How to save memories
|
||||
|
||||
Saving a memory is a two-step process:
|
||||
|
||||
**Step 1** \u{2014} write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
|
||||
|
||||
{FRONTMATTER_EXAMPLE}
|
||||
|
||||
**Step 2** \u{2014} add a pointer to that file in `{ep}`. `{ep}` is an index, not a memory \u{2014} each entry should be one line, under ~150 characters: `- [Title](file.md) \u{2014} one-line hook`. It has no frontmatter. Never write memory content directly into `{ep}`.
|
||||
|
||||
- `{ep}` is always loaded into your conversation context \u{2014} lines after {max_lines} will be truncated, so keep the index concise
|
||||
- Keep the name, description, and type fields in memory files up-to-date with the content
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.",
|
||||
ep = ENTRYPOINT_NAME,
|
||||
max_lines = MAX_INDEX_LINES,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter example
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FRONTMATTER_EXAMPLE: &str = "\
|
||||
```markdown
|
||||
---
|
||||
name: {{memory name}}
|
||||
description: {{one-line description \u{2014} used to decide relevance in future conversations, so be specific}}
|
||||
type: {{user, feedback, project, reference}}
|
||||
---
|
||||
|
||||
{{memory content \u{2014} for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
|
||||
```";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// When to access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WHEN_TO_ACCESS: &str = "\
|
||||
## When to access memories
|
||||
- When memories seem relevant, or the user references prior-conversation work.
|
||||
- You MUST access memory when the user explicitly asks you to check, recall, or remember.
|
||||
- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content.
|
||||
- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now \u{2014} and update or remove the stale memory rather than acting on it.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Before recommending from memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BEFORE_RECOMMENDING: &str = "\
|
||||
## Before recommending from memory
|
||||
|
||||
A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:
|
||||
|
||||
- If the memory names a file path: check the file exists.
|
||||
- If the memory names a function or flag: grep for it.
|
||||
- If the user is about to act on your recommendation (not just asking about history), verify first.
|
||||
|
||||
\"The memory says X exists\" is not the same as \"X exists now.\"
|
||||
|
||||
A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory vs other persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PERSISTENCE_SECTION: &str = "\
|
||||
## Memory and other forms of persistence
|
||||
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
|
||||
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
|
||||
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal memory prompt (lazy — saves ~2,500 tokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compact summary of the memory system rules, without the full type taxonomy,
|
||||
/// examples, or detailed save/access instructions. Enough for the LLM to
|
||||
/// read existing memories and know the system exists; the full instructions
|
||||
/// are injected on-demand when the LLM first writes to the memory directory.
|
||||
const MINIMAL_RULES: &str = "\
|
||||
You should build up this memory system over time so that future conversations \
|
||||
can have a complete picture of who the user is, how they'd like to collaborate \
|
||||
with you, what behaviors to avoid or repeat, and the context behind the work \
|
||||
the user gives you.
|
||||
|
||||
If the user explicitly asks you to remember something, save it immediately. \
|
||||
If they ask you to forget something, find and remove the relevant entry.
|
||||
|
||||
Memory types: user, feedback, project, reference. Each memory is a Markdown file \
|
||||
with YAML frontmatter (name, description, type). MEMORY.md is the index — one \
|
||||
line per entry, never write content directly into it.
|
||||
|
||||
Before saving, read existing memories to avoid duplicates. \
|
||||
Verify file/function names from memory still exist before recommending them.";
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
/// Build a minimal memory prompt with just the path, compact rules,
|
||||
/// and MEMORY.md index content. Omits the full type taxonomy and examples
|
||||
/// to save ~2,500 tokens on the first turn.
|
||||
pub fn build_memory_prompt_minimal(memory_dir: &Path) -> String {
|
||||
let dir_display = memory_dir.display();
|
||||
|
||||
let mut parts = vec![
|
||||
format!("# {DISPLAY_NAME}"),
|
||||
String::new(),
|
||||
format!(
|
||||
"You have a persistent, file-based memory system at `{dir_display}`. \
|
||||
{DIR_EXISTS_GUIDANCE}"
|
||||
),
|
||||
String::new(),
|
||||
MINIMAL_RULES.to_owned(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
// Append MEMORY.md index (same logic as the full version)
|
||||
let entrypoint = memory_dir.join(ENTRYPOINT_NAME);
|
||||
let raw = read_index(&entrypoint);
|
||||
let trimmed = raw.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
parts.push(format!("## {ENTRYPOINT_NAME}"));
|
||||
parts.push(String::new());
|
||||
parts.push(format!(
|
||||
"Your {ENTRYPOINT_NAME} is currently empty. \
|
||||
When you save new memories, they will appear here."
|
||||
));
|
||||
} else {
|
||||
let truncation = truncate_index(&raw);
|
||||
parts.push(format!("## {ENTRYPOINT_NAME}"));
|
||||
parts.push(String::new());
|
||||
parts.push(truncation.content);
|
||||
}
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Build the complete memory system prompt including behavioral instructions
|
||||
/// AND the current MEMORY.md content (or an empty-state message).
|
||||
///
|
||||
/// This is the all-in-one function used when the caller needs a single
|
||||
/// string to inject into the system prompt.
|
||||
pub fn build_memory_prompt(memory_dir: &Path) -> String {
|
||||
let mut lines = build_memory_instructions(memory_dir);
|
||||
|
||||
let entrypoint = memory_dir.join(ENTRYPOINT_NAME);
|
||||
let raw = read_index(&entrypoint);
|
||||
let trimmed = raw.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
lines.push(format!("## {ENTRYPOINT_NAME}"));
|
||||
lines.push(String::new());
|
||||
lines.push(format!(
|
||||
"Your {ENTRYPOINT_NAME} is currently empty. \
|
||||
When you save new memories, they will appear here."
|
||||
));
|
||||
} else {
|
||||
let truncation = truncate_index(&raw);
|
||||
lines.push(format!("## {ENTRYPOINT_NAME}"));
|
||||
lines.push(String::new());
|
||||
lines.push(truncation.content);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Build only the behavioral instructions (without MEMORY.md content).
|
||||
///
|
||||
/// Returns a `Vec<String>` of logical prompt sections. The caller is
|
||||
/// responsible for joining them with newlines and injecting any
|
||||
/// additional content (e.g. MEMORY.md via a separate path).
|
||||
pub fn build_memory_instructions(memory_dir: &Path) -> Vec<String> {
|
||||
let dir_display = memory_dir.display();
|
||||
|
||||
vec![
|
||||
format!("# {DISPLAY_NAME}"),
|
||||
String::new(),
|
||||
format!(
|
||||
"You have a persistent, file-based memory system at `{dir_display}`. \
|
||||
{DIR_EXISTS_GUIDANCE}"
|
||||
),
|
||||
String::new(),
|
||||
"You should build up this memory system over time so that future \
|
||||
conversations can have a complete picture of who the user is, how \
|
||||
they'd like to collaborate with you, what behaviors to avoid or \
|
||||
repeat, and the context behind the work the user gives you."
|
||||
.to_owned(),
|
||||
String::new(),
|
||||
"If the user explicitly asks you to remember something, save it \
|
||||
immediately as whichever type fits best. If they ask you to forget \
|
||||
something, find and remove the relevant entry."
|
||||
.to_owned(),
|
||||
String::new(),
|
||||
TYPES_SECTION.to_owned(),
|
||||
WHAT_NOT_TO_SAVE.to_owned(),
|
||||
String::new(),
|
||||
how_to_save_section(),
|
||||
String::new(),
|
||||
WHEN_TO_ACCESS.to_owned(),
|
||||
String::new(),
|
||||
BEFORE_RECOMMENDING.to_owned(),
|
||||
String::new(),
|
||||
PERSISTENCE_SECTION.to_owned(),
|
||||
String::new(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Return the memory type descriptions as a standalone string.
|
||||
///
|
||||
/// Useful when only the type taxonomy is needed (e.g. for help text
|
||||
/// or documentation), without the full behavioral instructions.
|
||||
pub fn memory_type_descriptions() -> &'static str {
|
||||
TYPES_SECTION
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Citation contract (citation reflow)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Instruction appended to the memory prompt so the model emits a structured
|
||||
/// citation block whenever its answer drew on a stored memory. The backend
|
||||
/// parses the filenames out of this block at turn end and bumps each cited
|
||||
/// file's `usage_count` / `last_used` (see `distill::parse_citation_filenames`
|
||||
/// and `store::bump_memory_usage`).
|
||||
///
|
||||
/// Kept short (a few dozen tokens) and only injected when a memory directory
|
||||
/// exists. The block is appended *after* the visible answer, one entry per
|
||||
/// line: `<filename>|note=[one-line how-it-was-used]`.
|
||||
pub const CITATION_CONTRACT: &str = "\
|
||||
## Citing memory
|
||||
|
||||
If your answer drew on the MEMORY.md index or any memory file above, append a \
|
||||
single citation block at the very end of your reply, listing only the files you \
|
||||
actually used:
|
||||
|
||||
<nomi-mem-citation>
|
||||
user_role.md|note=[one-line note on how this shaped the answer]
|
||||
feedback_testing.md|note=[…]
|
||||
</nomi-mem-citation>
|
||||
|
||||
One line per cited file: the memory filename, then `|note=[…]`. If you did not \
|
||||
use any stored memory, do not emit the block at all.";
|
||||
|
||||
// ===========================================================================
|
||||
// Unit tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- constants integrity -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn types_section_contains_all_four_types() {
|
||||
for ty in ["user", "feedback", "project", "reference"] {
|
||||
assert!(
|
||||
TYPES_SECTION.contains(&format!("<name>{ty}</name>")),
|
||||
"TYPES_SECTION missing type: {ty}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_section_has_no_scope_tags() {
|
||||
assert!(
|
||||
!TYPES_SECTION.contains("<scope>"),
|
||||
"individual-mode TYPES_SECTION should not contain <scope> tags"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_not_to_save_mentions_agents_md() {
|
||||
assert!(
|
||||
WHAT_NOT_TO_SAVE.contains("AGENTS.md"),
|
||||
"should reference AGENTS.md, not CLAUDE.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_not_to_save_no_claude_brand() {
|
||||
assert!(
|
||||
!WHAT_NOT_TO_SAVE.contains("CLAUDE.md"),
|
||||
"should not contain bb brand reference CLAUDE.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_example_has_all_fields() {
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("name:"));
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("description:"));
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("type:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_example_lists_all_types() {
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("user"));
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("feedback"));
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("project"));
|
||||
assert!(FRONTMATTER_EXAMPLE.contains("reference"));
|
||||
}
|
||||
|
||||
// -- how_to_save_section -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn how_to_save_references_entrypoint() {
|
||||
let section = how_to_save_section();
|
||||
assert!(section.contains(ENTRYPOINT_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn how_to_save_mentions_max_lines() {
|
||||
let section = how_to_save_section();
|
||||
assert!(section.contains(&MAX_INDEX_LINES.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn how_to_save_describes_two_steps() {
|
||||
let section = how_to_save_section();
|
||||
assert!(section.contains("Step 1"));
|
||||
assert!(section.contains("Step 2"));
|
||||
}
|
||||
|
||||
// -- build_memory_instructions -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn instructions_contain_display_name() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
assert!(joined.contains(DISPLAY_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_contain_memory_dir_path() {
|
||||
let lines = build_memory_instructions(Path::new("/custom/path/memory"));
|
||||
let joined = lines.join("\n");
|
||||
assert!(joined.contains("/custom/path/memory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_contain_dir_exists_guidance() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
assert!(joined.contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_contain_all_sections() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
assert!(joined.contains("## Types of memory"));
|
||||
assert!(joined.contains("## What NOT to save"));
|
||||
assert!(joined.contains("## How to save memories"));
|
||||
assert!(joined.contains("## When to access memories"));
|
||||
assert!(joined.contains("## Before recommending from memory"));
|
||||
assert!(joined.contains("## Memory and other forms of persistence"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_no_bb_brand() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
assert!(
|
||||
!joined.contains("~/.claude"),
|
||||
"should not reference bb config path"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains("CLAUDE.md"),
|
||||
"should not reference bb config file"
|
||||
);
|
||||
}
|
||||
|
||||
// -- memory_type_descriptions --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn type_descriptions_returns_types_section() {
|
||||
let desc = memory_type_descriptions();
|
||||
assert!(desc.contains("<types>"));
|
||||
assert!(desc.contains("</types>"));
|
||||
}
|
||||
|
||||
// -- CITATION_CONTRACT ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn citation_contract_has_block_tags_and_note_format() {
|
||||
assert!(CITATION_CONTRACT.contains("<nomi-mem-citation>"));
|
||||
assert!(CITATION_CONTRACT.contains("</nomi-mem-citation>"));
|
||||
assert!(CITATION_CONTRACT.contains("|note=["));
|
||||
}
|
||||
|
||||
// -- build_memory_prompt (filesystem-dependent, basic validation) ---------
|
||||
|
||||
#[test]
|
||||
fn prompt_with_nonexistent_dir_shows_empty_state() {
|
||||
let result = build_memory_prompt(Path::new("/nonexistent/memory/dir"));
|
||||
assert!(result.contains(ENTRYPOINT_NAME));
|
||||
assert!(result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_existing_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
let index_path = mem_dir.join(ENTRYPOINT_NAME);
|
||||
std::fs::write(
|
||||
&index_path,
|
||||
"- [Role](user_role.md) \u{2014} user role info\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = build_memory_prompt(&mem_dir);
|
||||
assert!(result.contains("user_role.md"));
|
||||
assert!(result.contains("user role info"));
|
||||
assert!(!result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_empty_index_file_shows_empty_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
let index_path = mem_dir.join(ENTRYPOINT_NAME);
|
||||
std::fs::write(&index_path, "").unwrap();
|
||||
|
||||
let result = build_memory_prompt(&mem_dir);
|
||||
assert!(result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_includes_instructions_before_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
let index_path = mem_dir.join(ENTRYPOINT_NAME);
|
||||
std::fs::write(&index_path, "- [A](a.md) \u{2014} test\n").unwrap();
|
||||
|
||||
let result = build_memory_prompt(&mem_dir);
|
||||
|
||||
// Instructions (type descriptions) should appear before the index content
|
||||
let types_pos = result.find("## Types of memory").unwrap();
|
||||
let index_pos = result.find(&format!("## {ENTRYPOINT_NAME}")).unwrap();
|
||||
assert!(
|
||||
types_pos < index_pos,
|
||||
"instructions should appear before MEMORY.md content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_truncates_large_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
let index_path = mem_dir.join(ENTRYPOINT_NAME);
|
||||
|
||||
// Create an index with 250 lines
|
||||
let content: String = (0..250)
|
||||
.map(|i| format!("- [Item {i}](item_{i}.md) \u{2014} summary {i}\n"))
|
||||
.collect();
|
||||
std::fs::write(&index_path, &content).unwrap();
|
||||
|
||||
let result = build_memory_prompt(&mem_dir);
|
||||
assert!(result.contains("WARNING"));
|
||||
}
|
||||
|
||||
// -- build_memory_prompt_minimal -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_contains_display_name() {
|
||||
let result = build_memory_prompt_minimal(Path::new("/test/memory"));
|
||||
assert!(result.contains(DISPLAY_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_contains_dir_path() {
|
||||
let result = build_memory_prompt_minimal(Path::new("/custom/path/memory"));
|
||||
assert!(result.contains("/custom/path/memory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_contains_compact_rules() {
|
||||
let result = build_memory_prompt_minimal(Path::new("/test/memory"));
|
||||
assert!(
|
||||
result.contains("Memory types:"),
|
||||
"should list memory types compactly"
|
||||
);
|
||||
assert!(
|
||||
result.contains("MEMORY.md is the index"),
|
||||
"should mention MEMORY.md role"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_omits_full_type_taxonomy() {
|
||||
let result = build_memory_prompt_minimal(Path::new("/test/memory"));
|
||||
assert!(
|
||||
!result.contains("## Types of memory"),
|
||||
"minimal prompt should NOT contain full type taxonomy heading"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("<types>"),
|
||||
"minimal prompt should NOT contain XML type definitions"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("## What NOT to save"),
|
||||
"minimal prompt should NOT contain what-not-to-save section"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("## How to save memories"),
|
||||
"minimal prompt should NOT contain detailed save instructions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_nonexistent_dir_shows_empty_state() {
|
||||
let result = build_memory_prompt_minimal(Path::new("/nonexistent/memory/dir"));
|
||||
assert!(result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_with_existing_index() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
std::fs::write(
|
||||
mem_dir.join(ENTRYPOINT_NAME),
|
||||
"- [Role](user_role.md) \u{2014} senior engineer\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = build_memory_prompt_minimal(&mem_dir);
|
||||
assert!(result.contains("user_role.md"));
|
||||
assert!(result.contains("senior engineer"));
|
||||
assert!(!result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_prompt_much_shorter_than_full() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
std::fs::create_dir_all(&mem_dir).unwrap();
|
||||
std::fs::write(mem_dir.join(ENTRYPOINT_NAME), "- [A](a.md) \u{2014} test\n").unwrap();
|
||||
|
||||
let full = build_memory_prompt(&mem_dir);
|
||||
let minimal = build_memory_prompt_minimal(&mem_dir);
|
||||
|
||||
assert!(
|
||||
minimal.len() < full.len() / 2,
|
||||
"minimal ({} chars) should be less than half of full ({} chars)",
|
||||
minimal.len(),
|
||||
full.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_prompt_contains_full_taxonomy() {
|
||||
let result = build_memory_prompt(Path::new("/test/memory"));
|
||||
assert!(
|
||||
result.contains("## Types of memory"),
|
||||
"full prompt should contain type taxonomy"
|
||||
);
|
||||
assert!(
|
||||
result.contains("<types>"),
|
||||
"full prompt should contain XML type definitions"
|
||||
);
|
||||
assert!(
|
||||
result.contains("## What NOT to save"),
|
||||
"full prompt should contain what-not-to-save"
|
||||
);
|
||||
assert!(
|
||||
result.contains("## How to save memories"),
|
||||
"full prompt should contain save instructions"
|
||||
);
|
||||
}
|
||||
|
||||
// -- no hardcoded platform paths -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn constants_no_hardcoded_home_paths() {
|
||||
let all_text = [
|
||||
TYPES_SECTION,
|
||||
WHAT_NOT_TO_SAVE,
|
||||
FRONTMATTER_EXAMPLE,
|
||||
WHEN_TO_ACCESS,
|
||||
BEFORE_RECOMMENDING,
|
||||
PERSISTENCE_SECTION,
|
||||
];
|
||||
for text in all_text {
|
||||
assert!(
|
||||
!text.contains("~/.config/nomi"),
|
||||
"should not hardcode platform-specific path"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("~/.claude"),
|
||||
"should not contain bb brand path"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
// Memory file read, write, delete, scan, and manifest formatting.
|
||||
//
|
||||
// This module handles the file-level operations for memory persistence:
|
||||
// parsing YAML frontmatter, writing memory entries, scanning directories
|
||||
// for memory headers, and formatting manifests.
|
||||
|
||||
use std::fs;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::paths::ENTRYPOINT_NAME;
|
||||
use crate::types::{MemoryEntry, MemoryFrontmatter, MemoryHeader};
|
||||
|
||||
/// Maximum number of lines to read when extracting frontmatter.
|
||||
const FRONTMATTER_MAX_LINES: usize = 30;
|
||||
|
||||
/// Maximum number of files returned by a directory scan.
|
||||
const MAX_MEMORY_FILES: usize = 200;
|
||||
|
||||
/// YAML frontmatter delimiter.
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a single memory file, parsing its YAML frontmatter and body.
|
||||
///
|
||||
/// Gracefully degrades: if the file has no valid frontmatter, returns
|
||||
/// a default (empty) frontmatter with the entire file as body content.
|
||||
pub fn read_memory(path: &Path) -> Result<MemoryEntry> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
let (frontmatter, content) = parse_frontmatter(&raw, Some(path));
|
||||
Ok(MemoryEntry::new(frontmatter, content))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Write a memory entry to a file in `dir`.
|
||||
///
|
||||
/// The filename is derived from the entry's type and name:
|
||||
/// `<type>_<sanitized_name>.md`. Returns the full path of the written file.
|
||||
///
|
||||
/// Creates the directory if it doesn't exist.
|
||||
pub fn write_memory(dir: &Path, entry: &MemoryEntry) -> Result<PathBuf> {
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
let filename = generate_filename(&entry.frontmatter);
|
||||
let path = dir.join(&filename);
|
||||
|
||||
let content = serialize_entry(entry);
|
||||
fs::write(&path, content)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Delete a memory file at the given path.
|
||||
///
|
||||
/// Returns an error if the file does not exist or cannot be removed.
|
||||
pub fn delete_memory(path: &Path) -> Result<()> {
|
||||
fs::remove_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Citation reflow: bump usage stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Citation reflow: increment `usage_count` and set `last_used = now` on the
|
||||
/// frontmatter of the memory file named `filename` inside `dir`, preserving
|
||||
/// the body verbatim.
|
||||
///
|
||||
/// A missing or unreadable file is a no-op (returns `Ok`): citations may name
|
||||
/// files that were renamed or removed, and that must not surface as an error.
|
||||
pub fn bump_memory_usage(dir: &Path, filename: &str, now: DateTime<Utc>) -> Result<()> {
|
||||
let path = dir.join(filename);
|
||||
// Missing / unreadable file = no-op. Citations can name stale filenames.
|
||||
let Ok(mut entry) = read_memory(&path) else {
|
||||
return Ok(());
|
||||
};
|
||||
entry.frontmatter.usage_count = Some(entry.frontmatter.usage_count.unwrap_or(0) + 1);
|
||||
entry.frontmatter.last_used = Some(now);
|
||||
let content = serialize_entry(&entry);
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scan a directory for memory files, returning lightweight headers.
|
||||
///
|
||||
/// - Recursively reads `.md` files, excluding `MEMORY.md`.
|
||||
/// - Reads only the first 30 lines of each file for frontmatter extraction.
|
||||
/// - Sorts by modification time (newest first).
|
||||
/// - Caps results at 200 files.
|
||||
///
|
||||
/// Returns an empty list for non-existent or empty directories.
|
||||
pub fn scan_memory_files(dir: &Path) -> Result<Vec<MemoryHeader>> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
|
||||
for entry in collect_md_files(dir)? {
|
||||
let path = entry;
|
||||
if let Some(header) = read_header(&path) {
|
||||
headers.push(header);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by mtime descending (newest first).
|
||||
headers.sort_by_key(|h| std::cmp::Reverse(h.mtime));
|
||||
|
||||
// Cap at limit.
|
||||
headers.truncate(MAX_MEMORY_FILES);
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a list of memory headers as a human-readable manifest.
|
||||
///
|
||||
/// Each line: `- [type] filename (ISO8601): description`
|
||||
/// Type tag omitted if absent; description omitted if absent.
|
||||
pub fn format_memory_manifest(headers: &[MemoryHeader]) -> String {
|
||||
let mut lines = Vec::with_capacity(headers.len());
|
||||
|
||||
for h in headers {
|
||||
let type_tag = h
|
||||
.memory_type
|
||||
.map(|t| format!("[{}] ", t))
|
||||
.unwrap_or_default();
|
||||
let ts = h.mtime.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
let desc = h
|
||||
.description
|
||||
.as_deref()
|
||||
.map(|d| format!(": {d}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
lines.push(format!("- {type_tag}{} ({ts}){desc}", h.filename));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter parsing (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse YAML frontmatter from raw file content.
|
||||
///
|
||||
/// Expects the format:
|
||||
/// ```text
|
||||
/// ---
|
||||
/// name: value
|
||||
/// type: user
|
||||
/// ---
|
||||
/// Body content here
|
||||
/// ```
|
||||
///
|
||||
/// Returns `(frontmatter, body)`. On parse failure, returns default
|
||||
/// frontmatter and the entire content as body.
|
||||
fn parse_frontmatter(raw: &str, path: Option<&Path>) -> (MemoryFrontmatter, String) {
|
||||
let trimmed = raw.trim_start();
|
||||
|
||||
// Must start with `---`
|
||||
if !trimmed.starts_with(FRONTMATTER_DELIM) {
|
||||
return (MemoryFrontmatter::default(), raw.to_owned());
|
||||
}
|
||||
|
||||
// Find the closing `---`
|
||||
let after_open = &trimmed[FRONTMATTER_DELIM.len()..];
|
||||
|
||||
// Skip the rest of the opening delimiter line (e.g. `---\n`)
|
||||
let after_newline = match after_open.find('\n') {
|
||||
Some(pos) => &after_open[pos + 1..],
|
||||
None => return (MemoryFrontmatter::default(), raw.to_owned()),
|
||||
};
|
||||
|
||||
// Find the closing delimiter within the frontmatter max lines
|
||||
let mut search_offset = 0;
|
||||
let mut lines_seen = 0;
|
||||
let close_pos = loop {
|
||||
if lines_seen >= FRONTMATTER_MAX_LINES {
|
||||
// No closing delimiter within limit — treat as no frontmatter
|
||||
return (MemoryFrontmatter::default(), raw.to_owned());
|
||||
}
|
||||
match after_newline[search_offset..].find('\n') {
|
||||
Some(nl) => {
|
||||
let line = after_newline[search_offset..search_offset + nl].trim();
|
||||
if line == FRONTMATTER_DELIM {
|
||||
break search_offset;
|
||||
}
|
||||
search_offset += nl + 1;
|
||||
lines_seen += 1;
|
||||
}
|
||||
None => {
|
||||
// Last line without trailing newline
|
||||
let line = after_newline[search_offset..].trim();
|
||||
if line == FRONTMATTER_DELIM {
|
||||
break search_offset;
|
||||
}
|
||||
// No closing delimiter found
|
||||
return (MemoryFrontmatter::default(), raw.to_owned());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let yaml_str = &after_newline[..close_pos];
|
||||
let body_start = search_offset + FRONTMATTER_DELIM.len();
|
||||
let body = after_newline
|
||||
.get(body_start..)
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('\n');
|
||||
|
||||
// Parse YAML
|
||||
let frontmatter = match serde_yaml::from_str::<MemoryFrontmatter>(yaml_str) {
|
||||
Ok(fm) => fm,
|
||||
Err(e) => {
|
||||
if let Some(p) = path {
|
||||
tracing::warn!(target: "nomi_memory", path = %p.display(), error = %e, "failed to parse memory frontmatter");
|
||||
}
|
||||
MemoryFrontmatter::default()
|
||||
}
|
||||
};
|
||||
|
||||
(frontmatter, body.to_owned())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry serialization (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serialize a memory entry into the frontmatter + body format.
|
||||
fn serialize_entry(entry: &MemoryEntry) -> String {
|
||||
let yaml = serde_yaml::to_string(&entry.frontmatter).unwrap_or_default();
|
||||
// serde_yaml adds a trailing newline; trim it for consistent formatting
|
||||
let yaml = yaml.trim_end();
|
||||
|
||||
format!(
|
||||
"{FRONTMATTER_DELIM}\n{yaml}\n{FRONTMATTER_DELIM}\n\n{}",
|
||||
entry.content
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filename generation (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a safe filename from an entry's frontmatter.
|
||||
///
|
||||
/// Format: `<type>_<sanitized_name>.md`
|
||||
/// Falls back to `memory_<hash>.md` if name is empty.
|
||||
fn generate_filename(fm: &MemoryFrontmatter) -> String {
|
||||
let type_prefix = fm
|
||||
.memory_type
|
||||
.map(|t| t.as_str().to_owned())
|
||||
.unwrap_or_else(|| "memory".to_owned());
|
||||
|
||||
let name_part = fm
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.map(sanitize_filename)
|
||||
.filter(|s| !s.is_empty()) // pure non-ASCII names sanitize to empty
|
||||
.unwrap_or_else(|| {
|
||||
// Use a simple hash of the current time as fallback
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("{now:x}")
|
||||
});
|
||||
|
||||
format!("{type_prefix}_{name_part}.md")
|
||||
}
|
||||
|
||||
/// Sanitize a string for use as part of a filename.
|
||||
///
|
||||
/// Converts to lowercase, replaces non-alphanumeric chars with underscores,
|
||||
/// collapses consecutive underscores, and trims leading/trailing underscores.
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
let sanitized: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Collapse consecutive underscores
|
||||
let mut result = String::with_capacity(sanitized.len());
|
||||
let mut prev_underscore = false;
|
||||
for c in sanitized.chars() {
|
||||
if c == '_' {
|
||||
if !prev_underscore {
|
||||
result.push(c);
|
||||
}
|
||||
prev_underscore = true;
|
||||
} else {
|
||||
result.push(c);
|
||||
prev_underscore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim leading/trailing underscores
|
||||
result.trim_matches('_').to_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory traversal (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collect all `.md` files in a directory (recursive), excluding MEMORY.md.
|
||||
fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
collect_md_files_recursive(dir, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_md_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
collect_md_files_recursive(&path, files)?;
|
||||
} else if is_scannable_md(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a path is a scannable `.md` file (not MEMORY.md).
|
||||
fn is_scannable_md(path: &Path) -> bool {
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
if ext != Some("md") {
|
||||
return false;
|
||||
}
|
||||
let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||
filename != ENTRYPOINT_NAME
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header extraction (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a file's first N lines and metadata to produce a header.
|
||||
///
|
||||
/// Returns `None` if the file cannot be read (silently drops failures).
|
||||
fn read_header(path: &Path) -> Option<MemoryHeader> {
|
||||
let file = fs::File::open(path).ok()?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
|
||||
let mut first_lines = String::new();
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
if i >= FRONTMATTER_MAX_LINES {
|
||||
break;
|
||||
}
|
||||
let line = line.ok()?;
|
||||
first_lines.push_str(&line);
|
||||
first_lines.push('\n');
|
||||
}
|
||||
|
||||
let (fm, _) = parse_frontmatter(&first_lines, None);
|
||||
let mtime = file_mtime(path)?;
|
||||
let filename = path.file_name()?.to_string_lossy().into_owned();
|
||||
|
||||
Some(MemoryHeader {
|
||||
filename,
|
||||
file_path: path.to_owned(),
|
||||
mtime,
|
||||
description: fm.description,
|
||||
memory_type: fm.memory_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a file's modification time as UTC datetime.
|
||||
fn file_mtime(path: &Path) -> Option<DateTime<Utc>> {
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
let modified = metadata.modified().ok()?;
|
||||
let duration = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Utc.timestamp_opt(duration.as_secs() as i64, duration.subsec_nanos())
|
||||
.single()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Unit tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::MemoryError;
|
||||
use crate::types::MemoryType;
|
||||
|
||||
// -- parse_frontmatter ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_full_frontmatter() {
|
||||
let raw = "---\nname: test\ndescription: a test\ntype: feedback\n---\nBody content";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm.name.as_deref(), Some("test"));
|
||||
assert_eq!(fm.description.as_deref(), Some("a test"));
|
||||
assert_eq!(fm.memory_type, Some(MemoryType::Feedback));
|
||||
assert_eq!(body, "Body content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_frontmatter() {
|
||||
let raw = "Just plain text\nNo frontmatter here";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm, MemoryFrontmatter::default());
|
||||
assert_eq!(body, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_content() {
|
||||
let (fm, body) = parse_frontmatter("", None);
|
||||
assert_eq!(fm, MemoryFrontmatter::default());
|
||||
assert_eq!(body, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_only_opening_delimiter() {
|
||||
let raw = "---\nname: orphan\nno closing delimiter";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm, MemoryFrontmatter::default());
|
||||
assert_eq!(body, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_partial_frontmatter_fields() {
|
||||
let raw = "---\nname: partial\n---\nBody";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm.name.as_deref(), Some("partial"));
|
||||
assert_eq!(fm.description, None);
|
||||
assert_eq!(fm.memory_type, None);
|
||||
assert_eq!(body, "Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_with_leading_whitespace() {
|
||||
let raw = " \n---\nname: spaced\n---\nContent";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm.name.as_deref(), Some("spaced"));
|
||||
assert_eq!(body, "Content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_yaml_degrades_gracefully() {
|
||||
// YAML with invalid structure — should return default frontmatter
|
||||
let raw = "---\n: :\n :\n---\nBody after bad yaml";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm, MemoryFrontmatter::default());
|
||||
assert_eq!(body, "Body after bad yaml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_body_newline_handling() {
|
||||
let raw = "---\nname: test\n---\n\nParagraph one\n\nParagraph two";
|
||||
let (fm, body) = parse_frontmatter(raw, None);
|
||||
assert_eq!(fm.name.as_deref(), Some("test"));
|
||||
// Body should start at first content line after delimiter
|
||||
assert_eq!(body, "Paragraph one\n\nParagraph two");
|
||||
}
|
||||
|
||||
// -- serialize_entry -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn serialize_and_parse_roundtrip() {
|
||||
let entry = MemoryEntry::build("role", "user role info", MemoryType::User, "I am a dev");
|
||||
let serialized = serialize_entry(&entry);
|
||||
let (fm, body) = parse_frontmatter(&serialized, None);
|
||||
assert_eq!(fm.name.as_deref(), Some("role"));
|
||||
assert_eq!(fm.description.as_deref(), Some("user role info"));
|
||||
assert_eq!(fm.memory_type, Some(MemoryType::User));
|
||||
assert_eq!(body, "I am a dev");
|
||||
}
|
||||
|
||||
// -- generate_filename ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn filename_with_type_and_name() {
|
||||
let fm = MemoryFrontmatter {
|
||||
name: Some("My Role".into()),
|
||||
description: None,
|
||||
memory_type: Some(MemoryType::User),
|
||||
..Default::default()
|
||||
};
|
||||
let name = generate_filename(&fm);
|
||||
assert_eq!(name, "user_my_role.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_without_type() {
|
||||
let fm = MemoryFrontmatter {
|
||||
name: Some("notes".into()),
|
||||
description: None,
|
||||
memory_type: None,
|
||||
..Default::default()
|
||||
};
|
||||
let name = generate_filename(&fm);
|
||||
assert_eq!(name, "memory_notes.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_without_name() {
|
||||
let fm = MemoryFrontmatter {
|
||||
name: None,
|
||||
description: None,
|
||||
memory_type: Some(MemoryType::Feedback),
|
||||
..Default::default()
|
||||
};
|
||||
let name = generate_filename(&fm);
|
||||
assert!(name.starts_with("feedback_"));
|
||||
assert!(name.ends_with(".md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_special_chars_sanitized() {
|
||||
let fm = MemoryFrontmatter {
|
||||
name: Some("Hello World! / Test: 123".into()),
|
||||
description: None,
|
||||
memory_type: Some(MemoryType::Project),
|
||||
..Default::default()
|
||||
};
|
||||
let name = generate_filename(&fm);
|
||||
assert_eq!(name, "project_hello_world_test_123.md");
|
||||
assert!(!name.contains(' '));
|
||||
assert!(!name.contains('/'));
|
||||
assert!(!name.contains('!'));
|
||||
}
|
||||
|
||||
// -- sanitize_filename ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sanitize_basic() {
|
||||
assert_eq!(sanitize_filename("Hello World"), "hello_world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_collapses_underscores() {
|
||||
assert_eq!(sanitize_filename("a---b___c"), "a_b_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_trims_underscores() {
|
||||
assert_eq!(sanitize_filename("__test__"), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_alphanumeric() {
|
||||
assert_eq!(sanitize_filename("abc123"), "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_pure_non_ascii_returns_empty() {
|
||||
assert_eq!(sanitize_filename("我的角色"), "");
|
||||
assert_eq!(sanitize_filename("全角文本"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_pure_non_ascii_name_falls_back_to_hash() {
|
||||
let fm1 = MemoryFrontmatter {
|
||||
name: Some("我的角色".into()),
|
||||
description: None,
|
||||
memory_type: Some(MemoryType::User),
|
||||
..Default::default()
|
||||
};
|
||||
let fm2 = MemoryFrontmatter {
|
||||
name: Some("项目状态".into()),
|
||||
description: None,
|
||||
memory_type: Some(MemoryType::User),
|
||||
..Default::default()
|
||||
};
|
||||
let name1 = generate_filename(&fm1);
|
||||
let name2 = generate_filename(&fm2);
|
||||
// Both should get unique hash-based names, not collide
|
||||
assert!(name1.starts_with("user_"));
|
||||
assert!(name1.ends_with(".md"));
|
||||
assert_ne!(name1, "user_.md", "should not produce empty name part");
|
||||
// With time-based hash, names should differ (race possible but
|
||||
// extremely unlikely given nanos resolution)
|
||||
assert_ne!(name1, name2, "pure non-ASCII names should not collide");
|
||||
}
|
||||
|
||||
// -- is_scannable_md -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scannable_normal_md() {
|
||||
assert!(is_scannable_md(Path::new("/dir/user_role.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scannable_rejects_memory_md() {
|
||||
assert!(!is_scannable_md(Path::new("/dir/MEMORY.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scannable_rejects_non_md() {
|
||||
assert!(!is_scannable_md(Path::new("/dir/notes.txt")));
|
||||
assert!(!is_scannable_md(Path::new("/dir/data.json")));
|
||||
}
|
||||
|
||||
// -- format_memory_manifest ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn manifest_with_full_headers() {
|
||||
let headers = vec![MemoryHeader {
|
||||
filename: "user_role.md".into(),
|
||||
file_path: PathBuf::from("/mem/user_role.md"),
|
||||
mtime: Utc.with_ymd_and_hms(2026, 4, 10, 12, 0, 0).unwrap(),
|
||||
description: Some("User role info".into()),
|
||||
memory_type: Some(MemoryType::User),
|
||||
}];
|
||||
let manifest = format_memory_manifest(&headers);
|
||||
assert_eq!(
|
||||
manifest,
|
||||
"- [user] user_role.md (2026-04-10T12:00:00): User role info"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_without_type_and_description() {
|
||||
let headers = vec![MemoryHeader {
|
||||
filename: "notes.md".into(),
|
||||
file_path: PathBuf::from("/mem/notes.md"),
|
||||
mtime: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
|
||||
description: None,
|
||||
memory_type: None,
|
||||
}];
|
||||
let manifest = format_memory_manifest(&headers);
|
||||
assert_eq!(manifest, "- notes.md (2026-01-01T00:00:00)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_empty() {
|
||||
assert_eq!(format_memory_manifest(&[]), "");
|
||||
}
|
||||
|
||||
// -- file operations (using tempdir) -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn write_then_read_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("role", "my role", MemoryType::User, "I am a developer");
|
||||
|
||||
let path = write_memory(tmp.path(), &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(path.file_name().unwrap().to_str().unwrap(), "user_role.md");
|
||||
|
||||
let read_back = read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.name, entry.frontmatter.name);
|
||||
assert_eq!(
|
||||
read_back.frontmatter.description,
|
||||
entry.frontmatter.description
|
||||
);
|
||||
assert_eq!(
|
||||
read_back.frontmatter.memory_type,
|
||||
entry.frontmatter.memory_type
|
||||
);
|
||||
assert_eq!(read_back.content, entry.content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_existing_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("test.md");
|
||||
fs::write(&path, "content").unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
delete_memory(&path).unwrap();
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_file_errors() {
|
||||
let err = delete_memory(Path::new("/nonexistent/file.md")).unwrap_err();
|
||||
assert!(matches!(err, MemoryError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_excludes_memory_md_and_non_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create files
|
||||
fs::write(dir.join("user_role.md"), "---\ntype: user\n---\nBody").unwrap();
|
||||
fs::write(dir.join("MEMORY.md"), "# Index").unwrap();
|
||||
fs::write(dir.join("notes.txt"), "not markdown").unwrap();
|
||||
|
||||
let headers = scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].filename, "user_role.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_nonexistent_dir_returns_empty() {
|
||||
let headers = scan_memory_files(Path::new("/nonexistent/dir")).unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_empty_dir_returns_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let headers = scan_memory_files(tmp.path()).unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
// -- bump_memory_usage (citation reflow) ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn bump_usage_first_time_sets_count_and_last_used() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("role", "my role", MemoryType::User, "I am a developer");
|
||||
let path = write_memory(tmp.path(), &entry).unwrap();
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
let now = Utc.with_ymd_and_hms(2026, 6, 14, 9, 30, 0).unwrap();
|
||||
bump_memory_usage(tmp.path(), filename, now).unwrap();
|
||||
|
||||
let read_back = read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.usage_count, Some(1));
|
||||
assert_eq!(read_back.frontmatter.last_used, Some(now));
|
||||
// Body preserved verbatim.
|
||||
assert_eq!(read_back.content, "I am a developer");
|
||||
// Original metadata preserved.
|
||||
assert_eq!(read_back.frontmatter.name.as_deref(), Some("role"));
|
||||
assert_eq!(read_back.frontmatter.memory_type, Some(MemoryType::User));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bump_usage_accumulates() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("topic", "desc", MemoryType::Project, "body text");
|
||||
let path = write_memory(tmp.path(), &entry).unwrap();
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
let t1 = Utc.with_ymd_and_hms(2026, 6, 14, 9, 0, 0).unwrap();
|
||||
let t2 = Utc.with_ymd_and_hms(2026, 6, 14, 10, 0, 0).unwrap();
|
||||
let t3 = Utc.with_ymd_and_hms(2026, 6, 14, 11, 0, 0).unwrap();
|
||||
bump_memory_usage(tmp.path(), filename, t1).unwrap();
|
||||
bump_memory_usage(tmp.path(), filename, t2).unwrap();
|
||||
bump_memory_usage(tmp.path(), filename, t3).unwrap();
|
||||
|
||||
let read_back = read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.usage_count, Some(3));
|
||||
assert_eq!(read_back.frontmatter.last_used, Some(t3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bump_usage_missing_file_is_noop() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// No file written; must not error.
|
||||
let now = Utc.with_ymd_and_hms(2026, 6, 14, 9, 30, 0).unwrap();
|
||||
bump_memory_usage(tmp.path(), "user_absent.md", now).unwrap();
|
||||
assert!(!tmp.path().join("user_absent.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bump_usage_preserves_multiline_body() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let body = "Line one\n\nLine two\n- bullet";
|
||||
let entry = MemoryEntry::build("notes", "desc", MemoryType::Reference, body);
|
||||
let path = write_memory(tmp.path(), &entry).unwrap();
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
let now = Utc.with_ymd_and_hms(2026, 6, 14, 9, 30, 0).unwrap();
|
||||
bump_memory_usage(tmp.path(), filename, now).unwrap();
|
||||
|
||||
let read_back = read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.content, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The four fixed memory categories.
|
||||
///
|
||||
/// - `User`: role, goals, responsibilities, knowledge
|
||||
/// - `Feedback`: corrections and confirmations on work approach
|
||||
/// - `Project`: ongoing work context not derivable from code/git
|
||||
/// - `Reference`: pointers to external systems
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MemoryType {
|
||||
User,
|
||||
Feedback,
|
||||
Project,
|
||||
Reference,
|
||||
}
|
||||
|
||||
impl MemoryType {
|
||||
/// All defined memory types.
|
||||
pub const ALL: [MemoryType; 4] = [
|
||||
MemoryType::User,
|
||||
MemoryType::Feedback,
|
||||
MemoryType::Project,
|
||||
MemoryType::Reference,
|
||||
];
|
||||
|
||||
/// Try to parse a string into a `MemoryType`, returning `None` for
|
||||
/// unrecognized values. This is intentionally lenient to handle
|
||||
/// legacy/hand-edited files.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// The lowercase string representation used in frontmatter and filenames.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MemoryType::User => "user",
|
||||
MemoryType::Feedback => "feedback",
|
||||
MemoryType::Project => "project",
|
||||
MemoryType::Reference => "reference",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MemoryType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MemoryType {
|
||||
type Err = ParseMemoryTypeError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"user" => Ok(MemoryType::User),
|
||||
"feedback" => Ok(MemoryType::Feedback),
|
||||
"project" => Ok(MemoryType::Project),
|
||||
"reference" => Ok(MemoryType::Reference),
|
||||
_ => Err(ParseMemoryTypeError(s.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when a string cannot be parsed into a [`MemoryType`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseMemoryTypeError(pub String);
|
||||
|
||||
impl fmt::Display for ParseMemoryTypeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "unknown memory type: {:?}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseMemoryTypeError {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// YAML frontmatter parsed from a memory file header.
|
||||
///
|
||||
/// All fields are optional to handle incomplete or legacy files gracefully.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemoryFrontmatter {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub memory_type: Option<MemoryType>,
|
||||
/// Citation reflow: how many times the model has cited this memory
|
||||
/// (absent in legacy files; treated as 0). Skipped on serialize when
|
||||
/// `None` so untouched memories keep their original frontmatter shape.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_count: Option<u64>,
|
||||
/// Citation reflow: the most recent UTC instant the model cited this
|
||||
/// memory (absent in legacy files). Skipped on serialize when `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header (lightweight metadata returned by directory scans)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lightweight metadata for a memory file, extracted without reading
|
||||
/// the full body. Used by directory scans and manifest formatting.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryHeader {
|
||||
/// Filename (without directory), e.g. `user_role.md`.
|
||||
pub filename: String,
|
||||
/// Full path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Last modification time.
|
||||
pub mtime: DateTime<Utc>,
|
||||
/// One-line description from frontmatter (may be absent).
|
||||
pub description: Option<String>,
|
||||
/// Memory type from frontmatter (may be absent).
|
||||
pub memory_type: Option<MemoryType>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry (full memory content)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A complete memory entry: metadata + body content.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryEntry {
|
||||
pub frontmatter: MemoryFrontmatter,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl MemoryEntry {
|
||||
/// Create a new entry with the given frontmatter and body content.
|
||||
pub fn new(frontmatter: MemoryFrontmatter, content: String) -> Self {
|
||||
Self {
|
||||
frontmatter,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor for a fully specified entry.
|
||||
pub fn build(
|
||||
name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
memory_type: MemoryType,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
frontmatter: MemoryFrontmatter {
|
||||
name: Some(name.into()),
|
||||
description: Some(description.into()),
|
||||
memory_type: Some(memory_type),
|
||||
..Default::default()
|
||||
},
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index truncation result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of truncating MEMORY.md content.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IndexTruncation {
|
||||
/// The (possibly truncated) content.
|
||||
pub content: String,
|
||||
/// Number of lines in the original (pre-truncation) content.
|
||||
pub line_count: usize,
|
||||
/// Byte count of the original (pre-truncation) content.
|
||||
pub byte_count: usize,
|
||||
/// Whether any truncation was applied.
|
||||
pub was_truncated: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
// -- MemoryType::parse --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_valid_types() {
|
||||
assert_eq!(MemoryType::parse("user"), Some(MemoryType::User));
|
||||
assert_eq!(MemoryType::parse("feedback"), Some(MemoryType::Feedback));
|
||||
assert_eq!(MemoryType::parse("project"), Some(MemoryType::Project));
|
||||
assert_eq!(MemoryType::parse("reference"), Some(MemoryType::Reference));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_returns_none() {
|
||||
assert_eq!(MemoryType::parse("invalid"), None);
|
||||
assert_eq!(MemoryType::parse(""), None);
|
||||
assert_eq!(MemoryType::parse("User"), None); // case-sensitive
|
||||
assert_eq!(MemoryType::parse("USER"), None);
|
||||
assert_eq!(MemoryType::parse("Feedback"), None);
|
||||
}
|
||||
|
||||
// -- Display + FromStr roundtrip ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn display_roundtrip() {
|
||||
for ty in MemoryType::ALL {
|
||||
let s = ty.to_string();
|
||||
let parsed: MemoryType = s.parse().unwrap();
|
||||
assert_eq!(parsed, ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_is_lowercase() {
|
||||
assert_eq!(MemoryType::User.to_string(), "user");
|
||||
assert_eq!(MemoryType::Feedback.to_string(), "feedback");
|
||||
assert_eq!(MemoryType::Project.to_string(), "project");
|
||||
assert_eq!(MemoryType::Reference.to_string(), "reference");
|
||||
}
|
||||
|
||||
// -- Serde roundtrip ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn serde_yaml_roundtrip() {
|
||||
for ty in MemoryType::ALL {
|
||||
let yaml = serde_yaml::to_string(&ty).unwrap();
|
||||
let parsed: MemoryType = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(parsed, ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_yaml_serializes_lowercase() {
|
||||
let yaml = serde_yaml::to_string(&MemoryType::User).unwrap();
|
||||
assert_eq!(yaml.trim(), "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_yaml_rejects_uppercase() {
|
||||
let result: Result<MemoryType, _> = serde_yaml::from_str("User");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- MemoryFrontmatter --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn frontmatter_deserialize_full() {
|
||||
let yaml = "name: test\ndescription: a test\ntype: feedback\n";
|
||||
let fm: MemoryFrontmatter = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(fm.name.as_deref(), Some("test"));
|
||||
assert_eq!(fm.description.as_deref(), Some("a test"));
|
||||
assert_eq!(fm.memory_type, Some(MemoryType::Feedback));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_deserialize_partial() {
|
||||
let yaml = "name: partial\n";
|
||||
let fm: MemoryFrontmatter = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(fm.name.as_deref(), Some("partial"));
|
||||
assert_eq!(fm.description, None);
|
||||
assert_eq!(fm.memory_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_deserialize_empty() {
|
||||
let fm: MemoryFrontmatter = serde_yaml::from_str("{}").unwrap();
|
||||
assert_eq!(fm, MemoryFrontmatter::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_serialize_roundtrip() {
|
||||
let fm = MemoryFrontmatter {
|
||||
name: Some("my memory".into()),
|
||||
description: Some("desc".into()),
|
||||
memory_type: Some(MemoryType::Project),
|
||||
..Default::default()
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&fm).unwrap();
|
||||
let parsed: MemoryFrontmatter = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(parsed, fm);
|
||||
}
|
||||
|
||||
// -- usage_count / last_used (citation reflow fields) -------------------
|
||||
|
||||
#[test]
|
||||
fn frontmatter_omits_usage_fields_when_absent() {
|
||||
// A freshly-built memory has no usage stats; serialize must not emit
|
||||
// the keys, so untouched memory files keep their original shape.
|
||||
let fm = MemoryFrontmatter {
|
||||
name: Some("role".into()),
|
||||
description: Some("desc".into()),
|
||||
memory_type: Some(MemoryType::User),
|
||||
..Default::default()
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&fm).unwrap();
|
||||
assert!(!yaml.contains("usage_count"), "yaml: {yaml}");
|
||||
assert!(!yaml.contains("last_used"), "yaml: {yaml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_legacy_file_defaults_usage_fields_to_none() {
|
||||
// Legacy frontmatter without the new keys must still deserialize.
|
||||
let yaml = "name: legacy\ndescription: old\ntype: project\n";
|
||||
let fm: MemoryFrontmatter = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(fm.usage_count, None);
|
||||
assert_eq!(fm.last_used, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_with_usage_fields_roundtrips() {
|
||||
let now = Utc.with_ymd_and_hms(2026, 6, 14, 9, 30, 0).unwrap();
|
||||
let fm = MemoryFrontmatter {
|
||||
name: Some("role".into()),
|
||||
description: Some("desc".into()),
|
||||
memory_type: Some(MemoryType::User),
|
||||
usage_count: Some(3),
|
||||
last_used: Some(now),
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&fm).unwrap();
|
||||
assert!(yaml.contains("usage_count"));
|
||||
assert!(yaml.contains("last_used"));
|
||||
let parsed: MemoryFrontmatter = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(parsed, fm);
|
||||
}
|
||||
|
||||
// -- MemoryEntry --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn entry_build_convenience() {
|
||||
let entry = MemoryEntry::build("name", "desc", MemoryType::User, "body");
|
||||
assert_eq!(entry.frontmatter.name.as_deref(), Some("name"));
|
||||
assert_eq!(entry.frontmatter.description.as_deref(), Some("desc"));
|
||||
assert_eq!(entry.frontmatter.memory_type, Some(MemoryType::User));
|
||||
assert_eq!(entry.content, "body");
|
||||
}
|
||||
|
||||
// -- MemoryType::ALL covers all variants --------------------------------
|
||||
|
||||
#[test]
|
||||
fn all_constant_is_exhaustive() {
|
||||
assert_eq!(MemoryType::ALL.len(), 4);
|
||||
// Ensure no duplicates
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for ty in MemoryType::ALL {
|
||||
assert!(seen.insert(ty), "duplicate in ALL: {ty}");
|
||||
}
|
||||
}
|
||||
|
||||
// -- ParseMemoryTypeError -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_error_displays_value() {
|
||||
let err = ParseMemoryTypeError("bad".into());
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("bad"), "error should mention the bad value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// End-to-end integration tests for the memory system (TC-8).
|
||||
//
|
||||
// These tests exercise the full memory lifecycle across multiple modules,
|
||||
// verifying that all components work together correctly.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use nomi_memory::index;
|
||||
use nomi_memory::paths;
|
||||
use nomi_memory::prompt::build_memory_prompt;
|
||||
use nomi_memory::store;
|
||||
use nomi_memory::types::{MemoryEntry, MemoryType};
|
||||
|
||||
// ===========================================================================
|
||||
// TC-8.1: Complete memory lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_8_1_complete_memory_lifecycle() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
|
||||
// 1. Ensure memory directory exists
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
assert!(mem_dir.is_dir());
|
||||
|
||||
// 2. Write a feedback-type memory
|
||||
let entry = MemoryEntry::build(
|
||||
"test policy",
|
||||
"integration tests must hit real DB",
|
||||
MemoryType::Feedback,
|
||||
"Never mock the database in integration tests.\n\n\
|
||||
**Why:** mocked tests once passed but prod migration failed.\n\n\
|
||||
**How to apply:** use testcontainers for all DB tests.",
|
||||
);
|
||||
let written_path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(written_path.exists());
|
||||
|
||||
// 3. Append index entry to MEMORY.md
|
||||
let index_path = paths::memory_entrypoint(&mem_dir);
|
||||
let filename = written_path.file_name().unwrap().to_str().unwrap();
|
||||
index::append_index_entry(
|
||||
&index_path,
|
||||
"Test Policy",
|
||||
filename,
|
||||
"integration tests must hit real DB",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 4. Scan directory — the memory should appear
|
||||
let headers = store::scan_memory_files(&mem_dir).unwrap();
|
||||
assert_eq!(
|
||||
headers.len(),
|
||||
1,
|
||||
"should find exactly 1 memory file (MEMORY.md excluded)"
|
||||
);
|
||||
assert_eq!(headers[0].filename, filename);
|
||||
assert_eq!(headers[0].memory_type, Some(MemoryType::Feedback));
|
||||
assert_eq!(
|
||||
headers[0].description.as_deref(),
|
||||
Some("integration tests must hit real DB")
|
||||
);
|
||||
|
||||
// 5. Build prompt — should include MEMORY.md content
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
assert!(
|
||||
prompt.contains(filename),
|
||||
"prompt should reference the memory file"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("integration tests must hit real DB"),
|
||||
"prompt should contain the index summary"
|
||||
);
|
||||
|
||||
// 6. Read back the memory file — verify content integrity
|
||||
let read_back = store::read_memory(&written_path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.name.as_deref(), Some("test policy"));
|
||||
assert_eq!(
|
||||
read_back.frontmatter.memory_type,
|
||||
Some(MemoryType::Feedback)
|
||||
);
|
||||
assert!(read_back.content.contains("testcontainers"));
|
||||
|
||||
// 7. Delete the memory file
|
||||
store::delete_memory(&written_path).unwrap();
|
||||
assert!(!written_path.exists());
|
||||
|
||||
// 8. Re-scan — should be empty
|
||||
let headers_after = store::scan_memory_files(&mem_dir).unwrap();
|
||||
assert!(
|
||||
headers_after.is_empty(),
|
||||
"should find no memory files after deletion"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-8.2: Chinese content memory
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_8_2_chinese_content_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
|
||||
let entry = MemoryEntry::build(
|
||||
"用户角色",
|
||||
"资深后端工程师",
|
||||
MemoryType::User,
|
||||
"用户是一位有十年经验的后端工程师,熟悉 Rust 和 Go。\n\
|
||||
偏好函数式编程风格,不喜欢过度抽象。",
|
||||
);
|
||||
|
||||
// Write
|
||||
let path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
// Read back — Chinese content should be intact
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.name.as_deref(), Some("用户角色"));
|
||||
assert_eq!(
|
||||
read_back.frontmatter.description.as_deref(),
|
||||
Some("资深后端工程师")
|
||||
);
|
||||
assert_eq!(read_back.frontmatter.memory_type, Some(MemoryType::User));
|
||||
assert!(read_back.content.contains("十年经验"));
|
||||
assert!(read_back.content.contains("函数式编程"));
|
||||
|
||||
// Scan — header should preserve Chinese description
|
||||
let headers = store::scan_memory_files(&mem_dir).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].description.as_deref(), Some("资深后端工程师"));
|
||||
|
||||
// Index — append Chinese title and verify
|
||||
let index_path = paths::memory_entrypoint(&mem_dir);
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
index::append_index_entry(&index_path, "用户角色", filename, "资深后端工程师").unwrap();
|
||||
|
||||
let index_content = fs::read_to_string(&index_path).unwrap();
|
||||
assert!(index_content.contains("用户角色"));
|
||||
assert!(index_content.contains("资深后端工程师"));
|
||||
|
||||
// Prompt — should include Chinese index content
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
assert!(prompt.contains("用户角色"));
|
||||
assert!(prompt.contains("资深后端工程师"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-8.3: Special character handling
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_8_3_special_characters_in_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
|
||||
// Name with special characters: spaces, slashes, colons, emoji
|
||||
let entry = MemoryEntry::build(
|
||||
"My Role / Senior: 🚀",
|
||||
"role with special chars",
|
||||
MemoryType::User,
|
||||
"Body with special chars: <tag>, \"quotes\", 'apostrophes' & ampersands",
|
||||
);
|
||||
|
||||
let path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
|
||||
// Filename should be safe (no slashes, colons, etc.)
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
assert!(
|
||||
!filename.contains('/'),
|
||||
"filename should not contain slash: {filename}"
|
||||
);
|
||||
assert!(
|
||||
!filename.contains(':'),
|
||||
"filename should not contain colon: {filename}"
|
||||
);
|
||||
assert!(
|
||||
filename.ends_with(".md"),
|
||||
"filename should end with .md: {filename}"
|
||||
);
|
||||
|
||||
// Content should round-trip correctly
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
assert!(read_back.content.contains("<tag>"));
|
||||
assert!(read_back.content.contains("\"quotes\""));
|
||||
assert!(read_back.content.contains("& ampersands"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_8_3_name_with_only_special_chars() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
|
||||
// Edge case: name is entirely special characters / non-ASCII
|
||||
let entry = MemoryEntry::build(
|
||||
"🔥💡✨",
|
||||
"emoji only name",
|
||||
MemoryType::Feedback,
|
||||
"Some body",
|
||||
);
|
||||
|
||||
let path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
// Should still produce a valid filename (hash fallback)
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
assert!(filename.ends_with(".md"));
|
||||
|
||||
// Content should round-trip
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.content, "Some body");
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Integration tests for the MEMORY.md index management.
|
||||
//
|
||||
// These tests target functional requirements from test-plan.md TC-5,
|
||||
// treating the public API as a black box.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use nomi_memory::index;
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.1: Truncation — under limits, no truncation
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_1_no_truncation_under_limits() {
|
||||
let content = (0..100)
|
||||
.map(|i| format!("- [Memory {i}](mem_{i}.md) \u{2014} summary {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 100);
|
||||
assert!(result.byte_count > 0);
|
||||
// Content should be the same as input (trimmed)
|
||||
assert_eq!(result.content, content.trim());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.2: Truncation — exceeds line limit
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_2_line_truncation() {
|
||||
let content = (0..250)
|
||||
.map(|i| format!("- [Memory {i}](mem_{i}.md) \u{2014} summary {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 250);
|
||||
// Warning should mention line count
|
||||
assert!(result.content.contains("250 lines"));
|
||||
assert!(result.content.contains("WARNING"));
|
||||
|
||||
// Only first 200 lines should be present (before warning)
|
||||
let before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
let output_lines: Vec<&str> = before_warning.lines().collect();
|
||||
assert_eq!(output_lines.len(), 200);
|
||||
assert!(output_lines[0].contains("Memory 0"));
|
||||
assert!(output_lines[199].contains("Memory 199"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.3: Truncation — exceeds byte limit (lines within limit)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_3_byte_truncation() {
|
||||
// 100 lines of 300 chars each = 30000 bytes > 25000, but 100 < 200 lines
|
||||
let content = (0..100)
|
||||
.map(|i| format!("{i:03}: {}", "x".repeat(296)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 100);
|
||||
// Warning should mention byte size and "too long"
|
||||
assert!(result.content.contains("index entries are too long"));
|
||||
assert!(result.content.contains("KB"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.4: Truncation — both line and byte limits exceeded
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_4_both_limits() {
|
||||
// 300 lines of 200 bytes each = 60000 bytes; both limits exceeded
|
||||
let content = (0..300)
|
||||
.map(|i| format!("{i:03}: {}", "y".repeat(196)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 300);
|
||||
// Warning should mention both
|
||||
assert!(result.content.contains("300 lines"));
|
||||
assert!(result.content.contains("KB"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.5: Truncation — empty content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_5_empty_content() {
|
||||
let result = index::truncate_index("");
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 0);
|
||||
assert_eq!(result.byte_count, 0);
|
||||
assert_eq!(result.content, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.6: Truncation — whitespace-only content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_6_whitespace_only() {
|
||||
let result = index::truncate_index(" \n \n ");
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.content, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.7: Truncation — exactly at line boundary (200 lines)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_7_exactly_200_lines() {
|
||||
let content = (0..200)
|
||||
.map(|i| format!("- line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 200);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.8: Truncation — exactly at byte boundary (25000 bytes)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_8_exactly_25000_bytes() {
|
||||
// 100 lines under 200 limit, totalling exactly 25000 bytes
|
||||
let per_line = (index::MAX_INDEX_BYTES - 99) / 100;
|
||||
let remainder = index::MAX_INDEX_BYTES - 99 - per_line * 100;
|
||||
let mut lines: Vec<String> = (0..100).map(|_| "x".repeat(per_line)).collect();
|
||||
if remainder > 0 {
|
||||
lines.last_mut().unwrap().push_str(&"x".repeat(remainder));
|
||||
}
|
||||
let content = lines.join("\n");
|
||||
assert_eq!(content.len(), index::MAX_INDEX_BYTES);
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.9: Truncation — single long line (no newline to cut at)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_9_single_long_line() {
|
||||
let content = "z".repeat(30_000);
|
||||
let result = index::truncate_index(&content);
|
||||
|
||||
assert!(result.was_truncated);
|
||||
// Should truncate at MAX_INDEX_BYTES since there's no newline
|
||||
let before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
assert_eq!(before_warning.len(), index::MAX_INDEX_BYTES);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.10: Read index — file doesn't exist
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_10_read_nonexistent() {
|
||||
let result = index::read_index(Path::new("/nonexistent/MEMORY.md"));
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.11: Read index — file exists with content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_11_read_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
let content = "# Index\n- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n";
|
||||
fs::write(&path, content).unwrap();
|
||||
|
||||
let result = index::read_index(&path);
|
||||
assert_eq!(result, content);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.12: Read index — empty file
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_12_read_empty_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(&path, "").unwrap();
|
||||
|
||||
let result = index::read_index(&path);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.13: Append entry — to existing content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_13_append_to_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
index::append_index_entry(&path, "My Memory", "my_memory.md", "a test").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[2], "- [My Memory](my_memory.md) \u{2014} a test");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.14: Append entry — file doesn't exist (auto-create)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_14_append_auto_create() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
assert!(!path.exists());
|
||||
|
||||
index::append_index_entry(&path, "First", "first.md", "the first entry").unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "- [First](first.md) \u{2014} the first entry\n");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.15: Append multiple entries sequentially
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_15_append_multiple() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
|
||||
index::append_index_entry(&path, "A", "a.md", "first").unwrap();
|
||||
index::append_index_entry(&path, "B", "b.md", "second").unwrap();
|
||||
index::append_index_entry(&path, "C", "c.md", "third").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].contains("[A]"));
|
||||
assert!(lines[1].contains("[B]"));
|
||||
assert!(lines[2].contains("[C]"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.16: Remove entry — by filename
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_16_remove_by_filename() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"- [A](a.md) \u{2014} first\n- [B](old_memory.md) \u{2014} second\n- [C](c.md) \u{2014} third\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
index::remove_index_entry(&path, "old_memory.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains("[A](a.md)"));
|
||||
assert!(lines[1].contains("[C](c.md)"));
|
||||
// Removed entry should not be present
|
||||
assert!(!content.contains("old_memory.md"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.17: Remove entry — target not found
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_17_remove_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
let original =
|
||||
"- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n- [C](c.md) \u{2014} third\n";
|
||||
fs::write(&path, original).unwrap();
|
||||
|
||||
index::remove_index_entry(&path, "nonexistent.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.18: Remove entry — file doesn't exist
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_18_remove_from_nonexistent() {
|
||||
let path = Path::new("/nonexistent/MEMORY.md");
|
||||
// Should not error — idempotent
|
||||
index::remove_index_entry(path, "anything.md").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Integration tests for the memory path system.
|
||||
//
|
||||
// These tests target the functional requirements from test-plan.md TC-2,
|
||||
// treating the public API as a black box.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nomi_memory::paths;
|
||||
use serial_test::serial;
|
||||
|
||||
// -- TC-2.1: Default memory base directory ------------------------------------
|
||||
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_1_default_base_dir_uses_platform_config() {
|
||||
// Ensure env override is NOT set
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::remove_var(env_key()) };
|
||||
|
||||
let base = paths::memory_base_dir();
|
||||
// Should return Some (platform provides a config dir in CI/test envs)
|
||||
assert!(
|
||||
base.is_some(),
|
||||
"memory_base_dir should return Some on this platform"
|
||||
);
|
||||
let base = base.unwrap();
|
||||
// Should end with "nomi" (the brand, not "claude")
|
||||
assert!(
|
||||
base.to_string_lossy().contains("nomi"),
|
||||
"base dir should use nomi brand: {base:?}"
|
||||
);
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
// -- TC-2.2: Environment variable overrides base directory --------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_2_env_var_overrides_base_dir() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "/custom/memory/path") };
|
||||
|
||||
let base = paths::memory_base_dir();
|
||||
assert_eq!(base, Some(PathBuf::from("/custom/memory/path")));
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_2_env_var_overrides_base_dir() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "C:\\custom\\memory\\path") };
|
||||
|
||||
let base = paths::memory_base_dir();
|
||||
assert_eq!(base, Some(PathBuf::from("C:\\custom\\memory\\path")));
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
// -- TC-2.3: Project memory directory path ------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_3_auto_memory_dir_structure() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "/base") };
|
||||
|
||||
let dir = paths::auto_memory_dir(Path::new("/home/user/my-project"));
|
||||
assert!(dir.is_some());
|
||||
let dir = dir.unwrap();
|
||||
|
||||
// Should have the structure: <base>/projects/<sanitized>/memory
|
||||
let dir_str = dir.to_string_lossy();
|
||||
assert!(
|
||||
dir_str.starts_with("/base/projects/"),
|
||||
"wrong prefix: {dir_str}"
|
||||
);
|
||||
assert!(
|
||||
dir_str.ends_with("/memory"),
|
||||
"should end with /memory: {dir_str}"
|
||||
);
|
||||
|
||||
// Sanitized name should not contain `/` (the original separator)
|
||||
let sanitized = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
!sanitized.contains('/'),
|
||||
"sanitized name should not contain /: {sanitized}"
|
||||
);
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_3_auto_memory_dir_structure() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "C:\\base") };
|
||||
|
||||
let dir = paths::auto_memory_dir(Path::new("C:\\Users\\user\\my-project"));
|
||||
assert!(dir.is_some());
|
||||
let dir = dir.unwrap();
|
||||
|
||||
let dir_str = dir.to_string_lossy();
|
||||
assert!(
|
||||
dir_str.starts_with("C:\\base\\projects\\"),
|
||||
"wrong prefix: {dir_str}"
|
||||
);
|
||||
assert!(
|
||||
dir_str.ends_with("\\memory"),
|
||||
"should end with \\memory: {dir_str}"
|
||||
);
|
||||
|
||||
let sanitized = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
!sanitized.contains('\\'),
|
||||
"sanitized name should not contain \\: {sanitized}"
|
||||
);
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
// -- TC-2.4: Reject relative path ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_4_reject_relative_path() {
|
||||
let result = paths::validate_memory_path(Path::new("relative/path"));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("absolute"),
|
||||
"error should mention 'absolute': {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.5: Reject null byte -------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tc_2_5_reject_null_byte() {
|
||||
let bad_path = PathBuf::from("/tmp/test\0evil");
|
||||
let result = paths::validate_memory_path(&bad_path);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("null"),
|
||||
"error should mention null: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tc_2_5_reject_null_byte() {
|
||||
let bad_path = PathBuf::from("C:\\tmp\\test\0evil");
|
||||
let result = paths::validate_memory_path(&bad_path);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("null"),
|
||||
"error should mention null: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.6: Reject path traversal --------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tc_2_6_reject_traversal() {
|
||||
let result = paths::validate_memory_path(Path::new("/tmp/../../../etc/passwd"));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("traversal"),
|
||||
"error should mention traversal: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tc_2_6_reject_traversal() {
|
||||
let result = paths::validate_memory_path(Path::new("C:\\tmp\\..\\..\\..\\etc\\passwd"));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("traversal"),
|
||||
"error should mention traversal: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.7: Memory entrypoint path -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_7_entrypoint_path() {
|
||||
// memory_entrypoint just appends MEMORY.md — no absolute path requirement,
|
||||
// so a platform-neutral relative path works fine here.
|
||||
let dir = Path::new("path").join("to").join("memory");
|
||||
let ep = paths::memory_entrypoint(&dir);
|
||||
assert_eq!(ep, dir.join("MEMORY.md"));
|
||||
}
|
||||
|
||||
// -- TC-2.8: Path membership positive -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_8_is_memory_path_inside() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let file = mem_dir.join("user_role.md");
|
||||
fs::write(&file, "test").unwrap();
|
||||
|
||||
assert!(
|
||||
paths::is_memory_path(&file, &mem_dir),
|
||||
"file inside memory dir should be recognized"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.9: Path membership negative -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_9_is_memory_path_outside() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let outside = tmp.path().join("other_file.md");
|
||||
fs::write(&outside, "test").unwrap();
|
||||
|
||||
assert!(
|
||||
!paths::is_memory_path(&outside, &mem_dir),
|
||||
"file outside memory dir should not be recognized"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.10: Ensure directory exists -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_10_ensure_dir_creates_and_is_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let deep = tmp.path().join("a").join("b").join("c").join("memory");
|
||||
|
||||
// Does not exist yet
|
||||
assert!(!deep.exists());
|
||||
|
||||
// First call creates it
|
||||
paths::ensure_memory_dir(&deep).unwrap();
|
||||
assert!(deep.is_dir());
|
||||
|
||||
// Second call is idempotent
|
||||
paths::ensure_memory_dir(&deep).unwrap();
|
||||
assert!(deep.is_dir());
|
||||
}
|
||||
|
||||
// -- Additional edge cases from test-plan TC-2 --------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_accepts_valid_absolute_path() {
|
||||
let result = paths::validate_memory_path(Path::new("/tmp/memory/test.md"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_accepts_valid_absolute_path() {
|
||||
let result = paths::validate_memory_path(Path::new("C:\\tmp\\memory\\test.md"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_rejects_root_path() {
|
||||
let result = paths::validate_memory_path(Path::new("/"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_rejects_root_path() {
|
||||
let result = paths::validate_memory_path(Path::new("C:\\"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_produces_deterministic_results() {
|
||||
let path = "/home/user/workspace/project";
|
||||
assert_eq!(paths::sanitize_path(path), paths::sanitize_path(path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_different_paths_produce_different_results() {
|
||||
let a = paths::sanitize_path("/home/alice/project");
|
||||
let b = paths::sanitize_path("/home/bob/project");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entrypoint_name_constant_is_memory_md() {
|
||||
assert_eq!(paths::ENTRYPOINT_NAME, "MEMORY.md");
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
fn env_key() -> &'static str {
|
||||
"NOMI_MEMORY_DIR"
|
||||
}
|
||||
|
||||
fn restore_env(saved: Option<String>) {
|
||||
// SAFETY: only called from #[serial(env)] tests.
|
||||
unsafe {
|
||||
match saved {
|
||||
Some(v) => std::env::set_var(env_key(), v),
|
||||
None => std::env::remove_var(env_key()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Integration tests for memory system prompt construction (TC-6).
|
||||
//
|
||||
// These are black-box tests that exercise the public API of the prompt
|
||||
// module against the functional requirements in test-plan.md.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use nomi_memory::prompt::{
|
||||
build_memory_instructions, build_memory_prompt, memory_type_descriptions,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.1: Complete prompt contains all required sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_1_prompt_contains_all_required_parts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
// Memory system introduction
|
||||
assert!(
|
||||
prompt.contains("persistent, file-based memory system"),
|
||||
"should contain memory system introduction"
|
||||
);
|
||||
|
||||
// 4 type definitions
|
||||
for ty in ["user", "feedback", "project", "reference"] {
|
||||
assert!(
|
||||
prompt.contains(&format!("<name>{ty}</name>")),
|
||||
"should contain type definition for: {ty}"
|
||||
);
|
||||
}
|
||||
|
||||
// What not to save
|
||||
assert!(
|
||||
prompt.contains("What NOT to save"),
|
||||
"should contain what-not-to-save section"
|
||||
);
|
||||
|
||||
// Save steps
|
||||
assert!(
|
||||
prompt.contains("How to save memories"),
|
||||
"should contain save instructions"
|
||||
);
|
||||
|
||||
// When to access
|
||||
assert!(
|
||||
prompt.contains("When to access memories"),
|
||||
"should contain access guidance"
|
||||
);
|
||||
|
||||
// MEMORY.md content or empty-state message
|
||||
assert!(
|
||||
prompt.contains("MEMORY.md"),
|
||||
"should reference MEMORY.md entrypoint"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.2: Prompt includes the memory directory path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_2_prompt_includes_memory_dir_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("my_project_memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains(&mem_dir.display().to_string()),
|
||||
"prompt should contain the memory directory path"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.3: With MEMORY.md present, prompt includes its content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_3_prompt_includes_memory_md_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let index_content = "- [User Role](user_role.md) \u{2014} senior engineer\n\
|
||||
- [Test Policy](feedback_tests.md) \u{2014} always use real DB\n";
|
||||
fs::write(mem_dir.join("MEMORY.md"), index_content).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("user_role.md"),
|
||||
"prompt should contain index entry references"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("senior engineer"),
|
||||
"prompt should contain index entry summaries"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("feedback_tests.md"),
|
||||
"prompt should contain all index entries"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.4: Without MEMORY.md, prompt shows empty-state message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_4_no_memory_md_shows_empty_message() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
// No MEMORY.md file created
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("currently empty"),
|
||||
"should indicate MEMORY.md is empty when file doesn't exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_4_empty_memory_md_shows_empty_message() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join("MEMORY.md"), "").unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("currently empty"),
|
||||
"should indicate MEMORY.md is empty when file is blank"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_4_whitespace_only_memory_md_shows_empty_message() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join("MEMORY.md"), " \n\n ").unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("currently empty"),
|
||||
"should indicate MEMORY.md is empty when file is whitespace-only"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.5: No bb brand identifiers in prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_5_no_bb_brand_in_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(
|
||||
mem_dir.join("MEMORY.md"),
|
||||
"- [Test](test.md) \u{2014} entry\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
!prompt.contains("~/.claude"),
|
||||
"prompt must not contain bb brand path ~/.claude"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("CLAUDE.md"),
|
||||
"prompt must not reference CLAUDE.md"
|
||||
);
|
||||
// Allow "claude" in lowercase only in non-brand contexts (e.g. general English).
|
||||
// The key check is no bb-specific identifiers.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_5_no_bb_brand_in_instructions() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
|
||||
assert!(!joined.contains("~/.claude"));
|
||||
assert!(!joined.contains("CLAUDE.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_5_no_bb_brand_in_type_descriptions() {
|
||||
let desc = memory_type_descriptions();
|
||||
|
||||
assert!(!desc.contains("~/.claude"));
|
||||
assert!(!desc.contains("CLAUDE.md"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.6: Paths use nomi brand, not hardcoded platform paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_6_no_hardcoded_platform_paths() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
|
||||
// Should not contain hardcoded Unix-specific config paths
|
||||
assert!(
|
||||
!joined.contains("~/.config/nomi"),
|
||||
"should not hardcode platform-specific config path"
|
||||
);
|
||||
|
||||
// Path should come from the memory_dir argument, not hardcoded
|
||||
assert!(
|
||||
joined.contains("/test/memory"),
|
||||
"should use the provided memory_dir path"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional integration tests beyond TC-6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn instructions_are_well_structured_vec() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
|
||||
// Should be a non-empty vec
|
||||
assert!(!lines.is_empty());
|
||||
|
||||
// First line should be the title
|
||||
assert!(lines[0].starts_with("# "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_large_index_includes_truncation_warning() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
// Create 250-line index (exceeds 200-line limit)
|
||||
let content: String = (0..250)
|
||||
.map(|i| format!("- [Item {i}](item_{i}.md) \u{2014} summary for item {i}\n"))
|
||||
.collect();
|
||||
fs::write(mem_dir.join("MEMORY.md"), &content).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("WARNING"),
|
||||
"should include truncation warning for large index"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("250 lines"),
|
||||
"warning should mention original line count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_descriptions_standalone() {
|
||||
let desc = memory_type_descriptions();
|
||||
|
||||
assert!(desc.contains("<types>"));
|
||||
assert!(desc.contains("</types>"));
|
||||
|
||||
// All four types present
|
||||
for ty in ["user", "feedback", "project", "reference"] {
|
||||
assert!(
|
||||
desc.contains(&format!("<name>{ty}</name>")),
|
||||
"type_descriptions should include: {ty}"
|
||||
);
|
||||
}
|
||||
|
||||
// Each type has description and examples
|
||||
assert!(desc.contains("<description>"));
|
||||
assert!(desc.contains("<examples>"));
|
||||
assert!(desc.contains("<when_to_save>"));
|
||||
assert!(desc.contains("<how_to_use>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_nonexistent_dir_succeeds() {
|
||||
// build_memory_prompt should not panic even if the directory doesn't exist
|
||||
// (read_index returns empty string for missing files)
|
||||
let result = build_memory_prompt(Path::new("/nonexistent/path/memory"));
|
||||
assert!(result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_sections_appear_in_correct_order() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join("MEMORY.md"), "- [A](a.md) \u{2014} test\n").unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
// Verify section ordering
|
||||
let positions = [
|
||||
("# auto memory", prompt.find("# auto memory")),
|
||||
("## Types of memory", prompt.find("## Types of memory")),
|
||||
("## What NOT to save", prompt.find("## What NOT to save")),
|
||||
("## How to save", prompt.find("## How to save")),
|
||||
("## When to access", prompt.find("## When to access")),
|
||||
(
|
||||
"## Before recommending",
|
||||
prompt.find("## Before recommending"),
|
||||
),
|
||||
(
|
||||
"## Memory and other forms",
|
||||
prompt.find("## Memory and other forms"),
|
||||
),
|
||||
("## MEMORY.md", prompt.find("## MEMORY.md")),
|
||||
];
|
||||
|
||||
for (name, pos) in &positions {
|
||||
assert!(pos.is_some(), "section missing: {name}");
|
||||
}
|
||||
|
||||
// Verify monotonically increasing positions
|
||||
let nums: Vec<usize> = positions.iter().map(|(_, p)| p.unwrap()).collect();
|
||||
for i in 1..nums.len() {
|
||||
assert!(
|
||||
nums[i] > nums[i - 1],
|
||||
"section '{}' should appear after '{}', but positions are {} vs {}",
|
||||
positions[i].0,
|
||||
positions[i - 1].0,
|
||||
nums[i],
|
||||
nums[i - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Integration tests for the memory store.
|
||||
//
|
||||
// These tests target functional requirements from test-plan.md TC-3 and TC-4,
|
||||
// treating the public API as a black box.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_memory::store;
|
||||
use nomi_memory::types::{MemoryEntry, MemoryFrontmatter, MemoryType};
|
||||
|
||||
// ===========================================================================
|
||||
// TC-3: Memory file read/write
|
||||
// ===========================================================================
|
||||
|
||||
// -- TC-3.1: Write then read full memory ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_1_write_then_read_full_memory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build(
|
||||
"test memory",
|
||||
"a test description",
|
||||
MemoryType::User,
|
||||
"Body content here",
|
||||
);
|
||||
|
||||
let path = store::write_memory(tmp.path(), &entry).unwrap();
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
|
||||
assert_eq!(read_back.frontmatter.name, entry.frontmatter.name);
|
||||
assert_eq!(
|
||||
read_back.frontmatter.description,
|
||||
entry.frontmatter.description
|
||||
);
|
||||
assert_eq!(
|
||||
read_back.frontmatter.memory_type,
|
||||
entry.frontmatter.memory_type
|
||||
);
|
||||
assert_eq!(read_back.content, entry.content);
|
||||
}
|
||||
|
||||
// -- TC-3.2: Read file with frontmatter -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_2_read_with_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("test.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"---\nname: test memory\ndescription: a test\ntype: feedback\n---\nBody content here",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
assert_eq!(entry.frontmatter.name.as_deref(), Some("test memory"));
|
||||
assert_eq!(entry.frontmatter.description.as_deref(), Some("a test"));
|
||||
assert_eq!(entry.frontmatter.memory_type, Some(MemoryType::Feedback));
|
||||
assert_eq!(entry.content, "Body content here");
|
||||
}
|
||||
|
||||
// -- TC-3.3: Read file without frontmatter ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_3_read_without_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("plain.md");
|
||||
fs::write(&path, "Just plain text").unwrap();
|
||||
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
assert_eq!(entry.frontmatter.name, None);
|
||||
assert_eq!(entry.frontmatter.description, None);
|
||||
assert_eq!(entry.frontmatter.memory_type, None);
|
||||
assert_eq!(entry.content, "Just plain text");
|
||||
}
|
||||
|
||||
// -- TC-3.4: Read empty file ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_4_read_empty_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("empty.md");
|
||||
fs::write(&path, "").unwrap();
|
||||
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
assert_eq!(entry.frontmatter, MemoryFrontmatter::default());
|
||||
assert_eq!(entry.content, "");
|
||||
}
|
||||
|
||||
// -- TC-3.5: Read incomplete frontmatter ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_5_read_incomplete_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("incomplete.md");
|
||||
fs::write(&path, "---\nname: orphan\nno closing delimiter").unwrap();
|
||||
|
||||
// Should not panic, should degrade gracefully
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
// Entire content treated as body since frontmatter is incomplete
|
||||
assert_eq!(entry.frontmatter, MemoryFrontmatter::default());
|
||||
assert!(entry.content.contains("orphan"));
|
||||
}
|
||||
|
||||
// -- TC-3.6: Delete existing memory file ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_6_delete_existing_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("to delete", "desc", MemoryType::Feedback, "content");
|
||||
let path = store::write_memory(tmp.path(), &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
store::delete_memory(&path).unwrap();
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
// -- TC-3.7: Delete non-existent file returns error -------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_7_delete_nonexistent_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("nonexistent.md");
|
||||
|
||||
let result = store::delete_memory(&path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- TC-3.8: Written filename format ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_8_filename_format() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("My Role", "desc", MemoryType::User, "content");
|
||||
|
||||
let path = store::write_memory(tmp.path(), &entry).unwrap();
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
// Should be lowercase, safe characters
|
||||
assert_eq!(filename, "user_my_role.md");
|
||||
assert!(
|
||||
filename
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-4: Directory scanning
|
||||
// ===========================================================================
|
||||
|
||||
// -- TC-4.1: Scan directory with multiple memory files ----------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_1_scan_multiple_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create 3 memory files + MEMORY.md
|
||||
fs::write(
|
||||
dir.join("user_role.md"),
|
||||
"---\ntype: user\ndescription: role\n---\nBody",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("feedback_testing.md"),
|
||||
"---\ntype: feedback\n---\nBody",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("project_status.md"),
|
||||
"---\ntype: project\n---\nBody",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.join("MEMORY.md"), "# Index\n- [role](user_role.md)").unwrap();
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
|
||||
// MEMORY.md should be excluded
|
||||
assert_eq!(headers.len(), 3);
|
||||
let filenames: Vec<&str> = headers.iter().map(|h| h.filename.as_str()).collect();
|
||||
assert!(!filenames.contains(&"MEMORY.md"));
|
||||
}
|
||||
|
||||
// -- TC-4.2: Scan empty directory -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_2_scan_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let headers = store::scan_memory_files(tmp.path()).unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
// -- TC-4.3: Scan non-existent directory ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_3_scan_nonexistent_dir() {
|
||||
let headers = store::scan_memory_files(Path::new("/nonexistent/dir")).unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
// -- TC-4.4: Sort by modification time (newest first) -----------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_4_sort_by_mtime() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Write files with small delays to ensure different mtimes
|
||||
fs::write(dir.join("old.md"), "---\nname: old\n---\nOld").unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
fs::write(dir.join("mid.md"), "---\nname: mid\n---\nMid").unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
fs::write(dir.join("new.md"), "---\nname: new\n---\nNew").unwrap();
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 3);
|
||||
|
||||
// Newest first
|
||||
assert_eq!(headers[0].filename, "new.md");
|
||||
assert_eq!(headers[2].filename, "old.md");
|
||||
}
|
||||
|
||||
// -- TC-4.5: File count cap at 200 -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_5_file_count_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create 210 files
|
||||
for i in 0..210 {
|
||||
fs::write(
|
||||
dir.join(format!("mem_{i:03}.md")),
|
||||
format!("---\nname: mem{i}\n---\nBody {i}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 200);
|
||||
}
|
||||
|
||||
// -- TC-4.6: Non-.md files are ignored --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_6_non_md_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
fs::write(dir.join("memory.md"), "---\nname: valid\n---\nBody").unwrap();
|
||||
fs::write(dir.join("notes.txt"), "text file").unwrap();
|
||||
fs::write(dir.join("data.json"), "{}").unwrap();
|
||||
fs::write(dir.join("script.py"), "pass").unwrap();
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].filename, "memory.md");
|
||||
}
|
||||
|
||||
// -- TC-4.7: Format memory manifest -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_7_format_manifest() {
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
let headers = vec![
|
||||
nomi_memory::types::MemoryHeader {
|
||||
filename: "user_role.md".into(),
|
||||
file_path: "/mem/user_role.md".into(),
|
||||
mtime: Utc.with_ymd_and_hms(2026, 4, 10, 12, 0, 0).unwrap(),
|
||||
description: Some("User role info".into()),
|
||||
memory_type: Some(MemoryType::User),
|
||||
},
|
||||
nomi_memory::types::MemoryHeader {
|
||||
filename: "notes.md".into(),
|
||||
file_path: "/mem/notes.md".into(),
|
||||
mtime: Utc.with_ymd_and_hms(2026, 4, 9, 8, 0, 0).unwrap(),
|
||||
description: None,
|
||||
memory_type: None,
|
||||
},
|
||||
];
|
||||
|
||||
let manifest = store::format_memory_manifest(&headers);
|
||||
let lines: Vec<&str> = manifest.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
// First: has type and description
|
||||
assert!(lines[0].contains("[user]"));
|
||||
assert!(lines[0].contains("user_role.md"));
|
||||
assert!(lines[0].contains("User role info"));
|
||||
// Second: no type, no description
|
||||
assert!(!lines[1].contains("["));
|
||||
assert!(lines[1].contains("notes.md"));
|
||||
}
|
||||
Reference in New Issue
Block a user