diff --git a/src-tauri/src/commands/memory_queue.rs b/src-tauri/src/commands/memory_queue.rs new file mode 100644 index 000000000..4fc95aa9f --- /dev/null +++ b/src-tauri/src/commands/memory_queue.rs @@ -0,0 +1,369 @@ +//! Backend-owned proposal queue operations. + +use berd_memory::{ + acquire_queue_lock, append_jsonl, is_suppressed, jsonl_records, memory_root, 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, + pub refresh_projection: 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 = content.trim(); + 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 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, + refresh_projection: 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.clone(), 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, + refresh_projection: 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 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); + + if let Some(content) = declined_content.filter(|content| !content.trim().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 { + if candidates.is_empty() { + return Ok(0); + } + let dir = memory_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 content = candidate.content.trim(); + if content.is_empty() + || content.chars().count() > 300 + || berd_memory::looks_like_credential(content) + || pending + .iter() + .any(|record| same_fact(record, content, candidate.topic.as_deref())) + || dismissed + .iter() + .any(|record| is_suppressed(record, content, candidate.topic.as_deref())) + { + continue; + } + let record = json!({ + "id": format!("n-{}", uuid::Uuid::new_v4()), + "ts": now_epoch_seconds(), + "content": content, + "topic": candidate.topic, + "agent": "noticer", + "sessionId": candidate.session_id, + "host": "berd", + }); + append_jsonl(&pending_path, &record)?; + pending.push(record); + count += 1; + } + Ok(count) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 286b86887..3cfd30ca3 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 42244ab3f..7f41b56e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -644,6 +644,9 @@ pub fn run() { commands::memory_store::write_memory_text_file, commands::memory_store::is_memory_content_approved, commands::memory_store::write_memory_agents_projection, + 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 ea701fc71..7defd493b 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -99,6 +99,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"; @@ -5380,6 +5381,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__/meProposals.test.ts b/src/features/me/lib/__tests__/meProposals.test.ts new file mode 100644 index 000000000..d2d9f0324 --- /dev/null +++ b/src/features/me/lib/__tests__/meProposals.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { appendBullet, insertIntoSection, removeBullet } from "../meProposals"; +import { vocabularyTopicName } from "../memoryTopicVocabulary"; + +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..2ff59ef1a --- /dev/null +++ b/src/features/me/lib/__tests__/memoryProposalReview.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + approveMemoryProposal: vi.fn(), + resolveMemoryProposal: vi.fn(), + loadMeFile: vi.fn(), + publishMeFile: vi.fn(), +})); + +vi.mock("@/shared/api/system", () => ({ + approveMemoryProposal: mocks.approveMemoryProposal, + resolveMemoryProposal: mocks.resolveMemoryProposal, +})); +vi.mock("../meFile", () => ({ loadMeFile: mocks.loadMeFile })); +vi.mock("../mePublish", () => ({ publishMeFile: mocks.publishMeFile })); + +import { + approveMemoryProposal, + CredentialMemoryError, + declineMemoryProposal, +} 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, + refreshProjection: true, + }); + mocks.loadMeFile.mockResolvedValue({ status: "missing" }); + }); + + it("delegates edited approval to the backend", async () => { + await approveMemoryProposal(proposal, "Prefers window seats."); + expect(mocks.approveMemoryProposal).toHaveBeenCalledWith( + proposal.id, + "Prefers window 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("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__/saveMemoryDocument.test.ts b/src/features/me/lib/__tests__/saveMemoryDocument.test.ts new file mode 100644 index 000000000..c0b16e9e8 --- /dev/null +++ b/src/features/me/lib/__tests__/saveMemoryDocument.test.ts @@ -0,0 +1,68 @@ +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"; + +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("blocks credential-shaped edits before writing", async () => { + await expect( + saveMemoryDocument({ + path: "/home/u/.me/me.md", + contents: "# Me\n\n- API key: ghp_16CharsAtLeastHere00\n", + topic: null, + }), + ).rejects.toBeInstanceOf(CredentialMemoryError); + 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/meProposals.ts b/src/features/me/lib/meProposals.ts new file mode 100644 index 000000000..ec311a1c5 --- /dev/null +++ b/src/features/me/lib/meProposals.ts @@ -0,0 +1,136 @@ +import { getHomeDir, pathExists, readTextFile } from "@/shared/api/system"; + +/** + * 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 content = typeof raw.content === "string" ? raw.content.trim() : ""; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id || !content) return null; + const ts = typeof raw.ts === "number" ? raw.ts : 0; + return { + id, + ts, + content, + topic: + typeof raw.topic === "string" && raw.topic.trim() + ? raw.topic.trim() + : 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 { + 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 040c2eaa9..566a40f2d 100644 --- a/src/features/me/lib/meTopics.ts +++ b/src/features/me/lib/meTopics.ts @@ -4,8 +4,8 @@ import { 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 @@ -112,8 +112,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 */ diff --git a/src/features/me/lib/memoryProposalReview.ts b/src/features/me/lib/memoryProposalReview.ts new file mode 100644 index 000000000..7fb19cbf8 --- /dev/null +++ b/src/features/me/lib/memoryProposalReview.ts @@ -0,0 +1,46 @@ +import { + approveMemoryProposal as approveMemoryProposalInBackend, + resolveMemoryProposal, +} from "@/shared/api/system"; +import { + CredentialMemoryError, + looksLikeCredential, +} from "./memoryCredentialGuard"; +import type { MemoryProposal } from "./meProposals"; +import { loadMeFile } from "./meFile"; +import { publishMeFile } from "./mePublish"; + +export { CredentialMemoryError } from "./memoryCredentialGuard"; + +export async function approveMemoryProposal( + proposal: MemoryProposal, + content = proposal.content, + topic = proposal.topic, +): Promise { + const edited = content.trim(); + if (!edited) throw new Error("Memory content is required."); + if (looksLikeCredential(edited)) throw new CredentialMemoryError(); + + const result = await approveMemoryProposalInBackend( + proposal.id, + edited, + topic?.trim() || null, + ); + if (result.approved && result.refreshProjection) { + // Projection is derived output. Approval remains complete if this + // best-effort refresh fails and will be repaired by the next refresh. + const state = await loadMeFile(); + if (state.status === "present") { + await publishMeFile(state.contents).catch(() => {}); + } + } +} + +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/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..9f036d67d --- /dev/null +++ b/src/features/me/lib/saveMemoryDocument.ts @@ -0,0 +1,38 @@ +import { + readTextFile, + resolveMemoryProposal, + writeTextFile, +} from "@/shared/api/system"; +import { removedMemoryEntries } from "./editSummary"; +import { + CredentialMemoryError, + looksLikeCredential, +} from "./memoryCredentialGuard"; + +/** 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 { + if (looksLikeCredential(contents)) throw new CredentialMemoryError(); + + const before = await readTextFile(path) + .then((payload) => payload.contents) + .catch(() => ""); + const removed = removedMemoryEntries(before, contents); + + // 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, contents); + for (const entry of removed) { + await resolveMemoryProposal(`manual-delete-${crypto.randomUUID()}`, { + content: entry, + topic, + }); + } +} diff --git a/src/features/me/ui/MeSettings.tsx b/src/features/me/ui/MeSettings.tsx new file mode 100644 index 000000000..d08a18081 --- /dev/null +++ b/src/features/me/ui/MeSettings.tsx @@ -0,0 +1,642 @@ +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 { readMemoryPolicy, writeMemoryPolicy } from "../lib/memoryPolicyFile"; +import { publishMeFile } from "../lib/mePublish"; +import { + isMemoryContentApproved, + writeMemoryAgentsProjection, +} from "@/shared/api/system"; + +type LoadState = { status: "loading" } | { status: "error" } | MeFileState; +type ViewMode = "preview" | "edit"; + +interface DocumentPanelProps { + contents: string; + onSave: (next: string) => Promise | void; + editorLabel: string; + saveErrorText: 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, + cancelText, + saveText, + previewText, + editText, + unsavedText, + refreshLabel, + onRefresh, + footer, +}: DocumentPanelProps) { + const [mode, setMode] = useState("preview"); + const [draft, setDraft] = useState(null); + const [saveFailed, setSaveFailed] = useState(false); + + const isEditing = mode === "edit"; + const hasUnsavedChanges = draft !== null && draft !== contents; + + const handleModeChange = (next: string) => { + if (next === "edit" && draft === null) { + setDraft(contents); + setSaveFailed(false); + } + setMode(next === "edit" ? "edit" : "preview"); + }; + + const handleCancel = () => { + setDraft(null); + setSaveFailed(false); + setMode("preview"); + }; + + const handleSave = async () => { + if (draft === null) return; + try { + await onSave(draft); + setDraft(null); + setSaveFailed(false); + setMode("preview"); + } catch { + setSaveFailed(true); + } + }; + + return ( +
+
+ + + {/* h-7 matches the xs Button height used by every other action + on this page (Add topic, View, Refresh). */} + + {previewText} + + + {editText} + + + +
+ + {isEditing ? ( +