diff --git a/native/native-host-rust/src/context_packet.rs b/native/native-host-rust/src/context_packet.rs index 9d76549..3842ce3 100644 --- a/native/native-host-rust/src/context_packet.rs +++ b/native/native-host-rust/src/context_packet.rs @@ -1,11 +1,14 @@ use anyhow::{Context, Result}; use chrono::{SecondsFormat, Utc}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; use crate::protocol::{SourceContextResult, SourceContextWarning, SummaryProviderSettings}; @@ -16,6 +19,11 @@ const RELATIONS_FILE_NAME: &str = "relations.jsonl"; const EVIDENCE_MAP_FILE_NAME: &str = "evidence-map.json"; const AGENT_BRIEF_FILE_NAME: &str = "agent-brief.md"; const PROJECT_CONTEXT_FILE_NAME: &str = "project-context.md"; +const PROJECTS_DIRECTORY_NAME: &str = "projects"; +const SOURCE_INDEX_FILE_NAME: &str = "source-index.jsonl"; +const DECISIONS_FILE_NAME: &str = "decisions.jsonl"; +const OPEN_QUESTIONS_FILE_NAME: &str = "open-questions.jsonl"; +const PROJECT_MEMORY_LOCK_DIRECTORY_NAME: &str = ".project-memory.lock"; const SOURCE_RECORD_TYPES: &[&str] = &[ "meeting", @@ -78,6 +86,18 @@ pub struct ContextPacketPaths { pub project_context: PathBuf, } +#[derive(Debug, Clone, PartialEq)] +pub struct ProjectMemoryPaths { + pub project_directory: PathBuf, + pub source_index: PathBuf, + pub memory_objects: PathBuf, + pub relations: PathBuf, + pub evidence_map: PathBuf, + pub decisions: PathBuf, + pub open_questions: PathBuf, + pub project_context: PathBuf, +} + pub fn context_packet_paths(bundle_directory: &Path) -> ContextPacketPaths { let context_directory = bundle_directory.join(CONTEXT_DIRECTORY_NAME); ContextPacketPaths { @@ -91,6 +111,20 @@ pub fn context_packet_paths(bundle_directory: &Path) -> ContextPacketPaths { } } +fn project_memory_paths(meetings_root: &Path, project_id: &str) -> ProjectMemoryPaths { + let project_directory = meetings_root.join(PROJECTS_DIRECTORY_NAME).join(project_id); + ProjectMemoryPaths { + source_index: project_directory.join(SOURCE_INDEX_FILE_NAME), + memory_objects: project_directory.join(MEMORY_OBJECTS_FILE_NAME), + relations: project_directory.join(RELATIONS_FILE_NAME), + evidence_map: project_directory.join(EVIDENCE_MAP_FILE_NAME), + decisions: project_directory.join(DECISIONS_FILE_NAME), + open_questions: project_directory.join(OPEN_QUESTIONS_FILE_NAME), + project_context: project_directory.join(PROJECT_CONTEXT_FILE_NAME), + project_directory, + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TranscriptSegmentInput { @@ -180,7 +214,7 @@ pub fn read_bundle_inputs(bundle_directory: &Path) -> Result { }) } -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SourceRecord { pub id: String, @@ -476,7 +510,10 @@ fn request_chat_completion( body["response_format"] = format; } - let url = format!("{}/chat/completions", settings.base_url.trim_end_matches('/')); + let url = format!( + "{}/chat/completions", + settings.base_url.trim_end_matches('/') + ); let token = if settings.api_key.trim().is_empty() { "local" } else { @@ -487,9 +524,11 @@ fn request_chat_completion( .set("Authorization", &format!("Bearer {token}")) .send_json(body) .map_err(provider_error)?; - let payload: Value = response - .into_json() - .map_err(|error| provider_error(format!("failed to parse summary provider response: {error}")))?; + let payload: Value = response.into_json().map_err(|error| { + provider_error(format!( + "failed to parse summary provider response: {error}" + )) + })?; payload["choices"][0]["message"]["content"] .as_str() .map(|content| content.to_string()) @@ -593,7 +632,7 @@ pub struct SourceLocation { pub session_id: Option, } -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct MemoryObject { pub id: String, @@ -608,7 +647,7 @@ pub struct MemoryObject { pub source_refs: Vec, } -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Relation { pub id: String, @@ -672,7 +711,10 @@ pub fn validate_context_packet_result( if !object_types.contains(object.object_type.as_str()) { continue; } - let id = non_empty_string(object.id, &format!("{}:{}", object.object_type, objects.len() + 1)); + let id = non_empty_string( + object.id, + &format!("{}:{}", object.object_type, objects.len() + 1), + ); let source_refs = validate_source_refs( &object.source_refs, &source_records_by_id, @@ -695,7 +737,9 @@ pub fn validate_context_packet_result( object_type: object.object_type, title: non_empty_string(object.title, &id), body: object.body.unwrap_or_default(), - status: object.status.filter(|status| statuses.contains(status.as_str())), + status: object + .status + .filter(|status| statuses.contains(status.as_str())), confidence: clamp_confidence(object.confidence), evidence_coverage: object .evidence_coverage @@ -709,7 +753,10 @@ pub fn validate_context_packet_result( .iter() .map(|object| object.id.clone()) .collect::>(); - let mut endpoint_ids = source_records_by_id.keys().cloned().collect::>(); + let mut endpoint_ids = source_records_by_id + .keys() + .cloned() + .collect::>(); endpoint_ids.extend(object_ids); let mut relations = Vec::new(); @@ -717,10 +764,7 @@ pub fn validate_context_packet_result( if !relation_types.contains(relation.relation_type.as_str()) { continue; } - let id = non_empty_string( - relation.id, - &format!("relation:{}", relations.len() + 1), - ); + let id = non_empty_string(relation.id, &format!("relation:{}", relations.len() + 1)); let source_refs = validate_source_refs( &relation.source_refs, &source_records_by_id, @@ -740,7 +784,11 @@ pub fn validate_context_packet_result( } let from_id = relation.from_id.unwrap_or_default().trim().to_string(); let to_id = relation.to_id.unwrap_or_default().trim().to_string(); - if from_id.is_empty() || to_id.is_empty() || !endpoint_ids.contains(&from_id) || !endpoint_ids.contains(&to_id) { + if from_id.is_empty() + || to_id.is_empty() + || !endpoint_ids.contains(&from_id) + || !endpoint_ids.contains(&to_id) + { warnings.push(warning( "invalid_relation_ref", Some(id), @@ -802,7 +850,10 @@ fn validate_source_refs( "invalid_source_ref", Some(owner_id.to_string()), Some(owner_kind.to_string()), - Some(format!("invalid transcript locationKind={}", source_ref.location.kind)), + Some(format!( + "invalid transcript locationKind={}", + source_ref.location.kind + )), )); continue; } @@ -811,7 +862,10 @@ fn validate_source_refs( "invalid_source_ref", Some(owner_id.to_string()), Some(owner_kind.to_string()), - Some(format!("transcript_segment on sourceType={}", record.source_type)), + Some(format!( + "transcript_segment on sourceType={}", + record.source_type + )), )); continue; } @@ -939,7 +993,10 @@ fn evidence_label(source_ref: &SourceRef) -> String { return markdown_inline(&format!("{}#{}", source_ref.source_id, segment_id)); } } - markdown_inline(&format!("{}#{}", source_ref.source_id, source_ref.location.kind)) + markdown_inline(&format!( + "{}#{}", + source_ref.source_id, source_ref.location.kind + )) } fn render_object_list(objects: &[&MemoryObject]) -> String { @@ -1111,8 +1168,15 @@ pub fn render_project_context(packet: &ContextPacket) -> String { .objects .iter() .filter(|object| { - !["constraint", "preference", "requirement", "task", "commitment", "open_question"] - .contains(&object.object_type.as_str()) + ![ + "constraint", + "preference", + "requirement", + "task", + "commitment", + "open_question", + ] + .contains(&object.object_type.as_str()) }) .collect::>(); let source_records = if packet.source_records.is_empty() { @@ -1196,7 +1260,10 @@ pub fn write_context_packet_files( ) -> Result { let paths = context_packet_paths(bundle_directory); fs::create_dir_all(&paths.context_directory)?; - fs::write(&paths.source_records, serialize_jsonl(&packet.source_records)?)?; + fs::write( + &paths.source_records, + serialize_jsonl(&packet.source_records)?, + )?; fs::write(&paths.memory_objects, serialize_jsonl(&packet.objects)?)?; fs::write(&paths.relations, serialize_jsonl(&packet.relations)?)?; fs::write( @@ -1208,6 +1275,360 @@ pub fn write_context_packet_files( Ok(paths) } +fn read_jsonl_file(path: &Path) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let file = File::open(path).with_context(|| format!("failed to read {}", path.display()))?; + let mut rows = Vec::new(); + for (index, line) in BufReader::new(file).lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + rows.push( + serde_json::from_str::(&line).with_context(|| { + format!("failed to parse {} line {}", path.display(), index + 1) + })?, + ); + } + Ok(rows) +} + +struct ProjectMemoryLock { + path: PathBuf, +} + +impl ProjectMemoryLock { + fn acquire(project_directory: &Path) -> Result { + let path = project_directory.join(PROJECT_MEMORY_LOCK_DIRECTORY_NAME); + for _ in 0..250 { + match fs::create_dir(&path) { + Ok(()) => return Ok(Self { path }), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + thread::sleep(Duration::from_millis(20)); + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to acquire {}", path.display())); + } + } + } + anyhow::bail!("timed out acquiring {}", path.display()); + } +} + +impl Drop for ProjectMemoryLock { + fn drop(&mut self) { + let _ = fs::remove_dir(&self.path); + } +} + +fn atomic_write_text(path: &Path, content: impl AsRef<[u8]>) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("{} has no parent directory", path.display()))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .with_context(|| format!("{} has no valid file name", path.display()))?; + let temp_path = parent.join(format!( + ".{file_name}.tmp-{}-{}", + std::process::id(), + uuid_like_timestamp() + )); + let write_result = (|| -> Result<()> { + let mut file = File::create(&temp_path) + .with_context(|| format!("failed to create {}", temp_path.display()))?; + file.write_all(content.as_ref()) + .with_context(|| format!("failed to write {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", temp_path.display()))?; + fs::rename(&temp_path, path) + .with_context(|| format!("failed to replace {}", path.display()))?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + write_result +} + +fn project_id_for_packet(packet: &ContextPacket) -> String { + let hint = packet + .source_records + .iter() + .find_map(|record| record.project_hint.as_deref()) + .filter(|hint| !hint.trim().is_empty()) + .unwrap_or("inbox"); + slugify_project_id(hint) +} + +fn slugify_project_id(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in value.chars().flat_map(|ch| ch.to_lowercase()) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + while slug.ends_with('-') { + slug.pop(); + } + if slug.is_empty() { + "inbox".to_string() + } else { + slug + } +} + +fn namespace_id(primary_source_id: &str, id: &str) -> String { + if id.starts_with(primary_source_id) { + id.to_string() + } else { + format!("{primary_source_id}::{id}") + } +} + +fn project_object(packet: &ContextPacket, object: &MemoryObject) -> MemoryObject { + let primary = packet + .primary_source_id + .as_deref() + .unwrap_or(&packet.packet_id); + let mut object = object.clone(); + object.id = namespace_id(primary, &object.id); + object +} + +fn project_relation(packet: &ContextPacket, relation: &Relation) -> Relation { + let primary = packet + .primary_source_id + .as_deref() + .unwrap_or(&packet.packet_id); + let source_ids = packet + .source_records + .iter() + .map(|record| record.id.as_str()) + .collect::>(); + let mut relation = relation.clone(); + relation.id = namespace_id(primary, &relation.id); + if !source_ids.contains(relation.from_id.as_str()) { + relation.from_id = namespace_id(primary, &relation.from_id); + } + if !source_ids.contains(relation.to_id.as_str()) { + relation.to_id = namespace_id(primary, &relation.to_id); + } + relation +} + +fn merge_by_id(existing: Vec, incoming: Vec, id: F) -> Vec +where + F: Fn(&T) -> &str, +{ + let mut rows = BTreeMap::::new(); + for row in existing.into_iter().chain(incoming) { + rows.insert(id(&row).to_string(), row); + } + rows.into_values().collect() +} + +fn packet_primary_id(packet: &ContextPacket) -> &str { + packet + .primary_source_id + .as_deref() + .unwrap_or(&packet.packet_id) +} + +fn is_packet_source_record(record: &SourceRecord, primary_source_id: &str) -> bool { + record.id == primary_source_id || record.parent_source_id.as_deref() == Some(primary_source_id) +} + +fn is_packet_scoped_id(id: &str, primary_source_id: &str) -> bool { + id == primary_source_id + || id + .strip_prefix(primary_source_id) + .map(|suffix| suffix.starts_with("::")) + .unwrap_or(false) +} + +fn is_packet_object(object: &MemoryObject, primary_source_id: &str) -> bool { + is_packet_scoped_id(&object.id, primary_source_id) +} + +fn is_packet_relation(relation: &Relation, primary_source_id: &str) -> bool { + is_packet_scoped_id(&relation.id, primary_source_id) + || is_packet_scoped_id(&relation.from_id, primary_source_id) + || is_packet_scoped_id(&relation.to_id, primary_source_id) +} + +fn render_project_memory_context( + project_title: &str, + objects: &[MemoryObject], + source_records: &[SourceRecord], +) -> String { + let mut decisions = Vec::new(); + let mut tasks = Vec::new(); + let mut open_questions = Vec::new(); + let mut risks = Vec::new(); + let mut context = Vec::new(); + for object in objects { + match object.object_type.as_str() { + "decision" => decisions.push(object), + "task" | "commitment" => tasks.push(object), + "open_question" => open_questions.push(object), + "risk" => risks.push(object), + _ => context.push(object), + } + } + let source_index = if source_records.is_empty() { + "- None\n".to_string() + } else { + format!( + "{}\n", + source_records + .iter() + .map(|record| { + format!( + "- {} ({}): {}", + markdown_inline(&record.id), + markdown_inline(&record.source_type), + markdown_inline(&record.title) + ) + }) + .collect::>() + .join("\n") + ) + }; + [ + format!("# Project Context: {}", markdown_inline(project_title)), + String::new(), + "This file is generated from canonical MirrorNote project memory files.".to_string(), + String::new(), + "## Decisions".to_string(), + render_object_list(&decisions), + String::new(), + "## Follow-up Work".to_string(), + render_object_list(&tasks), + String::new(), + "## Open Questions".to_string(), + render_object_list(&open_questions), + String::new(), + "## Risks".to_string(), + render_object_list(&risks), + String::new(), + "## Additional Context".to_string(), + render_object_list(&context), + String::new(), + "## Source Index".to_string(), + source_index, + ] + .join("\n") +} + +fn build_project_evidence_map( + project_id: &str, + objects: &[MemoryObject], + relations: &[Relation], +) -> Value { + let objects = objects + .iter() + .map(|object| (object.id.clone(), json!(object.source_refs))) + .collect::>(); + let relations = relations + .iter() + .map(|relation| (relation.id.clone(), json!(relation.source_refs))) + .collect::>(); + json!({ + "schemaVersion": 1, + "projectId": project_id, + "generatedAt": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + "objects": objects, + "relations": relations, + }) +} + +pub fn write_project_memory_files( + meetings_root: &Path, + packet: &ContextPacket, +) -> Result { + let project_id = project_id_for_packet(packet); + let project_title = packet + .source_records + .iter() + .find_map(|record| record.project_hint.as_deref()) + .filter(|hint| !hint.trim().is_empty()) + .unwrap_or("Inbox") + .to_string(); + let paths = project_memory_paths(meetings_root, &project_id); + fs::create_dir_all(&paths.project_directory)?; + let _lock = ProjectMemoryLock::acquire(&paths.project_directory)?; + let primary_source_id = packet_primary_id(packet); + + let source_records = merge_by_id( + read_jsonl_file::(&paths.source_index)? + .into_iter() + .filter(|record| !is_packet_source_record(record, primary_source_id)) + .collect::>(), + packet.source_records.clone(), + |record| record.id.as_str(), + ); + let objects = merge_by_id( + read_jsonl_file::(&paths.memory_objects)? + .into_iter() + .filter(|object| !is_packet_object(object, primary_source_id)) + .collect::>(), + packet + .objects + .iter() + .map(|object| project_object(packet, object)) + .collect::>(), + |object| object.id.as_str(), + ); + let relations = merge_by_id( + read_jsonl_file::(&paths.relations)? + .into_iter() + .filter(|relation| !is_packet_relation(relation, primary_source_id)) + .collect::>(), + packet + .relations + .iter() + .map(|relation| project_relation(packet, relation)) + .collect::>(), + |relation| relation.id.as_str(), + ); + let decisions = objects + .iter() + .filter(|object| object.object_type == "decision") + .collect::>(); + let open_questions = objects + .iter() + .filter(|object| object.object_type == "open_question") + .collect::>(); + + atomic_write_text(&paths.source_index, serialize_jsonl(&source_records)?)?; + atomic_write_text(&paths.memory_objects, serialize_jsonl(&objects)?)?; + atomic_write_text(&paths.relations, serialize_jsonl(&relations)?)?; + atomic_write_text(&paths.decisions, serialize_jsonl(&decisions)?)?; + atomic_write_text(&paths.open_questions, serialize_jsonl(&open_questions)?)?; + atomic_write_text( + &paths.evidence_map, + serde_json::to_string_pretty(&build_project_evidence_map( + &project_id, + &objects, + &relations, + ))?, + )?; + atomic_write_text( + &paths.project_context, + render_project_memory_context(&project_title, &objects, &source_records), + )?; + Ok(paths) +} + pub trait ChatProvider { fn request( &self, @@ -1287,12 +1708,9 @@ pub fn generate_source_context_with_provider( &source_records, &inputs.transcript_segments, )?; - validation.warnings.push(warning( - "provider_repair_used", - None, - None, - None, - )); + validation + .warnings + .push(warning("provider_repair_used", None, None, None)); validation } }; @@ -1307,6 +1725,9 @@ pub fn generate_source_context_with_provider( } let packet = assemble_context_packet(&inputs, source_records, validation)?; let paths = write_context_packet_files(bundle_directory, &packet)?; + if let Some(meetings_root) = bundle_directory.parent() { + write_project_memory_files(meetings_root, &packet)?; + } Ok(SourceContextResult::from_packet_and_paths(&packet, &paths)) } diff --git a/native/native-host-rust/tests/context_packet_contract.rs b/native/native-host-rust/tests/context_packet_contract.rs index 92ff0e0..761e904 100644 --- a/native/native-host-rust/tests/context_packet_contract.rs +++ b/native/native-host-rust/tests/context_packet_contract.rs @@ -2,8 +2,8 @@ use anyhow::Result; use native_host_rust::context_packet::{ build_source_context_messages, context_packet_json_schema, context_packet_paths, generate_source_context_with_provider, read_bundle_inputs, redact_provider_error_text, - source_records_for_meeting_bundle, validate_context_packet_result, - write_context_packet_files, ChatProvider, ContextPacket, CONTEXT_DIRECTORY_NAME, + source_records_for_meeting_bundle, validate_context_packet_result, write_context_packet_files, + write_project_memory_files, ChatProvider, ContextPacket, CONTEXT_DIRECTORY_NAME, }; use native_host_rust::protocol::SummaryProviderSettings; use serde_json::Value; @@ -218,6 +218,167 @@ fn writes_empty_jsonl_outputs_without_blank_records() { assert_eq!(std::fs::read_to_string(paths.relations).unwrap(), ""); } +#[test] +fn project_memory_accumulates_meeting_packets_by_project_hint() { + let temp = tempdir().unwrap(); + let meetings_root = temp.path(); + let first_bundle = meetings_root.join("meeting-1"); + let second_bundle = meetings_root.join("meeting-2"); + std::fs::create_dir_all(&first_bundle).unwrap(); + std::fs::create_dir_all(&second_bundle).unwrap(); + + let first_packet = test_packet( + "packet-1", + "meeting:note-1", + "Project Sync", + "MirrorNote Agent Runtime", + "decision-1", + "Keep context generation automatic", + &first_bundle, + ); + let second_packet = test_packet( + "packet-2", + "meeting:note-2", + "Implementation Sync", + "MirrorNote Agent Runtime", + "task-1", + "Update project memory after each meeting", + &second_bundle, + ); + + let first_paths = write_project_memory_files(meetings_root, &first_packet).unwrap(); + let second_paths = write_project_memory_files(meetings_root, &second_packet).unwrap(); + + assert_eq!( + first_paths.project_directory, + second_paths.project_directory + ); + assert!(second_paths + .project_directory + .ends_with("projects/mirrornote-agent-runtime")); + + let source_index = std::fs::read_to_string(second_paths.source_index).unwrap(); + assert!(source_index.contains("\"id\":\"meeting:note-1\"")); + assert!(source_index.contains("\"id\":\"meeting:note-2\"")); + + let memory_objects = std::fs::read_to_string(second_paths.memory_objects).unwrap(); + assert!(memory_objects.contains("\"id\":\"meeting:note-1::decision-1\"")); + assert!(memory_objects.contains("\"id\":\"meeting:note-2::task-1\"")); + + assert!(second_paths.relations.exists()); + assert!(second_paths.decisions.exists()); + assert!(second_paths.open_questions.exists()); + let decisions = std::fs::read_to_string(second_paths.decisions).unwrap(); + assert!(decisions.contains("\"id\":\"meeting:note-1::decision-1\"")); + let open_questions = std::fs::read_to_string(second_paths.open_questions).unwrap(); + assert_eq!(open_questions, ""); + let evidence_map = std::fs::read_to_string(second_paths.evidence_map).unwrap(); + assert!(evidence_map.contains("\"projectId\": \"mirrornote-agent-runtime\"")); + assert!(evidence_map.contains("meeting:note-1::decision-1")); + assert!(evidence_map.contains("meeting:note-2::task-1")); + + let project_context = std::fs::read_to_string(second_paths.project_context).unwrap(); + assert!(project_context.contains("# Project Context: MirrorNote Agent Runtime")); + assert!(project_context.contains("Keep context generation automatic")); + assert!(project_context.contains("Update project memory after each meeting")); + assert!(project_context + .contains("This file is generated from canonical MirrorNote project memory files.")); +} + +#[test] +fn project_memory_uses_inbox_when_project_hint_is_missing() { + let temp = tempdir().unwrap(); + let meetings_root = temp.path(); + let bundle = meetings_root.join("meeting-1"); + std::fs::create_dir_all(&bundle).unwrap(); + let mut packet = test_packet( + "packet-1", + "meeting:note-1", + "Unassigned Sync", + "MirrorNote Agent Runtime", + "decision-1", + "Keep unassigned meetings in inbox", + &bundle, + ); + for record in &mut packet.source_records { + record.project_hint = None; + } + + let paths = write_project_memory_files(meetings_root, &packet).unwrap(); + + assert!(paths.project_directory.ends_with("projects/inbox")); + assert!(std::fs::read_to_string(paths.project_context) + .unwrap() + .contains("# Project Context: Inbox")); +} + +#[test] +fn project_memory_replaces_prior_entries_for_same_meeting() { + let temp = tempdir().unwrap(); + let meetings_root = temp.path(); + let bundle = meetings_root.join("meeting-1"); + std::fs::create_dir_all(&bundle).unwrap(); + + let first_packet = test_packet( + "packet-1", + "meeting:note-1", + "Project Sync", + "MirrorNote Agent Runtime", + "decision-1", + "Remove stale decisions on regeneration", + &bundle, + ); + let second_packet = test_packet( + "packet-2", + "meeting:note-1", + "Project Sync", + "MirrorNote Agent Runtime", + "task-1", + "Keep only latest regenerated objects", + &bundle, + ); + + write_project_memory_files(meetings_root, &first_packet).unwrap(); + let paths = write_project_memory_files(meetings_root, &second_packet).unwrap(); + + let memory_objects = std::fs::read_to_string(paths.memory_objects).unwrap(); + assert!(!memory_objects.contains("meeting:note-1::decision-1")); + assert!(memory_objects.contains("meeting:note-1::task-1")); + + let project_context = std::fs::read_to_string(paths.project_context).unwrap(); + assert!(!project_context.contains("Remove stale decisions on regeneration")); + assert!(project_context.contains("Keep only latest regenerated objects")); +} + +#[test] +fn project_memory_write_cleans_temporary_lock_artifacts() { + let temp = tempdir().unwrap(); + let meetings_root = temp.path(); + let bundle = meetings_root.join("meeting-1"); + std::fs::create_dir_all(&bundle).unwrap(); + let packet = test_packet( + "packet-1", + "meeting:note-1", + "Project Sync", + "MirrorNote Agent Runtime", + "decision-1", + "Write project memory atomically", + &bundle, + ); + + let paths = write_project_memory_files(meetings_root, &packet).unwrap(); + + let leftovers = std::fs::read_dir(&paths.project_directory) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".tmp-") || name == ".project-memory.lock") + .collect::>(); + assert_eq!(leftovers, Vec::::new()); + assert!(std::fs::read_to_string(paths.project_context) + .unwrap() + .contains("Write project memory atomically")); +} + struct FakeProvider; impl ChatProvider for FakeProvider { @@ -252,19 +413,30 @@ impl ChatProvider for FakeProvider { #[test] fn generates_source_context_files_with_fake_provider() { let temp = tempdir().unwrap(); - let bundle = temp.path(); - write_minimal_bundle(bundle, "Rust owns source context."); + let meetings_root = temp.path(); + let bundle = meetings_root.join("meeting-1"); + std::fs::create_dir_all(&bundle).unwrap(); + write_minimal_bundle_with_project( + &bundle, + "Rust owns source context.", + Some("MirrorNote Agent Runtime"), + ); let settings = test_settings(); - let result = generate_source_context_with_provider(bundle, &settings, &FakeProvider).unwrap(); + let result = generate_source_context_with_provider(&bundle, &settings, &FakeProvider).unwrap(); assert_eq!(result.object_count, 1); assert!(bundle.join("context/source-records.jsonl").exists()); assert!(bundle.join("context/memory-objects.jsonl").exists()); assert!(bundle.join("context/evidence-map.json").exists()); - assert!(std::fs::read_to_string(bundle.join("context/agent-brief.md")) - .unwrap() - .contains("Rust owns source context")); + assert!( + std::fs::read_to_string(bundle.join("context/agent-brief.md")) + .unwrap() + .contains("Rust owns source context") + ); + assert!(meetings_root + .join("projects/mirrornote-agent-runtime/project-context.md") + .exists()); } struct RepairingFakeProvider { @@ -344,10 +516,23 @@ fn repairs_provider_validation_failures_once() { } fn write_minimal_bundle(bundle: &std::path::Path, transcript_text: &str) { + write_minimal_bundle_with_project(bundle, transcript_text, None); +} + +fn write_minimal_bundle_with_project( + bundle: &std::path::Path, + transcript_text: &str, + project_hint: Option<&str>, +) { std::fs::write(bundle.join("note.md"), "# Weekly Sync\n").unwrap(); + let project_hint_json = project_hint + .map(|project_hint| format!(",\"projectHint\":\"{project_hint}\"")) + .unwrap_or_default(); std::fs::write( bundle.join("metadata.json"), - "{\"id\":\"note-123\",\"title\":\"Weekly Sync\",\"createdAt\":\"2026-04-29T01:00:00Z\"}", + format!( + "{{\"id\":\"note-123\",\"title\":\"Weekly Sync\",\"createdAt\":\"2026-04-29T01:00:00Z\"{project_hint_json}}}" + ), ) .unwrap(); std::fs::write( @@ -368,3 +553,93 @@ fn test_settings() -> SummaryProviderSettings { api_key: String::new(), } } + +fn test_packet( + packet_id: &str, + meeting_id: &str, + title: &str, + project_hint: &str, + object_id: &str, + object_title: &str, + bundle: &std::path::Path, +) -> ContextPacket { + let note_id = meeting_id.trim_start_matches("meeting:"); + let transcript_id = format!("transcript:{note_id}"); + ContextPacket { + schema_version: 1, + packet_id: packet_id.to_string(), + primary_source_id: Some(meeting_id.to_string()), + generated_at: "2026-05-05T01:00:00Z".to_string(), + title: title.to_string(), + source_records: vec![ + native_host_rust::context_packet::SourceRecord { + id: meeting_id.to_string(), + source_type: "meeting".to_string(), + title: title.to_string(), + observed_at: "2026-05-05T01:00:00Z".to_string(), + local_path: Some(bundle.to_string_lossy().into_owned()), + parent_source_id: None, + project_hint: Some(project_hint.to_string()), + }, + native_host_rust::context_packet::SourceRecord { + id: transcript_id.clone(), + source_type: "transcript".to_string(), + title: format!("{title} transcript"), + observed_at: "2026-05-05T01:00:00Z".to_string(), + local_path: Some( + bundle + .join("transcript.jsonl") + .to_string_lossy() + .into_owned(), + ), + parent_source_id: Some(meeting_id.to_string()), + project_hint: None, + }, + ], + objects: vec![native_host_rust::context_packet::MemoryObject { + id: object_id.to_string(), + object_type: if object_id.starts_with("task") { + "task".to_string() + } else { + "decision".to_string() + }, + title: object_title.to_string(), + body: object_title.to_string(), + status: Some("active".to_string()), + confidence: 0.95, + evidence_coverage: "direct".to_string(), + source_refs: vec![native_host_rust::context_packet::SourceRef { + source_id: transcript_id, + source_type: "transcript".to_string(), + location: native_host_rust::context_packet::SourceLocation { + kind: "transcript_segment".to_string(), + segment_id: Some("seg-1".to_string()), + line_start: None, + line_end: None, + path: None, + repo: None, + channel_id: None, + thread_ts: None, + message_ts: None, + start_time_seconds: None, + end_time_seconds: None, + page: None, + section: None, + number: None, + sha: None, + url: None, + comment_id: None, + issue_id: None, + doc_id: None, + block_id: None, + session_id: None, + }, + start_time_seconds: Some(0.0), + end_time_seconds: Some(3.0), + quote: Some(object_title.to_string()), + }], + }], + relations: vec![], + warnings: vec![], + } +}