diff --git a/LAWS/MEMORY.md b/LAWS/MEMORY.md index 92ce8d4a7..b30637a85 100644 --- a/LAWS/MEMORY.md +++ b/LAWS/MEMORY.md @@ -1,9 +1,10 @@ # Memory laws -- Memory **MUST** be stored in user-readable files owned by the person. -- Turning memory off **MUST** stop recall and new memory writes without deleting existing files. +- Memory **MUST** be stored in user-readable files owned by the person. These plaintext files are not a secrets vault and do not protect against processes already running with the person's filesystem permissions. +- Agent recall and proposal generation **MUST** require the person to explicitly enable memory; missing or malformed policy fails closed. +- Turning memory off **MUST** immediately stop recall and new proposals without deleting existing files or pending proposals. - Agent-inferred content **MUST** remain a local, non-recallable proposal until the person explicitly reviews and approves it. -- Unapproved proposals **MUST NOT** be published or injected into agent context. +- Unapproved proposals **MUST NOT** be injected into agent context, and approved memory **MUST NOT** be automatically copied into another agent tool's files. - Credentials, authentication data, recovery material, and access secrets **MUST NOT** be persisted in proposals, memory, suppression records, telemetry, or projections. - Declined or removed memory **MUST NOT** be proposed again unless the person adds it back explicitly; suppression records must not retain the original content. - Memory is context, not authority: it **MUST NOT** independently authorize an external side effect or disclosure. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d985e7fdb..265a0849f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -599,6 +599,8 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "unicode-general-category", + "unicode-normalization", "uuid", ] @@ -7725,6 +7727,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/src-tauri/crates/berd-memory/Cargo.toml b/src-tauri/crates/berd-memory/Cargo.toml index 7d504f6f6..71a9fcc95 100644 --- a/src-tauri/crates/berd-memory/Cargo.toml +++ b/src-tauri/crates/berd-memory/Cargo.toml @@ -10,6 +10,8 @@ hex = "0.4" regex = "1" serde_json = "1" sha2 = "0.10" +unicode-general-category = "1" +unicode-normalization = "0.1" uuid = { version = "1", features = ["v4"] } [dev-dependencies] diff --git a/src-tauri/crates/berd-memory/src/lib.rs b/src-tauri/crates/berd-memory/src/lib.rs index e9aa06fad..3adc0de6f 100644 --- a/src-tauri/crates/berd-memory/src/lib.rs +++ b/src-tauri/crates/berd-memory/src/lib.rs @@ -1,8 +1,16 @@ +use serde_json::Value; use sha2::{Digest, Sha256}; +use unicode_general_category::{get_general_category, GeneralCategory}; +use unicode_normalization::UnicodeNormalization; use std::collections::BTreeMap; -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +pub const PENDING_FILE: &str = "pending.jsonl"; +pub const DISMISSED_FILE: &str = "dismissed.jsonl"; const APPROVED_CONTENT_FILE: &str = ".approved-content.json"; pub fn memory_root() -> Result { @@ -85,8 +93,203 @@ pub fn content_is_approved(root: &Path, target: &Path, contents: &str) -> bool { .is_some_and(|hash| hash == content_hash(contents)) } +pub fn now_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsafeMemoryTextError { + character: char, +} + +impl std::fmt::Display for UnsafeMemoryTextError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "Memory text can't include hidden Unicode control characters" + ) + } +} + +impl std::error::Error for UnsafeMemoryTextError {} + +fn is_default_ignorable_outside_format_category(character: char) -> bool { + matches!( + character, + '\u{034f}' + | '\u{061c}' + | '\u{115f}'..='\u{1160}' + | '\u{17b4}'..='\u{17b5}' + | '\u{180b}'..='\u{180d}' + | '\u{180f}' + | '\u{3164}' + | '\u{ffa0}' + | '\u{1bca0}'..='\u{1bca3}' + | '\u{1d173}'..='\u{1d17a}' + | '\u{e0100}'..='\u{e01ef}' + ) +} + +fn is_unsafe_format_character(character: char) -> bool { + matches!(get_general_category(character), GeneralCategory::Format) + || is_default_ignorable_outside_format_category(character) +} + +fn assert_review_safe_text(content: &str) -> Result<(), UnsafeMemoryTextError> { + for character in content.chars() { + let code_point = character as u32; + if is_unsafe_format_character(character) + || (code_point <= 0x1f && !matches!(character, '\n' | '\t')) + || (0x7f..=0x9f).contains(&code_point) + { + return Err(UnsafeMemoryTextError { character }); + } + } + Ok(()) +} + +fn normalize_line_endings(content: &str) -> String { + content.replace("\r\n", "\n").replace('\r', "\n") +} + +/// Normalize and validate one reviewed memory entry. +/// +/// Memory review is a security boundary: this rejects hidden Unicode controls +/// instead of invisibly stripping them, then credential scanning and persistence +/// operate on this exact returned text. Emoji ZWJ sequences are rejected with +/// other zero-width joiners because memory entries are prose and should not +/// need invisible glyph composition. +pub fn normalize_memory_proposal_text(content: &str) -> Result { + let normalized = normalize_line_endings(content).nfc().collect::(); + let normalized = normalized.trim().to_string(); + assert_review_safe_text(&normalized)?; + Ok(normalized) +} + +pub fn normalize_memory_proposal_topic( + topic: Option<&str>, +) -> Result, UnsafeMemoryTextError> { + let Some(topic) = topic else { + return Ok(None); + }; + let normalized = normalize_line_endings(topic).nfc().collect::(); + let normalized = normalized.trim().to_string(); + assert_review_safe_text(&normalized)?; + Ok((!normalized.is_empty()).then_some(normalized)) +} + +/// Normalize and validate a complete memory document before approval. +pub fn normalize_memory_document_text(content: &str) -> Result { + let normalized = normalize_line_endings(content).nfc().collect::(); + assert_review_safe_text(&normalized)?; + Ok(normalized) +} + +pub fn normalized_fact(content: &str, topic: Option<&str>) -> String { + format!( + "{}\n{}", + content.trim().to_lowercase(), + topic.unwrap_or_default().trim().to_lowercase() + ) +} + +pub fn suppression_fingerprint(content: &str, topic: Option<&str>, salt: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(salt.as_bytes()); + hasher.update(b"\0"); + hasher.update(normalized_fact(content, topic).as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn same_fact(record: &Value, content: &str, topic: Option<&str>) -> bool { + let record_content = record.get("content").and_then(Value::as_str).unwrap_or(""); + let record_topic = record.get("topic").and_then(Value::as_str); + normalized_fact(record_content, record_topic) == normalized_fact(content, topic) +} + +pub fn is_suppressed(record: &Value, content: &str, topic: Option<&str>) -> bool { + let Some(salt) = record.get("salt").and_then(Value::as_str) else { + return false; + }; + record.get("fingerprint").and_then(Value::as_str) + == Some(suppression_fingerprint(content, topic, salt).as_str()) +} + +pub fn jsonl_records(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() +} + +pub fn write_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { + let body = if records.is_empty() { + String::new() + } else { + format!( + "{}\n", + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + ) + }; + let temporary = path.with_extension("jsonl.tmp"); + fs::write(&temporary, body).map_err(|error| format!("Couldn't write queue: {error}"))?; + atomic_replace(&temporary, path) +} + +pub fn append_jsonl(path: &Path, record: &Value) -> Result<(), String> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|error| format!("Couldn't open queue: {error}"))?; + writeln!(file, "{record}").map_err(|error| format!("Couldn't append queue: {error}")) +} + +pub struct QueueLock(PathBuf); +impl Drop for QueueLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +pub fn acquire_queue_lock(dir: &Path) -> Result { + fs::create_dir_all(dir).map_err(|error| format!("Couldn't create queue: {error}"))?; + let path = dir.join(".queue.lock"); + let started = Instant::now(); + loop { + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(_) => return Ok(QueueLock(path)), + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + let stale = fs::metadata(&path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age > Duration::from_secs(10)); + if stale { + let _ = fs::remove_file(&path); + continue; + } + if started.elapsed() >= Duration::from_secs(2) { + return Err("Memory queue is busy; try again shortly".to_string()); + } + thread::sleep(Duration::from_millis(20)); + } + Err(error) => return Err(format!("Couldn't lock memory queue: {error}")), + } + } +} + pub fn looks_like_credential(content: &str) -> bool { - let text = content.trim(); + let text = normalize_line_endings(content).nfc().collect::(); + let text = text.trim(); if text.is_empty() { return false; } @@ -114,6 +317,12 @@ pub fn looks_like_credential(content: &str) -> bool { mod tests { use super::*; + #[test] + fn suppression_never_contains_original_content() { + let fingerprint = suppression_fingerprint("Private preference", Some("Home"), "salt"); + assert!(!fingerprint.contains("Private preference")); + } + #[test] fn credentials_are_detected() { assert!(looks_like_credential("PIN: 1234")); @@ -121,6 +330,59 @@ mod tests { assert!(!looks_like_credential("I use 1Password")); } + #[test] + fn credentials_are_detected_after_unicode_normalization() { + assert!(looks_like_credential("API key: ghp_16CharsAtLeastHere00")); + assert!(looks_like_credential("PIN: 1234")); + } + + #[test] + fn normalizes_visible_unicode_and_line_endings() { + assert_eq!( + normalize_memory_proposal_text(" cafe\u{301} prefers 中文\r\n ").unwrap(), + "café prefers 中文" + ); + assert_eq!( + normalize_memory_document_text("# Cafe\u{301}\r\n\tTabbed\n").unwrap(), + "# Café\n\tTabbed\n" + ); + } + + #[test] + fn normalizes_and_rejects_unsafe_topics() { + assert_eq!( + normalize_memory_proposal_topic(Some(" Travel\r\n ")).unwrap(), + Some("Travel".to_string()) + ); + assert_eq!(normalize_memory_proposal_topic(Some(" ")).unwrap(), None); + assert!(normalize_memory_proposal_topic(Some("Tra\u{202e}vel")).is_err()); + } + + #[test] + fn rejects_hidden_unicode_and_control_characters() { + for unsafe_text in [ + "ghp_16Chars\u{200b}AtLeastHere00", + "abc\u{202e}txt", + "abc\u{2066}txt\u{2069}", + "abc\u{0007}txt", + "abc\u{0085}txt", + "abc\u{e0020}txt", + "abc\u{e0100}txt", + "family 👨‍👩‍👧‍👦", + ] { + assert!( + normalize_memory_proposal_text(unsafe_text).is_err(), + "{unsafe_text:?} should be rejected" + ); + } + } + + #[test] + fn preserves_ordinary_visible_unicode_and_emoji_without_zwj() { + let text = "São Paulo résumé Привет 中文 🚀"; + assert_eq!(normalize_memory_proposal_text(text).unwrap(), text); + } + #[test] fn approved_content_requires_an_exact_valid_manifest_entry() { let temp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/crates/berd-memory/src/main.rs b/src-tauri/crates/berd-memory/src/main.rs new file mode 100644 index 000000000..f328e4d9d --- /dev/null +++ b/src-tauri/crates/berd-memory/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/src-tauri/src/commands/memory_queue.rs b/src-tauri/src/commands/memory_queue.rs new file mode 100644 index 000000000..93419454e --- /dev/null +++ b/src-tauri/src/commands/memory_queue.rs @@ -0,0 +1,498 @@ +//! Backend-owned proposal queue operations. + +use berd_memory::{ + acquire_queue_lock, append_jsonl, is_suppressed, jsonl_records, memory_root, + normalize_memory_proposal_text, normalize_memory_proposal_topic, now_epoch_seconds, same_fact, + suppression_fingerprint, write_jsonl, DISMISSED_FILE, PENDING_FILE, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::commands::memory_store::{ + memory_store_root, record_approved_content_at, write_from_store_handle_at, +}; + +const TOPICS: [&str; 7] = [ + "Home", + "Social", + "Interests", + "Travel", + "Shopping", + "Work", + "Tools", +]; + +const ME_TEMPLATE: &str = "# Me\n\n## About me\n\n## Preferences\n\n## Boundaries\n\n## Topics\n"; + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalResult { + pub approved: bool, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryCandidateInput { + pub content: String, + pub topic: Option, + pub session_id: Option, +} + +fn slug(name: &str) -> String { + let mut result = String::new(); + for character in name.trim().to_lowercase().chars() { + if character.is_ascii_alphanumeric() { + result.push(character); + } else if !result.ends_with('-') && !result.is_empty() { + result.push('-'); + } + } + result.trim_matches('-').to_string() +} + +fn topic_label(contents: &str, file_name: &str) -> String { + contents + .lines() + .find_map(|line| line.trim().strip_prefix("# ").map(str::trim)) + .filter(|label| !label.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| file_name.trim_end_matches(".md").replace('-', " ")) +} + +fn matching_topic(root: &Path, query: &str) -> Option { + let directory = root.join("topics"); + let entries = fs::read_dir(directory).ok()?; + let wanted = query.trim().to_lowercase(); + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() || file_type.is_symlink() { + continue; + } + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !file_name.ends_with(".md") { + continue; + } + let contents = fs::read_to_string(&path).ok()?; + let stem = file_name.trim_end_matches(".md").to_lowercase(); + if stem == wanted || topic_label(&contents, file_name).to_lowercase() == wanted { + return Some(path); + } + } + None +} + +fn append_bullet(contents: &str, entry: &str) -> String { + let bullet = format!("- {}", entry.trim()); + if contents.lines().any(|line| line.trim() == bullet) { + return contents.to_string(); + } + format!("{}\n{bullet}\n", contents.trim_end()) +} + +fn insert_preference(contents: &str, entry: &str) -> String { + let bullet = format!("- {}", entry.trim()); + if contents.lines().any(|line| line.trim() == bullet) { + return contents.to_string(); + } + let mut lines: Vec = contents.lines().map(str::to_string).collect(); + let Some(start) = lines + .iter() + .position(|line| line.trim() == "## Preferences") + else { + return append_bullet(contents, entry); + }; + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, line)| line.starts_with("## ")) + .map(|(index, _)| index) + .unwrap_or(lines.len()); + let mut insert_at = end; + while insert_at > start + 1 && lines[insert_at - 1].trim().is_empty() { + insert_at -= 1; + } + lines.insert(insert_at, bullet); + format!("{}\n", lines.join("\n").trim_end()) +} + +fn approval_target(root: &Path, topic: Option<&str>) -> Result<(PathBuf, bool), String> { + let Some(topic) = topic.map(str::trim).filter(|topic| !topic.is_empty()) else { + return Ok((root.join("me.md"), true)); + }; + if let Some(path) = matching_topic(root, topic) { + return Ok((path, false)); + } + if let Some(label) = TOPICS + .iter() + .find(|label| label.eq_ignore_ascii_case(topic)) + { + return Ok(( + root.join("topics").join(format!("{}.md", slug(label))), + false, + )); + } + Ok((root.join("me.md"), true)) +} + +/// Approve one pending proposal under the queue lock. The proposal is removed +/// last, so retrying after any partial failure repairs the same entry without +/// creating a duplicate. +#[tauri::command] +pub fn approve_memory_proposal( + id: String, + content: String, + topic: Option, +) -> Result { + approve_memory_proposal_at(&memory_store_root()?, id, content, topic) +} + +fn approve_memory_proposal_at( + root: &Path, + id: String, + content: String, + topic: Option, +) -> Result { + let content = normalize_memory_proposal_text(&content).map_err(|error| error.to_string())?; + if content.is_empty() { + return Err("Memory content is required".to_string()); + } + if content.chars().count() > 300 { + return Err("Memory entries must be 300 characters or fewer".to_string()); + } + if berd_memory::looks_like_credential(&content) { + return Err("Authentication and access data can't be saved to memory".to_string()); + } + let topic = + normalize_memory_proposal_topic(topic.as_deref()).map_err(|error| error.to_string())?; + + let dir = root.join("proposals"); + let _lock = acquire_queue_lock(&dir)?; + let pending_path = dir.join(PENDING_FILE); + let records = jsonl_records(&pending_path); + if !records + .iter() + .any(|record| record.get("id").and_then(Value::as_str) == Some(id.as_str())) + { + return Ok(ApprovalResult { approved: false }); + } + + let (target, spine) = approval_target(root, topic.as_deref())?; + let current = fs::read_to_string(&target).unwrap_or_else(|_| { + if spine { + ME_TEMPLATE.to_string() + } else { + format!("# {}\n", topic.as_deref().unwrap_or("Topic").trim()) + } + }); + let next = if spine { + insert_preference(¤t, &content) + } else { + append_bullet(¤t, &content) + }; + write_from_store_handle_at(&target, root, &next, false)?; + record_approved_content_at(&target, root, &next)?; + + let kept: Vec = records + .into_iter() + .filter(|record| record.get("id").and_then(Value::as_str) != Some(id.as_str())) + .collect(); + write_jsonl(&pending_path, &kept)?; + Ok(ApprovalResult { approved: true }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seed(root: &Path, id: &str, content: &str, topic: Option<&str>) { + let proposals = root.join("proposals"); + fs::create_dir_all(&proposals).unwrap(); + append_jsonl( + &proposals.join(PENDING_FILE), + &json!({ "id": id, "content": content, "topic": topic }), + ) + .unwrap(); + } + + #[test] + fn approval_writes_memory_then_removes_proposal() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + seed(&root, "p-1", "Prefers aisle seats.", Some("Travel")); + + let result = approve_memory_proposal_at( + &root, + "p-1".into(), + "Prefers aisle seats.".into(), + Some("Travel".into()), + ) + .unwrap(); + + assert!(result.approved); + assert!(fs::read_to_string(root.join("topics/travel.md")) + .unwrap() + .contains("- Prefers aisle seats.")); + assert!(jsonl_records(&root.join("proposals/pending.jsonl")).is_empty()); + } + + #[test] + fn retry_does_not_duplicate_an_already_written_entry() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + seed(&root, "p-1", "Prefers aisle seats.", Some("Travel")); + fs::create_dir_all(root.join("topics")).unwrap(); + fs::write( + root.join("topics/travel.md"), + "# Travel\n- Prefers aisle seats.\n", + ) + .unwrap(); + + approve_memory_proposal_at( + &root, + "p-1".into(), + "Prefers aisle seats.".into(), + Some("Travel".into()), + ) + .unwrap(); + + let contents = fs::read_to_string(root.join("topics/travel.md")).unwrap(); + assert_eq!(contents.matches("Prefers aisle seats.").count(), 1); + } + + #[test] + fn approval_persists_the_exact_normalized_reviewed_text() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + seed(&root, "p-1", "placeholder", Some("Travel")); + + approve_memory_proposal_at( + &root, + "p-1".into(), + " cafe\u{301} preferences\r\n".into(), + Some("Travel".into()), + ) + .unwrap(); + + let contents = fs::read_to_string(root.join("topics/travel.md")).unwrap(); + assert_eq!(contents, "# Travel\n- café preferences\n"); + assert!(berd_memory::content_is_approved( + &root, + &root.join("topics/travel.md"), + &contents, + )); + } + + #[test] + fn approval_rejects_hidden_topic_unicode_without_resolving_proposal() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + seed(&root, "p-1", "placeholder", Some("Travel")); + + assert!(approve_memory_proposal_at( + &root, + "p-1".into(), + "Safe content.".into(), + Some("Tra\u{202e}vel".into()), + ) + .is_err()); + assert_eq!( + jsonl_records(&root.join("proposals/pending.jsonl")).len(), + 1 + ); + assert!(!root.join("topics/travel.md").exists()); + } + + #[test] + fn approval_rejects_hidden_unicode_without_resolving_proposal() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + seed(&root, "p-1", "placeholder", None); + + assert!(approve_memory_proposal_at( + &root, + "p-1".into(), + "ghp_16Chars\u{200b}AtLeastHere00".into(), + None, + ) + .is_err()); + assert_eq!( + jsonl_records(&root.join("proposals/pending.jsonl")).len(), + 1 + ); + assert!(!root.join("me.md").exists()); + } + + #[test] + fn credentials_are_rejected_without_resolving_proposal() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + seed(&root, "p-1", "placeholder", None); + + assert!( + approve_memory_proposal_at(&root, "p-1".into(), "PIN: 1234".into(), None,).is_err() + ); + assert_eq!( + jsonl_records(&root.join("proposals/pending.jsonl")).len(), + 1 + ); + assert!(!root.join("me.md").exists()); + } +} + +/// Decline a proposal or resolve an already-completed approval. Suppression is +/// persisted before the pending record is removed, so a failed decline never +/// loses the proposal. +#[tauri::command] +pub fn resolve_memory_proposal( + id: String, + declined_content: Option, + declined_topic: Option, +) -> Result<(), String> { + let dir = memory_root()?.join("proposals"); + let _lock = acquire_queue_lock(&dir)?; + let path = dir.join(PENDING_FILE); + let records = jsonl_records(&path); + + let declined_topic = normalize_memory_proposal_topic(declined_topic.as_deref()) + .map_err(|error| error.to_string())?; + if let Some(content) = declined_content { + let content = + normalize_memory_proposal_text(&content).map_err(|error| error.to_string())?; + if !content.is_empty() { + let salt = uuid::Uuid::new_v4().simple().to_string(); + append_jsonl( + &dir.join(DISMISSED_FILE), + &json!({ + "id": id, + "ts": now_epoch_seconds(), + "salt": salt, + "fingerprint": suppression_fingerprint( + &content, + declined_topic.as_deref(), + &salt, + ), + }), + )?; + } + } + + let kept: Vec = records + .into_iter() + .filter(|record| record.get("id").and_then(Value::as_str) != Some(id.as_str())) + .collect(); + write_jsonl(&path, &kept) +} + +/// Append noticer candidates under the same lock used by the MCP sidecar. +#[tauri::command] +pub fn append_memory_proposals(candidates: Vec) -> Result { + append_memory_proposals_at(&memory_root()?, candidates) +} + +fn append_memory_proposals_at( + root: &Path, + candidates: Vec, +) -> Result { + if candidates.is_empty() { + return Ok(0); + } + let dir = root.join("proposals"); + let _lock = acquire_queue_lock(&dir)?; + let pending_path = dir.join(PENDING_FILE); + let mut pending = jsonl_records(&pending_path); + let dismissed = jsonl_records(&dir.join(DISMISSED_FILE)); + let mut count = 0; + + for candidate in candidates { + let Ok(content) = normalize_memory_proposal_text(&candidate.content) else { + continue; + }; + let Ok(topic) = normalize_memory_proposal_topic(candidate.topic.as_deref()) else { + continue; + }; + if content.is_empty() + || content.chars().count() > 300 + || berd_memory::looks_like_credential(&content) + || pending + .iter() + .any(|record| same_fact(record, &content, topic.as_deref())) + || dismissed + .iter() + .any(|record| is_suppressed(record, &content, topic.as_deref())) + { + continue; + } + let record = json!({ + "id": format!("n-{}", uuid::Uuid::new_v4()), + "ts": now_epoch_seconds(), + "content": content, + "topic": topic, + "agent": "noticer", + "sessionId": candidate.session_id, + "host": "berd", + }); + append_jsonl(&pending_path, &record)?; + pending.push(record); + count += 1; + } + Ok(count) +} + +#[cfg(test)] +mod append_tests { + use super::*; + + #[test] + fn append_normalizes_before_queueing_scanning_and_deduping() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(".me"); + let candidates = vec![ + MemoryCandidateInput { + content: " cafe\u{301} preference\r\n".into(), + topic: Some("Travel".into()), + session_id: Some("s-1".into()), + }, + MemoryCandidateInput { + content: "café preference".into(), + topic: Some("Travel".into()), + session_id: Some("s-1".into()), + }, + MemoryCandidateInput { + content: "PIN: 1234".into(), + topic: None, + session_id: None, + }, + MemoryCandidateInput { + content: "token ghp_16Chars\u{200b}AtLeastHere00".into(), + topic: None, + session_id: None, + }, + MemoryCandidateInput { + content: "safe but hidden topic".into(), + topic: Some("Tra\u{202e}vel".into()), + session_id: None, + }, + MemoryCandidateInput { + content: "safe but tag topic".into(), + topic: Some("Tra\u{e0020}vel".into()), + session_id: None, + }, + ]; + + let count = append_memory_proposals_at(&root, candidates).unwrap(); + + assert_eq!(count, 1); + let records = jsonl_records(&root.join("proposals").join(PENDING_FILE)); + assert_eq!( + records[0].get("content").and_then(Value::as_str), + Some("café preference") + ); + } +} diff --git a/src-tauri/src/commands/memory_store.rs b/src-tauri/src/commands/memory_store.rs index 110496019..b1f6f0151 100644 --- a/src-tauri/src/commands/memory_store.rs +++ b/src-tauri/src/commands/memory_store.rs @@ -6,7 +6,7 @@ //! Every memory mutation resolves against the canonical `~/.me` root here, //! follows symlinks for existing ancestors, and rejects anything that escapes. -use berd_memory::{content_is_approved, looks_like_credential, mark_content_approved}; +use berd_memory::{content_is_approved, mark_content_approved, normalize_memory_document_text}; use cap_std::ambient_authority; use cap_std::fs::Dir; use std::fs; @@ -91,7 +91,7 @@ fn store_relative_path(target: &Path, root: &Path) -> Result { pub(crate) fn write_from_store_handle( target: &Path, - contents: String, + contents: &str, create_new: bool, ) -> Result<(), String> { write_from_store_handle_at(target, &memory_store_root()?, contents, create_new) @@ -100,12 +100,9 @@ pub(crate) fn write_from_store_handle( pub(crate) fn write_from_store_handle_at( target: &Path, root: &Path, - contents: String, + contents: &str, create_new: bool, ) -> Result<(), String> { - if looks_like_credential(&contents) { - return Err("Authentication and access data can't be saved to memory.".to_string()); - } fs::create_dir_all(root).map_err(|error| format!("Failed to create memory store: {error}"))?; let relative = store_relative_path(target, root)?; let parent = relative @@ -140,6 +137,15 @@ pub(crate) fn write_from_store_handle_at( .map_err(|error| format!("Failed to write memory file: {error}")) } +fn admit_reviewed_memory_document(contents: String) -> Result { + let normalized = + normalize_memory_document_text(&contents).map_err(|error| error.to_string())?; + if berd_memory::looks_like_credential(&normalized) { + return Err("Authentication and access data can't be saved to memory".to_string()); + } + Ok(normalized) +} + pub(crate) fn record_approved_content(target: &Path, contents: &str) -> Result<(), String> { record_approved_content_at(target, &memory_store_root()?, contents) } @@ -161,6 +167,7 @@ pub fn is_approved_memory_content(target: &Path, contents: &str) -> bool { #[tauri::command] pub fn is_memory_content_approved(path: String, contents: String) -> Result { let target = validate_memory_path(&path)?; + let contents = normalize_memory_document_text(&contents).map_err(|error| error.to_string())?; Ok(is_approved_memory_content(&target, &contents)) } @@ -168,7 +175,8 @@ pub fn is_memory_content_approved(path: String, contents: String) -> Result Result<(), String> { let target = validate_memory_path(&path)?; - write_from_store_handle(&target, contents.clone(), true)?; + let contents = admit_reviewed_memory_document(contents)?; + write_from_store_handle(&target, &contents, true)?; record_approved_content(&target, &contents) } @@ -176,7 +184,8 @@ pub fn create_memory_text_file(path: String, contents: String) -> Result<(), Str #[tauri::command] pub fn write_memory_text_file(path: String, contents: String) -> Result<(), String> { let target = validate_memory_path(&path)?; - write_from_store_handle(&target, contents.clone(), false)?; + let contents = admit_reviewed_memory_document(contents)?; + write_from_store_handle(&target, &contents, false)?; record_approved_content(&target, &contents) } @@ -226,45 +235,26 @@ mod tests { assert!(validate(&root, &root.join("escaped/secret.md")).is_err()); } #[test] - fn rust_write_funnel_rejects_credentials_before_file_or_approval_metadata() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join(".me"); - let target = root.join("me.md"); - - let result = write_from_store_handle_at( - &target, - &root, - "API key: ghp_16CharsAtLeastHere00".to_string(), - false, - ); - - assert!(result.is_err()); - assert!(!target.exists()); - assert!(!root.join(".approved-content.json").exists()); - } - - #[test] - fn rust_write_funnel_accepts_template_warning_prose() { + fn memory_document_writes_persist_the_exact_normalized_approved_text() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join(".me"); + fs::create_dir_all(&root).unwrap(); let target = root.join("me.md"); - let template = - "# Me\n\n*Don't add passwords, credentials, or other access information here.*\n"; + let reviewed = "# Cafe\u{301}\r\n\n- Prefers São Paulo.\n"; + let normalized = "# Café\n\n- Prefers São Paulo.\n"; - write_from_store_handle_at(&target, &root, template.to_string(), true).unwrap(); + let contents = admit_reviewed_memory_document(reviewed.into()).unwrap(); + write_from_store_handle_at(&target, &root, &contents, false).unwrap(); + record_approved_content_at(&target, &root, &contents).unwrap(); - assert_eq!(fs::read_to_string(target).unwrap(), template); + assert_eq!(fs::read_to_string(&target).unwrap(), normalized); + assert!(content_is_approved(&root, &target, normalized)); + assert!(!content_is_approved(&root, &target, reviewed)); } #[test] - fn rust_write_funnel_accepts_explicit_policy_files() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join(".me"); - let target = root.join("policy.json"); - let policy = "{\n \"enabled\": false\n}\n"; - - write_from_store_handle_at(&target, &root, policy.to_string(), true).unwrap(); - - assert_eq!(fs::read_to_string(target).unwrap(), policy); + fn memory_document_writes_reject_unsafe_text_and_credentials() { + assert!(admit_reviewed_memory_document("# Me\nabc\u{202e}txt\n".into()).is_err()); + assert!(admit_reviewed_memory_document("# Me\nPIN: 1234\n".into()).is_err()); } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 847fbdd27..986f5fd3e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -30,6 +30,7 @@ pub mod installation; pub mod layout; pub mod local_mcp_inventory; pub mod mac_speech; +pub mod memory_queue; pub mod memory_store; pub mod message_queues; pub mod microphone_permission; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9dd4ff56e..299e5affa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -655,6 +655,9 @@ pub fn run() { commands::memory_store::create_memory_text_file, commands::memory_store::write_memory_text_file, commands::memory_store::is_memory_content_approved, + commands::memory_queue::append_memory_proposals, + commands::memory_queue::approve_memory_proposal, + commands::memory_queue::resolve_memory_proposal, commands::terminal::start_terminal, commands::terminal::write_terminal, commands::terminal::resize_terminal, diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 3683ac261..45a518080 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -100,6 +100,7 @@ import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { useAppStartup } from "./hooks/useAppStartup"; import { useRemoteSessionExperimentReconciliation } from "@/features/chat/hooks/useRemoteSessionExperimentReconciliation"; import { useCompletionNotifications } from "@/shared/hooks/useCompletionNotifications"; +import { MemoryProposalToasts } from "@/features/me/ui/MemoryProposalToasts"; import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; import { runPinnedPrompt } from "@/features/home/lib/runPinnedPrompt"; @@ -5409,6 +5410,7 @@ export function AppShell({ return ( + ) : null} + ([]); + + const refresh = useCallback(async () => { + const all = await listProposals(); + setProposals( + sessionId + ? all.filter((proposal) => proposal.sessionId === sessionId) + : options?.sessionlessOnly + ? all.filter((proposal) => proposal.sessionId === null) + : all, + ); + }, [sessionId, options?.sessionlessOnly]); + + useEffect(() => { + void refresh(); + const interval = setInterval(() => void refresh(), POLL_INTERVAL_MS); + const onFocus = () => void refresh(); + window.addEventListener("focus", onFocus); + return () => { + clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, [refresh]); + + const approve = useCallback( + async ( + proposal: MemoryProposal, + content?: string, + topic?: string | null, + ) => { + await approveMemoryProposal(proposal, content, topic); + await refresh(); + }, + [refresh], + ); + const decline = useCallback( + async (proposal: MemoryProposal) => { + await declineMemoryProposal(proposal); + await refresh(); + }, + [refresh], + ); + + return { proposals, approve, decline, refresh }; +} diff --git a/src/features/me/hooks/useMemoryProposalsPending.ts b/src/features/me/hooks/useMemoryProposalsPending.ts new file mode 100644 index 000000000..ef08a2bf4 --- /dev/null +++ b/src/features/me/hooks/useMemoryProposalsPending.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useState } from "react"; + +import { listProposals } from "../lib/meProposals"; + +/** + * Count of pending proposals for the Memory nav badge. The badge is a real + * review queue: nothing enters durable or recallable memory until resolved. + * + * Polling is deliberately lazy (a tiny local file); a focus listener + * catches the common "came back to the app" moment. + */ +const POLL_INTERVAL_MS = 30_000; + +export function useMemoryProposalsPending(): number { + const [count, setCount] = useState(0); + + const refresh = useCallback(async () => { + try { + setCount((await listProposals()).length); + } catch { + // Badge is best-effort; a read failure just means no badge. + setCount(0); + } + }, []); + + useEffect(() => { + void refresh(); + const interval = setInterval(() => void refresh(), POLL_INTERVAL_MS); + const onFocus = () => void refresh(); + window.addEventListener("focus", onFocus); + return () => { + clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, [refresh]); + + return count; +} diff --git a/src/features/me/lib/__tests__/editSummary.test.ts b/src/features/me/lib/__tests__/editSummary.test.ts new file mode 100644 index 000000000..20505c809 --- /dev/null +++ b/src/features/me/lib/__tests__/editSummary.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { removedMemoryEntries } from "../editSummary"; + +const FILE = `# Me + +*This file is yours.* + +## Preferences + +*How you want agents to work with you.* + +- Keep answers brief. +- Git branch names: use \`clay/\` as the prefix. + +## Boundaries + +*Things agents should ask about first.* +`; + +describe("removedMemoryEntries", () => { + it("returns exact removed entries without markdown syntax", () => { + const after = FILE.replace("- Keep answers brief.\n", ""); + expect(removedMemoryEntries(FILE, after)).toEqual(["Keep answers brief."]); + }); + + it("does not suppress entries during additions or rewording", () => { + expect( + removedMemoryEntries( + FILE, + FILE.replace( + "- Keep answers brief.", + "- Keep answers brief.\n- Use headings for long answers.", + ), + ), + ).toEqual([]); + expect( + removedMemoryEntries( + FILE, + FILE.replace("- Keep answers brief.", "- Keep responses brief."), + ), + ).toEqual([]); + }); + + it("ignores whitespace, headings, and italic notes", () => { + expect(removedMemoryEntries(FILE, `${FILE}\n\n`)).toEqual([]); + expect( + removedMemoryEntries( + FILE, + FILE.replace("*This file is yours.*", "*Yours.*"), + ), + ).toEqual([]); + expect( + removedMemoryEntries(FILE, FILE.replace("## Boundaries", "## Limits")), + ).toEqual([]); + }); +}); diff --git a/src/features/me/lib/__tests__/meFile.test.ts b/src/features/me/lib/__tests__/meFile.test.ts index 93fdfcd2c..96b476638 100644 --- a/src/features/me/lib/__tests__/meFile.test.ts +++ b/src/features/me/lib/__tests__/meFile.test.ts @@ -4,17 +4,24 @@ const mocks = vi.hoisted(() => ({ getHomeDir: vi.fn(), pathExists: vi.fn(), readTextFile: vi.fn(), - createTextFile: vi.fn(), - writeTextFile: vi.fn(), + saveMemoryDocument: vi.fn(), })); -vi.mock("@/shared/api/system", () => mocks); +vi.mock("@/shared/api/system", () => ({ + getHomeDir: mocks.getHomeDir, + pathExists: mocks.pathExists, + readTextFile: mocks.readTextFile, +})); +vi.mock("../saveMemoryDocument", () => ({ + saveMemoryDocument: mocks.saveMemoryDocument, +})); import { createMeFile, ME_FILE_TEMPLATE, saveMeFile } from "../meFile"; beforeEach(() => { vi.clearAllMocks(); mocks.getHomeDir.mockResolvedValue("/home/u"); + mocks.saveMemoryDocument.mockResolvedValue(undefined); }); describe("me file writes", () => { @@ -24,23 +31,21 @@ describe("me file writes", () => { await createMeFile(); - expect(mocks.createTextFile).toHaveBeenCalledTimes(1); - expect(mocks.createTextFile).toHaveBeenCalledWith( - "/home/u/.me/me.md", - ME_FILE_TEMPLATE, - ); - expect(mocks.writeTextFile).not.toHaveBeenCalled(); + expect(mocks.saveMemoryDocument).toHaveBeenCalledWith({ + path: "/home/u/.me/me.md", + contents: ME_FILE_TEMPLATE, + topic: null, + }); }); it("saves only the user-owned memory file without automatic sharing", async () => { await saveMeFile("/home/u/.me/me.md", "## Preferences\n\n- Keep it brief."); - expect(mocks.writeTextFile).toHaveBeenCalledTimes(1); - expect(mocks.writeTextFile).toHaveBeenCalledWith( - "/home/u/.me/me.md", - "## Preferences\n\n- Keep it brief.", - ); - expect(mocks.createTextFile).not.toHaveBeenCalled(); + expect(mocks.saveMemoryDocument).toHaveBeenCalledWith({ + path: "/home/u/.me/me.md", + contents: "## Preferences\n\n- Keep it brief.", + topic: null, + }); }); it("documents the plaintext local-filesystem boundary in the starter file", () => { diff --git a/src/features/me/lib/__tests__/meProposals.test.ts b/src/features/me/lib/__tests__/meProposals.test.ts new file mode 100644 index 000000000..04f47f5dd --- /dev/null +++ b/src/features/me/lib/__tests__/meProposals.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBullet, + insertIntoSection, + parseProposalLine, + removeBullet, +} from "../meProposals"; +import { vocabularyTopicName } from "../memoryTopicVocabulary"; + +describe("parseProposalLine", () => { + it("normalizes proposal text and topic before Settings display", () => { + const proposal = parseProposalLine( + JSON.stringify({ + id: "p-1", + content: " cafe\u0301 prefers 中文\r\n", + topic: " Travel\r\n ", + }), + ); + expect(proposal?.content).toBe("café prefers 中文"); + expect(proposal?.topic).toBe("Travel"); + }); + + it("rejects unsafe hidden Unicode before Settings display", () => { + expect( + parseProposalLine(JSON.stringify({ id: "p-1", content: "abc\u202etxt" })), + ).toBeNull(); + expect( + parseProposalLine(JSON.stringify({ id: "p-1", content: "abc\u0007txt" })), + ).toBeNull(); + expect( + parseProposalLine( + JSON.stringify({ id: "p-1", content: "family 👨‍👩‍👧‍👦" }), + ), + ).toBeNull(); + expect( + parseProposalLine( + JSON.stringify({ id: "p-1", content: "safe", topic: "Tra\u202evel" }), + ), + ).toBeNull(); + }); + + it("preserves ordinary visible Unicode and non-ZWJ emoji", () => { + const text = "São Paulo résumé Привет 中文 🚀"; + expect( + parseProposalLine(JSON.stringify({ id: "p-1", content: text }))?.content, + ).toBe(text); + }); +}); + +describe("appendBullet", () => { + it("appends a bullet to existing content with one trailing newline", () => { + const next = appendBullet("# Family\n\n- Existing entry.\n", "New entry."); + expect(next).toBe("# Family\n\n- Existing entry.\n- New entry.\n"); + }); + + it("starts a doc when contents are empty", () => { + expect(appendBullet("", "First entry.")).toBe("- First entry.\n"); + }); +}); + +describe("insertIntoSection", () => { + const SPINE = [ + "# Me", + "", + "## About me", + "", + "- Clay, Atlanta.", + "", + "## Preferences", + "", + "- Keep answers brief.", + "", + "## Boundaries", + "", + "- Ask before deleting.", + "", + ].join("\n"); + + it("inserts at the end of the named section, before the next heading", () => { + const next = insertIntoSection(SPINE, "## Preferences", "Use metric."); + const lines = next.split("\n"); + const prefIndex = lines.indexOf("- Keep answers brief."); + expect(lines[prefIndex + 1]).toBe("- Use metric."); + // Boundaries untouched and still after the insertion. + expect(next.indexOf("- Use metric.")).toBeLessThan( + next.indexOf("## Boundaries"), + ); + }); + + it("falls back to appending when the section is missing", () => { + const next = insertIntoSection("# Me\n", "## Nonexistent", "Entry."); + expect(next.trimEnd().endsWith("- Entry.")).toBe(true); + }); +}); + +describe("vocabularyTopicName", () => { + it("accepts the broad areas, case-insensitively", () => { + expect(vocabularyTopicName("home")).toBe("Home"); + expect(vocabularyTopicName(" Travel ")).toBe("Travel"); + expect(vocabularyTopicName("Interests")).toBe("Interests"); + }); + + it("rejects narrow names a drifting model might invent", () => { + // Approval falls back to the spine for these rather than minting a + // topic file the noticer would never produce. + expect(vocabularyTopicName("Soccer")).toBeNull(); + expect(vocabularyTopicName("Jazz")).toBeNull(); + expect(vocabularyTopicName("family")).toBeNull(); + }); +}); + +describe("removeBullet", () => { + const DOC = [ + "# Home", + "", + "*What goes here.*", + "", + "- Kids' soccer is Mondays.", + "- Wife works late Tuesdays.", + "", + ].join("\n"); + + it("removes the matching bullet and leaves the rest", () => { + const next = removeBullet(DOC, "Wife works late Tuesdays."); + expect(next).not.toContain("Wife works late Tuesdays."); + expect(next).toContain("- Kids' soccer is Mondays."); + expect(next).toContain("*What goes here.*"); + }); + + it("no-ops when the entry was reworded or already gone", () => { + // Deleting a nearby line the user wrote themselves would be far worse + // than a delete that does nothing, so matching is exact. + expect(removeBullet(DOC, "Wife works late on Tuesdays")).toBe(DOC); + expect(removeBullet(DOC, "Never mentioned.")).toBe(DOC); + }); + + it("removes only the first match", () => { + const doubled = "- Same fact.\n- Same fact.\n"; + expect(removeBullet(doubled, "Same fact.")).toBe("- Same fact.\n"); + }); +}); diff --git a/src/features/me/lib/__tests__/memoryProposalReview.test.ts b/src/features/me/lib/__tests__/memoryProposalReview.test.ts new file mode 100644 index 000000000..f9ec010a3 --- /dev/null +++ b/src/features/me/lib/__tests__/memoryProposalReview.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + approveMemoryProposal: vi.fn(), + resolveMemoryProposal: vi.fn(), +})); + +vi.mock("@/shared/api/system", () => ({ + approveMemoryProposal: mocks.approveMemoryProposal, + resolveMemoryProposal: mocks.resolveMemoryProposal, +})); + +import { + approveMemoryProposal, + CredentialMemoryError, + declineMemoryProposal, + UnsafeMemoryTextError, +} from "../memoryProposalReview"; + +const proposal = { + id: "proposal-1", + ts: 1, + content: "Prefers aisle seats.", + topic: "Travel", + agent: "noticer", + sessionId: "session-1", +}; + +describe("memory proposal review", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.approveMemoryProposal.mockResolvedValue({ approved: true }); + }); + + it("delegates the exact normalized reviewed approval to the backend", async () => { + await approveMemoryProposal( + proposal, + " Prefers cafe\u0301 seats.\r\n", + " Travel\r\n ", + ); + expect(mocks.approveMemoryProposal).toHaveBeenCalledWith( + proposal.id, + "Prefers café seats.", + "Travel", + ); + }); + + it("rejects edited authentication data before backend admission", async () => { + await expect( + approveMemoryProposal(proposal, "API key: ghp_16CharsAtLeastHere00"), + ).rejects.toBeInstanceOf(CredentialMemoryError); + expect(mocks.approveMemoryProposal).not.toHaveBeenCalled(); + }); + + it("rejects hidden Unicode before backend admission", async () => { + await expect( + approveMemoryProposal( + proposal, + "API key: ghp_16Chars\u200bAtLeastHere00", + ), + ).rejects.toBeInstanceOf(UnsafeMemoryTextError); + await expect( + approveMemoryProposal(proposal, "Safe content.", "Tra\u202evel"), + ).rejects.toBeInstanceOf(UnsafeMemoryTextError); + expect(mocks.approveMemoryProposal).not.toHaveBeenCalled(); + }); + + it("declines through fingerprint-only backend suppression", async () => { + await declineMemoryProposal(proposal); + expect(mocks.resolveMemoryProposal).toHaveBeenCalledWith(proposal.id, { + content: proposal.content, + topic: proposal.topic, + }); + }); +}); diff --git a/src/features/me/lib/__tests__/memoryTextContract.test.ts b/src/features/me/lib/__tests__/memoryTextContract.test.ts new file mode 100644 index 000000000..6a70218d3 --- /dev/null +++ b/src/features/me/lib/__tests__/memoryTextContract.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeMemoryDocumentText, + normalizeMemoryProposalText, + normalizeMemoryProposalTopic, + UnsafeMemoryTextError, +} from "../memoryTextContract"; + +describe("memory text contract", () => { + it("normalizes proposal text consistently", () => { + expect(normalizeMemoryProposalText(" cafe\u0301\r\n")).toBe("café"); + }); + + it("normalizes document text without trimming reviewed bytes", () => { + expect(normalizeMemoryDocumentText("# Cafe\u0301\r\n\n")).toBe( + "# Café\n\n", + ); + }); + + it("normalizes reviewed topics before display and approval", () => { + expect(normalizeMemoryProposalTopic(" Travel\r\n ")).toBe("Travel"); + expect(normalizeMemoryProposalTopic(" ")).toBeNull(); + expect(normalizeMemoryProposalTopic(null)).toBeNull(); + }); + + it("rejects bidi, zero-width, C0, and C1 controls", () => { + for (const text of [ + "abc\u202etxt", + "abc\u2066txt\u2069", + "ghp_16Chars\u200bAtLeastHere00", + "abc\u0007txt", + "abc\u0085txt", + "abc\u{e0020}txt", + "abc\u{e0100}txt", + ]) { + expect(() => normalizeMemoryProposalText(text), text).toThrow( + UnsafeMemoryTextError, + ); + } + }); + + it("preserves ordinary accents, non-Latin text, and non-ZWJ emoji", () => { + const text = "São Paulo résumé Привет 中文 🚀"; + expect(normalizeMemoryProposalText(text)).toBe(text); + }); + + it("rejects unsafe topic text and emoji ZWJ sequences deliberately", () => { + expect(() => normalizeMemoryProposalTopic("Tra\u202evel")).toThrow( + UnsafeMemoryTextError, + ); + expect(() => normalizeMemoryProposalText("family 👨‍👩‍👧‍👦")).toThrow( + UnsafeMemoryTextError, + ); + }); +}); diff --git a/src/features/me/lib/__tests__/saveMemoryDocument.test.ts b/src/features/me/lib/__tests__/saveMemoryDocument.test.ts new file mode 100644 index 000000000..719ac6bc6 --- /dev/null +++ b/src/features/me/lib/__tests__/saveMemoryDocument.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readTextFile: vi.fn(), + resolveMemoryProposal: vi.fn(), + writeTextFile: vi.fn(), +})); + +vi.mock("@/shared/api/system", () => ({ + readTextFile: mocks.readTextFile, + resolveMemoryProposal: mocks.resolveMemoryProposal, + writeTextFile: mocks.writeTextFile, +})); + +import { CredentialMemoryError } from "../memoryCredentialGuard"; +import { saveMemoryDocument } from "../saveMemoryDocument"; +import { UnsafeMemoryTextError } from "../memoryTextContract"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("crypto", { randomUUID: () => "delete-id" }); + mocks.writeTextFile.mockResolvedValue(undefined); + mocks.resolveMemoryProposal.mockResolvedValue(undefined); + mocks.readTextFile.mockResolvedValue({ + contents: "# Travel\n\n- Prefers aisle seats.\n- Packs light.\n", + }); +}); + +describe("saveMemoryDocument", () => { + it("writes before suppressing an unambiguous deletion", async () => { + await saveMemoryDocument({ + path: "/home/u/.me/topics/travel.md", + contents: "# Travel\n\n- Packs light.\n", + topic: "Travel", + }); + + expect(mocks.writeTextFile).toHaveBeenCalledOnce(); + expect(mocks.resolveMemoryProposal).toHaveBeenCalledWith( + "manual-delete-delete-id", + { content: "Prefers aisle seats.", topic: "Travel" }, + ); + expect(mocks.writeTextFile.mock.invocationCallOrder[0]).toBeLessThan( + mocks.resolveMemoryProposal.mock.invocationCallOrder[0], + ); + }); + + it("does not suppress anything when the write fails", async () => { + mocks.writeTextFile.mockRejectedValue(new Error("read only")); + await expect( + saveMemoryDocument({ + path: "/home/u/.me/topics/travel.md", + contents: "# Travel\n\n- Packs light.\n", + topic: "Travel", + }), + ).rejects.toThrow("read only"); + expect(mocks.resolveMemoryProposal).not.toHaveBeenCalled(); + }); + + it("normalizes direct Settings document saves before write and diff", async () => { + await saveMemoryDocument({ + path: "/home/u/.me/topics/travel.md", + contents: "# Cafe\u0301\r\n\r\n- Packs light.\r\n", + topic: " Travel\r\n ", + }); + + expect(mocks.writeTextFile).toHaveBeenCalledWith( + "/home/u/.me/topics/travel.md", + "# Café\n\n- Packs light.\n", + ); + }); + + it("blocks credential-shaped edits before writing", async () => { + await expect( + saveMemoryDocument({ + path: "/home/u/.me/me.md", + contents: "# Me\n\n- PIN: 1234\n", + topic: null, + }), + ).rejects.toBeInstanceOf(CredentialMemoryError); + expect(mocks.writeTextFile).not.toHaveBeenCalled(); + }); + + it("blocks hidden Unicode before writing", async () => { + await expect( + saveMemoryDocument({ + path: "/home/u/.me/me.md", + contents: "# Me\n\n- token ghp_16Chars\u200bAtLeastHere00\n", + topic: null, + }), + ).rejects.toBeInstanceOf(UnsafeMemoryTextError); + await expect( + saveMemoryDocument({ + path: "/home/u/.me/topics/travel.md", + contents: "# Travel\n\n- Packs light.\n", + topic: "Tra\u202evel", + }), + ).rejects.toBeInstanceOf(UnsafeMemoryTextError); + expect(mocks.writeTextFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/me/lib/editSummary.ts b/src/features/me/lib/editSummary.ts new file mode 100644 index 000000000..93104238f --- /dev/null +++ b/src/features/me/lib/editSummary.ts @@ -0,0 +1,43 @@ +/** + * Extract memory-bearing lines so deliberate deletions can create suppression + * fingerprints. Headings, blanks, and italic notes are file scaffolding, not + * memories. + */ + +/** Lines that carry memory, as opposed to the file's scaffolding. */ +export function memoryContentLines(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => { + if (!line) return false; + if (line.startsWith("#")) return false; // headings + // Italic notes are guidance for the person, never sent to agents. + const italic = + line.startsWith("*") && + !line.startsWith("**") && + !line.startsWith("* "); + if (italic) return false; + return true; + }); +} + +/** Exact memory lines removed by an edit, with markdown bullet syntax stripped. */ +export function removedMemoryEntries(before: string, after: string): string[] { + const beforeLines = memoryContentLines(before); + const afterLines = memoryContentLines(after); + const beforeSet = new Set(beforeLines); + // When a save also adds content, a missing line may have been reworded or + // reorganized rather than rejected. Only pure deletions are safe to turn + // into durable suppression decisions automatically. + if (afterLines.some((line) => !beforeSet.has(line))) return []; + const afterSet = new Set(afterLines); + return [ + ...new Set( + beforeLines + .filter((line) => !afterSet.has(line)) + .map((line) => line.replace(/^[-*]\s+/, "").trim()) + .filter(Boolean), + ), + ]; +} diff --git a/src/features/me/lib/meFile.ts b/src/features/me/lib/meFile.ts index ab08df9a3..f97d6a314 100644 --- a/src/features/me/lib/meFile.ts +++ b/src/features/me/lib/meFile.ts @@ -1,10 +1,5 @@ -import { - createTextFile, - getHomeDir, - pathExists, - readTextFile, - writeTextFile, -} from "@/shared/api/system"; +import { getHomeDir, pathExists, readTextFile } from "@/shared/api/system"; +import { saveMemoryDocument } from "./saveMemoryDocument"; /** * Canonical home for the user's me.md, relative to the home directory. @@ -116,7 +111,11 @@ export async function createMeFile(): Promise { if (existing.status === "present") { return existing; } - await createTextFile(existing.path, ME_FILE_TEMPLATE); + await saveMemoryDocument({ + path: existing.path, + contents: ME_FILE_TEMPLATE, + topic: null, + }); const payload = await readTextFile(existing.path); return { status: "present", @@ -131,5 +130,5 @@ export async function saveMeFile( path: string, contents: string, ): Promise { - await writeTextFile(path, contents); + await saveMemoryDocument({ path, contents, topic: null }); } diff --git a/src/features/me/lib/meProposals.ts b/src/features/me/lib/meProposals.ts new file mode 100644 index 000000000..3524729f6 --- /dev/null +++ b/src/features/me/lib/meProposals.ts @@ -0,0 +1,143 @@ +import { getHomeDir, pathExists, readTextFile } from "@/shared/api/system"; +import { + normalizeMemoryProposalText, + normalizeMemoryProposalTopic, + UnsafeMemoryTextError, +} from "./memoryTextContract"; + +/** + * Reviewable memory proposals. Agent and noticer output stops here until the + * person explicitly approves it; this file is never recalled or projected. + */ + +export interface MemoryProposal { + /** Stable ID written by the proposal producer. */ + id: string; + /** Seconds since epoch, as written by the server. */ + ts: number; + content: string; + /** Topic hint from the agent, e.g. "style" or "Family". Null = spine. */ + topic: string | null; + /** Proposing agent, when the server knew it. */ + agent: string | null; + /** + * Session the proposal came from, when known. The noticer records it so + * the chat that produced a fact can surface the card in place; server + * proposals leave it null (the tool call renders its own card). + */ + sessionId: string | null; +} + +function queuePath(homeDir: string): string { + return `${homeDir}/.me/proposals/pending.jsonl`; +} + +export function parseProposalLine(line: string): MemoryProposal | null { + try { + const raw = JSON.parse(line) as Record; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id || typeof raw.content !== "string") return null; + const content = normalizeMemoryProposalText(raw.content); + if (!content) return null; + const ts = typeof raw.ts === "number" ? raw.ts : 0; + return { + id, + ts, + content, + topic: + typeof raw.topic === "string" + ? normalizeMemoryProposalTopic(raw.topic) + : null, + agent: + typeof raw.agent === "string" && raw.agent.trim() + ? raw.agent.trim() + : null, + sessionId: + typeof raw.sessionId === "string" && raw.sessionId.trim() + ? raw.sessionId.trim() + : null, + }; + } catch (error) { + if (error instanceof UnsafeMemoryTextError) return null; + return null; + } +} + +/** Pending proposals, oldest first. Missing or unreadable queue = none. */ +export async function listProposals(): Promise { + try { + const path = queuePath(await getHomeDir()); + if (!(await pathExists(path))) return []; + const payload = await readTextFile(path); + return payload.contents + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map(parseProposalLine) + .filter((proposal): proposal is MemoryProposal => proposal !== null); + } catch { + return []; + } +} + +/** Append a bullet to the end of a doc, normalizing trailing whitespace. */ +export function appendBullet(contents: string, entry: string): string { + const bullet = `- ${entry}`; + if (contents.split("\n").some((line) => line.trim() === bullet)) + return contents; + const trimmed = contents.replace(/\s+$/, ""); + return trimmed ? `${trimmed}\n${bullet}\n` : `${bullet}\n`; +} + +/** + * Remove the bullet matching `entry` from a doc. + * + * Removal of an approved memory has to be conservative: + * only a line that is exactly this bullet is removed, and only the first + * one. Anything the user has since reworded stays put — a delete that + * quietly took out a nearby line the user wrote themselves would be much + * worse than a delete that no-ops. + */ +export function removeBullet(contents: string, entry: string): string { + const wanted = entry.trim(); + const lines = contents.split("\n"); + const index = lines.findIndex((line) => { + const text = line.trim(); + if (!text.startsWith("- ")) return false; + return text.slice(2).trim() === wanted; + }); + if (index === -1) return contents; + lines.splice(index, 1); + return lines.join("\n"); +} + +/** + * Insert a bullet at the end of a `## Section` in the spine, before the + * next heading. Falls back to appending at the end of the file when the + * section doesn't exist. + */ +export function insertIntoSection( + contents: string, + sectionHeading: string, + entry: string, +): string { + const lines = contents.split("\n"); + if (lines.some((line) => line.trim() === `- ${entry}`)) return contents; + const start = lines.findIndex((line) => line.trim() === sectionHeading); + if (start === -1) return appendBullet(contents, entry); + + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith("## ")) { + end = i; + break; + } + } + // Walk back past blank lines so the bullet lands tight to the section. + let insertAt = end; + while (insertAt > start + 1 && lines[insertAt - 1].trim() === "") { + insertAt--; + } + lines.splice(insertAt, 0, `- ${entry}`); + return lines.join("\n"); +} diff --git a/src/features/me/lib/meTopics.ts b/src/features/me/lib/meTopics.ts index d5b1625c1..040562aaf 100644 --- a/src/features/me/lib/meTopics.ts +++ b/src/features/me/lib/meTopics.ts @@ -1,11 +1,10 @@ import { - createTextFile, getHomeDir, listDirectoryEntries, pathExists, readTextFile, - writeTextFile, } from "@/shared/api/system"; +import { saveMemoryDocument } from "./saveMemoryDocument"; /** * Topic docs: the spokes of the memory-v2 hub-and-spokes shape. Every @@ -111,8 +110,12 @@ export async function listTopics(): Promise { } /** Save a user edit to a topic document. */ -export async function saveTopic(path: string, contents: string): Promise { - await writeTextFile(path, contents); +export async function saveTopic( + path: string, + contents: string, + topic: string, +): Promise { + await saveMemoryDocument({ path, contents, topic }); } /** Turn a display name into a topic file name: "Side projects" → side-projects.md */ @@ -134,15 +137,15 @@ function topicTemplate(name: string): string { } /** - * Create a new, empty topic doc. Refuses to overwrite (createTextFile's - * contract), so an existing topic can't be clobbered by a name collision. + * Create a new, empty topic doc through the reviewed memory write funnel, so + * an existing topic can't be clobbered by a name collision. */ export async function createTopic(name: string): Promise { const homeDir = await getHomeDir(); const fileName = topicFileName(name); const path = `${topicsDirPath(homeDir)}/${fileName}`; const contents = topicTemplate(name); - await createTextFile(path, contents); + await saveMemoryDocument({ path, contents, topic: name }); const meta = parseTopicMeta(contents, fileName); return { path, fileName, contents, ...meta }; } diff --git a/src/features/me/lib/memoryCredentialGuard.ts b/src/features/me/lib/memoryCredentialGuard.ts index 197c8a258..c650eb97e 100644 --- a/src/features/me/lib/memoryCredentialGuard.ts +++ b/src/features/me/lib/memoryCredentialGuard.ts @@ -102,7 +102,7 @@ export class CredentialMemoryError extends Error { * immediate UX, not the security boundary. */ export function looksLikeCredential(content: string): boolean { - const text = content.trim(); + const text = content.normalize("NFC").trim(); if (!text) return false; for (const pattern of TOKEN_PATTERNS) { diff --git a/src/features/me/lib/memoryProposalReview.ts b/src/features/me/lib/memoryProposalReview.ts new file mode 100644 index 000000000..4fc1a8722 --- /dev/null +++ b/src/features/me/lib/memoryProposalReview.ts @@ -0,0 +1,38 @@ +import { + approveMemoryProposal as approveMemoryProposalInBackend, + resolveMemoryProposal, +} from "@/shared/api/system"; +import { + CredentialMemoryError, + looksLikeCredential, +} from "./memoryCredentialGuard"; +import { + normalizeMemoryProposalText, + normalizeMemoryProposalTopic, +} from "./memoryTextContract"; +import type { MemoryProposal } from "./meProposals"; + +export { CredentialMemoryError } from "./memoryCredentialGuard"; +export { UnsafeMemoryTextError } from "./memoryTextContract"; + +export async function approveMemoryProposal( + proposal: MemoryProposal, + content = proposal.content, + topic = proposal.topic, +): Promise { + const reviewed = normalizeMemoryProposalText(content); + if (!reviewed) throw new Error("Memory content is required."); + if (looksLikeCredential(reviewed)) throw new CredentialMemoryError(); + + const reviewedTopic = normalizeMemoryProposalTopic(topic); + await approveMemoryProposalInBackend(proposal.id, reviewed, reviewedTopic); +} + +export async function declineMemoryProposal( + proposal: MemoryProposal, +): Promise { + await resolveMemoryProposal(proposal.id, { + content: proposal.content, + topic: proposal.topic, + }); +} diff --git a/src/features/me/lib/memoryProposalToast.ts b/src/features/me/lib/memoryProposalToast.ts new file mode 100644 index 000000000..d3293db7e --- /dev/null +++ b/src/features/me/lib/memoryProposalToast.ts @@ -0,0 +1,55 @@ +import { toast } from "sonner"; +import type { MemoryProposal } from "./meProposals"; + +const shown = new Set(); +const TOAST_DURATION_MS = 10_000; + +export function resetMemoryProposalToasts(): void { + shown.clear(); +} + +export function showMemoryProposalToast({ + proposal, + title, + destination, + reviewLabel, + declineLabel, + onReview, + onDecline, + renderActions, +}: { + proposal: MemoryProposal; + title: string; + destination: string; + reviewLabel: string; + declineLabel: string; + onReview: (proposal: MemoryProposal) => void; + onDecline: (proposal: MemoryProposal) => void; + renderActions: (args: { + reviewLabel: string; + declineLabel: string; + onReview: () => void; + onDecline: () => void; + }) => React.ReactNode; +}): void { + if (shown.has(proposal.id)) return; + shown.add(proposal.id); + let toastId: string | number | undefined; + const dismiss = () => toastId !== undefined && toast.dismiss(toastId); + toastId = toast(title, { + description: `${proposal.content} · ${destination}`, + duration: TOAST_DURATION_MS, + action: renderActions({ + reviewLabel, + declineLabel, + onReview: () => { + dismiss(); + onReview(proposal); + }, + onDecline: () => { + dismiss(); + onDecline(proposal); + }, + }), + }); +} diff --git a/src/features/me/lib/memoryTextContract.ts b/src/features/me/lib/memoryTextContract.ts new file mode 100644 index 000000000..5f8635d58 --- /dev/null +++ b/src/features/me/lib/memoryTextContract.ts @@ -0,0 +1,66 @@ +/** + * Review-safe text contract for memory proposal and document admission. + * + * Memory review is a security boundary: the text a person sees in Settings + * must be the same Unicode text that is scanned for credentials and persisted. + * We normalize to NFC and LF line endings, trim proposal fields, and reject + * Unicode format/default-ignorable and control characters that can make + * displayed text differ from stored bytes or hide tokens from scanners. + * + * Emoji ZWJ sequences are rejected deliberately. They are useful for composing + * visible emoji glyphs, but ZWJ is also a zero-width format character that can + * split credentials or make reviewed text differ from persisted text. Memory is + * prose, so rejecting composed emoji is safer than special-casing renderers. + */ + +const UNSAFE_DEFAULT_IGNORABLE_OR_FORMAT = + /[\p{Default_Ignorable_Code_Point}\p{Cf}]/u; + +export class UnsafeMemoryTextError extends Error { + constructor() { + super("Memory text can't include hidden Unicode control characters."); + this.name = "UnsafeMemoryTextError"; + } +} + +function normalizeMemoryString(value: string): string { + return value.replace(/\r\n?/g, "\n").normalize("NFC"); +} + +function assertReviewSafeText(value: string): void { + for (const character of value) { + if (UNSAFE_DEFAULT_IGNORABLE_OR_FORMAT.test(character)) { + throw new UnsafeMemoryTextError(); + } + const codePoint = character.codePointAt(0) ?? 0; + const allowedWhitespace = character === "\n" || character === "\t"; + if ( + !allowedWhitespace && + ((codePoint <= 0x1f && codePoint !== 0x20) || + (codePoint >= 0x7f && codePoint <= 0x9f)) + ) { + throw new UnsafeMemoryTextError(); + } + } +} + +export function normalizeMemoryProposalText(content: string): string { + const normalized = normalizeMemoryString(content).trim(); + assertReviewSafeText(normalized); + return normalized; +} + +export function normalizeMemoryProposalTopic( + topic: string | null | undefined, +): string | null { + if (topic === null || topic === undefined) return null; + const normalized = normalizeMemoryString(topic).trim(); + assertReviewSafeText(normalized); + return normalized || null; +} + +export function normalizeMemoryDocumentText(contents: string): string { + const normalized = normalizeMemoryString(contents); + assertReviewSafeText(normalized); + return normalized; +} diff --git a/src/features/me/lib/memoryTopicVocabulary.ts b/src/features/me/lib/memoryTopicVocabulary.ts new file mode 100644 index 000000000..23215f181 --- /dev/null +++ b/src/features/me/lib/memoryTopicVocabulary.ts @@ -0,0 +1,40 @@ +/** + * The broad areas a *new* memory topic may be named after. + * + * Kept deliberately small and life-shaped. The risk isn't list length — + * unused names are invisible until earned — it's overlap: two plausible + * homes for one fact means the same fact routes differently across passes + * and piles up as near-duplicates. So every pair has a boundary: + * household vs. outside it (Home/Social), people vs. tastes + * (Social/Interests), tastes vs. logistics (Interests/Travel), personal + * vs. professional (Social/Work). + * + * Both memory doors are bound by this list: the noticer picks from it, + * and a saved entry only creates a topic file when its name matches it — + * otherwise a drifting model ("Soccer", "Jazz") could sprawl memory into + * narrow topics the noticer would never produce. + * + * A user's existing topics always win over this list, and users can name + * their own topics however they like in Settings → Memory. + */ +export const MEMORY_TOPIC_VOCABULARY = [ + "Home", + "Social", + "Interests", + "Travel", + "Shopping", + "Work", + "Tools", +] as const; + +/** + * The vocabulary name matching `topic`, or null when it isn't one of the + * broad areas. Case-insensitive; existing topics are matched elsewhere. + */ +export function vocabularyTopicName(topic: string): string | null { + const wanted = topic.trim().toLowerCase(); + return ( + MEMORY_TOPIC_VOCABULARY.find((name) => name.toLowerCase() === wanted) ?? + null + ); +} diff --git a/src/features/me/lib/saveMemoryDocument.ts b/src/features/me/lib/saveMemoryDocument.ts new file mode 100644 index 000000000..b9dee3cda --- /dev/null +++ b/src/features/me/lib/saveMemoryDocument.ts @@ -0,0 +1,44 @@ +import { + readTextFile, + resolveMemoryProposal, + writeTextFile, +} from "@/shared/api/system"; +import { removedMemoryEntries } from "./editSummary"; +import { + CredentialMemoryError, + looksLikeCredential, +} from "./memoryCredentialGuard"; +import { + normalizeMemoryDocumentText, + normalizeMemoryProposalTopic, +} from "./memoryTextContract"; + +/** One reviewed Settings edit for either the spine or a topic document. */ +export async function saveMemoryDocument({ + path, + contents, + topic, +}: { + path: string; + contents: string; + topic: string | null; +}): Promise { + const reviewed = normalizeMemoryDocumentText(contents); + const reviewedTopic = normalizeMemoryProposalTopic(topic); + if (looksLikeCredential(reviewed)) throw new CredentialMemoryError(); + + const before = await readTextFile(path) + .then((payload) => normalizeMemoryDocumentText(payload.contents)) + .catch(() => ""); + const removed = removedMemoryEntries(before, reviewed); + + // The edit must land before its deletions become durable suppression + // decisions. A failed write must not suppress content still in the file. + await writeTextFile(path, reviewed); + for (const entry of removed) { + await resolveMemoryProposal(`manual-delete-${crypto.randomUUID()}`, { + content: entry, + topic: reviewedTopic, + }); + } +} diff --git a/src/features/me/ui/MeSettings.tsx b/src/features/me/ui/MeSettings.tsx new file mode 100644 index 000000000..82edbdb01 --- /dev/null +++ b/src/features/me/ui/MeSettings.tsx @@ -0,0 +1,629 @@ +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { ChevronDown, RefreshCw } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { SettingsPage } from "@/shared/ui/SettingsPage"; +import { + SettingsSection, + SettingsSections, +} from "@/shared/ui/settings-section"; +import { SettingsRow } from "@/shared/ui/settings-row"; +import { Switch } from "@/shared/ui/switch"; +import { StorePathLink } from "./StorePathLink"; +import { + createMeFile, + loadMeFile, + ME_FILE_TEMPLATE, + saveMeFile, + type MeFileState, +} from "../lib/meFile"; +import { + createTopic, + listTopics, + saveTopic, + type TopicDoc, +} from "../lib/meTopics"; +import { useMemoryProposals } from "../hooks/useMemoryProposals"; +import type { MemoryProposal } from "../lib/meProposals"; +import { CredentialMemoryError } from "../lib/memoryCredentialGuard"; +import { UnsafeMemoryTextError } from "../lib/memoryTextContract"; +import { readMemoryPolicy, writeMemoryPolicy } from "../lib/memoryPolicyFile"; + +type LoadState = { status: "loading" } | { status: "error" } | MeFileState; +type ViewMode = "preview" | "edit"; + +interface DocumentPanelProps { + contents: string; + onSave: (next: string) => Promise | void; + editorLabel: string; + saveErrorText: string; + unsafeUnicodeErrorText: string; + cancelText: string; + saveText: string; + previewText: string; + editText: string; + unsavedText: string; + refreshLabel?: string; + onRefresh?: () => void; + /** Quiet footer content sharing the action row's left side, e.g. the file's location. */ + footer?: ReactNode; +} + +/** + * One contained document with Preview/Edit modes — the treatment every + * memory doc gets, spine and topics alike. + */ +function DocumentPanel({ + contents, + onSave, + editorLabel, + saveErrorText, + unsafeUnicodeErrorText, + cancelText, + saveText, + previewText, + editText, + unsavedText, + refreshLabel, + onRefresh, + footer, +}: DocumentPanelProps) { + const [mode, setMode] = useState("preview"); + const [draft, setDraft] = useState(null); + const [saveError, setSaveError] = useState(null); + + const isEditing = mode === "edit"; + const hasUnsavedChanges = draft !== null && draft !== contents; + + const handleModeChange = (next: string) => { + if (next === "edit" && draft === null) { + setDraft(contents); + setSaveError(null); + } + setMode(next === "edit" ? "edit" : "preview"); + }; + + const handleCancel = () => { + setDraft(null); + setSaveError(null); + setMode("preview"); + }; + + const handleSave = async () => { + if (draft === null) return; + try { + await onSave(draft); + setDraft(null); + setSaveError(null); + setMode("preview"); + } catch (error) { + setSaveError( + error instanceof UnsafeMemoryTextError + ? unsafeUnicodeErrorText + : saveErrorText, + ); + } + }; + + return ( +
+
+ + + {/* h-7 matches the xs Button height used by every other action + on this page (Add topic, View, Refresh). */} + + {previewText} + + + {editText} + + + +
+ + {isEditing ? ( +